diff --git a/package.json b/package.json index 630d3cc6e..f4c8068ec 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "mixpanel-browser": "^2.41.0", "offline-js": "^0.7.19", "plyr": "^3.6.3", + "prism-es6": "^1.2.0", "secure-ls": "^1.2.3", "tailwindcss-interaction-variants": "^5.0.0", "vue": "^3.2.23", diff --git a/src/App.vue b/src/App.vue index b7be5acff..deb58496c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -608,7 +608,8 @@ export default { clonedeep(this.activeWorkspaceSettings) ); - SettingsUtilities.prepareSettingsToRender(this.settingsToRender); + // preparing settings to render but only for the App.vue component + SettingsUtilities.prepareSettingsToRender(this.settingsToRender, true, "App.vue"); }, closeSettingsMenu() { if (this.isMobileScreen) this.resetMenuState(); diff --git a/src/assets/locales/en.js b/src/assets/locales/en.js index 8db7cf6c0..ac2a69337 100644 --- a/src/assets/locales/en.js +++ b/src/assets/locales/en.js @@ -181,6 +181,11 @@ export default { }, }, }, + settings: { + webhook: { + url_input_placeholder: "https://your-webhook-url.com/your/path", + } + } }, home: { create_button: "Create", @@ -537,7 +542,9 @@ export default { hasUnsavedChanges: "Save your changes", noUnsavedChanges: "Nothing to save", }, + apply_webhook_configuration: "Save the webhook configuration and go back to settings", cancel: "", + discard_webhook_configuration: "Discard the configuration and go back to settings", }, }, }, @@ -580,11 +587,14 @@ export default { skipEnabled: "Viewers can skip questions while attempting plios", firstTimeLanguagePickerPopup: "Viewers will see the language picker on first login", + customWebhook: "Configure a webhook for recieving events from plio", }, description: { skipEnabled: "Provide viewers the option to skip a question", firstTimeLanguagePickerPopup: "Your users will see the language picker on their first login. This sets what language they will see the platform in. Currently only two values, English and Hindi, are supported.", + customWebhook: + "Configure to recieve realtime events from plio. This can be used to integrate plio with other services", }, info: "The new settings will only apply to plios created in the future and not the existing plios", @@ -596,6 +606,7 @@ export default { tab: { configuration: "Configuration", ui: "UI", + advanced: "Advanced", }, }, badge: { diff --git a/src/assets/locales/hi.js b/src/assets/locales/hi.js index 0af5917b6..d5157e2a5 100644 --- a/src/assets/locales/hi.js +++ b/src/assets/locales/hi.js @@ -182,6 +182,11 @@ export default { "यह तस्वीर 10 MB से ज़्यादा बड़ी ह। कृपया कोई छोटी तस्वीर अपलोड करें", }, }, + settings: { + webhook: { + url_input_placeholder: "https://your-webhook-url.com/your/path" + } + } }, }, home: { @@ -580,14 +585,17 @@ export default { title: { skipEnabled: "प्लायो का प्रयास करते समय दर्शक प्रश्नों को छोड़ सकते हैं", - // firstTimeLanguagePickerPopup: "Viewers will see the language picker on first login", firstTimeLanguagePickerPopup: "दर्शकों को पहली बार लॉगिन करते समय भाषा चुनने का विकल्प प्रदान करें", + // customWebhook: "Configure a webhook for recieving events from plio", + customWebhook: "प्लायो से घटनाओं प्राप्त करने के लिए एक वेबहुक कॉन्फ़िगर करें", }, description: { skipEnabled: "दर्शकों को प्रश्न छोड़ने का विकल्प प्रदान करें", firstTimeLanguagePickerPopup: "आपके दर्शक अपनी पहली लॉगिन पर भाषा चुनने का विकल्प देखेंगे। यह निर्धारित करता है कि वे प्लेटफ़ॉर्म को किस भाषा में देखेंगे। वर्तमान में केवल दो, अंग्रेजी और हिंदी, का समर्थन किया जाता है।", + customWebhook: + "प्लायो घटनाओं को अपने वेबहुक के माध्यम से प्राप्त करने के लिए एक वेबहुक कॉन्फ़िगर करें। यह अन्य सेवाओं के साथ प्लायो को एकीकृत करने के लिए उपयोग किया जा सकता है।", }, info: "नई सेटिंग्स केवल भविष्य में बनाए जाने वाले प्लायो पर लागू होंगी, न कि पहले से बनाए गए प्लायो पर", @@ -599,6 +607,7 @@ export default { tab: { configuration: "विन्यास", ui: "यूआई", + advanced: "एडवांस्ड" }, }, badge: { diff --git a/src/components/App/ConfigureWebhookWindow.vue b/src/components/App/ConfigureWebhookWindow.vue new file mode 100644 index 000000000..f39ba09e4 --- /dev/null +++ b/src/components/App/ConfigureWebhookWindow.vue @@ -0,0 +1,502 @@ + + + diff --git a/src/components/App/Settings.vue b/src/components/App/Settings.vue index a1e065e55..9cc5f2e66 100644 --- a/src/components/App/Settings.vue +++ b/src/components/App/Settings.vue @@ -141,6 +141,20 @@ @change="updateCheckboxSetting($event.target.checked, leafDetails, leafName)" data-test="input" /> + + +
@@ -195,13 +209,24 @@
+ + + diff --git a/src/main.js b/src/main.js index 0f56a8153..19012e670 100644 --- a/src/main.js +++ b/src/main.js @@ -20,6 +20,7 @@ import { Integrations } from "@sentry/tracing"; import GAuth from "vue3-google-oauth2"; import "./index.css"; +import "./components/Editor/CodeHighlighter.css" import "vue-toastification/dist/index.css"; import "tippy.js/dist/tippy.css"; import "tippy.js/animations/shift-toward.css"; diff --git a/src/pages/Editor.vue b/src/pages/Editor.vue index 2222d94ce..2e152b9a7 100644 --- a/src/pages/Editor.vue +++ b/src/pages/Editor.vue @@ -1478,7 +1478,7 @@ export default { constructSettingsMenu() { // keep a clone of the plio settings in a local variable this.settingsToRender = clonedeep(this.plioSettings); - SettingsUtilities.prepareSettingsToRender(this.settingsToRender, false); + SettingsUtilities.prepareSettingsToRender(this.settingsToRender, false, "Editor.vue"); }, closeSettingsMenu() { this.isSettingsMenuShown = false; diff --git a/src/pages/Embeds/Plio.vue b/src/pages/Embeds/Plio.vue index 0145b0ecd..a8985ad57 100644 --- a/src/pages/Embeds/Plio.vue +++ b/src/pages/Embeds/Plio.vue @@ -399,6 +399,44 @@ export default { return this.plioSettings.get("player").children.get("ui").children; }, + defaultAdvancedSettings() { + return globalDefaultSettings.get("player").children.get("advanced").children; + }, + defaultWebhookSettings() { + return this.defaultAdvancedSettings.get("customWebhook").value; + }, + webhookSettings() { + if ( + this.plioSettings == null || + !(this.plioSettings instanceof Map) || + !this.plioSettings.has("player") || + !this.plioSettings.get("player").children.has("advanced") || + !this.plioSettings.get("player").children.get("advanced").children.has("customWebhook") + ) return this.defaultWebhookSettings + + return this.plioSettings.get("player").children.get("advanced").children.get("customWebhook").value; + }, + isWebhookFunctionalityEnabled() { + if ( + this.previewMode || + !('enabledEvents' in this.webhookSettings) || + this.webhookSettings.enabledEvents.length == 0 || + !('webhookURL' in this.webhookSettings) || + this.webhookSettings.webhookURL == "" || + this.thirdPartyUniqueId == null || + this.thirdPartyApiKey == null + ) return false; + + return true; + }, + webhookURL() { + if (this.isWebhookFunctionalityEnabled) return this.webhookSettings.webhookURL; + return null; + }, + enabledEvents() { + if (this.isWebhookFunctionalityEnabled) return this.webhookSettings.enabledEvents; + return null; + }, /** * whether player has the correct aspect ratio as desired */ @@ -585,6 +623,71 @@ export default { ] ), ...mapActions("auth", ["setAccessToken", "setActiveWorkspace"]), + checkAndSendEventToWebhook(eventName, eventData) { + if (!this.isWebhookFunctionalityEnabled) return; + + if (this.enabledEvents.includes(eventName)) { + let extraData = [] + + if (eventName == 'played' || eventName == 'paused') { + extraData = [ + { + key: 'plio_seekbar_time', + value: this.player.currentTime + } + ] + } else if (eventName == 'item_opened') { + extraData = [ + { + key: 'item_index', + value: eventData.itemIndex + }, + { + key: 'item_id', + value: this.items[eventData.itemIndex].id + } + ] + } else if (eventName == 'plio_finished') { + extraData = [ + { + key: 'num_correct', + value: this.numCorrect + }, + { + key: 'num_wrong', + value: this.numWrong + }, + { + key: 'num_skipped', + value: this.numSkipped + } + ] + } + + let payload = { + unique_user_id: this.thirdPartyUniqueId, + plio_id: this.plioId, + timestamp: new Date().toISOString(), + event_type: eventName, + event_data: extraData, + } + + try { + fetch( + this.webhookURL, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ) + } catch (error) { + console.error("Error in sending event to webhook", error) + } + } + }, /** * @param {Number} itemIndex - the index of the item whose response is to be checked * @param {Number} answer - the response to be checked @@ -649,6 +752,11 @@ export default { if (!this.isMovingToTimestampAllowed(this.player.duration)) return; if (!this.isScorecardShown) { this.isScorecardShown = true; + + this.checkAndSendEventToWebhook( + 'plio_finished', {} + ) + var scorecardModal = document.getElementById("scorecardmodal"); if (scorecardModal != undefined) this.mountOnFullscreenPlyr(scorecardModal); } @@ -885,6 +993,10 @@ export default { else { if (this.locale == null) this.setFirstTimeLanguagePickerShownBySetting() } + + this.checkAndSendEventToWebhook( + 'plio_loaded', {} + ) }) .then(() => this.createSession()) .then(() => this.logData()); @@ -1006,6 +1118,10 @@ export default { } // once itemResponses is full, calculate all the scorecard metrics this.calculateScorecardMetrics(); + + this.checkAndSendEventToWebhook( + 'user_authenticated', {} + ) }); }, /** @@ -1283,6 +1399,9 @@ export default { * in preview mode */ if (!this.hasSessionStarted || !this.isAuthenticated || this.previewMode) return; + + this.checkAndSendEventToWebhook(eventType, eventDetails) + let response = await EventAPIService.createEvent({ type: eventType, details: eventDetails, diff --git a/src/services/Config/GlobalDefaultSettings.js b/src/services/Config/GlobalDefaultSettings.js index 6a31ee0a8..56ec99cc7 100644 --- a/src/services/Config/GlobalDefaultSettings.js +++ b/src/services/Config/GlobalDefaultSettings.js @@ -1,5 +1,7 @@ /** default settings */ let skipEnabled = true; +let firstTimeLanguagePickerPopup = true; +// let customWebhookEnabled = false; /** * This object contains a mapping of a setting and its metadata. @@ -21,8 +23,188 @@ export let settingsMetadata = { description: "settings.menu.description.firstTimeLanguagePickerPopup", type: "checkbox", }, + customWebhook: { + title: "settings.menu.title.customWebhook", + description: "settings.menu.description.customWebhook", + type: "button", + }, }; +export const webhookEvents = [ + { + displayName: "User Authenticated", + code: "user_authenticated", + isSelected: false, + description: "This event is triggered when a user is authenticated by the system", + payloadExample: ` +{ + "unique_user_id": "9999988888", + "plio_id": "aspokqwehn", + "timestamp": "2023-04-23T18:25:43.511Z", + "event_type": "user_authenticated", + "event_data": [] +} + ` + }, + { + displayName: "Plio Loaded", + code: "plio_loaded", + isSelected: false, + description: "This event is triggered when setting up of the Plio video is finished in the browser.", + payloadExample: ` +{ + "unique_user_id": "9999988888", + "plio_id": "aspokqwehn", + "timestamp": "2023-04-23T18:25:43.511Z", + "event_type": "plio_loaded", + "event_data": [] +} + ` + }, + { + displayName: "Play Button Clicked", + code: "played", + isSelected: false, + description: "This event is triggered when the play button is clicked by the user", + extraData: [ + { + code: "plio_seekbar_time", + displayName: "Seekbar Time", + type: "number", + description: "The time in seconds on the video seekbar when the user clicked the play button", + } + ], + payloadExample: ` +{ + "unique_user_id": "9999988888", + "plio_id": "aspokqwehn", + "timestamp": "2023-04-23T18:25:43.511Z", + "event_type": "played", + "event_data": [ + { + "key": "plio_seekbar_time", + "value": 120 + } + ] +} + ` + }, + { + displayName: "Pause Button Clicked", + code: "paused", + isSelected: false, + description: "This event is triggered when the pause button is clicked by the user", + extraData: [ + { + code: "plio_seekbar_time", + displayName: "Seekbar Time", + type: "number", + description: "The time in seconds on the video seekbar when the user clicked the pause button", + } + ], + payloadExample: ` +{ + "unique_user_id": "9999988888", + "plio_id": "aspokqwehn", + "timestamp": "2023-04-23T18:25:43.511Z", + "event_type": "paused", + "event_data": [ + { + "key": "plio_seekbar_time", + "value": 120 + } + ] +} + ` + }, + { + displayName: "Item Opened", + code: "item_opened", + isSelected: false, + description: "This event is triggered when an item/question pops up on the screen", + extraData: [ + { + code: "item_id", + displayName: "Item ID", + type: "number", + description: "The ID of the item that has been opened. This ID can be matched with the one on BigQuery tables.", + }, + { + code: "item_index", + displayName: "Item Index", + type: "number", + description: "The index of the item in the items array, which has been opened.", + } + ], + payloadExample: ` +{ + "unique_user_id": "9999988888", + "plio_id": "aspokqwehn", + "timestamp": "2023-04-23T18:25:43.511Z", + "event_type": "item_opened", + "event_data": [ + { + "key": "item_id", + "value": 123 + }, + { + "key": "item_index", + "value": 2 + } + ] +} + ` + }, + { + displayName: "Plio Finished / Scorecard Shown", + code: "plio_finished", + isSelected: false, + description: "This event is triggered when the user has finished watching the Plio and the scorecard is shown to the user with some calculated metrics.", + // three types of data -- numCorrect, numWrong, numSkipped + extraData: [ + { + code: "num_correct", + displayName: "Number of Correct Answers", + type: "number", + description: "The number of questions the user has answered correctly", + }, + { + code: "num_wrong", + displayName: "Number of Wrong Answers", + type: "number", + description: "The number of questions the user has answered incorrectly", + }, + { + code: "num_skipped", + displayName: "Number of Skipped Questions", + type: "number", + description: "The number of questions the user has skipped", + } + ], + payloadExample: ` +{ + "unique_user_id": "9999988888", + "plio_id": "aspokqwehn", + "timestamp": "2023-04-23T18:25:43.511Z", + "event_type": "plio_finished", + "event_data": [ + { + "key": "num_correct", + "value": 5 + }, + { + "key": "num_wrong", + "value": 3 + }, + { + "key": "num_skipped", + "value": 2 + } + ] +}` + } +] + /** * The below exported map is the global default settings object. * These are the default values of settings to be used when no setting has been explicitly set. @@ -62,11 +244,27 @@ let globalDefaultSetings = new Map( Object.entries({ firstTimeLanguagePickerPopup: { scope: ["org-admin", "super-admin"], - value: true, + value: firstTimeLanguagePickerPopup }, }) ), }, + advanced: { + scope: ["org-admin", "super-admin", "only-plio-setting", "no-personal-workspace"], + children: new Map( + Object.entries({ + customWebhook: { + scope: ["org-admin", "super-admin"], + // value: customWebhookEnabled, + value: { + enabledEvents: [], + // webhookURL: "http://localhost:3000/v1/temp/plioWebhook", + webhookURL: "", + } + } + }) + ) + } }) ), }, diff --git a/src/services/Functional/Utilities/Settings.js b/src/services/Functional/Utilities/Settings.js index a3bcc60d1..518056f89 100644 --- a/src/services/Functional/Utilities/Settings.js +++ b/src/services/Functional/Utilities/Settings.js @@ -110,8 +110,10 @@ export default { * For each of the leaf settings, we attach some metadata to it and pass it back to the parent. * @param {Map} settingsToRender - the object that needs to be prepared * @param {Boolean} checkUserScoping - if the user's scope has to be taken into account + * @param {Object} toRenderIn - the component which is the caller of this function. It will either be the App.vue (homepage) or Editor.vue. + * Some settings should only render in the homepage and some only in the editor. This is for that */ - prepareSettingsToRender(settingsToRender, checkUserScoping = true) { + prepareSettingsToRender(settingsToRender, checkUserScoping = true, toRenderIn) { // Checks if the current user has access to a particular setting level. Only valid for non personal workspace let canUserAccess = (settingLevel) => { if ( @@ -125,8 +127,28 @@ export default { return true; }; + const shouldSettingElementBeRendered = (settingElement) => { + if ( + settingElement.scope.includes('no-personal-workspace') && + store.getters["auth/isPersonalWorkspace"] + ) return false + + if (settingElement.scope.includes('only-plio-setting')) { + if (toRenderIn == 'App.vue') return false + if (toRenderIn == 'Editor.vue') return true + } else if (settingElement.scope.includes('only-home-setting')) { + if (toRenderIn == 'App.vue') return true + if (toRenderIn == 'Editor.vue') return false + } + + return true + } + for (let [headerName, headerDetails] of settingsToRender) { - if (checkUserScoping && !canUserAccess(headerDetails)) { + if ( + (checkUserScoping && !canUserAccess(headerDetails)) || + !shouldSettingElementBeRendered(headerDetails) + ) { // in case of a workspace, we also need to check for scope. If the current user does not // have rights for a particular setting, we remove that key from settingsToRender settingsToRender.delete(headerName); @@ -134,7 +156,10 @@ export default { } settingsToRender.set(headerName, clonedeep(headerDetails.children)); for (let [tabName, tabDetails] of settingsToRender.get(headerName)) { - if (checkUserScoping && !canUserAccess(tabDetails)) { + if ( + (checkUserScoping && !canUserAccess(tabDetails)) || + !shouldSettingElementBeRendered(tabDetails) + ) { // in case of a workspace, we also need to check for scope. If the current user does not // have rights for a particular setting, we remove that key from settingsToRender settingsToRender.get(headerName).delete(tabName); @@ -146,7 +171,10 @@ export default { for (let [leafName, leafDetails] of settingsToRender .get(headerName) .get(tabName)) { - if (checkUserScoping && !canUserAccess(leafDetails)) { + if ( + (checkUserScoping && !canUserAccess(leafDetails)) || + !shouldSettingElementBeRendered(leafDetails) + ) { // in case of a workspace, we also need to check for scope. If the current user does not // have rights for a particular setting, we remove that key from settingsToRender settingsToRender.get(headerName).get(tabName).delete(leafName); @@ -161,6 +189,7 @@ export default { .get(tabName) .set(leafName, { ...settingsMetadata[leafName], + ...leafDetails.data, value: leafDetails.value, isWorkspaceSetting: checkUserScoping && diff --git a/tailwind.config.js b/tailwind.config.js index 7227b8d8d..aee1398f7 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,6 +5,9 @@ module.exports = { darkMode: false, // or 'media' or 'class' theme: { extend: { + width: { + '99/100': '99%' + }, colors: { primary: "#F78000", "primary-hover": "#db7506",