- {boardingCompletion < 75 && !hideDiscover && (
+ {/* {boardingCompletion < 75 && !hideDiscover && (
diff --git a/frontend/app/components/shared/GettingStarted/CircleProgress.tsx b/frontend/app/components/shared/GettingStarted/CircleProgress.tsx
new file mode 100644
index 000000000..9504dfbc2
--- /dev/null
+++ b/frontend/app/components/shared/GettingStarted/CircleProgress.tsx
@@ -0,0 +1,72 @@
+import React, { useEffect, useState } from 'react';
+
+interface Props {
+ percentage: number;
+ radius?: number;
+ progressColor?: string;
+ bgColor?: string;
+ label?: string;
+}
+const CircleProgress = ({
+ percentage = 0,
+ radius = 18,
+ progressColor = '#394eff',
+ bgColor = '#9fa8da',
+ label = '',
+}: Props) => {
+ const [offset, setOffset] = useState(0);
+
+ useEffect(() => {
+ const progress = percentage / 100;
+ const _radius = radius * 0.8;
+ const circumference = 2 * Math.PI * _radius;
+ const offsetValue = circumference * (1 - progress);
+
+ setOffset(offsetValue);
+ }, [percentage, radius]);
+
+ const strokeWidth = radius * 0.3;
+ const _radius = radius * 0.8;
+
+ const circumference = 2 * Math.PI * _radius;
+ const dashOffset = circumference * (1 - (percentage / 100));
+ const circleStyle = {
+ transition: 'stroke-dashoffset 1s ease-in-out',
+ };
+
+ return (
+
+ );
+};
+
+export default CircleProgress;
diff --git a/frontend/app/components/shared/GettingStarted/GettingStarted.stories.tsx b/frontend/app/components/shared/GettingStarted/GettingStarted.stories.tsx
new file mode 100644
index 000000000..1892b3893
--- /dev/null
+++ b/frontend/app/components/shared/GettingStarted/GettingStarted.stories.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { Story, Meta } from '@storybook/react';
+
+import GettingStartedModal, { Props } from './GettingStartedModal';
+import { Step } from './StepList';
+
+const list: Step[] = [
+ {
+ title: '🕵️ Install OpenReplay',
+ status: 'pending',
+ description: 'Install OpenReplay on your website or mobile app.',
+ icon: 'tools',
+ },
+ {
+ title: '🕵️ Identify Users',
+ status: 'pending',
+ description: 'Identify users across devices and sessions.',
+ icon: 'users',
+ },
+ {
+ title: '🕵️ Integrations',
+ status: 'completed',
+ description: 'Identify users across devices and sessions.',
+ icon: 'users',
+ },
+ {
+ title: '🕵️ Invite Team Members',
+ status: 'ignored',
+ description: 'Identify users across devices and sessions.',
+ icon: 'users',
+ },
+];
+
+export default {
+ title: 'GettingStarted',
+ component: GettingStartedModal,
+} as Meta;
+
+const Template: Story
= (args) => ;
+
+export const Default = Template.bind({});
+Default.args = {
+ list,
+};
diff --git a/frontend/app/components/shared/GettingStarted/GettingStartedModal.tsx b/frontend/app/components/shared/GettingStarted/GettingStartedModal.tsx
new file mode 100644
index 000000000..dd5926b88
--- /dev/null
+++ b/frontend/app/components/shared/GettingStarted/GettingStartedModal.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import StepList, { Step } from './StepList';
+import Modal from 'App/components/Modal/Modal';
+import CircleProgress from './CircleProgress';
+import GettingStartedProgress from './GettingStartedProgress';
+import { observer } from 'mobx-react-lite';
+
+export interface Props {
+ list: Step[];
+}
+
+function GettingStartedModal(props: Props) {
+ const { list } = props;
+ const pendingSteps = list.filter((step) => step.status === 'pending');
+ const completedSteps = list.filter(
+ (step) => step.status === 'completed' || step.status === 'ignored'
+ );
+
+ return (
+ <>
+
+
+
Setup Openreplay
+
Find all the ways in which OpenReplay can benefit you and your product.
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default observer(GettingStartedModal);
diff --git a/frontend/app/components/shared/GettingStarted/GettingStartedProgress.tsx b/frontend/app/components/shared/GettingStarted/GettingStartedProgress.tsx
new file mode 100644
index 000000000..6ebbb5412
--- /dev/null
+++ b/frontend/app/components/shared/GettingStarted/GettingStartedProgress.tsx
@@ -0,0 +1,40 @@
+import React, { useEffect } from 'react';
+import CircleProgress from './CircleProgress';
+import { useModal } from 'App/components/Modal';
+import GettingStartedModal from './GettingStartedModal';
+import { useStore } from 'App/mstore';
+import { observer } from 'mobx-react-lite';
+
+const GettingStartedProgress: React.FC = () => {
+ const { showModal } = useModal();
+
+ const {
+ settingsStore: { gettingStarted },
+ } = useStore();
+
+ useEffect(() => {
+ gettingStarted.fetchData();
+ }, []);
+
+ const clickHandler = () => {
+ showModal(, { right: true, width: 450 });
+ };
+ return gettingStarted.status === 'completed' ? null : (
+
+
+
+
+
+ Setup
+
+
{gettingStarted.numPending} Pending
+
+
+
+ );
+};
+
+export default observer(GettingStartedProgress);
diff --git a/frontend/app/components/shared/GettingStarted/StepList.tsx b/frontend/app/components/shared/GettingStarted/StepList.tsx
new file mode 100644
index 000000000..442f112f2
--- /dev/null
+++ b/frontend/app/components/shared/GettingStarted/StepList.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { Icon } from 'UI';
+import cn from 'classnames';
+import { Step } from 'App/mstore/types/gettingStarted';
+import { useStore } from 'App/mstore';
+import { onboarding as onboardingRoute, withSiteId } from 'App/routes';
+import { RouteComponentProps, withRouter } from 'react-router';
+import { connect } from 'react-redux';
+import { useModal } from 'App/components/Modal';
+
+interface StepListProps extends RouteComponentProps {
+ title: string;
+ steps: Step[];
+ status: 'pending' | 'completed';
+ docsLink?: string;
+ siteId: string;
+}
+
+const StepItem = React.memo(
+ ({
+ step,
+ onClick,
+ onIgnore,
+ }: {
+ step: Step;
+ onIgnore: (e: React.MouseEvent, step: any) => void;
+ onClick: () => void;
+ }) => {
+ const { title, description, status, docsLink } = step;
+ const isCompleted = status === 'completed';
+
+ return (
+
+
+
+
+
+
{}}>{title}
+
{description}
+
+
+
+ );
+ }
+);
+
+const StepList = React.memo((props: StepListProps) => {
+ const { title, steps, status } = props;
+ const { hideModal } = useModal();
+
+ const {
+ settingsStore: { gettingStarted },
+ } = useStore();
+
+ const onIgnore = (e: React.MouseEvent, step: any) => {
+ e.preventDefault();
+ gettingStarted.completeStep(step);
+ };
+
+ if (steps.length === 0) {
+ return null;
+ }
+
+ const onClick = (step: any) => {
+ const { siteId, history } = props;
+ console.log('step', withSiteId(onboardingRoute(step.url), siteId));
+ hideModal();
+ history.push(withSiteId(onboardingRoute(step.url), siteId));
+ };
+
+ return (
+
+
+ {title} {steps.length}
+
+ {steps.map((step) => (
+
onClick(step)}/>
+ ))}
+
+ );
+});
+
+export default connect((state: any) => ({
+ siteId: state.getIn(['site', 'siteId']),
+}))(withRouter(StepList));
diff --git a/frontend/app/components/shared/GettingStarted/index.ts b/frontend/app/components/shared/GettingStarted/index.ts
new file mode 100644
index 000000000..449316b96
--- /dev/null
+++ b/frontend/app/components/shared/GettingStarted/index.ts
@@ -0,0 +1 @@
+export { default } from './GettingStartedModal';
\ No newline at end of file
diff --git a/frontend/app/constants/storageKeys.ts b/frontend/app/constants/storageKeys.ts
index e06625fb6..70a930618 100644
--- a/frontend/app/constants/storageKeys.ts
+++ b/frontend/app/constants/storageKeys.ts
@@ -4,4 +4,5 @@ export const DURATION_FILTER = "__$session-durationFilter$__"
export const SESSION_FILTER = "__$session-filter$__"
export const GLOBAL_DESTINATION_PATH = "__$global-destinationPath$__"
export const GLOBAL_HAS_NO_RECORDINGS = "__$global-hasNoRecordings$__"
-export const SITE_ID_STORAGE_KEY = "__$user-siteId$__"
\ No newline at end of file
+export const SITE_ID_STORAGE_KEY = "__$user-siteId$__"
+export const GETTING_STARTED = "__$user-gettingStarted$__"
\ No newline at end of file
diff --git a/frontend/app/mstore/settingsStore.ts b/frontend/app/mstore/settingsStore.ts
index c31071694..641167dd5 100644
--- a/frontend/app/mstore/settingsStore.ts
+++ b/frontend/app/mstore/settingsStore.ts
@@ -1,98 +1,102 @@
-import { makeAutoObservable, observable } from "mobx"
-import SessionSettings from "./types/sessionSettings"
-import { sessionService } from "App/services"
+import { makeAutoObservable, observable } from 'mobx';
+import SessionSettings from './types/sessionSettings';
+import { sessionService } from 'App/services';
import { toast } from 'react-toastify';
import Webhook, { IWebhook } from 'Types/webhook';
-import {
- webhookService
-} from 'App/services';
+import { webhookService } from 'App/services';
+import { GettingStarted } from './types/gettingStarted';
export default class SettingsStore {
loadingCaptureRate: boolean = false;
- sessionSettings: SessionSettings = new SessionSettings()
+ sessionSettings: SessionSettings = new SessionSettings();
captureRateFetched: boolean = false;
limits: any = null;
-
- webhooks: Webhook[] = []
- webhookInst = new Webhook()
-
- hooksLoading = false
+ webhooks: Webhook[] = [];
+ webhookInst = new Webhook();
+ hooksLoading = false;
+ gettingStarted: GettingStarted = new GettingStarted();
constructor() {
makeAutoObservable(this, {
sessionSettings: observable,
- })
+ });
}
saveCaptureRate(data: any) {
- return sessionService.saveCaptureRate(data)
- .then(data => data.json())
+ return sessionService
+ .saveCaptureRate(data)
+ .then((data) => data.json())
.then(({ data }) => {
this.sessionSettings.merge({
captureRate: data.rate,
- captureAll: data.captureAll
- })
- toast.success("Settings updated successfully");
- }).catch(err => {
- toast.error("Error saving capture rate");
+ captureAll: data.captureAll,
+ });
+ toast.success('Settings updated successfully');
})
+ .catch((err) => {
+ toast.error('Error saving capture rate');
+ });
}
fetchCaptureRate(): Promise {
this.loadingCaptureRate = true;
- return sessionService.fetchCaptureRate()
- .then(data => {
+ return sessionService
+ .fetchCaptureRate()
+ .then((data) => {
this.sessionSettings.merge({
captureRate: data.rate,
- captureAll: data.captureAll
- })
+ captureAll: data.captureAll,
+ });
this.captureRateFetched = true;
- }).finally(() => {
- this.loadingCaptureRate = false;
})
+ .finally(() => {
+ this.loadingCaptureRate = false;
+ });
}
fetchWebhooks = () => {
- this.hooksLoading = true
- return webhookService.fetchList()
- .then(data => {
- this.webhooks = data.map(hook => new Webhook(hook))
- this.hooksLoading = false
- })
- }
+ this.hooksLoading = true;
+ return webhookService.fetchList().then((data) => {
+ this.webhooks = data.map((hook) => new Webhook(hook));
+ this.hooksLoading = false;
+ });
+ };
initWebhook = (inst?: Partial | Webhook) => {
- this.webhookInst = inst instanceof Webhook ? inst : new Webhook(inst)
- }
+ this.webhookInst = inst instanceof Webhook ? inst : new Webhook(inst);
+ };
saveWebhook = (inst: Webhook) => {
- this.hooksLoading = true
- return webhookService.saveWebhook(inst)
- .then(data => {
- this.webhookInst = new Webhook(data)
- if (inst.webhookId === undefined) this.setWebhooks([...this.webhooks, this.webhookInst])
- else this.setWebhooks([...this.webhooks.filter(hook => hook.webhookId !== data.webhookId), this.webhookInst])
-
- })
- .finally(() => {
- this.hooksLoading = false
+ this.hooksLoading = true;
+ return webhookService
+ .saveWebhook(inst)
+ .then((data) => {
+ this.webhookInst = new Webhook(data);
+ if (inst.webhookId === undefined) this.setWebhooks([...this.webhooks, this.webhookInst]);
+ else
+ this.setWebhooks([
+ ...this.webhooks.filter((hook) => hook.webhookId !== data.webhookId),
+ this.webhookInst,
+ ]);
})
- }
+ .finally(() => {
+ this.hooksLoading = false;
+ });
+ };
setWebhooks = (webhooks: Webhook[]) => {
- this.webhooks = webhooks
- }
+ this.webhooks = webhooks;
+ };
removeWebhook = (hookId: string) => {
- this.hooksLoading = true
- return webhookService.removeWebhook(hookId)
- .then(() => {
- this.webhooks = this.webhooks.filter(hook => hook.webhookId!== hookId)
- this.hooksLoading = false
- })
- }
+ this.hooksLoading = true;
+ return webhookService.removeWebhook(hookId).then(() => {
+ this.webhooks = this.webhooks.filter((hook) => hook.webhookId !== hookId);
+ this.hooksLoading = false;
+ });
+ };
editWebhook = (diff: Partial) => {
- Object.assign(this.webhookInst, diff)
- }
+ Object.assign(this.webhookInst, diff);
+ };
}
diff --git a/frontend/app/mstore/types/gettingStarted.ts b/frontend/app/mstore/types/gettingStarted.ts
new file mode 100644
index 000000000..ddff89d61
--- /dev/null
+++ b/frontend/app/mstore/types/gettingStarted.ts
@@ -0,0 +1,141 @@
+import { action, computed, makeObservable, observable } from 'mobx';
+import { configService } from 'App/services';
+import { GETTING_STARTED } from 'App/constants/storageKeys';
+
+const stepsMap: any = {
+ 'Install OpenReplay': {
+ title: '🛠️ Install OpenReplay',
+ status: 'pending',
+ description: 'Install via script or NPM package',
+ docsLink: 'https://docs.openreplay.com/en/sdk/constructor/',
+ url: 'installing',
+ },
+ 'Identify Users': {
+ title: '🕵️ Identify Users',
+ status: 'pending',
+ description: 'Filter sessions by user ID.',
+ docsLink: 'https://docs.openreplay.com/en/v1.10.0/installation/identify-user/',
+ url: 'identify-users',
+ },
+ 'Invite Team Members': {
+ title: '🧑💻 Invite Team Members',
+ status: 'pending',
+ description: 'Invite team members, collaborate and start improving your app now.',
+ docsLink: 'https://docs.openreplay.com/en/tutorials/adding-users/',
+ url: 'team',
+ },
+ Integrations: {
+ title: '🔌 Integrations',
+ status: 'pending',
+ description: 'Sync your backend errors with sessions replays.',
+ docsLink: 'https://docs.openreplay.com/en/integrations/',
+ url: 'integrations',
+ },
+};
+export interface Step {
+ title: string;
+ status: 'pending' | 'ignored' | 'completed';
+ description: string;
+ url: string;
+ docsLink: string;
+}
+
+export class GettingStarted {
+ steps: Step[] = [];
+ status: 'in-progress' | 'completed';
+
+ constructor() {
+ makeObservable(this, {
+ steps: observable,
+ completeStep: action,
+ status: observable,
+ fetchData: action,
+ numCompleted: computed,
+ numPending: computed,
+ percentageCompleted: computed,
+ label: computed,
+ numPendingSteps: computed,
+ });
+
+ // steps = {'tenatId': {steps: [], status: 'in-progress'}
+ const gettingStartedSteps = localStorage.getItem(GETTING_STARTED);
+ if (gettingStartedSteps) {
+ const steps = JSON.parse(gettingStartedSteps);
+ this.steps = steps.steps;
+ this.status = steps.status;
+ }
+ }
+
+ fetchData() {
+ if (this.status === 'completed') {
+ return;
+ }
+ configService.fetchGettingStarted().then((data) => {
+ this.steps = data.map((item: any) => {
+ const step = stepsMap[item.task];
+
+ return {
+ ...step,
+ status: item.done ? 'completed' : 'pending',
+ };
+ });
+ this.status = this.calculateStatus();
+ this.updateLocalStorage();
+ });
+ }
+
+ updateLocalStorage() {
+ localStorage.setItem(
+ GETTING_STARTED,
+ JSON.stringify({
+ steps: this.steps.map((item: any) => ({
+ title: item.title,
+ status: item.status,
+ })),
+ status: this.status,
+ })
+ );
+ }
+
+ calculateStatus() {
+ const numCompleted = this.numCompleted;
+ const numPending = this.numPending;
+ const numIgnored = this.steps.length - numCompleted - numPending;
+
+ if (numIgnored > 0) {
+ return 'in-progress';
+ } else {
+ return numPending > 0 ? 'in-progress' : 'completed';
+ }
+ }
+
+ completeStep(step: Step) {
+ step.status = 'completed';
+ this.status = this.calculateStatus();
+ this.updateLocalStorage();
+ }
+
+ get numCompleted() {
+ return this.steps.filter((step) => step.status === 'completed').length;
+ }
+
+ get numPending() {
+ return this.steps.filter((step) => step.status === 'pending').length;
+ }
+
+ get percentageCompleted() {
+ const completed = this.numCompleted;
+ const total = this.steps.length;
+ return Math.round((completed / total) * 100);
+ }
+
+ get label() {
+ const completed = this.numCompleted;
+ const total = this.steps.length;
+ return `${completed}/${total}`;
+ }
+
+ get numPendingSteps() {
+ return this.steps.filter((step) => step.status === 'pending').length;
+ }
+}
diff --git a/frontend/app/services/ConfigService.ts b/frontend/app/services/ConfigService.ts
index 6afda4858..676987e49 100644
--- a/frontend/app/services/ConfigService.ts
+++ b/frontend/app/services/ConfigService.ts
@@ -14,4 +14,9 @@ export default class ConfigService extends BaseService {
return this.client.post('/config/weekly_report', config)
.then(r => r.json()).then(j => j.data)
}
+
+ async fetchGettingStarted(): Promise {
+ return this.client.get('/boarding')
+ .then(r => r.json()).then(j => j.data)
+ }
}
\ No newline at end of file