import { Form, Response } from "@samelogic/models";
import { observe } from "selector-observer";
// import { nanoid } from "nanoid/async";
import { Root, createRoot } from "react-dom/client";
// import { MicrosurveyClient } from "./MicrosurveyClient";
import {
MicrosurveyClient,
MicrosurveyClientProps,
} from "@samelogic/microsurvey-ui";
import { sendCompletedEvent, sendResponseEvent, sendViewedEvent } from "./api";
import tippy from "tippy.js";
// import "tippy.js/dist/tippy.css"; // optional for styling
import "tippy.js/animations/perspective.css"; // optional for styling
export class SurveyRunner {
rootId: string = "";
public container?: HTMLElement;
public emotionRoot?: HTMLElement;
public shadowRoot?: HTMLElement;
sessionId: string = "";
isRunning: boolean = false;
runningElement: Element | null = null;
identity?: string = undefined;
domRoot: Root | null = null;
constructor(
private researchId: string,
private form: Form
) {
// this.rootId = "sl-root-" + nanoid();
this.rootId = "sl-root-" + uniqueId();
this.sessionId = uniqueId();
this.watchElement();
}
private setup() {
this.container = document.createElement("div");
this.container.setAttribute("id", this.rootId);
document.body.appendChild(this.container);
const shadowContainer = this.container.attachShadow({ mode: "open" });
const emotionRoot = document.createElement("div");
const shadowRootElement = document.createElement("div");
shadowContainer.appendChild(emotionRoot);
shadowContainer.appendChild(shadowRootElement);
this.emotionRoot = emotionRoot;
this.shadowRoot = shadowRootElement;
}
private cleanup() {
// only do shutdown if this is our running element
if (this.domRoot) {
this.domRoot.unmount();
this.domRoot = null;
}
this.isRunning = false;
this.runningElement = null;
if (this.container) {
this.container.remove();
}
}
public setIdentity(identity?: string) {
this.identity = identity;
}
private handleElementEvent = (event: Event, anchor: Element) => {
// console.log("event click");
//TODO: event.preventDefault(); and event.preventPropagation(); here when needed to stop default behavior
// if (this.props.preventDefault) {
event.preventDefault();
// }
// prevent multiple microsurveys from being triggered
if (this.isRunning) return;
// trigger microsurvey
this.setup();
// renderForm(this.container!, event.target as HTMLElement, this.form);
const domRoot = createRoot(this.shadowRoot!);
if (!this.form.settings?.anchorEl) {
console.log("samelogic: error, no anchor element specified");
return;
}
// const anchor = document.querySelector(this.form.settings?.anchorEl!);
// console.log("anchor");
// console.log(anchor);
const props: MicrosurveyClientProps = {
form: this.form,
// page?: number,
// open?: boolean;
anchorEl: anchor,
container: null,
styleRoot: this.emotionRoot,
onClosed: () => {
// console.log("form closed!");
// this.isRunning = false;
this.cleanup();
},
onAnswer: (formResponse: Response) => {
// console.log("form answer!");
// console.log(formResponse);
sendResponseEvent(
this.researchId,
this.sessionId,
formResponse,
this.identity
);
},
onSubmit: (formResponse: Response) => {
// console.log("form completed!");
// console.log(formResponse);
sendCompletedEvent(
this.researchId,
this.sessionId,
formResponse,
this.identity
);
},
};
// start showing
this.isRunning = true;
this.runningElement = anchor;
domRoot.render();
this.domRoot = domRoot;
sendViewedEvent(this.researchId, this.sessionId);
};
// https://github.com/samelogic/samelogic.js/blob/master/sdks/samelogic.js/src/workflows/Triggers/TriggerEvent/EventStrategies/PageEventStrategy.ts
private watchPage() {}
// https://github.com/samelogic/samelogic.js/blob/master/sdks/samelogic.js/src/workflows/Triggers/TriggerEvent/EventStrategies/ElementEventStrategy.ts
private activeElements: Set = new Set();
private watchElement() {
const selector = this.form.settings?.anchorEl;
if (!selector) return;
if (this.form.settings?.trigger?.type == "hover") {
const tip = this.form.settings.trigger.hoverToolTip || " ";
observe(selector, {
add: (el) => {
if (isWithinTippy(el) || this.activeElements.has(el)) return;
const iconHtml = `
`;
const tooltipContent = `
${iconHtml}
${tip}
`;
const instance = tippy(el, {
content: tooltipContent,
allowHTML: true,
interactive: true,
hideOnClick: true,
animation: "perspective",
interactiveBorder: 12,
interactiveDebounce: 1000,
onShow: (instance) => {
if (this.activeElements.has(el)) {
instance.disable();
return false;
}
const box = instance.popper.querySelector(
".tippy-box"
) as HTMLElement;
const content = box.querySelector(
".tippy-content"
) as HTMLElement;
// Style the content box for rounded borders and drop shadow
content.style.padding = "10px 20px";
content.style.borderRadius = "200px";
content.style.boxShadow = "0px 4px 8px rgba(0, 0, 0, 0.3)";
content.style.background = "rgba(0, 0, 0, 0.85)";
content.style.cursor = "pointer";
content.style.fontSize = "13px";
content.style.color = "#fff";
content.style.fontWeight = "550";
// Ensure the icon and text are aligned
const icon = content.querySelector("svg");
if (icon) {
icon.style.marginRight = "8px";
}
},
});
instance.popper.addEventListener("click", (evt) => {
if (!this.activeElements.has(el)) {
this.handleElementEvent(evt, el);
this.activeElements.add(el);
instance.hide();
instance.disable();
}
});
},
remove: (el) => {
if (isWithinTippy(el)) return;
if (this.runningElement == el) {
this.cleanup();
}
this.activeElements.delete(el);
},
});
} else if (this.form.settings?.trigger?.type == "click") {
observe(selector, {
add: (el) => {
if (isWithinTippy(el) || this.activeElements.has(el)) return;
el.addEventListener("click", (evt) => {
if (!this.activeElements.has(el)) {
this.handleElementEvent(evt, el);
this.activeElements.add(el);
}
});
},
remove: (el) => {
if (isWithinTippy(el)) return;
if (this.runningElement == el) {
this.cleanup();
}
this.activeElements.delete(el);
},
});
}
}
}
// https://paulius-repsys.medium.com/simplest-possible-way-to-generate-unique-id-in-javascript-a0d7566f3b0c
function uniqueId(): string {
const dateString = Date.now().toString(36);
const randomness = Math.random().toString(36).substr(2);
return dateString + randomness;
}
function isWithinTippy(el: Element) {
// check all the way up top
// if tippy is found, return true
while (el != null) {
if (el.classList.contains("tippy-content")) {
return true;
}
if (el.id.startsWith("tippy")) return true;
if (el instanceof HTMLElement && el.dataset.tippy) return true;
el = el.parentElement!;
}
return false;
}