@highloop/feedback-internal
Version:
390 lines (325 loc) • 9.79 kB
text/typescript
import mitt from 'mitt';
// @ts-ignore
import App from './widget.svelte';
// @ts-ignore
import ScreenshotLightbox from './screenshotLightbox.svelte';
import type { IData, ITheme } from './interfaces/data';
import { sdk } from './lib/sdk';
import type { IText } from './interfaces/text';
import { defaultText } from './config/text';
import { logError } from './lib/logError';
import { takeScreenshot } from './lib/takeScreenshot';
const FETCHED = 'fetched';
const CLOSE = 'close';
const SUBMIT = 'submit';
const RESET = 'reset';
const RENDERED = 'rendered';
const ERROR = 'error';
export let events = {
FETCHED,
CLOSE,
SUBMIT,
RESET,
RENDERED,
ERROR
};
export class HighloopFeedbackCore {
public id: string;
public root: HTMLElement;
public emitter: ReturnType<typeof mitt>;
public isReady = false;
public data?: IData;
private demo?: IData;
private text: IText;
private themeOverride?: Partial<ITheme>;
private meta?: any;
private closable = false;
private apiEndpoint: string;
private error?: { message: string };
private app?: InstanceType<typeof App>;
private updateToken?: string;
private wrapper?: HTMLDivElement;
private expanded?: boolean;
private hideHeader?: boolean;
private screenshotData?: string;
private disableScreenshot?: boolean;
private disablePath?: boolean;
private screenshotLightbox?: InstanceType<typeof ScreenshotLightbox>;
private screenshotLightboxWrapper?: HTMLElement;
constructor(
id: string,
{
root,
meta,
apiEndpoint,
theme,
demo,
text,
closable,
expanded,
hideHeader,
disablePath,
disableScreenshot
}: {
apiEndpoint?: string;
theme?: Partial<ITheme>;
text?: IText;
demo?: IData;
root: HTMLElement;
meta?: any;
closable?: boolean;
expanded?: boolean;
hideHeader?: boolean;
disableScreenshot?: boolean;
disablePath?: boolean;
}
) {
this.id = id;
this.root = root;
this.meta = meta;
this.apiEndpoint = apiEndpoint;
this.themeOverride = theme;
this.text = text;
this.demo = demo;
this.closable = closable;
this.expanded = expanded;
this.hideHeader = hideHeader;
this.disablePath = disablePath;
this.disableScreenshot = disableScreenshot;
this.emitter = mitt();
this.fetch();
}
get isMounted() {
return !!this.app;
}
private fire(type: string, value?: any) {
this.emitter.emit(type, value);
}
public on<T>(type: string, handler: (d: T) => unknown) {
this.emitter.on(type, handler);
return () => this.emitter.off(type, handler);
}
public once<T>(type: string, handler: (d: T) => unknown) {
let realHandler = (d: T) => {
this.emitter.off(type, handler);
handler(d);
};
this.emitter.on(type, realHandler);
}
private fetch() {
if (this.demo) {
this.data = this.demo;
this.isReady = true;
this.fire(FETCHED, { demo: true });
} else {
if (this.data) return this.fire(FETCHED, { data: this.data });
sdk.getWidget(this.id, { api: this.apiEndpoint }).then(({ data, error }) => {
if (error) {
logError(error);
this.fire(ERROR, error);
}
this.data = data;
this.error = error;
this.isReady = true;
this.fire(FETCHED, { data, error });
});
}
}
setMeta(meta: any) {
this.meta = meta;
}
private ensureWrapper() {
if (this.wrapper) return this.wrapper;
let wrapper = document.createElement('div');
wrapper.setAttribute('data-highloop-feedback-id', this.id);
wrapper.classList.add('highloop-feedback-wrapper');
this.root.appendChild(wrapper);
this.wrapper = wrapper;
this.updateTheme();
return wrapper;
}
private takeScreenshot() {
if (!this.app) return;
this.app.$set({
screenshotLoading: true
});
takeScreenshot()
.then(screenshot => {
this.screenshotData = screenshot;
this.app.$set({
screenshotLoading: false,
screenshotData: screenshot
});
})
.catch(() => {
this.app.$set({
screenshotLoading: false,
validationError: 'Could not take screenshot.'
});
console.warn('[highloop feedback] failed to take screenshot');
});
}
private closeScreenshotLightbox() {
if (this.screenshotLightbox) {
this.screenshotLightbox.$destroy();
this.screenshotLightbox = undefined;
}
if (this.screenshotLightboxWrapper) {
this.screenshotLightboxWrapper.parentElement.removeChild(this.screenshotLightboxWrapper);
}
}
private openScreenshotLightbox() {
if (!this.screenshotData) return;
let wrapper = document.createElement('div');
wrapper.classList.add('highloop_feedback_screenshot_preview');
this.addThemeProperties(wrapper);
this.screenshotLightbox = new ScreenshotLightbox({
target: wrapper,
props: {
screenshot: this.screenshotData,
onClose: () => this.closeScreenshotLightbox()
}
});
document.body.appendChild(wrapper);
this.screenshotLightboxWrapper = wrapper;
}
render() {
if (this.app) return this.update();
let wrapper = this.ensureWrapper();
this.app = new App({
target: wrapper,
props: {
loading: false,
done: false,
text: this.text || defaultText,
widget: this.data?.widget,
flags: {
poweredBy: this.data?.flags.poweredBy,
screenshot: this.data?.flags.screenshot
},
disableScreenshot: this.disableScreenshot,
error: this.error?.message,
closable: !!this.closable,
expanded: !!this.expanded,
hideHeader: !!this.hideHeader,
onClose: () => this.fire(CLOSE),
onSubmit: (data: { stars?: number; optionId?: string; text?: string }) => {
this.submitResult(data);
},
onScreenshot: () => {
this.takeScreenshot();
},
onShowScreenshot: () => {
this.openScreenshotLightbox();
},
onClearScreenshot: () => {
this.screenshotData = undefined;
console.warn('[highloop feedback] cleared screenshot');
this.app.$set({
screenshotLoading: false,
screenshotData: undefined
});
},
onRatingOrStarsSelected: (data: {
stars?: number;
optionId?: string;
text?: string;
}) => {
if (this.data.flags.autoSubmit) this.saveInternal(data);
}
}
});
this.fire(RENDERED);
if (!this.isReady) this.once(FETCHED, () => this.update());
}
update() {
if (!this.app) return;
this.updateTheme();
this.fire(RENDERED);
this.app.$set({
text: this.text || defaultText,
widget: this.data?.widget,
flags: this.data?.flags,
error: this.error?.message,
closable: !!this.closable,
expanded: !!this.expanded,
hideHeader: !!this.hideHeader
});
}
private async saveInternal(
payload:
| Parameters<typeof sdk.updateSubmission>[1]
| Parameters<typeof sdk.createSubmission>[1]
) {
if (this.demo) return Promise.resolve();
if (this.updateToken) {
let res = await sdk.updateSubmission(
this.id,
{ updateToken: this.updateToken, ...payload },
{ api: this.apiEndpoint }
);
if (res.error) logError(res.error);
return res;
} else {
let res = await sdk.createSubmission(
this.id,
{
...payload,
meta: JSON.stringify(this.meta),
path: this.disablePath ? undefined : window.location.pathname.substr(0, 255)
},
{ api: this.apiEndpoint }
);
if (res.error) logError(res.error);
this.updateToken = res.data?.updateToken;
return res;
}
}
private submitResult(data: { stars?: number; optionId?: string; text?: string }) {
if (!this.app) return;
this.app.$set({ loading: true });
let done = () => {
this.updateToken = undefined;
this.app.$set({ done: true });
this.fire(SUBMIT);
};
let error = () => {
this.updateToken = undefined;
this.app.$set({ error: 'Could not save submission' });
};
this.saveInternal({ ...data, finish: true, screenshot: this.screenshotData }).then(res => {
if (res && res.error) error();
else done();
});
}
reset() {
if (!this.app) return;
this.app.$set({ reset: true });
this.updateToken = undefined;
this.fire(RESET);
setTimeout(() => {
this.app.$set({ done: false, loading: false });
}, 100);
}
destroy() {
if (this.app) this.app.$destroy();
if (this.wrapper) this.root.removeChild(this.wrapper);
this.closeScreenshotLightbox();
this.wrapper = undefined;
this.app = undefined;
}
private addThemeProperties(el: HTMLElement) {
let theme = Object.assign({}, this.data?.theme, this.themeOverride);
for (let key in theme) {
el.style.setProperty(`--loop-${key}`, theme[key]);
}
}
private updateTheme() {
if (!this.wrapper) return;
this.addThemeProperties(this.wrapper);
}
setTheme(theme: ITheme) {
this.themeOverride = theme;
this.updateTheme();
}
}