Source: Analytics.ts

import { BoundingBox } from "./FeatureTemplate";

function getCurrentUser() {
    const clientAgent: Record<string, string> = {};
    clientAgent["uuid"] = localStorage["client_agent_uuid"];
    return clientAgent;
}

function getMetadata() {
    const clientAgent: Record<string, string> = {};
    const startIndex = navigator.userAgent.search("\\(");
    const endIndex = navigator.userAgent.search("\\)");
    clientAgent["device"] = navigator.userAgent.substring(
        startIndex + 1,
        endIndex,
    );
    clientAgent["platform"] = navigator.userAgent.substring(endIndex + 2);
    return clientAgent;
}

function getClientAgent() {
    return "client_agent_uuid" in localStorage
        ? getCurrentUser()
        : getMetadata();
}

function setCurrentUser(uuid: string) {
    localStorage["client_agent_uuid"] = uuid;
}

type LoadData = {
    type: "ENTER";
    timestamp: number;
    payload: {
        country: string;
        client_agent: ReturnType<typeof getClientAgent>;
        timezone_offset_mins: number;
    };
};
type UnLoadData = {
    type: "CLEAN_EXIT";
    timestamp: number;
    payload: {
        session_uuid: string;
    };
};

export type EffectDataPayload = {
    session_uuid: string;
    shade_color?: number;
    session_product_interaction?: number;
    // --- from effect
    application_area_name: string;
    rendering_hex: string;
    finish_name: string;
    color_opacity: number;
    finish_opacity: number;
    bounding_box: BoundingBox;
    effect: number;
};

export type EffectData = {
    type: "EFFECT_SIMULATED" | "EFFECT_DISABLED";
    timestamp: number;
    payload: EffectDataPayload;
};

export type ProductData = {
    type: "PRODUCT_SIMULATED" | "PRODUCT_DISABLED";
    timestamp: number;
    payload: {
        session_uuid: string;
        product: number;
    };
};

async function sendAnalyticsData(
    data: EffectData | ProductData | LoadData | UnLoadData,
    cmsURL: string,
    accessToken: string,
) {
    try {
        const url = `${cmsURL}/analytics?access_token=${accessToken}`;

        const response = await fetch(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify(data),
            keepalive: true,
        });

        if (response.status === 404) {
            throw new Error("Resource not found (404)");
        } else if (!response.ok) {
            throw new Error(`An error occurred: ${response.status}`);
        }

        return await response.json();
    } catch (error) {
        return null;
    }
}

export class SdkAnalytics {
    cmsURL: string;
    accessToken: string;
    sessionUuid: string;

    private constructor(
        cmsURL: string,
        accessToken: string,
        sessionUuid: string,
    ) {
        this.cmsURL = cmsURL;
        this.accessToken = accessToken;
        this.sessionUuid = sessionUuid;
    }

    /**
     * This is part of "async constructor" function create.
     * It is to request sessionId when analytics object is initialized.
     * Extracted only so that function is more managable/abstract. Keep them together.
     */
    private static async requestSessionId(
        cmsURL: string,
        accessToken: string,
    ): Promise<string> {
        const clientAgent = getClientAgent();

        const data: LoadData = {
            type: "ENTER",
            timestamp: new Date().getTime(),
            payload: {
                country: Intl.DateTimeFormat().resolvedOptions().timeZone, // TODO (in BEA-4635?),
                client_agent: clientAgent,
                timezone_offset_mins: new Date().getTimezoneOffset(),
            },
        };

        const enterResult = await sendAnalyticsData(data, cmsURL, accessToken);
        const sessionUuid = enterResult?.data?.[0]?.session_uuid;

        if (!("client_agent_uuid" in localStorage)) {
            setCurrentUser(enterResult.data[0].client_agent_uuid);
        }

        return sessionUuid;
    }

    static async create(
        cmsURL: string,
        accessToken: string,
    ): Promise<SdkAnalytics> {
        if (!cmsURL) throw new Error("CMS URL is required");
        if (!accessToken) throw new Error("Access token is required");

        const session_uuid = await this.requestSessionId(cmsURL, accessToken);
        return new SdkAnalytics(cmsURL, accessToken, session_uuid);
    }

    async onUnload() {
        const data: UnLoadData = {
            type: "CLEAN_EXIT",
            timestamp: new Date().getTime(),
            payload: {
                session_uuid: this.sessionUuid,
            },
        };

        await sendAnalyticsData(data, this.cmsURL, this.accessToken);
    }

    async sendEffect(
        type: EffectData["type"],
        data: Omit<EffectData["payload"], "session_uuid">,
    ) {
        sendAnalyticsData(
            {
                type,
                timestamp: new Date().getTime(),
                payload: {
                    ...data,
                    session_uuid: this.sessionUuid,
                },
            },
            this.cmsURL,
            this.accessToken,
        );
    }

    /**
     * @returns interaction_id of the created product interaction, (PS. if it did ?!?! *confusion*)
     */
    async sendProduct(
        type: ProductData["type"],
        productId: number,
    ): Promise<number | undefined> {
        const productSimulatedResult = await sendAnalyticsData(
            {
                type,
                timestamp: new Date().getTime(),
                payload: {
                    product: productId,
                    session_uuid: this.sessionUuid,
                },
            },
            this.cmsURL,
            this.accessToken,
        );

        const sessionProductInteractionId =
            productSimulatedResult?.data[0]?.interaction_id ?? undefined;

        // For some reason unbeknownst to me, it is expected to fail (undefined is expected result) and continue execution as everything is normal.
        // If it was expected to behave that way there would be no need for this warning, and if that didn't fit somewhere else, it should be handled there, it's own semantics clear.
        // Alas, leaving the warning here...
        if (!sessionProductInteractionId) {
            console.warn(
                "sessionProductInteractionId returned by post request is undefined",
            );
        }

        return sessionProductInteractionId;
    }

    /**
     * UNSAFE: effectsWithoutInteraction is mutated.
     *
     * Convenience function for operations that go in series.
     *
     * https://visagetechnologies.atlassian.net/jira/software/c/projects/BEA/boards/38?selectedIssue=BEA-6931
     * It doesn't solve second AC point
     *
     * @param productData product id for interaction payload data. If it's undefined, it will be skipped.
     * @param effectsWithoutInteraction You can pass anything in `payload.session_product_interaction`, the value will be assigned internally if productData is passed in.
     */
    async sendProductAndEffects(
        productData: {
            type: ProductData["type"];
            productId: number;
        } | null,
        effectsDataWithoutInteraction: {
            type: EffectData["type"];
            payload: Omit<EffectData["payload"], "session_uuid">;
        }[],
    ) {
        if (productData) {
            const interactionId = await this.sendProduct(
                productData.type,
                productData.productId,
            );

            for (const eff of effectsDataWithoutInteraction) {
                eff.payload.session_product_interaction = interactionId;
            }
        }

        const effectSends = effectsDataWithoutInteraction.map((eff) =>
            this.sendEffect(eff.type, eff.payload),
        );

        await Promise.all(effectSends);
    }
}