Untitled

mail@pastecode.io avatar
unknown
plain_text
a year ago
17 kB
1
Indexable
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)
    );
  }
}