From 19e549086f6d4a43f02244e1793ae4e546f6ef3f Mon Sep 17 00:00:00 2001 From: "Soare Robert Daniel (Mac 2023)" Date: Tue, 10 Oct 2023 13:46:10 +0300 Subject: [PATCH 01/15] chore: add tracking prototype --- package-lock.json | 7 + package.json | 1 + src/blocks/blocks/form/inspector.js | 12 +- src/blocks/global.d.ts | 5 +- src/blocks/helpers/tracking.ts | 224 ++++++++++++++++++++ src/pro/components/webhook-editor/index.tsx | 3 +- src/pro/plugins/form/index.js | 20 +- 7 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 src/blocks/helpers/tracking.ts diff --git a/package-lock.json b/package-lock.json index 8fac6c9e5..c4fd8c4a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.5.1", + "@types/object-hash": "^3.0.4", "@types/wordpress__block-editor": "^11.5.1", "@types/wordpress__components": "^23.0.1", "@typescript-eslint/parser": "^6.3.0", @@ -5257,6 +5258,12 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "node_modules/@types/object-hash": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-3.0.4.tgz", + "integrity": "sha512-w4fEy2suq1bepUxHoJRCBHJz0vS5DPAYpSbcgNwOahljxwyJsiKmi8qyes2/TJc+4Avd7fsgP+ZgUuXZjPvdug==", + "dev": true + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", diff --git a/package.json b/package.json index 0770d1694..7d386910b 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.5.1", + "@types/object-hash": "^3.0.4", "@types/wordpress__block-editor": "^11.5.1", "@types/wordpress__components": "^23.0.1", "@typescript-eslint/parser": "^6.3.0", diff --git a/src/blocks/blocks/form/inspector.js b/src/blocks/blocks/form/inspector.js index 15fa8e5cf..540e4c089 100644 --- a/src/blocks/blocks/form/inspector.js +++ b/src/blocks/blocks/form/inspector.js @@ -518,6 +518,7 @@ const Inspector = ({ { label: __( 'Sendinblue', 'otter-blocks' ), value: 'sendinblue' } ] } onChange={ provider => { + window.oTrk?.add({ feature: 'marketing', featureComponent: 'provider', featureValue: provider }); setFormOption({ provider, listId: '', apiKey: '' }); } } /> @@ -553,6 +554,7 @@ const Inspector = ({ help={ __( 'You can find the key in the provider\'s website', 'otter-blocks' ) } value={ formOptions.apiKey ? `*************************${formOptions.apiKey.slice( -8 )}` : '' } onChange={ apiKey => { + window.oTrk?.add({ feature: 'marketing', featureComponent: 'api-key' }); setListIDOptions([]); setFormOption({ listId: '', @@ -593,7 +595,10 @@ const Inspector = ({ label={ __( 'Contact List', 'otter-blocks' ) } value={ formOptions.listId } options={ listIDOptions } - onChange={ listId => setFormOption({ listId }) } + onChange={ listId => { + window.oTrk?.add({ feature: 'marketing', featureComponent: 'contact-list' }); + setFormOption({ listId }); + } } /> { 1 >= listIDOptions?.length &&

{ __( 'No Contact list found. Please create a list in your provider interface or check if the API key is correct.', 'otter-blocks' ) }

} @@ -608,7 +613,10 @@ const Inspector = ({ { label: __( 'Subscribe', 'otter-blocks' ), value: 'subscribe' }, { label: __( 'Submit & Subscribe', 'otter-blocks' ), value: 'submit-subscribe' } ] } - onChange={ action => setFormOption({ action }) } + onChange={ action => { + window.oTrk?.add({ feature: 'marketing', featureComponent: 'provider', featureValue: action }); + setFormOption({ action }); + } } /> { 'submit-subscribe' === formOptions.action && ( diff --git a/src/blocks/global.d.ts b/src/blocks/global.d.ts index f1fc0f06b..d106a288d 100644 --- a/src/blocks/global.d.ts +++ b/src/blocks/global.d.ts @@ -1,3 +1,5 @@ +import type OtterEventTracking from './helpers/tracking'; + declare global { interface Window { themeisleGutenberg?: { @@ -102,7 +104,8 @@ declare global { }, oSavedStates?: { [key: string]: any - } + }, + oTrk?: OtterEventTracking } } diff --git a/src/blocks/helpers/tracking.ts b/src/blocks/helpers/tracking.ts new file mode 100644 index 000000000..bd808446f --- /dev/null +++ b/src/blocks/helpers/tracking.ts @@ -0,0 +1,224 @@ +import hash from 'object-hash'; + +import { __ } from '@wordpress/i18n'; + +import { OtterBlock } from './blocks'; +import { getChoice } from './helper-functions'; + +const TRACKING_URL = 'http://localhost:3000/track'; +const TRACKING_BULK_URL = 'http://localhost:3000/bulk-tracking'; + +type TrackingData = { + block?: string, + env?: string, + action?: 'block-created' | 'block-updated' | 'block-deleted', + attributeName?: string, + + feature?: string, + featureComponent?: string, + featureValue?: string, + + userPrompt?: string, + hasOpenAIKey?: boolean, +} + +export type TrackingPayload = { + slug: 'otter', + site: string, + license: string, + data: TrackingData, + createdAt: string, +} + +function sendTracking( payload: TrackingPayload ) { + return fetch( TRACKING_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify( payload ) + }); +}; + +function sendBulkTracking( payload: TrackingPayload[]) { + return fetch( TRACKING_BULK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify( payload ) + }); +} + +/** + * Add common metadata to the tracking data. Metadata includes the environment, etc. It does not overwrite the given data. + * + * @param data - Tracking data to be sent. + * @returns - Tracking data with the common metadata. + */ +export function trkMetadata( data: TrackingData ) { + return { + env: getChoice([ + [ window.location.href.includes( 'customize.php' ), 'customizer' ], + [ window.location.href.includes( 'site-editor.php' ), 'site-editor' ], + [ window.location.href.includes( 'widgets.php' ), 'widgets' ], + [ 'post-editor' ] + ]), + ...( data ?? {}) + }; +} + +type EventResponse = { + error?: string, + success?: boolean, + response?: any, +} + +type EventOptions = { + + /** + * If true, the data will be saved without any modification from the accumulator. Check the `trkMetadata` function for more details. + */ + directSave?: boolean, + + /** + * Bypass the consent check. Use this for data that does not need consent. + */ + consent?: boolean, +} + +export class EventTrackingAccumulator { + private events: Map = new Map(); + private eventsLimit = 3; + private listeners: ( ( result: EventResponse ) => void )[] = []; + private interval: number | null = null; + + constructor() { + + // When tab is closed, send all events. + window.addEventListener( 'beforeunload', () => { + this.sendAll(); + }); + } + + /** + * Set tracking data to the accumulator. If the key already exists, it will overwrite the existing data. + * + * @param key - The key to store the data under. With the same key, the data will be overwritten. + * @param data - Tracking data to be sent. + * @param options - Options to be passed to the accumulator. + */ + set( key: string, data: TrackingData, options?: EventOptions ) { + const enhancedData = options?.directSave ? data : trkMetadata( data ); + if ( options?.consent || this.hasConsent() ) { + this.events.set( key, enhancedData ); + } + console.log( 'Added tracking event', enhancedData ); + this.sendIfLimitReached(); + } + + /** + * Add tracking data to the accumulator. If the hash of the data already exists, it will overwrite the existing data. + * + * @param data - Tracking data to be sent. + * @param options - Options to be passed to the accumulator. + * @returns - Hash of the data. + */ + add( data: TrackingData, options?: EventOptions ) { + const h = hash( data ); + this.set( h, data, options ); + return h; + } + + /** + * Send all the events in the accumulator. Clears the accumulator after sending. All the listeners will be notified. + */ + async sendAll() { + const events = Array.from( this.events.values() ); + this.events.clear(); + const response = await sendBulkTracking( events.map( event => ({ + slug: 'otter', + site: window.location.href, + license: '32klhuioqbiuehri23', + data: event, + createdAt: new Date().toISOString() + }) ) ); + + if ( ! response.ok ) { + this.listeners.forEach( listener => listener({ success: false, error: __( 'Failed to send tracking events' ) }) ); + } + + const body = await response.json(); + this.listeners.forEach( listener => listener({ success: true, response: body }) ); + } + + /** + * Automatically send all the events if the limit is reached. + * @returns - Promise that resolves when all the events are sent. + */ + sendIfLimitReached() { + if ( this.events.size >= this.eventsLimit ) { + return this.sendAll(); + } + } + + /** + * Subscribe to the event when the events are sent. + * + * @param callback - Callback to be called when the events are sent. + * @returns - Function to unsubscribe from the event. + */ + subscribe( callback: () => void ) { + this.listeners.push( callback ); + return () => { + this.listeners = this.listeners.filter( listener => listener !== callback ); + }; + } + + /** + * Check if the user has given consent to send the events. + * + * @returns - True if the user has given consent to send the events. + */ + hasConsent() { + + // TODO: Add the real consent check. + return true; + } + + /** + * Start the interval to send the events automatically. + */ + start() { + if ( this.interval ) { + return; + } + + this.interval = window.setInterval( () => { + this.sendAll(); + }, 5 * 60 * 1000 ); // 5 minutes + } + + /** + * Stop the interval to send the events automatically. + */ + stop() { + if ( this.interval ) { + window.clearInterval( this.interval ); + this.interval = null; + } + } + + /** + * Refresh the interval to send the events automatically. + */ + refreshTimer() { + this.stop(); + this.start(); + } +} + +window.oTrk = new EventTrackingAccumulator(); +window.oTrk.start(); + +export default EventTrackingAccumulator; diff --git a/src/pro/components/webhook-editor/index.tsx b/src/pro/components/webhook-editor/index.tsx index e9fff0794..87d910b8c 100644 --- a/src/pro/components/webhook-editor/index.tsx +++ b/src/pro/components/webhook-editor/index.tsx @@ -25,7 +25,6 @@ import { arrowRight, closeSmall } from '@wordpress/icons'; */ import useSettings from '../../../blocks/helpers/use-settings'; - type WebhookEditorProps = { webhookId: string, onChange: ( webhookId: string ) => void, @@ -104,6 +103,7 @@ const WebhookEditor = ( props: WebhookEditorProps ) => { // Save to wp options setOption?.( 'themeisle_webhooks_options', [ ...webhooksToSave ], __( 'Webhooks saved.', 'otter-blocks' ), 'webhook', ( response ) => { setWebhooks( response?.['themeisle_webhooks_options'] ?? []); + window.oTrk?.add({ feature: 'webhook', featureComponent: 'saving' }); }); }; @@ -205,6 +205,7 @@ const WebhookEditor = ( props: WebhookEditorProps ) => { @@ -266,7 +269,10 @@ const Edit = ({ }, ...productsList ] } - onChange={ ( product: string ) => setAttributes({ product: 'none' !== product ? product : undefined }) } + onChange={ ( product: string ) =>{ + window.oTrk?.add({ feature: 'stripe-checkout', featureComponent: 'product-changed' }); + setAttributes({ product: 'none' !== product ? product : undefined }); + } } /> ) } @@ -281,7 +287,10 @@ const Edit = ({ }, ...pricesList ] } - onChange={ ( price: string ) => setAttributes({ price: 'none' !== price ? price : undefined }) } + onChange={ ( price: string ) => { + window.oTrk?.add({ feature: 'stripe-checkout', featureComponent: 'price-changed' }); + setAttributes({ price: 'none' !== price ? price : undefined }); + } } /> ) } diff --git a/src/blocks/blocks/stripe-checkout/inspector.js b/src/blocks/blocks/stripe-checkout/inspector.js index 6e2497e1e..a0fb820b0 100644 --- a/src/blocks/blocks/stripe-checkout/inspector.js +++ b/src/blocks/blocks/stripe-checkout/inspector.js @@ -111,6 +111,7 @@ const Inspector = ({ ...productsList ] } onChange={ product => { + window.oTrk?.add({ feature: 'stripe-checkout', featureComponent: 'product-changed' }); setAttributes({ product: 'none' !== product ? product : undefined, price: undefined @@ -130,7 +131,10 @@ const Inspector = ({ }, ...pricesList ] } - onChange={ price => setAttributes({ price: 'none' !== price ? price : undefined }) } + onChange={ price => { + window.oTrk?.add({ feature: 'stripe-checkout', featureComponent: 'price-changed' }); + setAttributes({ price: 'none' !== price ? price : undefined }); + } } /> ) } diff --git a/src/blocks/components/prompt/index.tsx b/src/blocks/components/prompt/index.tsx index a56059266..9e09ffb52 100644 --- a/src/blocks/components/prompt/index.tsx +++ b/src/blocks/components/prompt/index.tsx @@ -275,6 +275,8 @@ const PromptPlaceholder = ( props: PromptPlaceholderProps ) => { const sendPrompt = regenerate ? sendPromptToOpenAIWithRegenerate : sendPromptToOpenAI; + window.oTrk?.add({ feature: 'ai-generation', featureComponent: 'prompt', featureValue: value }, { consent: true }); + sendPrompt?.( value, embeddedPrompt, { 'otter_used_action': 'textTransformation' === promptID ? 'textTransformation::otter_action_prompt' : ( promptID ?? '' ), 'otter_user_content': value diff --git a/src/blocks/global.d.ts b/src/blocks/global.d.ts index d106a288d..f478fc5c0 100644 --- a/src/blocks/global.d.ts +++ b/src/blocks/global.d.ts @@ -20,6 +20,8 @@ declare global { imageSizes: string[] isWPVIP: boolean canTrack: boolean + trackHash: string + trackAPI: string userRoles: { [key: string]: { name: string diff --git a/src/blocks/helpers/index.js b/src/blocks/helpers/index.js index 39dc84803..72a52339d 100644 --- a/src/blocks/helpers/index.js +++ b/src/blocks/helpers/index.js @@ -7,6 +7,8 @@ import * as icons from './icons.js'; import useSettings from './use-settings.js'; +import './tracking.js'; + window.otterUtils = {}; window.otterUtils.blockInit = blockInit; diff --git a/src/blocks/helpers/prompt.ts b/src/blocks/helpers/prompt.ts index 97dfb550d..b4597901a 100644 --- a/src/blocks/helpers/prompt.ts +++ b/src/blocks/helpers/prompt.ts @@ -99,6 +99,7 @@ function promptRequestBuilder( settings?: OpenAiSettings ) { // TODO: remove the apiKey from the function definition. return async( prompt: string, embeddedPrompt: PromptData, metadata: Record ) => { + const body = { ...embeddedPrompt, messages: embeddedPrompt.messages.map( ( message ) => { diff --git a/src/blocks/helpers/tracking.js b/src/blocks/helpers/tracking.js new file mode 100644 index 000000000..fd4337004 --- /dev/null +++ b/src/blocks/helpers/tracking.js @@ -0,0 +1,242 @@ +import hash from 'object-hash'; + +import { __ } from '@wordpress/i18n'; + +import { getChoice } from './helper-functions'; + +/** + * @typedef {Object} TrackingData + * @property {string} [block] - The block identifier. (E.g: 'core/paragraph', 'core/image') + * @property {string} [env] - The environment. (E.g: 'customizer', 'site-editor', 'widgets', 'post-editor') + * @property {('block-created'|'block-updated'|'block-deleted')} [action] - The action performed. + * @property {string} [feature] - The feature identifier. (E.g: 'webhooks', 'form-file') + * @property {string} [groupID] - The group identifier. Used for tracking the evolution of the features in a group (it can be a block, a component, etc.) + * @property {string} [featureComponent] - The component of the feature. (E.g: 'file-size', 'file-number') + * @property {string} [featureValue] - The value of the feature. + * @property {boolean} [hasOpenAIKey] - Indicates whether an OpenAI key is present. + * @property {string} [usedTheme] - The theme used. + */ + +/** + * @typedef {Object} TrackingPayload + * @property {string} slug - The slug identifier, always 'otter'. + * @property {string} site - The site identifier. + * @property {string} license - The license identifier. + * @property {TrackingData} data - The tracking data. + * @property {string} createdAt - The creation timestamp. + */ + +/** + * @typedef {Object} EventResponse + * @property {string} [error] - Description of the error, if any. + * @property {boolean} [success] - Indicates whether the operation was successful. + * @property {*} [response] - The response data. + */ + +/** + * @typedef {Object} EventOptions + * @property {boolean} [directSave] - If true, the data will be saved without any modification from the accumulator. Check the `trkMetadata` function for more details. + * @property {boolean} [consent] - Bypass the consent check. Use this for data that does not need consent. + * @property {boolean} [refreshTimer] - Refresh the timer to send the events automatically. + * @property {boolean} [sendNow] - Send the events immediately. + * @property {boolean} [ignoreLimit] - Ignore the limit of the events to be send. + */ + +export class EventTrackingAccumulator { + constructor() { + + /** + * @type {Map} - The events to be sent. + */ + this.events = new Map(); + + /** + * @type {number} - The maximum number of events to be sent at once. + * @private + * @readonly + * @constant + */ + this.eventsLimit = 50; + + /** + * @type {Array<(response: EventResponse) => void>} - The listeners to be notified when the events are sent. + * @private + * @readonly + */ + this.listeners = []; + + /** + * @type {number|null} - The interval to send the events automatically. + * @private + */ + this.interval = null; + + // When tab is closed, send all events. + window.addEventListener( 'beforeunload', async() => { + await this.sendAll(); + }); + } + + /** + * Set tracking data to the accumulator. If the key already exists, it will overwrite the existing data. + * + * @param {string} key - The key to store the data under. With the same key, the data will be overwritten. + * @param {TrackingData} data - Tracking data to be sent. + * @param {EventOptions} [options] - Options to be passed to the accumulator. + */ + set( key, data, options ) { + if ( options?.consent || this.hasConsent() ) { + const enhancedData = options?.directSave ? data : this.trkMetadata( data ); + this.events.set( key, enhancedData ); + } + + if ( options?.refreshTimer ) { + this.refreshTimer(); + } + + if ( options?.sendNow ) { + this.sendAll(); + } else if ( ! options?.ignoreLimit ) { + this.sendIfLimitReached(); + } + } + + /** + * Add tracking data to the accumulator. If the hash of the data already exists, it will overwrite the existing data. + * + * @param {TrackingData} data - Tracking data to be sent. + * @param {EventOptions} [options] - Options to be passed to the accumulator. + * @returns {string} - Hash of the data. + */ + add( data, options ) { + const h = hash( data ); + this.set( h.toString(), data, options ); + return h.toString(); + } + + /** + * Send all the events in the accumulator. Clears the accumulator after sending. All the listeners will be notified. + */ + async sendAll() { + try { + const events = Array.from( this.events.values() ); + this.events.clear(); + const response = await this.sendBulkTracking( events.map( event => ({ + slug: 'otter', + site: window?.themeisleGutenberg?.rootUrl ?? window.location.hostname, + license: window?.themeisleGutenberg?.trackHash, + data: event + }) ) ); + + if ( ! response.ok ) { + this.listeners.forEach( listener => listener({ success: false, error: __( 'Failed to send tracking events' ) }) ); + } + + const body = await response.json(); + this.listeners.forEach( listener => listener({ success: true, response: body }) ); + } catch ( error ) { + console.error( error ); + } + } + + /** + * Automatically send all the events if the limit is reached. + * @returns - Promise that resolves when all the events are sent. + */ + sendIfLimitReached() { + if ( this.events.size >= this.eventsLimit ) { + return this.sendAll(); + } + } + + /** + * Subscribe to the event when the events are sent. + * + * @param {(response: EventResponse) => void} callback - Callback to be called when the events are sent. + * @returns {() => void} - Function to unsubscribe from the event. + */ + subscribe( callback ) { + this.listeners.push( callback ); + return () => { + this.listeners = this.listeners.filter( listener => listener !== callback ); + }; + } + + /** + * Check if the user has given consent to send the events. + * + * @returns - True if the user has given consent to send the events. + */ + hasConsent() { + return Boolean( '1' === window?.themeisleGutenberg?.canTrack ); + } + + /** + * Send the tracking data to the server. + * + * @param {Array} payload - Tracking data to be sent. + * @returns {Promise} - Response from the server. + */ + sendBulkTracking( payload ) { + return fetch( window.themeisleGutenberg.trackAPI, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify( payload ) + }); + } + + /** + * Add common metadata to the tracking data. Metadata includes the environment, etc. It does not overwrite the given data. + * + * @param {TrackingData} data - Tracking data to be sent. + * @returns {TrackingData} - Tracking data with the common metadata. + */ + trkMetadata( data ) { + return { + env: getChoice([ + [ window.location.href.includes( 'customize.php' ), 'customizer' ], + [ window.location.href.includes( 'site-editor.php' ), 'site-editor' ], + [ window.location.href.includes( 'widgets.php' ), 'widgets' ], + [ 'post-editor' ] + ]), + ...( data ?? {}) + }; + } + + /** + * Start the interval to send the events automatically. + */ + start() { + if ( this.interval ) { + return; + } + + this.interval = window.setInterval( () => { + this.sendAll(); + }, 5 * 60 * 1000 ); // 5 minutes + } + + /** + * Stop the interval to send the events automatically. + */ + stop() { + if ( this.interval ) { + window.clearInterval( this.interval ); + this.interval = null; + } + } + + /** + * Refresh the interval to send the events automatically. + */ + refreshTimer() { + this.stop(); + this.start(); + } +} + +window.oTrk = new EventTrackingAccumulator(); + +export default EventTrackingAccumulator; diff --git a/src/blocks/helpers/tracking.ts b/src/blocks/helpers/tracking.ts deleted file mode 100644 index bd808446f..000000000 --- a/src/blocks/helpers/tracking.ts +++ /dev/null @@ -1,224 +0,0 @@ -import hash from 'object-hash'; - -import { __ } from '@wordpress/i18n'; - -import { OtterBlock } from './blocks'; -import { getChoice } from './helper-functions'; - -const TRACKING_URL = 'http://localhost:3000/track'; -const TRACKING_BULK_URL = 'http://localhost:3000/bulk-tracking'; - -type TrackingData = { - block?: string, - env?: string, - action?: 'block-created' | 'block-updated' | 'block-deleted', - attributeName?: string, - - feature?: string, - featureComponent?: string, - featureValue?: string, - - userPrompt?: string, - hasOpenAIKey?: boolean, -} - -export type TrackingPayload = { - slug: 'otter', - site: string, - license: string, - data: TrackingData, - createdAt: string, -} - -function sendTracking( payload: TrackingPayload ) { - return fetch( TRACKING_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify( payload ) - }); -}; - -function sendBulkTracking( payload: TrackingPayload[]) { - return fetch( TRACKING_BULK_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify( payload ) - }); -} - -/** - * Add common metadata to the tracking data. Metadata includes the environment, etc. It does not overwrite the given data. - * - * @param data - Tracking data to be sent. - * @returns - Tracking data with the common metadata. - */ -export function trkMetadata( data: TrackingData ) { - return { - env: getChoice([ - [ window.location.href.includes( 'customize.php' ), 'customizer' ], - [ window.location.href.includes( 'site-editor.php' ), 'site-editor' ], - [ window.location.href.includes( 'widgets.php' ), 'widgets' ], - [ 'post-editor' ] - ]), - ...( data ?? {}) - }; -} - -type EventResponse = { - error?: string, - success?: boolean, - response?: any, -} - -type EventOptions = { - - /** - * If true, the data will be saved without any modification from the accumulator. Check the `trkMetadata` function for more details. - */ - directSave?: boolean, - - /** - * Bypass the consent check. Use this for data that does not need consent. - */ - consent?: boolean, -} - -export class EventTrackingAccumulator { - private events: Map = new Map(); - private eventsLimit = 3; - private listeners: ( ( result: EventResponse ) => void )[] = []; - private interval: number | null = null; - - constructor() { - - // When tab is closed, send all events. - window.addEventListener( 'beforeunload', () => { - this.sendAll(); - }); - } - - /** - * Set tracking data to the accumulator. If the key already exists, it will overwrite the existing data. - * - * @param key - The key to store the data under. With the same key, the data will be overwritten. - * @param data - Tracking data to be sent. - * @param options - Options to be passed to the accumulator. - */ - set( key: string, data: TrackingData, options?: EventOptions ) { - const enhancedData = options?.directSave ? data : trkMetadata( data ); - if ( options?.consent || this.hasConsent() ) { - this.events.set( key, enhancedData ); - } - console.log( 'Added tracking event', enhancedData ); - this.sendIfLimitReached(); - } - - /** - * Add tracking data to the accumulator. If the hash of the data already exists, it will overwrite the existing data. - * - * @param data - Tracking data to be sent. - * @param options - Options to be passed to the accumulator. - * @returns - Hash of the data. - */ - add( data: TrackingData, options?: EventOptions ) { - const h = hash( data ); - this.set( h, data, options ); - return h; - } - - /** - * Send all the events in the accumulator. Clears the accumulator after sending. All the listeners will be notified. - */ - async sendAll() { - const events = Array.from( this.events.values() ); - this.events.clear(); - const response = await sendBulkTracking( events.map( event => ({ - slug: 'otter', - site: window.location.href, - license: '32klhuioqbiuehri23', - data: event, - createdAt: new Date().toISOString() - }) ) ); - - if ( ! response.ok ) { - this.listeners.forEach( listener => listener({ success: false, error: __( 'Failed to send tracking events' ) }) ); - } - - const body = await response.json(); - this.listeners.forEach( listener => listener({ success: true, response: body }) ); - } - - /** - * Automatically send all the events if the limit is reached. - * @returns - Promise that resolves when all the events are sent. - */ - sendIfLimitReached() { - if ( this.events.size >= this.eventsLimit ) { - return this.sendAll(); - } - } - - /** - * Subscribe to the event when the events are sent. - * - * @param callback - Callback to be called when the events are sent. - * @returns - Function to unsubscribe from the event. - */ - subscribe( callback: () => void ) { - this.listeners.push( callback ); - return () => { - this.listeners = this.listeners.filter( listener => listener !== callback ); - }; - } - - /** - * Check if the user has given consent to send the events. - * - * @returns - True if the user has given consent to send the events. - */ - hasConsent() { - - // TODO: Add the real consent check. - return true; - } - - /** - * Start the interval to send the events automatically. - */ - start() { - if ( this.interval ) { - return; - } - - this.interval = window.setInterval( () => { - this.sendAll(); - }, 5 * 60 * 1000 ); // 5 minutes - } - - /** - * Stop the interval to send the events automatically. - */ - stop() { - if ( this.interval ) { - window.clearInterval( this.interval ); - this.interval = null; - } - } - - /** - * Refresh the interval to send the events automatically. - */ - refreshTimer() { - this.stop(); - this.start(); - } -} - -window.oTrk = new EventTrackingAccumulator(); -window.oTrk.start(); - -export default EventTrackingAccumulator; diff --git a/src/blocks/plugins/ai-content/index.tsx b/src/blocks/plugins/ai-content/index.tsx index 654a7f90b..e0429def9 100644 --- a/src/blocks/plugins/ai-content/index.tsx +++ b/src/blocks/plugins/ai-content/index.tsx @@ -158,6 +158,9 @@ const withConditions = createHigherOrderComponent( BlockEdit => { } setIsProcessing( prevState => ({ ...prevState, [ actionKey ]: true }) ); + + window.oTrk?.add({ feature: 'ai-generation', featureComponent: 'ai-toolbar', featureValue: actionKey }, { consent: true }); + sendPromptToOpenAI( content, injectActionIntoPrompt( diff --git a/src/blocks/plugins/conditions/edit.js b/src/blocks/plugins/conditions/edit.js index 83fe53b43..ae695d5dc 100644 --- a/src/blocks/plugins/conditions/edit.js +++ b/src/blocks/plugins/conditions/edit.js @@ -261,7 +261,8 @@ const Separator = ({ label }) => { const Edit = ({ attributes, - setAttributes: _setAttributes + setAttributes: _setAttributes, + name }) => { const [ buffer, setBuffer ] = useState( null ); const [ conditions, setConditions ] = useState({}); @@ -359,6 +360,8 @@ const Edit = ({ }; const changeCondition = ( value, index, key ) => { + window.oTrk?.add({ block: name, feature: 'condition', featureComponent: 'condition-type', featureValue: value }); + const otterConditions = [ ...attributes.otterConditions ]; const attrs = applyFilters( 'otter.blockConditions.defaults', {}, value ); diff --git a/src/blocks/plugins/css-handler/index.js b/src/blocks/plugins/css-handler/index.js index f5811c312..2aeb963ef 100644 --- a/src/blocks/plugins/css-handler/index.js +++ b/src/blocks/plugins/css-handler/index.js @@ -141,6 +141,7 @@ subscribe( () => { }); if ( ( isPublishing || ( postPublished && isSaving ) ) && ! isAutoSaving && ! isSavingCSS ) { + window.oTrk?.sendAll(); isSavingCSS = true; savePostMeta(); } diff --git a/src/pro/blocks/add-to-cart-button/edit.js b/src/pro/blocks/add-to-cart-button/edit.js index 450671918..01adc2d59 100644 --- a/src/pro/blocks/add-to-cart-button/edit.js +++ b/src/pro/blocks/add-to-cart-button/edit.js @@ -49,7 +49,11 @@ const Edit = ({ label={ __( 'Select Product', 'otter-blocks' ) } hideLabelFromVision value={ attributes.product || '' } - onChange={ product => setAttributes({ product: Number( product ) }) } + onChange={ product => { + window.oTrk?.add({ feature: 'add-to-cart', featureComponent: 'product-changed' }); + + setAttributes({ product: Number( product ) }); + } } /> ) } diff --git a/src/pro/blocks/business-hours/edit.js b/src/pro/blocks/business-hours/edit.js index 2dae44c12..c9438161c 100644 --- a/src/pro/blocks/business-hours/edit.js +++ b/src/pro/blocks/business-hours/edit.js @@ -40,10 +40,16 @@ const Edit = ({ attributes, setAttributes, isSelected, - clientId + clientId, + name }) => { useEffect( () => { const unsubscribe = blockInit( clientId, defaultAttributes ); + + if ( undefined === attributes.id ) { + window.oTrk?.add({ block: name, action: 'block-created', groupID: attributes.id }); + } + return () => unsubscribe( attributes.id ); }, [ attributes.id ]); diff --git a/src/pro/blocks/file/edit.js b/src/pro/blocks/file/edit.js index 18ea485a1..49296defd 100644 --- a/src/pro/blocks/file/edit.js +++ b/src/pro/blocks/file/edit.js @@ -38,15 +38,18 @@ const { attributes: defaultAttributes } = metadata; const Edit = ({ attributes, setAttributes, - clientId + clientId, + name }) => { useEffect( () => { const unsubscribe = blockInit( clientId, defaultAttributes ); if ( attributes.id === undefined ) { + window.oTrk?.add({ block: name, action: 'block-created', groupID: attributes.id }); // Set the default value for newly created blocks. setAttributes({ saveFiles: 'media-library' }); } + return () => unsubscribe( attributes.id ); }, [ attributes.id ]); diff --git a/src/pro/blocks/form-hidden-field/edit.js b/src/pro/blocks/form-hidden-field/edit.js index 18a347dd7..f746f0f2e 100644 --- a/src/pro/blocks/form-hidden-field/edit.js +++ b/src/pro/blocks/form-hidden-field/edit.js @@ -25,11 +25,17 @@ const { attributes: defaultAttributes } = metadata; const Edit = ({ attributes, setAttributes, - clientId + clientId, + name }) => { useEffect( () => { const unsubscribe = blockInit( clientId, defaultAttributes ); + + if ( undefined === attributes.id ) { + window.oTrk?.add({ block: name, action: 'block-created', groupID: attributes.id }); + } + return () => unsubscribe( attributes.id ); }, [ attributes.id ]); diff --git a/src/pro/blocks/form-stripe-field/edit.js b/src/pro/blocks/form-stripe-field/edit.js index 641eac919..5f32ecfe3 100644 --- a/src/pro/blocks/form-stripe-field/edit.js +++ b/src/pro/blocks/form-stripe-field/edit.js @@ -33,11 +33,17 @@ const { attributes: defaultAttributes } = metadata; const Edit = ({ attributes, setAttributes, - clientId + clientId, + name }) => { useEffect( () => { const unsubscribe = blockInit( clientId, defaultAttributes ); + + if ( undefined === attributes.id ) { + window.oTrk?.add({ block: name, action: 'block-created', groupID: attributes.id }); + } + return () => unsubscribe( attributes.id ); }, [ attributes.id ]); diff --git a/src/pro/blocks/form-stripe-field/inspector.js b/src/pro/blocks/form-stripe-field/inspector.js index 9cbdef5f2..790e890ee 100644 --- a/src/pro/blocks/form-stripe-field/inspector.js +++ b/src/pro/blocks/form-stripe-field/inspector.js @@ -124,6 +124,7 @@ const Inspector = ({ ...productsList ] } onChange={ product => { + window.oTrk?.add({ feature: 'stripe-field', featureComponent: 'product-changed' }); setAttributes({ product: 'none' !== product ? product : undefined, price: undefined @@ -144,6 +145,7 @@ const Inspector = ({ ...pricesList ] } onChange={ price => { + window.oTrk?.add({ feature: 'stripe-field', featureComponent: 'price-changed' }); setAttributes({ price: 'none' !== price ? price : undefined }); } } /> diff --git a/src/pro/blocks/review-comparison/edit.js b/src/pro/blocks/review-comparison/edit.js index 2a9aea307..938b1f750 100644 --- a/src/pro/blocks/review-comparison/edit.js +++ b/src/pro/blocks/review-comparison/edit.js @@ -57,10 +57,16 @@ let tableLinks = []; const Edit = ({ attributes, setAttributes, - clientId + clientId, + name }) => { useEffect( () => { const unsubscribe = blockInit( clientId, defaultAttributes ); + + if ( undefined === attributes.id ) { + window.oTrk?.add({ block: name, action: 'block-created', groupID: attributes.id }); + } + return () => unsubscribe( attributes.id ); }, [ attributes.id ]); diff --git a/src/pro/plugins/countdown/index.tsx b/src/pro/plugins/countdown/index.tsx index 2779f4802..5dd2b101d 100644 --- a/src/pro/plugins/countdown/index.tsx +++ b/src/pro/plugins/countdown/index.tsx @@ -91,6 +91,7 @@ const CountdownProFeaturesSettings = ( Template: React.FC<{}>, { attributes, set attrs.endInterval = undefined; } + window.oTrk?.set( `${attributes.id}_type`, { feature: 'countdown', featureComponent: 'countdown-type', featureValue: value, groupID: attributes.id }); setAttributes( attrs ); } @@ -243,6 +244,7 @@ const CountdownProFeaturesEnd = ( Template: React.FC<{}>, { label={ __( 'On Expire', 'otter-blocks' ) } value={ attributes.behaviour } onChange={ behaviour => { + window.oTrk?.set( `${attributes.id}_beh`, { feature: 'countdown', featureComponent: 'countdown-behaviour', featureValue: behaviour, groupID: attributes.id }); if ( 'redirectLink' === behaviour ) { setAttributes({ behaviour, redirectLink: undefined }); } else { @@ -283,6 +285,7 @@ const CountdownProFeaturesEnd = ( Template: React.FC<{}>, { help={ __( 'Enable Hide/Show other blocks when the Countdown ends.', 'otter-blocks' ) } checked={ attributes.onEndAction !== undefined } onChange={ value => { + window.oTrk?.set( `${attributes.id}_hide`, { feature: 'countdown', featureComponent: 'countdown-hide', featureValue: value ? 'all' : 'none', groupID: attributes.id }); if ( value ) { setAttributes({ onEndAction: 'all' }); } else { diff --git a/src/pro/plugins/form/index.js b/src/pro/plugins/form/index.js index 8c5f1c9e7..bfab69af7 100644 --- a/src/pro/plugins/form/index.js +++ b/src/pro/plugins/form/index.js @@ -46,9 +46,10 @@ const helpMessages = { * @param {import('../../../blocks/blocks/form/type').FormOptions} formOptions The form options. * @param { (options: import('../../../blocks/blocks/form/type').FormOptions) => void } setFormOption The function to set the form options. * @param {any} config The form config. + * @param {import('../../../blocks/blocks/form/type').FormAttrs} attributes The form attributes. * @returns {JSX.Element} */ -const FormOptions = ( Options, formOptions, setFormOption, config ) => { +const FormOptions = ( Options, formOptions, setFormOption, config, attributes ) => { return ( <> @@ -65,7 +66,7 @@ const FormOptions = ( Options, formOptions, setFormOption, config ) => { label={ __( 'Save Location', 'otter-blocks' ) } value={ formOptions.submissionsSaveLocation ?? 'database-email' } onChange={ submissionsSaveLocation => { - window.oTrk?.set( `${attributes.id}_save`, { feature: 'form-file', featureComponent: 'save-location', featureValue: submissionsSaveLocation }); + window.oTrk?.set( `${attributes.id}_save`, { feature: 'form-storing', featureComponent: 'save-location', featureValue: submissionsSaveLocation, groupID: attributes.id }); setFormOption({ submissionsSaveLocation }); } } options={ @@ -110,7 +111,7 @@ const FormOptions = ( Options, formOptions, setFormOption, config ) => { )} value={formOptions.autoresponder?.subject} onChange={( subject ) => { - window.oTrk?.add({ feature: 'form-autoresponder', featureComponent: 'subject' }); + window.oTrk?.add({ feature: 'form-autoresponder', featureComponent: 'subject', groupID: attributes.id }); setFormOption({ autoresponder: { ...formOptions.autoresponder, @@ -156,7 +157,7 @@ const FormOptions = ( Options, formOptions, setFormOption, config ) => { )} formOptions.webhookId } + hasValue={() => formOptions?.webhookId } label={__( 'Webhook', 'otter-blocks' )} onDeselect={() => setFormOption({ webhookId: undefined })} > @@ -165,7 +166,7 @@ const FormOptions = ( Options, formOptions, setFormOption, config ) => { { - window.oTrk?.add({ feature: 'form-webhook', featureComponent: 'webhook-creation' }); + window.oTrk?.add({ feature: 'form-webhook', featureComponent: 'webhook-set', groupID: attributes.id }); setFormOption({ webhookId: webhookId }); @@ -280,7 +281,7 @@ const FormFileInspector = ( Template, { type="number" value={ ! isNaN( parseInt( attributes.maxFileSize ) ) ? attributes.maxFileSize : undefined } onChange={ maxFileSize => { - window.oTrk?.set( `${attributes.id}_size`, { feature: 'form-file', featureComponent: 'file-size', featureValue: maxFileSize }); + window.oTrk?.set( `${attributes.id}_size`, { feature: 'form-file', featureComponent: 'file-size', featureValue: maxFileSize, groupID: attributes.id }); setSavedState( attributes.id, true ); setAttributes({ maxFileSize: maxFileSize ? maxFileSize?.toString() : undefined }); } } @@ -291,7 +292,7 @@ const FormFileInspector = ( Template, { label={ __( 'Allowed File Types', 'otter-blocks' ) } value={ attributes.allowedFileTypes } onChange={ allowedFileTypes => { - window.oTrk?.set( `${attributes.id}_type`, { feature: 'form-file', featureComponent: 'file-size', featureValue: allowedFileTypes }); + window.oTrk?.set( `${attributes.id}_type`, { feature: 'form-file', featureComponent: 'file-type', featureValue: allowedFileTypes, groupID: attributes.id }); setSavedState( attributes.id, true ); setAttributes({ allowedFileTypes: allowedFileTypes ? allowedFileTypes.map( replaceJPGWithJPEG ) : undefined }); } } @@ -324,7 +325,7 @@ const FormFileInspector = ( Template, { label={ __( 'Allow multiple file uploads', 'otter-blocks' ) } checked={ Boolean( attributes.multipleFiles ) } onChange={ multipleFiles => { - window.oTrk?.add({ feature: 'form-file', featureComponent: 'enable-multiple-file' }); + window.oTrk?.add({ feature: 'form-file', featureComponent: 'enable-multiple-file', groupID: attributes.id }); setSavedState( attributes.id, true ); setAttributes({ multipleFiles: multipleFiles ? multipleFiles : undefined }); } } @@ -337,7 +338,7 @@ const FormFileInspector = ( Template, { type="number" value={ ! isNaN( parseInt( attributes.maxFilesNumber ) ) ? ( attributes.maxFilesNumber ) : undefined } onChange={ maxFilesNumber => { - window.oTrk?.set( `${attributes.id}_num`, { feature: 'form-file', featureComponent: 'multiple-file', featureValue: maxFilesNumber }); + window.oTrk?.set( `${attributes.id}_num`, { feature: 'form-file', featureComponent: 'multiple-file', featureValue: maxFilesNumber, groupID: attributes.id }); setSavedState( attributes.id, true ); setAttributes({ maxFilesNumber: maxFilesNumber ? maxFilesNumber?.toString() : undefined }); } } @@ -351,7 +352,7 @@ const FormFileInspector = ( Template, { help={ __( 'If enabled, the files will be saved to Media Library instead of adding them as attachments to email.', 'otter-blocks' ) } checked={ 'media-library' === attributes.saveFiles } onChange={ value => { - window.oTrk?.add({ feature: 'form-file', featureComponent: 'enable-media-saving' }); + window.oTrk?.add({ feature: 'form-file', featureComponent: 'enable-media-saving', groupID: attributes.id }); setSavedState( attributes.id, true ); setAttributes({ saveFiles: value ? 'media-library' : undefined }); } } diff --git a/src/pro/plugins/live-search/edit.js b/src/pro/plugins/live-search/edit.js index 7326f555b..ee9c3b594 100644 --- a/src/pro/plugins/live-search/edit.js +++ b/src/pro/plugins/live-search/edit.js @@ -73,7 +73,14 @@ const Edit = ({ { + + if ( value ) { + window.oTrk?.add({ feature: 'live-search', featureComponent: 'enable' }); + } + + toggleLive(); + } } /> { props.attributes.otterIsLive && ( From 3c8bc0b9b7287e13bd3f85e11f734a096b2214f2 Mon Sep 17 00:00:00 2001 From: "Soare Robert Daniel (Mac 2023)" Date: Thu, 12 Oct 2023 11:56:16 +0300 Subject: [PATCH 03/15] chore: add data validation --- src/blocks/blocks/form/edit.js | 1 + src/blocks/helpers/tracking.js | 31 +++++++++++++++++-- .../plugins/dynamic-content/link/fields.js | 5 ++- .../dynamic-content/media/media-content.js | 5 ++- src/css/editor.js | 1 + 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/blocks/blocks/form/edit.js b/src/blocks/blocks/form/edit.js index c7fa34897..0a7eaae92 100644 --- a/src/blocks/blocks/form/edit.js +++ b/src/blocks/blocks/form/edit.js @@ -1091,6 +1091,7 @@ const Edit = ({ variations={ variations } onSelect={ ( nextVariation = defaultVariation ) => { if ( nextVariation ) { + window.oTrk?.add({ feature: 'form', featureComponent: 'variant', featureValue: nextVariation.name }); replaceInnerBlocks( clientId, createBlocksFromInnerBlocksTemplate( diff --git a/src/blocks/helpers/tracking.js b/src/blocks/helpers/tracking.js index fd4337004..18e898367 100644 --- a/src/blocks/helpers/tracking.js +++ b/src/blocks/helpers/tracking.js @@ -85,11 +85,17 @@ export class EventTrackingAccumulator { * @param {EventOptions} [options] - Options to be passed to the accumulator. */ set( key, data, options ) { - if ( options?.consent || this.hasConsent() ) { - const enhancedData = options?.directSave ? data : this.trkMetadata( data ); - this.events.set( key, enhancedData ); + if ( ! ( options?.consent || this.hasConsent() ) ) { + return; + } + + if ( ! this.validate( data ) ) { + return; } + const enhancedData = options?.directSave ? data : this.trkMetadata( data ); + this.events.set( key, enhancedData ); + if ( options?.refreshTimer ) { this.refreshTimer(); } @@ -235,6 +241,25 @@ export class EventTrackingAccumulator { this.stop(); this.start(); } + + /** + * Validate the tracking data. The data is valid if it has at least one property and all the values are defined. + * + * @param {any} data - Tracking data to be validated. + * @returns {boolean} - True if the data is valid. + */ + validate( data ) { + if ( 'object' === typeof data ) { + + if ( 0 === Object.keys( data ).length ) { + return false; + } + + return Object.values( data ).every( this.validate ); + } + + return 'undefined' !== typeof data; + } } window.oTrk = new EventTrackingAccumulator(); diff --git a/src/blocks/plugins/dynamic-content/link/fields.js b/src/blocks/plugins/dynamic-content/link/fields.js index 4bfa0b01c..004c31727 100644 --- a/src/blocks/plugins/dynamic-content/link/fields.js +++ b/src/blocks/plugins/dynamic-content/link/fields.js @@ -62,7 +62,10 @@ const Fields = ({ >