diff --git a/package-lock.json b/package-lock.json index bd95e9d1a..ad5db616c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@babel/preset-env": "^7.13.12", "@babel/preset-typescript": "^7.13.0", "@tsconfig/node14": "^1.0.3", + "@types/gtag.js": "^0.0.12", "@types/jest": "^26.0.21", "@types/minimist": "^1.2.2", "@types/node-fetch": "^2.5.8", @@ -5411,6 +5412,12 @@ "@types/node": "*" } }, + "node_modules/@types/gtag.js": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", + "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==", + "dev": true + }, "node_modules/@types/intro.js": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/intro.js/-/intro.js-3.0.0.tgz", @@ -25769,6 +25776,12 @@ "@types/node": "*" } }, + "@types/gtag.js": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", + "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==", + "dev": true + }, "@types/intro.js": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/intro.js/-/intro.js-3.0.0.tgz", diff --git a/package.json b/package.json index 9c397b3dd..81fb7b89d 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@babel/preset-env": "^7.13.12", "@babel/preset-typescript": "^7.13.0", "@tsconfig/node14": "^1.0.3", + "@types/gtag.js": "^0.0.12", "@types/jest": "^26.0.21", "@types/minimist": "^1.2.2", "@types/node-fetch": "^2.5.8", diff --git a/src/components/BottomBar/BottomBarState.ts b/src/components/BottomBar/BottomBarState.ts index 863f0fa90..2f9d61fca 100644 --- a/src/components/BottomBar/BottomBarState.ts +++ b/src/components/BottomBar/BottomBarState.ts @@ -1,5 +1,6 @@ import { reactive } from 'vue'; -import { GTag, GTagEvent } from '../../gtag'; +import { VueGtag } from 'vue-gtag-next'; +import { GTagEvent } from '../../gtag'; import { checkNotNull } from '../../utilities'; import { @@ -117,7 +118,7 @@ export const addCourseToBottomBar = (course: FirestoreSemesterCourse): void => { ]); }; -export const toggleBottomBar = (gtag?: GTag): void => { +export const toggleBottomBar = (gtag?: VueGtag): void => { vueForBottomBar.isExpanded = !vueForBottomBar.isExpanded; if (vueForBottomBar.isExpanded) { GTagEvent(gtag, 'bottom-bar-open'); @@ -126,7 +127,7 @@ export const toggleBottomBar = (gtag?: GTag): void => { } }; -export const closeBottomBar = (gtag?: GTag): void => { +export const closeBottomBar = (gtag?: VueGtag): void => { vueForBottomBar.isExpanded = false; GTagEvent(gtag, 'bottom-bar-close'); }; @@ -135,7 +136,7 @@ export const changeBottomBarCourseFocus = (index: number): void => { vueForBottomBar.bottomCourseFocus = index; }; -export const deleteBottomBarCourse = (index: number, gtag?: GTag): void => { +export const deleteBottomBarCourse = (index: number, gtag?: VueGtag): void => { GTagEvent(gtag, 'bottom-bar-delete-tab'); vueForBottomBar.bottomCourses = vueForBottomBar.bottomCourses.filter((_, i) => i !== index); if (vueForBottomBar.bottomCourseFocus >= vueForBottomBar.bottomCourses.length) { diff --git a/src/global-firestore-data/user-semesters.ts b/src/global-firestore-data/user-semesters.ts index 77760c765..8075dc1b0 100644 --- a/src/global-firestore-data/user-semesters.ts +++ b/src/global-firestore-data/user-semesters.ts @@ -1,8 +1,9 @@ import { doc, updateDoc } from 'firebase/firestore'; +import { VueGtag } from 'vue-gtag-next'; import { semestersCollection } from '../firebase-config'; import store from '../store'; -import { GTag, GTagEvent } from '../gtag'; +import { GTagEvent } from '../gtag'; import { sortedSemesters } from '../utilities'; import { @@ -68,7 +69,7 @@ export const semesterEquals = ( export const addSemester = ( year: number, season: FirestoreSemesterSeason, - gtag?: GTag, + gtag?: VueGtag, courses: readonly FirestoreSemesterCourse[] = [] ): void => { GTagEvent(gtag, 'add-semester'); @@ -78,7 +79,7 @@ export const addSemester = ( export const deleteSemester = ( year: number, season: FirestoreSemesterSeason, - gtag?: GTag + gtag?: VueGtag ): void => { GTagEvent(gtag, 'delete-semester'); const semester = store.state.semesters.find(sem => semesterEquals(sem, year, season)); @@ -93,7 +94,7 @@ export const addCourseToSemester = ( season: FirestoreSemesterSeason, newCourse: FirestoreSemesterCourse, choiceUpdater: (choice: FirestoreCourseOptInOptOutChoices) => FirestoreCourseOptInOptOutChoices, - gtag?: GTag + gtag?: VueGtag ): void => { GTagEvent(gtag, 'add-course'); editSemesters(oldSemesters => { @@ -115,7 +116,7 @@ export const deleteCourseFromSemester = ( year: number, season: FirestoreSemesterSeason, courseUniqueID: number, - gtag?: GTag + gtag?: VueGtag ): void => { GTagEvent(gtag, 'delete-course'); const semester = store.state.semesters.find(sem => semesterEquals(sem, year, season)); @@ -135,7 +136,7 @@ export const deleteCourseFromSemester = ( export const deleteAllCoursesFromSemester = ( year: number, season: FirestoreSemesterSeason, - gtag?: GTag + gtag?: VueGtag ): void => { GTagEvent(gtag, 'delete-semester-courses'); const semester = store.state.semesters.find(sem => semesterEquals(sem, year, season)); @@ -150,7 +151,7 @@ export const deleteAllCoursesFromSemester = ( } }; -export const deleteCourseFromSemesters = (courseUniqueID: number, gtag?: GTag): void => { +export const deleteCourseFromSemesters = (courseUniqueID: number, gtag?: VueGtag): void => { GTagEvent(gtag, 'delete-course'); editSemesters(oldSemesters => oldSemesters.map(semester => { diff --git a/src/gtag.ts b/src/gtag.ts index a56905933..c1f05ecae 100644 --- a/src/gtag.ts +++ b/src/gtag.ts @@ -1,12 +1,23 @@ +import { VueGtag, query } from 'vue-gtag-next'; + type EventPayload = { event_category: string; event_label: string; value: number }; -type LoginEventPayload = { method: string }; -export type GTag = { - event(eventType: string, eventPayload: LoginEventPayload | EventPayload): void; +/** + * Set a user's properties for analytics + * + * @param gtag the `VueGtag` instance to query + * @param properties the user's properties + */ +export const setUserProperties = (onboardingData: AppOnboardingData) => { + const gtag = query as Gtag.Gtag; + gtag('set', 'user_properties', { + major: onboardingData.major, + gradYear: onboardingData.gradYear, + }); }; /** GTagLoginEvent represents the gtag that tracks when users login. */ -export const GTagLoginEvent = (gtag: GTag | undefined, method: string): void => { +export const GTagLoginEvent = (gtag: VueGtag | undefined, method: string): void => { if (!gtag) return; gtag.event('login', { method }); }; @@ -41,7 +52,7 @@ type EventType = * @param gtag is the global site tag that sends events to Google Analytics * @param eventType specifies the type of event that the gtag sends */ -export const GTagEvent = (gtag: GTag | undefined, eventType: EventType): void => { +export const GTagEvent = (gtag: VueGtag | undefined, eventType: EventType): void => { if (!gtag) return; let eventPayload: EventPayload | undefined; switch (eventType) { diff --git a/src/main.ts b/src/main.ts index 058065bc9..6c2b7a32f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,7 @@ auth.onAuthStateChanged(() => { app.use(router); // Enable Google analytics with custom events app.use(VueGtag, { - property: { id: 'UA-124837875-2' }, + property: { id: 'G-BQ6CTZQPSF' }, }); app.use(store); app.mount('#app'); diff --git a/src/store.ts b/src/store.ts index 273932703..75ba96e6c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,6 +16,7 @@ import { getFirstPlan, } from './utilities'; import featureFlagCheckers from './feature-flags'; +import { setUserProperties } from './gtag'; type SimplifiedFirebaseUser = { readonly displayName: string; readonly email: string }; @@ -161,45 +162,53 @@ const store: TypedVuexStore = new TypedVuexStore({ }); const autoRecomputeDerivedData = (): (() => void) => - store.subscribe((payload, state) => { - if (payload.type === 'setOrderByNewest') { - store.commit('setSemesters', sortedSemesters(state.semesters, state.orderByNewest)); - } - // Recompute courses - if (payload.type === 'setSemesters') { - const allCourseSet = new Set(); - const duplicatedCourseCodeSet = new Set(); - const courseMap: Record = {}; - const courseToSemesterMap: Record = {}; - state.semesters.forEach(semester => { - semester.courses.forEach(course => { - if (isPlaceholderCourse(course)) { - return; - } + store.subscribe((mutation, state) => { + switch (mutation.type) { + case 'setOnboardingData': { + setUserProperties(mutation.payload); + break; + } + case 'setOrderByNewest': { + store.commit('setSemesters', sortedSemesters(state.semesters, state.orderByNewest)); + break; + } + case 'setSemesters': { + const allCourseSet = new Set(); + const duplicatedCourseCodeSet = new Set(); + const courseMap: Record = {}; + const courseToSemesterMap: Record = {}; + state.semesters.forEach(semester => { + semester.courses.forEach(course => { + if (isPlaceholderCourse(course)) { + return; + } - const { code } = course; - if (allCourseSet.has(code)) { - duplicatedCourseCodeSet.add(code); - } else { - allCourseSet.add(code); - } - courseMap[course.uniqueID] = course; - courseToSemesterMap[course.uniqueID] = semester; + const { code } = course; + if (allCourseSet.has(code)) { + duplicatedCourseCodeSet.add(code); + } else { + allCourseSet.add(code); + } + courseMap[course.uniqueID] = course; + courseToSemesterMap[course.uniqueID] = semester; + }); }); - }); - const derivedCourseData: DerivedCoursesData = { - duplicatedCourseCodeSet, - courseMap, - courseToSemesterMap, - }; - store.commit('setDerivedCourseData', derivedCourseData); + const derivedCourseData: DerivedCoursesData = { + duplicatedCourseCodeSet, + courseMap, + courseToSemesterMap, + }; + store.commit('setDerivedCourseData', derivedCourseData); + break; + } + default: } // Recompute requirements if ( - payload.type === 'setOnboardingData' || - payload.type === 'setSemesters' || - payload.type === 'setToggleableRequirementChoices' || - payload.type === 'setOverriddenFulfillmentChoices' + mutation.type === 'setOnboardingData' || + mutation.type === 'setSemesters' || + mutation.type === 'setToggleableRequirementChoices' || + mutation.type === 'setOverriddenFulfillmentChoices' ) { if (state.onboardingData.college !== '') { store.commit( diff --git a/src/vue-gtag-next.d.ts b/src/vue-gtag-next.d.ts new file mode 100644 index 000000000..ef47f498e --- /dev/null +++ b/src/vue-gtag-next.d.ts @@ -0,0 +1,6 @@ +export {}; + +declare module 'vue-gtag-next' { + // eslint-disable-next-line import/prefer-default-export + export const query: Gtag.Gtag; +} diff --git a/tsconfig.json b/tsconfig.json index 8474ab4f9..2d247fc6f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "baseUrl": ".", "types": [ "node", - "jest" + "jest", + "@types/gtag.js" ], "paths": { "@/*": [ @@ -34,9 +35,10 @@ "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts", - "tests/**/*.tsx" -, "scripts/population/courses-json-generator.ts" ], + "tests/**/*.tsx", + "scripts/population/courses-json-generator.ts" + ], "exclude": [ "node_modules" ] -} +} \ No newline at end of file