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)
);
}
}