Untitled
unknown
plain_text
a year ago
17 kB
1
Indexable
Never
import { MessageTransaction } from "jschannel"; import GenericWidget from "./GenericWidget"; import { AwaitPromise, ChannelMethods, CompleteReason, FormSubmissionDetails, FormSubmissionResponse, LinkData, PublishedWidget, WaitReason, WidgetFrequencyConfig, WidgetState, } from "../../../../types"; import { get, isNull, isNumber, isUndefined, isValidString, safeParseJSON, } from "../../../../utils/common"; import { getFormSubmissionKey } from "../../utils"; import { SEVEN_DAYS_INTERVAL } from "../../constants"; import WidgetsController from "../Controller"; import { getDeviceID } from "../../../../utils/deviceID"; import { WidgetFeature } from "../features"; import { CFEvent } from "../../../../utils/events"; const FORM_SUBMISSION_QUERY = "form_submission_id"; const CONTENT_TYPE_ID_QUERY = "content_type_id"; const FORM_STATE_CACHE_KEY_PREFIX = "mm_quizState"; const TIMER_STATE_CACHE_KEY_PREFIX = "mm_quiz_timerState"; export default class Form extends GenericWidget { isSubmitted: boolean; // If true, it will include submission details(if any) with the link data includeSubmissionDetails: boolean; // If true, the widget will be opened even if it has been submitted once openIfSubmitted: boolean; // key where the state of the quiz is cached in local storage stateCacheKey: string; // key where the state of the timer inside quiz is cached in local storage timerStateCacheKey: string; static logs = { color: "white", "background-color": "#32cd32", }; constructor(publishedWidget: PublishedWidget, controller: WidgetsController) { super(publishedWidget, controller); this.isSubmitted = false; this.includeSubmissionDetails = false; this.openIfSubmitted = false; this.stateCacheKey = `${FORM_STATE_CACHE_KEY_PREFIX}_${this.widgetId}`; this.timerStateCacheKey = `${TIMER_STATE_CACHE_KEY_PREFIX}_${this.widgetId}`; this.shouldResolveData = Array.isArray(publishedWidget.widget.features) ? publishedWidget.widget.features.includes(WidgetFeature.QUIZ_TIMER) : false; this.showTimerAlways = !!get<typeof publishedWidget, boolean | undefined>( publishedWidget, "widget.props.showTimerAlways", false ); } getFrequencyConfig(): WidgetFrequencyConfig { return { ...super.getFrequencyConfig(), enabled: true, defaultInterval: SEVEN_DAYS_INTERVAL, }; } updateSavedFormDetails( formSubmissionResponse?: FormSubmissionResponse ): FormSubmissionDetails { const { widgetId } = this; let formSubmissionId; let contentTypeId; const queryParams = new URLSearchParams(window.location.search); if (!isUndefined(formSubmissionResponse)) { ({ formSubmissionId = "", contentTypeId = "" } = formSubmissionResponse); } else { // if the shopify URL contains these query params, store it in localstorage and remove from window history formSubmissionId = queryParams.get(FORM_SUBMISSION_QUERY) ?? ""; contentTypeId = queryParams.get(CONTENT_TYPE_ID_QUERY) ?? ""; } if (isValidString(formSubmissionId) && isValidString(contentTypeId)) { window.localStorage.setItem( getFormSubmissionKey(widgetId), JSON.stringify({ formSubmissionId, contentTypeId, }) ); queryParams.delete(FORM_SUBMISSION_QUERY); queryParams.delete(CONTENT_TYPE_ID_QUERY); window.history.replaceState( window.history.state, "", `${window.location.href.split("?")[0]}?${queryParams.toString()}` ); } // localstorage will have the stringified data in the formt // {"formSubmissionId":"0c0cfa56-7480-4d17-8feb-7f7e2620588e", // "contentTypeId":"784a9f53-3542-400f-8d26-bc649be4927b"} return safeParseJSON( window.localStorage.getItem(getFormSubmissionKey(widgetId)), {} ); } async getLinkData(data: { linkDataOverride?: LinkData; formSubmissionResponse?: FormSubmissionResponse; timerExpired?: boolean; includeSubmissionDetails?: boolean; }): Promise<LinkData> { console.log("form class get link data"); const { widgetConfig, discountHelper } = this; const publishedLinkData = widgetConfig.linkData; const { linkDataOverride, formSubmissionResponse, timerExpired, includeSubmissionDetails = true, } = data ?? {}; const formSubmissionLinkData = formSubmissionResponse?.linkData ?? {}; const formSubmissionDetails = this.updateSavedFormDetails( formSubmissionResponse ); const isFormSubmission = !isUndefined(formSubmissionResponse); const setDefaultDiscount = !isFormSubmission && isUndefined(linkDataOverride); const finalLinkData = { ...publishedLinkData, ...formSubmissionLinkData, ...linkDataOverride, discount_info: { ...publishedLinkData?.discount_info, ...formSubmissionLinkData?.discount_info, ...linkDataOverride?.discount_info, }, ...(includeSubmissionDetails ? formSubmissionDetails : {}), ...(!!formSubmissionResponse?.formData && { form_data: { // data from form submission response ...formSubmissionResponse?.formData, }, formData: undefined, }), // attach reward details if present in the form submission response ...(!!formSubmissionResponse?.rewardDetails && { reward_details: { // data from form submission response ...formSubmissionResponse?.rewardDetails, }, rewardDetails: undefined, }), // form_data: { // ...formSubmissionResponse?.formData, // ...linkDataOverride?.form_data, // }, }; if (false && setDefaultDiscount) { return discountHelper.getLinkData(finalLinkData, false); } const linkDataWithDiscount = discountHelper.getLinkData( finalLinkData, true, timerExpired, "reset" ); // if a duration for quiz timer is present // generate link data slice associated with the timer if (isNumber(widgetConfig.settings?.timer?.duration)) { const cachedTimerState = this.getTimerStateCache(); // generate a default timer config for the duration // this will be the in the future as the timer has to show a static version // till it is manually started const timerConfig = Form.getTimerConfig( widgetConfig.settings.timer?.duration as number ); // if cache data is present, the link data has to use the timer state for link data from the cache itself if (!isNull(cachedTimerState)) { timerConfig.timer = Object.keys(timerConfig.timer).reduce( (prev, curr) => { // eslint-disable-next-line no-param-reassign prev[curr as keyof typeof timerConfig["timer"]] = get( cachedTimerState, curr ) as never; // copy over values of the fields present in the default timer config from the state cache return prev; }, { ...timerConfig.timer } as typeof timerConfig["timer"] ); } return { ...linkDataWithDiscount, ...timerConfig, }; } return linkDataWithDiscount; } async getRenderData( linkDataOverride?: Parameters<GenericWidget["getRenderData"]>[0], // when syncState is true, the cache data for the quiz is retrieved and send back for state propagation { syncState = true }: { syncState?: boolean } = { syncState: true } ): Promise<AwaitPromise<ReturnType<GenericWidget["getRenderData"]>>> { const renderData = await super.getRenderData(linkDataOverride); const statesCache: Record<string, unknown> = {}; if (syncState) { const cachedFormState = this.getFormStateCache(); // restore when form cache is present if (!isNull(cachedFormState)) { statesCache.quizState = cachedFormState; } // retrieve cached state of the timer // and pass in the callback data for syncing of state with cache const cachedTimerState = this.getTimerStateCache(); if (!isNull(cachedTimerState)) { statesCache.timerState = cachedTimerState; } } return { ...renderData, ...statesCache, }; } async onInitialize(transaction: MessageTransaction): Promise<void> { const { frequencyHelper, widgetId, frameHelper, openIfSubmitted } = this; // If we have already submitted the widget, don't show again const submissionResponse = safeParseJSON( window.localStorage.getItem(getFormSubmissionKey(widgetId)), null ); // if null, submitted = false // if boolean, submitted = value of boolean => this is a migration step, previously we were storing booleans // if object, submitted = true this.isSubmitted = !!submissionResponse; if (!openIfSubmitted && this.isSubmitted) { frameHelper.removeOverlay(); this.setState(WidgetState.COMPLETED, CompleteReason.FORM_SUBMITTED); // return undefined so that the widget remains invisible console.log("form already submitted"); return this.completeInitTransaction(transaction); } // If we have not shown the widget in the last 7 days if (!frequencyHelper.shouldShow("closed")) { this.setState(WidgetState.WAITING, WaitReason.FREQUENCY); // return undefined so that the widget remains invisible return this.completeInitTransaction(transaction); } frequencyHelper.opened(); await super.onInitialize(transaction); return undefined; } async onFormSubmit( transaction: MessageTransaction, { finalResponse: formSubmissionResponse, }: { finalResponse: FormSubmissionResponse } ) { // mark as submitted this.isSubmitted = true; const { includeSubmissionDetails } = this; const updatedLinkData = await this.getLinkData({ formSubmissionResponse, includeSubmissionDetails, }); const renderData = await this.getRenderData(updatedLinkData, { syncState: false, // No need to restore the state as its already in sync }); // const renderData = await this.getRenderData(updatedLinkData); this.updateRenderData(renderData, { transaction, alwaysComplete: true, }); this.setState(WidgetState.COMPLETED); } // gets the timer config associated with the quiz static getTimerConfig(interval: number): { timer: { startTime: number; endTime: number }; } { const now = new Date(); // when given a start time and end time, timer calculates the distance between the current time and the end time if time is NOT in the future // if time is in the future, the distance is calculated between the start time and the end time // so if given the time relative to current time, milli second difference will occur causing a second difference to show up in UI // So, To prevent the timer from running and adjusting the countdown in preview // an year from now is considered as the start time and then the interval is added to it // this way, the future time will be compared against the start time and not the current time // so it will not even make a difference in milli second values const year = 1000 * 60 * 60 * 24 * 365; const startTime = new Date(now.getTime() + year); const endTime = new Date(startTime.getTime() + interval); return { timer: { startTime: startTime.getTime(), endTime: endTime.getTime(), }, }; } // called with the current context state of quiz // caches the state to localstorage and fires cf events conditionally onQuizStateUpdate(_: MessageTransaction, state: Record<string, unknown>) { // current index of the field item const currentIndex = get<ReturnType<typeof this.getFormStateCache>, number>( state, "context.currentIndex" ); // current focussed item's field id const currentFieldId = get< ReturnType<typeof this.getFormStateCache>, string >(state, `context.registeredFields.${currentIndex}`); // index of the dynamic item in the list of dynamic items const currentDynamicIndex = get< ReturnType<typeof this.getFormStateCache>, number >(state, `context.meta.${currentFieldId}.dynamicIndex`, -1); // get previous cached state const previousStateCache = this.getFormStateCache(); // total number of dynamic fields const dynamicFieldsCount = get< ReturnType<typeof this.getFormStateCache>, number >(state, `context.meta.${currentFieldId}.dynamicFieldsCount`, -1); // current index of the field item const previousIndex = get< ReturnType<typeof this.getFormStateCache>, number >(previousStateCache, "context.currentIndex", -1); // previous focussed item's field id const previousFieldId = get< ReturnType<typeof this.getFormStateCache>, string | undefined >( previousStateCache, `context.registeredFields.${previousIndex}`, undefined ); // index of the dynamic item in the list of dynamic items const previousDynamicIndex = get< ReturnType<typeof this.getFormStateCache>, number >(previousStateCache, `context.meta.${previousFieldId}.dynamicIndex`, -1); // // when first dynamic entry is seen, fire the start event for quiz // // Note: if previously already on the first dynamic entry, ignore the action // if ( // this.fireEvents.start && // currentDynamicIndex === 0 && // previousDynamicIndex === -1 // ) { // CFEvent.quizStart(this.getEventData()); // } // // if navigated away from the last dynamic item, it means quiz is completed // // then fire the end event for quiz // if ( // this.fireEvents.end && // dynamicFieldsCount > 0 && // // when -1, it means navigated away from a dynamic item // currentDynamicIndex === -1 && // // and was previously on a dynamic item // previousDynamicIndex !== -1 // ) { // CFEvent.quizEnd(this.getEventData()); // } window.localStorage.setItem( this.stateCacheKey, JSON.stringify({ deviceId: getDeviceID(), state, }) ); } // returns the cached state for the quiz/form getFormStateCache(): Record<string, unknown> | null { const cachedFormState = safeParseJSON<{ deviceId: string; state: { // }; } | null>(window.localStorage.getItem(this.stateCacheKey), null); if (!isNull(cachedFormState)) { const { deviceId: cachedDeviceId, state } = cachedFormState; if (getDeviceID() === cachedDeviceId) { return state; } } return null; } // returns the cached state(calibrated) for the quiz timer getTimerStateCache() { const cachedTimerState = safeParseJSON<{ deviceId: string; state: { elapsed: number; distance: number; remaining: number; startTime: number; endTime: number; currentTime: number; }; } | null>(window.localStorage.getItem(this.timerStateCacheKey), null); if (!isNull(cachedTimerState)) { const { deviceId: cachedDeviceId, state } = cachedTimerState; if (getDeviceID() === cachedDeviceId) { const duration = get( this.widgetConfig, "settings.timer.duration", 0 ) as number; // make start time relative to the elapsed time // so that the timer resumes as if it was from the past const now = new Date().setMilliseconds(0); const startTime = now - state.elapsed; const endTime = new Date(startTime + duration).setMilliseconds(0); return { ...state, // stop the timer by default as view event determines whether to run the timer or not state: "stopped", // recalibrate the time fields in the state currentTime: now, startTime, endTime, }; } } return null; } onTimerStateUpdate(_: MessageTransaction, params: Record<string, unknown>) { window.localStorage.setItem( this.timerStateCacheKey, JSON.stringify({ deviceId: getDeviceID(), state: params, }) ); } listenChannel(): void { super.listenChannel(); const { channel } = this; channel?.bind(ChannelMethods.FORM_SUBMIT, this.onFormSubmit.bind(this)); channel?.bind( ChannelMethods.QUIZ_STATE_UPDATE, this.onQuizStateUpdate.bind(this) ); channel?.bind( ChannelMethods.TIMER_STATE_UPDATE, this.onTimerStateUpdate.bind(this) ); } }