{
if (suction !== Suction.None) return;
diff --git a/frontend/desktop/src/constants/account.ts b/frontend/desktop/src/constants/account.ts
new file mode 100644
index 000000000..6a1b19680
--- /dev/null
+++ b/frontend/desktop/src/constants/account.ts
@@ -0,0 +1 @@
+export const GUIDE_DESKTOP_INDEX_KEY = 'frontend.guide.desktop.index';
diff --git a/frontend/desktop/src/hooks/useDriver.tsx b/frontend/desktop/src/hooks/useDriver.tsx
new file mode 100644
index 000000000..9b0d5b4c1
--- /dev/null
+++ b/frontend/desktop/src/hooks/useDriver.tsx
@@ -0,0 +1,343 @@
+import { getPriceBonus, getSystemEnv, getUserAccount, updateDesktopGuide } from '@/api/platform';
+import { GUIDE_DESKTOP_INDEX_KEY } from '@/constants/account';
+import { formatMoney } from '@/utils/format';
+import { Box, Button, Flex, FlexProps, Icon, Image, Text } from '@chakra-ui/react';
+import { driver } from '@sealos/driver';
+import { useTranslation } from 'next-i18next';
+import { useEffect, useState } from 'react';
+
+export function DriverStarIcon() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function useDriver({ openDesktopApp }: { openDesktopApp: any }) {
+ const { t, i18n } = useTranslation();
+ const [showGuide, setShowGuide] = useState(false);
+ const [giftAmount, setGiftAmount] = useState(8);
+
+ const handleSkipGuide = () => {
+ console.log('handleSkipGuide');
+ setShowGuide(false);
+ updateDesktopGuide().catch((err) => {
+ console.log(err);
+ });
+ };
+
+ useEffect(() => {
+ const handleUserGuide = async () => {
+ try {
+ const { data: env } = await getSystemEnv();
+ const { data } = await getUserAccount();
+ const bonus = await getPriceBonus();
+ if (bonus.data?.activities) {
+ const strategy = JSON.parse(bonus.data?.activities);
+ const rewardBalance = formatMoney(
+ strategy?.['beginner-guide']?.phases?.launchpad?.giveAmount || 8000000
+ );
+ setGiftAmount(rewardBalance);
+ }
+ if (env?.guideEnabled && data?.metadata?.annotations) {
+ const isGuidedDesktop = !!data.metadata.annotations?.[GUIDE_DESKTOP_INDEX_KEY];
+ !isGuidedDesktop ? setShowGuide(true) : '';
+ }
+ } catch (error) {
+ console.log(error);
+ }
+ };
+ handleUserGuide();
+ }, []);
+
+ const PopoverBodyInfo = (props: FlexProps) => (
+
+
+ {t('Click on any shadow to skip')}
+
+
+
+
+
+ );
+
+ const driverObj = driver({
+ showProgress: false,
+ allowClose: false,
+ allowClickMaskNextStep: true,
+ allowPreviousStep: true,
+ isShowButtons: false,
+ allowKeyboardControl: false,
+ disableActiveInteraction: true,
+ steps: [
+ {
+ element: '.floatButtonNav',
+ popover: {
+ side: 'left',
+ align: 'center',
+ borderRadius: '12px 12px 0px 12px',
+ PopoverBody: (
+
+
+
+ {t('Quick application switching floating ball')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.system-terminal',
+ popover: {
+ side: 'bottom',
+ align: 'start',
+ borderRadius: '0px 12px 12px 12px',
+ PopoverBody: (
+
+
+
+ {t('You can use the kubectl command directly from the terminal')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.system-dbprovider',
+ popover: {
+ side: 'bottom',
+ align: 'start',
+ borderRadius: '0px 12px 12px 12px',
+ PopoverBody: (
+
+
+
+ {t('Help you enable high availability database')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.system-template',
+ popover: {
+ side: 'left',
+ align: 'center',
+ borderRadius: '12px 12px 0px 12px',
+ PopoverBody: (
+
+
+
+ {t('Launch various third-party applications with one click')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.system-costcenter',
+ popover: {
+ side: 'top',
+ align: 'start',
+ borderRadius: '12px 12px 12px 0px',
+ PopoverBody: (
+
+
+
+ {t('You can view fees through the fee center')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.system-applaunchpad',
+ popover: {
+ side: 'left',
+ align: 'start',
+ borderRadius: '12px 0px 12px 12px',
+ onPopoverRender: () => {
+ const svg = driverObj.getState('__overlaySvg');
+ if (svg) {
+ const pathElement = svg.querySelector('path');
+ if (pathElement) {
+ pathElement.style.pointerEvents = 'none';
+ }
+ }
+ },
+ PopoverBody: (
+
+
+
+
+ {t('deploy an application')}
+
+
+
+ {t('spend')}
+
+ 30s
+
+ {t('Completed the deployment of an nginx for the first time')}
+
+ {t('gift amount', { amount: giftAmount })}
+
+
+
+
+
+
+
+ )
+ }
+ }
+ ],
+ onDestroyed: () => {
+ console.log('onDestroyed');
+ handleSkipGuide();
+ }
+ });
+
+ const startGuide = () => {
+ setShowGuide(false);
+ driverObj.drive();
+ };
+
+ const UserGuide = () => (
+
+
+
+
+ {t('Hello, welcome')}
+
+ Sealos
+
+ 👏
+
+
+
+
+ );
+
+ return { UserGuide, showGuide, startGuide };
+}
diff --git a/frontend/desktop/src/pages/_app.tsx b/frontend/desktop/src/pages/_app.tsx
index f835e779a..237817539 100644
--- a/frontend/desktop/src/pages/_app.tsx
+++ b/frontend/desktop/src/pages/_app.tsx
@@ -8,6 +8,7 @@ import type { AppProps } from 'next/app';
import Router from 'next/router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
+import '@sealos/driver/src/driver.css';
import { useEffect } from 'react';
const queryClient = new QueryClient({
diff --git a/frontend/desktop/src/pages/api/account/getAccount.ts b/frontend/desktop/src/pages/api/account/getAccount.ts
new file mode 100644
index 000000000..a6e300a06
--- /dev/null
+++ b/frontend/desktop/src/pages/api/account/getAccount.ts
@@ -0,0 +1,26 @@
+import { authSession } from '@/services/backend/auth';
+import { GetCRD } from '@/services/backend/kubernetes/user';
+import { jsonRes } from '@/services/backend/response';
+import { CRDMeta } from '@/types';
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+export const AccountMeta: CRDMeta = {
+ group: 'account.sealos.io',
+ version: 'v1',
+ namespace: 'sealos-system',
+ plural: 'accounts'
+};
+
+// req header is kubeconfig
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const payload = await authSession(req.headers);
+ if (!payload) return jsonRes(res, { code: 401, message: 'token is vaild' });
+
+ const result = await GetCRD(payload.kc, AccountMeta, payload.user.k8s_username);
+ jsonRes(res, { data: result?.body });
+ } catch (error) {
+ console.log(error);
+ jsonRes(res, { code: 500, data: error });
+ }
+}
diff --git a/frontend/desktop/src/pages/api/account/updateGuide.ts b/frontend/desktop/src/pages/api/account/updateGuide.ts
new file mode 100644
index 000000000..506be41a8
--- /dev/null
+++ b/frontend/desktop/src/pages/api/account/updateGuide.ts
@@ -0,0 +1,36 @@
+import { authSession } from '@/services/backend/auth';
+import { K8sApiDefault } from '@/services/backend/kubernetes/admin';
+import { UpdateCRD } from '@/services/backend/kubernetes/user';
+import { jsonRes } from '@/services/backend/response';
+import type { NextApiRequest, NextApiResponse } from 'next';
+import { AccountMeta } from './getAccount';
+import { GUIDE_DESKTOP_INDEX_KEY } from '@/constants/account';
+
+// req header is kubeconfig
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const payload = await authSession(req.headers);
+ if (!payload) return jsonRes(res, { code: 401, message: 'token is vaild' });
+
+ const defaultKc = K8sApiDefault();
+
+ if (!defaultKc) return jsonRes(res, { code: 401, message: 'No cluster permissions' });
+
+ const endTime = new Date().toISOString();
+
+ const jsonPatch = [
+ {
+ op: 'add',
+ path: `/metadata/annotations/${GUIDE_DESKTOP_INDEX_KEY}`,
+ value: endTime
+ }
+ ];
+
+ const reuslt = await UpdateCRD(defaultKc, AccountMeta, payload.user.k8s_username, jsonPatch);
+
+ jsonRes(res, { data: reuslt?.body });
+ } catch (error) {
+ console.log(error, 'get user account err');
+ jsonRes(res, { code: 500, data: error });
+ }
+}
diff --git a/frontend/desktop/src/pages/api/platform/getEnv.ts b/frontend/desktop/src/pages/api/platform/getEnv.ts
index 108f42b41..d75685300 100644
--- a/frontend/desktop/src/pages/api/platform/getEnv.ts
+++ b/frontend/desktop/src/pages/api/platform/getEnv.ts
@@ -28,6 +28,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const wechatEnabledRecharge = enableWechatRecharge();
const licenseEnabled = enableLicense();
const rechargeEnabled = enableRecharge();
+ const guideEnabled = process.env.GUIDE_ENABLED === 'true';
+
jsonRes(res, {
data: {
SEALOS_CLOUD_DOMAIN: process.env.SEALOS_CLOUD_DOMAIN || 'cloud.sealos.io',
@@ -45,7 +47,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
stripeEnabled,
wechatEnabledRecharge,
rechargeEnabled,
- licenseEnabled
+ licenseEnabled,
+ guideEnabled
}
});
}
diff --git a/frontend/desktop/src/pages/api/price/bonus.ts b/frontend/desktop/src/pages/api/price/bonus.ts
index 62cadd7c6..b018bc1ef 100644
--- a/frontend/desktop/src/pages/api/price/bonus.ts
+++ b/frontend/desktop/src/pages/api/price/bonus.ts
@@ -1,7 +1,6 @@
import { authSession } from '@/services/backend/auth';
import { GetConfigMap } from '@/services/backend/kubernetes/user';
import { jsonRes } from '@/services/backend/response';
-import { enableRecharge } from '@/services/enable';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, resp: NextApiResponse) {
@@ -18,7 +17,8 @@ export default async function handler(req: NextApiRequest, resp: NextApiResponse
code: 200,
data: {
ratios: result.body.data.ratios,
- steps: result.body.data.steps
+ steps: result.body.data.steps,
+ activities: result.body.data.activities
}
});
} catch (error) {
diff --git a/frontend/desktop/src/pages/api/v1alpha/account/getAccount.ts b/frontend/desktop/src/pages/api/v1alpha/account/getAccount.ts
new file mode 100644
index 000000000..8949311f7
--- /dev/null
+++ b/frontend/desktop/src/pages/api/v1alpha/account/getAccount.ts
@@ -0,0 +1,26 @@
+import { AccountMeta } from '@/pages/api/account/getAccount';
+import { jsonRes } from '@/services/backend/response';
+import type { NextApiRequest, NextApiResponse } from 'next';
+import { initK8s } from 'sealos-desktop-sdk/service';
+
+// req header is kubeconfig
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const { k8sCustomObjects, kube_user } = await initK8s({
+ req
+ });
+
+ const { body } = await k8sCustomObjects.getNamespacedCustomObject(
+ AccountMeta.group,
+ AccountMeta.version,
+ AccountMeta.namespace,
+ AccountMeta.plural,
+ kube_user.name
+ );
+
+ jsonRes(res, { data: body });
+ } catch (error) {
+ console.log(error);
+ jsonRes(res, { code: 500, data: error });
+ }
+}
diff --git a/frontend/desktop/src/pages/api/v1alpha/account/updateGuide.ts b/frontend/desktop/src/pages/api/v1alpha/account/updateGuide.ts
new file mode 100644
index 000000000..7f0e7b83a
--- /dev/null
+++ b/frontend/desktop/src/pages/api/v1alpha/account/updateGuide.ts
@@ -0,0 +1,68 @@
+import { K8sApiDefault } from '@/services/backend/kubernetes/admin';
+import { UpdateCRD } from '@/services/backend/kubernetes/user';
+import { jsonRes } from '@/services/backend/response';
+import type { NextApiRequest, NextApiResponse } from 'next';
+import { initK8s } from 'sealos-desktop-sdk/service';
+import { AccountMeta } from '@/pages/api/account/getAccount';
+
+export type UpdateUserGuideParams = {
+ activityType: 'beginner-guide';
+ phase: 'launchpad' | 'database' | 'template' | 'terminal';
+ phasePage: 'create' | 'detail' | 'index';
+ shouldSendGift: boolean;
+};
+
+// req header is kubeconfig
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const {
+ activityType,
+ phase,
+ phasePage,
+ shouldSendGift = false
+ } = req.body as UpdateUserGuideParams;
+ if (!activityType || !phase || !phasePage)
+ return jsonRes(res, { code: 400, message: 'Desktop Bad Request: Invalid parameters' });
+
+ const { namespace, kube_user, kc } = await initK8s({ req });
+ const defaultKc = K8sApiDefault();
+ if (!namespace) return jsonRes(res, { code: 401, message: 'kubeConfig is vaild' });
+ if (!defaultKc) return jsonRes(res, { code: 401, message: 'No cluster permissions' });
+
+ const endTime = new Date().toISOString();
+
+ const jsonPatch = shouldSendGift
+ ? [
+ {
+ op: 'add',
+ path: `/metadata/annotations/activity.${activityType}.current-phase`,
+ value: phase
+ },
+ {
+ op: 'add',
+ path: `/metadata/annotations/activity.${activityType}.${phase}.endTime`,
+ value: endTime
+ },
+ {
+ op: 'add',
+ path: `/metadata/annotations/frontend.guide.${phase}.${phasePage}`,
+ value: endTime
+ }
+ ]
+ : [
+ {
+ op: 'add',
+ path: `/metadata/annotations/frontend.guide.${phase}.${phasePage}`,
+ value: endTime
+ }
+ ];
+
+ const reuslt = await UpdateCRD(defaultKc, AccountMeta, kube_user.name, jsonPatch);
+ console.log('updateDesktopGuide', jsonPatch, reuslt?.body);
+
+ jsonRes(res, { data: reuslt?.body });
+ } catch (error) {
+ console.log(error, 'get user account err');
+ jsonRes(res, { code: 500, data: error });
+ }
+}
diff --git a/frontend/desktop/src/pages/index.tsx b/frontend/desktop/src/pages/index.tsx
index a9e45ff3c..6810e2077 100644
--- a/frontend/desktop/src/pages/index.tsx
+++ b/frontend/desktop/src/pages/index.tsx
@@ -47,7 +47,7 @@ export default function Home({ sealos_cloud_domain }: { sealos_cloud_domain: str
useEffect(() => {
const { query } = router;
const is_login = isUserLogin();
- const whitelistApps = ['system-fastdeploy'];
+ const whitelistApps = ['system-template'];
if (!is_login) {
const { appkey, appQuery } = parseOpenappQuery((query?.openapp as string) || '');
// sealos_inside=true internal call
diff --git a/frontend/desktop/src/styles/globals.scss b/frontend/desktop/src/styles/globals.scss
index 77fd1ec56..07e642a54 100644
--- a/frontend/desktop/src/styles/globals.scss
+++ b/frontend/desktop/src/styles/globals.scss
@@ -77,3 +77,7 @@ div {
}
}
}
+
+#driver-popover-content {
+ padding: 14px;
+}
diff --git a/frontend/desktop/src/types/user.ts b/frontend/desktop/src/types/user.ts
index 7cd28a206..7be1ddf89 100644
--- a/frontend/desktop/src/types/user.ts
+++ b/frontend/desktop/src/types/user.ts
@@ -97,3 +97,20 @@ export type TeamUserDto = UserDto & {
role: UserRole;
status: InvitedStatus;
};
+
+export type AccountCRD = {
+ apiVersion: string;
+ kind: string;
+ metadata: {
+ annotations: Record
;
+ creationTimestamp: string;
+ generation: number;
+ };
+ status: {
+ activityBonus: number;
+ balance: number;
+ deductionBalance: number;
+ encryptBalance: string;
+ encryptDeductionBalance: string;
+ };
+};
diff --git a/frontend/packages/driver/package.json b/frontend/packages/driver/package.json
new file mode 100644
index 000000000..565683fa6
--- /dev/null
+++ b/frontend/packages/driver/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@sealos/driver",
+ "version": "1.0.0",
+ "description": "",
+ "main": "./src/driver.ts",
+ "src": "./src",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "dev": "next dev"
+ },
+ "keywords": [
+ "driver",
+ "tour",
+ "guide",
+ "overlay"
+ ],
+ "author": "3161362058@qq.com",
+ "license": "ISC",
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "18.2.37",
+ "@types/react-dom": "18.0.11"
+ }
+}
\ No newline at end of file
diff --git a/frontend/packages/driver/src/config.ts b/frontend/packages/driver/src/config.ts
new file mode 100644
index 000000000..bf60f818b
--- /dev/null
+++ b/frontend/packages/driver/src/config.ts
@@ -0,0 +1,88 @@
+import { DriveStep } from './driver';
+import { AllowedButtons, PopoverDOM } from './popover';
+import { State } from './state';
+
+export type DriverHook = (
+ element: Element | undefined,
+ step: DriveStep,
+ opts: { config: Config; state: State }
+) => void;
+
+export type Config = {
+ steps?: DriveStep[];
+
+ animate?: boolean;
+ overlayColor?: string;
+ overlayOpacity?: number;
+ smoothScroll?: boolean;
+ allowClose?: boolean;
+ stagePadding?: number;
+ stageRadius?: number;
+
+ disableActiveInteraction?: boolean;
+
+ allowKeyboardControl?: boolean;
+
+ // Popover specific configuration
+ popoverClass?: string;
+ popoverOffset?: number;
+ showButtons?: AllowedButtons[];
+ disableButtons?: AllowedButtons[];
+ showProgress?: boolean;
+
+ // Button texts
+ progressText?: string;
+ nextBtnText?: string;
+ prevBtnText?: string;
+ doneBtnText?: string;
+
+ // Called after the popover is rendered
+ onPopoverRender?: (popover: PopoverDOM, opts: { config: Config; state: State }) => void;
+
+ // State based callbacks, called upon state changes
+ onHighlightStarted?: DriverHook;
+ onHighlighted?: DriverHook;
+ onDeselected?: DriverHook;
+ onDestroyStarted?: DriverHook;
+ onDestroyed?: DriverHook;
+
+ // Event based callbacks, called upon events
+ onNextClick?: DriverHook;
+ onPrevClick?: DriverHook;
+ onCloseClick?: DriverHook;
+
+ // custom config
+ allowClickMaskNextStep?: boolean;
+ allowPreviousStep?: boolean;
+ isShowButtons?: boolean;
+ overlaySkipButton?: string;
+ interceptSkipButtonClick?: () => void;
+};
+
+let currentConfig: Config = {};
+
+export function configure(config: Config = {}) {
+ currentConfig = {
+ animate: true,
+ allowClose: true,
+ overlayOpacity: 0.7,
+ smoothScroll: false,
+ disableActiveInteraction: false,
+ showProgress: false,
+ stagePadding: 10,
+ stageRadius: 5,
+ popoverOffset: 10,
+ showButtons: ['next', 'previous', 'close'],
+ disableButtons: [],
+ overlayColor: '#000',
+ allowPreviousStep: false,
+ isShowButtons: true,
+ ...config
+ };
+}
+
+export function getConfig(): Config;
+export function getConfig(key: K): Config[K];
+export function getConfig(key?: K) {
+ return key ? currentConfig[key] : currentConfig;
+}
diff --git a/frontend/packages/driver/src/driver.css b/frontend/packages/driver/src/driver.css
new file mode 100644
index 000000000..3ad3d4ee1
--- /dev/null
+++ b/frontend/packages/driver/src/driver.css
@@ -0,0 +1,269 @@
+.driver-active .driver-overlay {
+ pointer-events: none;
+}
+
+.driver-active * {
+ pointer-events: none;
+}
+
+.driver-active .driver-popover-overlay-skip-btn,
+.driver-active .driver-active-element,
+.driver-active .driver-active-element *,
+.driver-popover,
+.driver-popover * {
+ pointer-events: auto;
+}
+
+@keyframes animate-fade-in {
+ 0% {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+.driver-fade .driver-overlay {
+ animation: animate-fade-in 200ms ease-in-out;
+}
+
+.driver-fade .driver-popover {
+ animation: animate-fade-in 200ms;
+}
+
+/* Popover styles */
+.driver-popover {
+ all: unset;
+ box-sizing: border-box;
+ color: #2d2d2d;
+ margin: 0;
+ padding: 15px;
+ border-radius: 5px;
+ border: 1px solid #ffc1a4;
+ min-width: 250px;
+ max-width: 300px;
+ box-shadow: 0 1px 10px #0006;
+ z-index: 1000000000;
+ position: fixed;
+ top: 0;
+ right: 0;
+ background-color: #fff;
+}
+
+.driver-popover * {
+ font-family: 'Helvetica Neue', Inter, ui-sans-serif, 'Apple Color Emoji', Helvetica, Arial,
+ sans-serif;
+}
+
+.driver-popover-title {
+ font: 19px / normal sans-serif;
+ font-weight: 700;
+ display: block;
+ position: relative;
+ line-height: 1.5;
+ zoom: 1;
+ margin: 0;
+}
+
+.driver-popover-close-btn {
+ all: unset;
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 32px;
+ height: 28px;
+ cursor: pointer;
+ font-size: 18px;
+ font-weight: 500;
+ color: #d2d2d2;
+ z-index: 1;
+ text-align: center;
+ transition: color;
+ transition-duration: 200ms;
+}
+
+.driver-popover-close-btn:hover,
+.driver-popover-close-btn:focus {
+ color: #2d2d2d;
+}
+
+.driver-popover-title[style*='block'] + .driver-popover-description {
+ margin-top: 5px;
+}
+
+.driver-popover-description {
+ margin-bottom: 0;
+ font: 14px / normal sans-serif;
+ line-height: 1.4;
+ font-weight: 400;
+ color: #24282c;
+ zoom: 1;
+}
+
+.driver-popover-footer {
+ margin-top: 15px;
+ text-align: right;
+ zoom: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.driver-popover-progress-text {
+ font-size: 13px;
+ font-weight: 400;
+ color: #727272;
+ zoom: 1;
+}
+
+.driver-popover-footer button {
+ all: unset;
+ display: inline-block;
+ box-sizing: border-box;
+ padding: 3px 7px;
+ text-decoration: none;
+ text-shadow: 1px 1px 0 #fff;
+ background-color: #ffffff;
+ color: #2d2d2d;
+ font: 12px / normal sans-serif;
+ cursor: pointer;
+ outline: 0;
+ zoom: 1;
+ line-height: 1.3;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+}
+
+.driver-popover-footer .driver-popover-btn-disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+/* Disable the scrolling of parent element if it has an active element*/
+:not(body):has(> .driver-active-element) {
+ overflow: hidden !important;
+}
+
+.driver-no-interaction,
+.driver-no-interaction * {
+ pointer-events: none !important;
+}
+
+.driver-popover-footer button:hover,
+.driver-popover-footer button:focus {
+ background-color: #f7f7f7;
+}
+
+.driver-popover-navigation-btns {
+ display: flex;
+ flex-grow: 1;
+ justify-content: flex-end;
+}
+
+.driver-popover-navigation-btns button + button {
+ margin-left: 4px;
+}
+
+.driver-popover-arrow {
+ content: '';
+ position: absolute;
+ border: 5px solid #fff;
+ /* 取消 Arrow */
+ display: none;
+}
+
+.driver-popover-arrow-side-over {
+ display: none;
+}
+
+/** Popover Arrow Sides **/
+.driver-popover-arrow-side-left {
+ left: 100%;
+ border-right-color: transparent;
+ border-bottom-color: transparent;
+ border-top-color: transparent;
+}
+
+.driver-popover-arrow-side-right {
+ right: 100%;
+ border-left-color: transparent;
+ border-bottom-color: transparent;
+ border-top-color: transparent;
+}
+
+.driver-popover-arrow-side-top {
+ top: 100%;
+ border-right-color: transparent;
+ border-bottom-color: transparent;
+ border-left-color: transparent;
+}
+
+.driver-popover-arrow-side-bottom {
+ bottom: 100%;
+ border-left-color: transparent;
+ border-top-color: transparent;
+ border-right-color: transparent;
+}
+
+.driver-popover-arrow-side-center {
+ display: none;
+}
+
+/* Left/Start + Right/Start */
+.driver-popover-arrow-side-left.driver-popover-arrow-align-start,
+.driver-popover-arrow-side-right.driver-popover-arrow-align-start {
+ top: 15px;
+}
+
+/* Top/Start + Bottom/Start */
+.driver-popover-arrow-side-top.driver-popover-arrow-align-start,
+.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start {
+ left: 15px;
+}
+
+/* End/Left + End/Right */
+.driver-popover-arrow-align-end.driver-popover-arrow-side-left,
+.driver-popover-arrow-align-end.driver-popover-arrow-side-right {
+ bottom: 15px;
+}
+
+/* Top/End + Bottom/End */
+.driver-popover-arrow-side-top.driver-popover-arrow-align-end,
+.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end {
+ right: 15px;
+}
+
+/* Left/Center + Right/Center */
+.driver-popover-arrow-side-left.driver-popover-arrow-align-center,
+.driver-popover-arrow-side-right.driver-popover-arrow-align-center {
+ top: 50%;
+ margin-top: -5px;
+}
+
+/* Top/Center + Bottom/Center */
+.driver-popover-arrow-side-top.driver-popover-arrow-align-center,
+.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center {
+ left: 50%;
+ margin-left: -5px;
+}
+
+/* No arrow */
+.driver-popover-arrow-none {
+ display: none;
+}
+
+.driver-popover-overlay-skip-btn {
+ position: fixed;
+ top: 20px;
+ left: 20px;
+ z-index: 11111;
+ padding: 4px 16px;
+ color: #ffffff;
+ font-size: 12px;
+ font-weight: 500;
+ border-radius: 6px;
+ background: #a9a9a9;
+ opacity: 0.9;
+ cursor: pointer;
+}
diff --git a/frontend/packages/driver/src/driver.ts b/frontend/packages/driver/src/driver.ts
new file mode 100644
index 000000000..b4223ebda
--- /dev/null
+++ b/frontend/packages/driver/src/driver.ts
@@ -0,0 +1,345 @@
+import { AllowedButtons, destroyPopover, Popover } from './popover';
+import { destroyOverlay, destroyOverlayBtn } from './overlay';
+import { destroyEvents, initEvents, requireRefresh } from './events';
+import { Config, configure, DriverHook, getConfig } from './config';
+import { destroyHighlight, highlight } from './highlight';
+import { destroyEmitter, listen } from './emitter';
+import { getState, resetState, setState } from './state';
+
+export type DriveStep = {
+ element?: string | Element;
+ onHighlightStarted?: DriverHook;
+ onHighlighted?: DriverHook;
+ onDeselected?: DriverHook;
+ popover?: Popover;
+};
+
+export function driver(options: Config = {}) {
+ configure(options);
+
+ function handleClose() {
+ if (!getConfig('allowClose')) {
+ return;
+ }
+
+ destroy();
+ }
+
+ function moveNext() {
+ const activeIndex = getState('activeIndex');
+ const steps = getConfig('steps') || [];
+ if (typeof activeIndex === 'undefined') {
+ return;
+ }
+
+ const nextStepIndex = activeIndex + 1;
+ if (steps[nextStepIndex]) {
+ drive(nextStepIndex);
+ } else {
+ destroy();
+ }
+ }
+
+ function movePrevious() {
+ const activeIndex = getState('activeIndex');
+ const steps = getConfig('steps') || [];
+ if (typeof activeIndex === 'undefined') {
+ return;
+ }
+
+ const previousStepIndex = activeIndex - 1;
+ if (steps[previousStepIndex]) {
+ drive(previousStepIndex);
+ } else {
+ destroy();
+ }
+ }
+
+ function moveTo(index: number) {
+ const steps = getConfig('steps') || [];
+
+ if (steps[index]) {
+ drive(index);
+ } else {
+ destroy();
+ }
+ }
+
+ function handleArrowLeft() {
+ const isTransitioning = getState('__transitionCallback');
+ if (isTransitioning) {
+ return;
+ }
+
+ const activeIndex = getState('activeIndex');
+ const activeStep = getState('__activeStep');
+ const activeElement = getState('__activeElement');
+ if (typeof activeIndex === 'undefined' || typeof activeStep === 'undefined') {
+ return;
+ }
+
+ const currentStepIndex = getState('activeIndex');
+ if (typeof currentStepIndex === 'undefined') {
+ return;
+ }
+
+ const onPrevClick = activeStep.popover?.onPrevClick || getConfig('onPrevClick');
+ if (onPrevClick) {
+ return onPrevClick(activeElement, activeStep, {
+ config: getConfig(),
+ state: getState()
+ });
+ }
+
+ movePrevious();
+ }
+
+ function handleArrowRight() {
+ const isTransitioning = getState('__transitionCallback');
+ if (isTransitioning) {
+ return;
+ }
+
+ const activeIndex = getState('activeIndex');
+ const activeStep = getState('__activeStep');
+ const activeElement = getState('__activeElement');
+ if (typeof activeIndex === 'undefined' || typeof activeStep === 'undefined') {
+ return;
+ }
+
+ const onNextClick = activeStep.popover?.onNextClick || getConfig('onNextClick');
+ if (onNextClick) {
+ return onNextClick(activeElement, activeStep, {
+ config: getConfig(),
+ state: getState()
+ });
+ }
+
+ moveNext();
+ }
+
+ function init() {
+ if (getState('isInitialized')) {
+ return;
+ }
+
+ setState('isInitialized', true);
+ document.body.classList.add(
+ 'driver-active',
+ getConfig('animate') ? 'driver-fade' : 'driver-simple'
+ );
+
+ initEvents();
+
+ listen('overlayClick', getConfig('allowClickMaskNextStep') ? moveNext : handleClose);
+ listen('escapePress', handleClose);
+ listen('skipButtonClick', () => {
+ const interceptSkipButtonClick = getConfig('interceptSkipButtonClick');
+ if (interceptSkipButtonClick) {
+ interceptSkipButtonClick();
+ } else {
+ destroy();
+ }
+ });
+ listen('arrowLeftPress', handleArrowLeft);
+ listen('arrowRightPress', handleArrowRight);
+ }
+
+ function drive(stepIndex: number = 0) {
+ const steps = getConfig('steps');
+ if (!steps) {
+ console.error('No steps to drive through');
+ destroy();
+ return;
+ }
+
+ if (!steps[stepIndex]) {
+ destroy();
+
+ return;
+ }
+
+ setState('__activeOnDestroyed', document.activeElement as HTMLElement);
+ setState('activeIndex', stepIndex);
+
+ const currentStep = steps[stepIndex];
+ const hasNextStep = steps[stepIndex + 1];
+ const hasPreviousStep = steps[stepIndex - 1];
+
+ const doneBtnText = currentStep.popover?.doneBtnText || getConfig('doneBtnText') || 'Done';
+ const allowsClosing = getConfig('allowClose');
+ const showProgress =
+ typeof currentStep.popover?.showProgress !== 'undefined'
+ ? currentStep.popover?.showProgress
+ : getConfig('showProgress');
+ const progressText =
+ currentStep.popover?.progressText || getConfig('progressText') || '{{current}} of {{total}}';
+ const progressTextReplaced = progressText
+ .replace('{{current}}', `${stepIndex + 1}`)
+ .replace('{{total}}', `${steps.length}`);
+
+ const configuredButtons = currentStep.popover?.showButtons || getConfig('showButtons');
+ const calculatedButtons: AllowedButtons[] = [
+ 'next',
+ 'previous',
+ ...(allowsClosing ? ['close' as AllowedButtons] : [])
+ ].filter((b) => {
+ return !configuredButtons?.length || configuredButtons.includes(b as AllowedButtons);
+ }) as AllowedButtons[];
+
+ const onNextClick = currentStep.popover?.onNextClick || getConfig('onNextClick');
+ const onPrevClick = currentStep.popover?.onPrevClick || getConfig('onPrevClick');
+ const onCloseClick = currentStep.popover?.onCloseClick || getConfig('onCloseClick');
+
+ highlight({
+ ...currentStep,
+ popover: {
+ showButtons: calculatedButtons,
+ nextBtnText: !hasNextStep ? doneBtnText : undefined,
+ disableButtons: [...(!hasPreviousStep ? ['previous' as AllowedButtons] : [])],
+ showProgress: showProgress,
+ progressText: progressTextReplaced,
+ onNextClick: onNextClick
+ ? onNextClick
+ : () => {
+ if (!hasNextStep) {
+ destroy();
+ } else {
+ drive(stepIndex + 1);
+ }
+ },
+ onPrevClick: onPrevClick
+ ? onPrevClick
+ : () => {
+ drive(stepIndex - 1);
+ },
+ onCloseClick: onCloseClick
+ ? onCloseClick
+ : () => {
+ destroy();
+ },
+ ...(currentStep?.popover || {})
+ }
+ });
+ }
+
+ function destroy(withOnDestroyStartedHook = true) {
+ const activeElement = getState('__activeElement');
+ const activeStep = getState('__activeStep');
+
+ const activeOnDestroyed = getState('__activeOnDestroyed');
+
+ const onDestroyStarted = getConfig('onDestroyStarted');
+ // `onDestroyStarted` is used to confirm the exit of tour. If we trigger
+ // the hook for when user calls `destroy`, driver will get into infinite loop
+ // not causing tour to be destroyed.
+ if (withOnDestroyStartedHook && onDestroyStarted) {
+ const isActiveDummyElement = !activeElement || activeElement?.id === 'driver-dummy-element';
+ onDestroyStarted(isActiveDummyElement ? undefined : activeElement, activeStep!, {
+ config: getConfig(),
+ state: getState()
+ });
+ return;
+ }
+
+ const onDeselected = activeStep?.onDeselected || getConfig('onDeselected');
+ const onDestroyed = getConfig('onDestroyed');
+
+ document.body.classList.remove('driver-active', 'driver-fade', 'driver-simple');
+
+ destroyEvents();
+ destroyPopover();
+ destroyHighlight();
+ destroyOverlay();
+ destroyEmitter();
+ destroyOverlayBtn();
+
+ resetState();
+
+ if (activeElement && activeStep) {
+ const isActiveDummyElement = activeElement.id === 'driver-dummy-element';
+ if (onDeselected) {
+ onDeselected(isActiveDummyElement ? undefined : activeElement, activeStep, {
+ config: getConfig(),
+ state: getState()
+ });
+ }
+
+ if (onDestroyed) {
+ onDestroyed(isActiveDummyElement ? undefined : activeElement, activeStep, {
+ config: getConfig(),
+ state: getState()
+ });
+ }
+ }
+
+ if (activeOnDestroyed) {
+ (activeOnDestroyed as HTMLElement).focus();
+ }
+ }
+
+ return {
+ isActive: () => getState('isInitialized') || false,
+ refresh: requireRefresh,
+ drive: (stepIndex: number = 0) => {
+ init();
+ drive(stepIndex);
+ },
+ setConfig: configure,
+ setSteps: (steps: DriveStep[]) => {
+ resetState();
+ configure({
+ ...getConfig(),
+ steps
+ });
+ },
+ getConfig,
+ getState,
+ getActiveIndex: () => getState('activeIndex'),
+ isFirstStep: () => getState('activeIndex') === 0,
+ isLastStep: () => {
+ const steps = getConfig('steps') || [];
+ const activeIndex = getState('activeIndex');
+
+ return activeIndex !== undefined && activeIndex === steps.length - 1;
+ },
+ getActiveStep: () => getState('activeStep'),
+ getActiveElement: () => getState('activeElement'),
+ getPreviousElement: () => getState('previousElement'),
+ getPreviousStep: () => getState('previousStep'),
+ moveNext,
+ movePrevious,
+ moveTo,
+ hasNextStep: () => {
+ const steps = getConfig('steps') || [];
+ const activeIndex = getState('activeIndex');
+
+ return activeIndex !== undefined && steps[activeIndex + 1];
+ },
+ hasPreviousStep: () => {
+ const steps = getConfig('steps') || [];
+ const activeIndex = getState('activeIndex');
+
+ return activeIndex !== undefined && steps[activeIndex - 1];
+ },
+ highlight: (step: DriveStep) => {
+ init();
+ highlight({
+ ...step,
+ popover: step.popover
+ ? {
+ showButtons: [],
+ showProgress: false,
+ progressText: '',
+ ...step.popover!
+ }
+ : undefined
+ });
+ },
+ destroy: () => {
+ destroy(false);
+ }
+ };
+}
+
+export type Driver = ReturnType;
diff --git a/frontend/packages/driver/src/emitter.ts b/frontend/packages/driver/src/emitter.ts
new file mode 100644
index 000000000..aaa70a280
--- /dev/null
+++ b/frontend/packages/driver/src/emitter.ts
@@ -0,0 +1,23 @@
+type allowedEvents =
+ | 'overlayClick'
+ | 'escapePress'
+ | 'nextClick'
+ | 'prevClick'
+ | 'closeClick'
+ | 'arrowRightPress'
+ | 'arrowLeftPress'
+ | 'skipButtonClick';
+
+let registeredListeners: Partial<{ [key in allowedEvents]: () => void }> = {};
+
+export function listen(hook: allowedEvents, callback: () => void) {
+ registeredListeners[hook] = callback;
+}
+
+export function emit(hook: allowedEvents) {
+ registeredListeners[hook]?.();
+}
+
+export function destroyEmitter() {
+ registeredListeners = {};
+}
diff --git a/frontend/packages/driver/src/events.ts b/frontend/packages/driver/src/events.ts
new file mode 100644
index 000000000..6a3d98581
--- /dev/null
+++ b/frontend/packages/driver/src/events.ts
@@ -0,0 +1,133 @@
+import { refreshActiveHighlight } from './highlight';
+import { emit } from './emitter';
+import { getState, setState } from './state';
+import { getConfig } from './config';
+import { getFocusableElements } from './utils';
+
+export function requireRefresh() {
+ const resizeTimeout = getState('__resizeTimeout');
+ if (resizeTimeout) {
+ window.cancelAnimationFrame(resizeTimeout);
+ }
+
+ setState('__resizeTimeout', window.requestAnimationFrame(refreshActiveHighlight));
+}
+
+function trapFocus(e: KeyboardEvent) {
+ const isActivated = getState('isInitialized');
+ if (!isActivated) {
+ return;
+ }
+
+ const isTabKey = e.key === 'Tab' || e.keyCode === 9;
+ if (!isTabKey) {
+ return;
+ }
+
+ const activeElement = getState('__activeElement');
+ const popoverEl = getState('popover')?.wrapper;
+
+ const focusableEls = getFocusableElements([
+ ...(popoverEl ? [popoverEl] : []),
+ ...(activeElement ? [activeElement] : [])
+ ]);
+
+ const firstFocusableEl = focusableEls[0];
+ const lastFocusableEl = focusableEls[focusableEls.length - 1];
+
+ e.preventDefault();
+
+ if (e.shiftKey) {
+ const previousFocusableEl =
+ focusableEls[focusableEls.indexOf(document.activeElement as HTMLElement) - 1] ||
+ lastFocusableEl;
+ previousFocusableEl?.focus();
+ } else {
+ const nextFocusableEl =
+ focusableEls[focusableEls.indexOf(document.activeElement as HTMLElement) + 1] ||
+ firstFocusableEl;
+ nextFocusableEl?.focus();
+ }
+}
+
+function onKeyup(e: KeyboardEvent) {
+ const allowKeyboardControl = getConfig('allowKeyboardControl') ?? true;
+
+ if (!allowKeyboardControl) {
+ return;
+ }
+
+ if (e.key === 'Escape') {
+ emit('escapePress');
+ } else if (e.key === 'ArrowRight') {
+ emit('arrowRightPress');
+ } else if (e.key === 'ArrowLeft') {
+ emit('arrowLeftPress');
+ }
+}
+
+/**
+ * Attaches click handler to the elements created by driver.js. It makes
+ * sure to give the listener the first chance to handle the event, and
+ * prevents all other pointer-events to make sure no external-library
+ * ever knows the click happened.
+ *
+ * @param {Element} element Element to listen for click events
+ * @param {(pointer: MouseEvent | PointerEvent) => void} listener Click handler
+ * @param {(target: HTMLElement) => boolean} shouldPreventDefault Whether to prevent default action i.e. link clicks etc
+ */
+export function onDriverClick(
+ element: Element,
+ listener: (pointer: MouseEvent | PointerEvent) => void,
+ shouldPreventDefault?: (target: HTMLElement) => boolean
+) {
+ const listenerWrapper = (
+ e: MouseEvent | PointerEvent,
+ listener?: (pointer: MouseEvent | PointerEvent) => void
+ ) => {
+ const target = e.target as HTMLElement;
+
+ if (!element.contains(target)) {
+ return;
+ }
+
+ if (!shouldPreventDefault || shouldPreventDefault(target)) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ }
+
+ listener?.(e);
+ };
+
+ // We want to be the absolute first one to hear about the event
+ const useCapture = true;
+
+ // Events to disable
+ document.addEventListener('pointerdown', listenerWrapper, useCapture);
+ document.addEventListener('mousedown', listenerWrapper, useCapture);
+ document.addEventListener('pointerup', listenerWrapper, useCapture);
+ document.addEventListener('mouseup', listenerWrapper, useCapture);
+
+ // Actual click handler
+ document.addEventListener(
+ 'click',
+ (e) => {
+ listenerWrapper(e, listener);
+ },
+ useCapture
+ );
+}
+
+export function initEvents() {
+ window.addEventListener('keyup', onKeyup, false);
+ window.addEventListener('keydown', trapFocus, false);
+ window.addEventListener('resize', requireRefresh);
+ window.addEventListener('scroll', requireRefresh);
+}
+
+export function destroyEvents() {
+ window.removeEventListener('keyup', onKeyup);
+ window.removeEventListener('resize', requireRefresh);
+ window.removeEventListener('scroll', requireRefresh);
+}
diff --git a/frontend/packages/driver/src/highlight.ts b/frontend/packages/driver/src/highlight.ts
new file mode 100644
index 000000000..b36d7bf7d
--- /dev/null
+++ b/frontend/packages/driver/src/highlight.ts
@@ -0,0 +1,185 @@
+import { DriveStep } from './driver';
+import { refreshOverlay, trackActiveElement, transitionStage } from './overlay';
+import { getConfig } from './config';
+import { hidePopover, renderPopover, repositionPopover } from './popover';
+import { bringInView } from './utils';
+import { getState, setState } from './state';
+
+function mountDummyElement(): Element {
+ const existingDummy = document.getElementById('driver-dummy-element');
+ if (existingDummy) {
+ return existingDummy;
+ }
+
+ let element = document.createElement('div');
+
+ element.id = 'driver-dummy-element';
+ element.style.width = '0';
+ element.style.height = '0';
+ element.style.pointerEvents = 'none';
+ element.style.opacity = '0';
+ element.style.position = 'fixed';
+ element.style.top = '50%';
+ element.style.left = '50%';
+
+ document.body.appendChild(element);
+
+ return element;
+}
+
+export function highlight(step: DriveStep) {
+ const { element } = step;
+ let elemObj = typeof element === 'string' ? document.querySelector(element) : element;
+
+ // If the element is not found, we mount a 1px div
+ // at the center of the screen to highlight and show
+ // the popover on top of that. This is to show a
+ // modal-like highlight.
+ if (!elemObj) {
+ elemObj = mountDummyElement();
+ }
+
+ transferHighlight(elemObj, step);
+}
+
+export function refreshActiveHighlight() {
+ const activeHighlight = getState('__activeElement');
+ const activeStep = getState('__activeStep')!;
+
+ if (!activeHighlight) {
+ return;
+ }
+
+ // 如果存在上一步的 popover,则不隐藏
+ // if (previousPopover) {
+ // refreshOverlay();
+ // repositionPopover(activeHighlight, activeStep);
+ // return;
+ // }
+
+ trackActiveElement(activeHighlight);
+ refreshOverlay();
+ repositionPopover(activeHighlight, activeStep);
+}
+
+function transferHighlight(toElement: Element, toStep: DriveStep) {
+ const duration = 400;
+ const start = Date.now();
+
+ const fromStep = getState('__activeStep');
+ const fromElement = getState('__activeElement') || toElement;
+
+ // If it's the first time we're highlighting an element, we show
+ // the popover immediately. Otherwise, we wait for the animation
+ // to finish before showing the popover.
+ const isFirstHighlight = !fromElement || fromElement === toElement;
+ const isToDummyElement = toElement.id === 'driver-dummy-element';
+ const isFromDummyElement = fromElement.id === 'driver-dummy-element';
+
+ const isAnimatedTour = getConfig('animate');
+ const highlightStartedHook = toStep.onHighlightStarted || getConfig('onHighlightStarted');
+ const highlightedHook = toStep?.onHighlighted || getConfig('onHighlighted');
+ const deselectedHook = fromStep?.onDeselected || getConfig('onDeselected');
+
+ const config = getConfig();
+ const state = getState();
+
+ if (!isFirstHighlight && deselectedHook) {
+ deselectedHook(isFromDummyElement ? undefined : fromElement, fromStep!, {
+ config,
+ state
+ });
+ }
+
+ if (highlightStartedHook) {
+ highlightStartedHook(isToDummyElement ? undefined : toElement, toStep, {
+ config,
+ state
+ });
+ }
+
+ const hasDelayedPopover = !isFirstHighlight && isAnimatedTour;
+ let isPopoverRendered = false;
+
+ hidePopover();
+
+ setState('previousStep', fromStep);
+ setState('previousElement', fromElement);
+ setState('activeStep', toStep);
+ setState('activeElement', toElement);
+
+ const animate = () => {
+ const transitionCallback = getState('__transitionCallback');
+
+ // This makes sure that the repeated calls to transferHighlight
+ // don't interfere with each other. Only the last call will be
+ // executed.
+ if (transitionCallback !== animate) {
+ return;
+ }
+
+ const elapsed = Date.now() - start;
+ const timeRemaining = duration - elapsed;
+ const isHalfwayThrough = timeRemaining <= duration / 2;
+
+ if (toStep.popover && isHalfwayThrough && !isPopoverRendered && hasDelayedPopover) {
+ renderPopover(toElement, toStep);
+ isPopoverRendered = true;
+ }
+
+ if (getConfig('animate') && elapsed < duration) {
+ transitionStage(elapsed, duration, fromElement, toElement);
+ } else {
+ trackActiveElement(toElement);
+
+ if (highlightedHook) {
+ highlightedHook(isToDummyElement ? undefined : toElement, toStep, {
+ config: getConfig(),
+ state: getState()
+ });
+ }
+
+ setState('__transitionCallback', undefined);
+ setState('__previousStep', fromStep);
+ setState('__previousElement', fromElement);
+ setState('__activeStep', toStep);
+ setState('__activeElement', toElement);
+ }
+
+ window.requestAnimationFrame(animate);
+ };
+
+ setState('__transitionCallback', animate);
+
+ window.requestAnimationFrame(animate);
+
+ bringInView(toElement);
+ if (!hasDelayedPopover && toStep.popover) {
+ renderPopover(toElement, toStep);
+ }
+
+ fromElement.classList.remove('driver-active-element', 'driver-no-interaction');
+ fromElement.removeAttribute('aria-haspopup');
+ fromElement.removeAttribute('aria-expanded');
+ fromElement.removeAttribute('aria-controls');
+
+ const disableActiveInteraction = getConfig('disableActiveInteraction');
+ if (disableActiveInteraction) {
+ toElement.classList.add('driver-no-interaction');
+ }
+
+ toElement.classList.add('driver-active-element');
+ toElement.setAttribute('aria-haspopup', 'dialog');
+ toElement.setAttribute('aria-expanded', 'true');
+ toElement.setAttribute('aria-controls', 'driver-popover-content');
+}
+
+export function destroyHighlight() {
+ document.getElementById('driver-dummy-element')?.remove();
+ document.querySelectorAll('.driver-active-element').forEach((element) => {
+ element.classList.remove('driver-active-element', 'driver-no-interaction');
+ element.removeAttribute('aria-haspopup');
+ element.removeAttribute('aria-expanded');
+ element.removeAttribute('aria-controls');
+ });
+}
diff --git a/frontend/packages/driver/src/overlay.ts b/frontend/packages/driver/src/overlay.ts
new file mode 100644
index 000000000..682153782
--- /dev/null
+++ b/frontend/packages/driver/src/overlay.ts
@@ -0,0 +1,254 @@
+import { easeInOutQuad } from './utils';
+import { onDriverClick } from './events';
+import { emit } from './emitter';
+import { getConfig } from './config';
+import { getState, setState } from './state';
+import { PopoverDOM } from './popover';
+import { driver } from './driver';
+export const allPopovers: PopoverDOM[] = [];
+
+export type StageDefinition = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+// This method calculates the animated new position of the
+// stage (called for each frame by requestAnimationFrame)
+export function transitionStage(elapsed: number, duration: number, from: Element, to: Element) {
+ let activeStagePosition = getState('__activeStagePosition');
+
+ const fromDefinition = activeStagePosition ? activeStagePosition : from.getBoundingClientRect();
+ const toDefinition = to.getBoundingClientRect();
+
+ const x = easeInOutQuad(elapsed, fromDefinition.x, toDefinition.x - fromDefinition.x, duration);
+ const y = easeInOutQuad(elapsed, fromDefinition.y, toDefinition.y - fromDefinition.y, duration);
+ const width = easeInOutQuad(
+ elapsed,
+ fromDefinition.width,
+ toDefinition.width - fromDefinition.width,
+ duration
+ );
+ const height = easeInOutQuad(
+ elapsed,
+ fromDefinition.height,
+ toDefinition.height - fromDefinition.height,
+ duration
+ );
+
+ activeStagePosition = {
+ x,
+ y,
+ width,
+ height
+ };
+
+ renderOverlay(activeStagePosition);
+ setState('__activeStagePosition', activeStagePosition);
+}
+
+export function trackActiveElement(element: Element) {
+ if (!element) {
+ return;
+ }
+
+ const definition = element.getBoundingClientRect();
+
+ const activeStagePosition: StageDefinition = {
+ x: definition.x,
+ y: definition.y,
+ width: definition.width,
+ height: definition.height
+ };
+
+ setState('__activeStagePosition', activeStagePosition);
+
+ renderOverlay(activeStagePosition);
+}
+
+export function refreshOverlay() {
+ const activeStagePosition = getState('__activeStagePosition');
+ const overlaySvg = getState('__overlaySvg');
+
+ if (!activeStagePosition) {
+ return;
+ }
+
+ if (!overlaySvg) {
+ console.warn('No stage svg found.');
+ return;
+ }
+
+ const windowX = window.innerWidth;
+ const windowY = window.innerHeight;
+
+ overlaySvg.setAttribute('viewBox', `0 0 ${windowX} ${windowY}`);
+}
+
+function mountOverlay(stagePosition: StageDefinition) {
+ const overlaySvg = createOverlaySvg(stagePosition);
+ document.body.appendChild(overlaySvg);
+
+ onDriverClick(overlaySvg, (e) => {
+ const target = e.target as SVGElement;
+ if (target.tagName !== 'path') {
+ return;
+ }
+
+ emit('overlayClick');
+ });
+
+ setState('__overlaySvg', overlaySvg);
+
+ const allowSkipButton = getConfig('overlaySkipButton');
+
+ if (allowSkipButton && allowSkipButton !== '') {
+ const skipButton = document.createElement('div');
+ skipButton.id = 'driver-popover-overlay-skip-btn';
+ skipButton.classList.add('driver-popover-overlay-skip-btn');
+ skipButton.innerText = getConfig('overlaySkipButton') || '';
+
+ document.body.appendChild(skipButton);
+
+ let countdown = 5;
+ let timeoutId: any;
+ let isButtonDisabled = true;
+
+ const updateButtonText = () => {
+ skipButton.innerText = `${getConfig('overlaySkipButton') || ''} (${countdown}s)`;
+ };
+
+ const countdownInterval = setInterval(() => {
+ countdown -= 1;
+ updateButtonText();
+ }, 1000);
+
+ timeoutId = setTimeout(() => {
+ console.log('Auto skipping after 5 seconds');
+ clearInterval(countdownInterval);
+ enableButton();
+ skipButton.innerText = getConfig('overlaySkipButton') || '';
+ }, 5000);
+
+ updateButtonText();
+
+ function disableButton() {
+ isButtonDisabled = true;
+ skipButton.style.pointerEvents = 'none';
+ skipButton.style.opacity = '0.5';
+ }
+
+ function enableButton() {
+ isButtonDisabled = false;
+ skipButton.style.pointerEvents = 'auto';
+ skipButton.style.opacity = '1';
+ }
+
+ onDriverClick(skipButton, () => {
+ if (!isButtonDisabled) {
+ disableButton();
+ clearInterval(countdownInterval);
+ clearTimeout(timeoutId);
+ emit('skipButtonClick');
+ }
+ });
+
+ setState('__overlaySkipBtn', skipButton);
+ }
+}
+
+function renderOverlay(stagePosition: StageDefinition) {
+ const overlaySvg = getState('__overlaySvg');
+
+ // TODO: cancel rendering if element is not visible
+ if (!overlaySvg) {
+ mountOverlay(stagePosition);
+
+ return;
+ }
+
+ const pathElement = overlaySvg.firstElementChild as SVGPathElement | null;
+ if (pathElement?.tagName !== 'path') {
+ throw new Error('no path element found in stage svg');
+ }
+
+ pathElement.setAttribute('d', generateStageSvgPathString(stagePosition));
+}
+
+function createOverlaySvg(stage: StageDefinition): SVGSVGElement {
+ const windowX = window.innerWidth;
+ const windowY = window.innerHeight;
+
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.classList.add('driver-overlay', 'driver-overlay-animated');
+
+ svg.setAttribute('viewBox', `0 0 ${windowX} ${windowY}`);
+ svg.setAttribute('xmlSpace', 'preserve');
+ svg.setAttribute('xmlnsXlink', 'http://www.w3.org/1999/xlink');
+ svg.setAttribute('version', '1.1');
+ svg.setAttribute('preserveAspectRatio', 'xMinYMin slice');
+
+ svg.style.fillRule = 'evenodd';
+ svg.style.clipRule = 'evenodd';
+ svg.style.strokeLinejoin = 'round';
+ svg.style.strokeMiterlimit = '2';
+ svg.style.zIndex = '10000';
+ svg.style.position = 'fixed';
+ svg.style.top = '0';
+ svg.style.left = '0';
+ svg.style.width = '100%';
+ svg.style.height = '100%';
+
+ const stagePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+
+ stagePath.setAttribute('d', generateStageSvgPathString(stage));
+
+ stagePath.style.fill = getConfig('overlayColor') || 'rgb(0,0,0)';
+ stagePath.style.opacity = `${getConfig('overlayOpacity')}`;
+ stagePath.style.pointerEvents = 'auto';
+ stagePath.style.cursor = 'auto';
+
+ svg.appendChild(stagePath);
+
+ return svg;
+}
+
+function generateStageSvgPathString(stage: StageDefinition) {
+ const windowX = window.innerWidth;
+ const windowY = window.innerHeight;
+
+ const stagePadding = getConfig('stagePadding') || 0;
+ const stageRadius = getConfig('stageRadius') || 0;
+
+ const stageWidth = stage.width + stagePadding * 2;
+ const stageHeight = stage.height + stagePadding * 2;
+
+ // prevent glitches when stage is too small for radius
+ const limitedRadius = Math.min(stageRadius, stageWidth / 2, stageHeight / 2);
+
+ // no value below 0 allowed + round down
+ const normalizedRadius = Math.floor(Math.max(limitedRadius, 0));
+
+ const highlightBoxX = stage.x - stagePadding + normalizedRadius;
+ const highlightBoxY = stage.y - stagePadding;
+ const highlightBoxWidth = stageWidth - normalizedRadius * 2;
+ const highlightBoxHeight = stageHeight - normalizedRadius * 2;
+
+ return `M${windowX},0L0,0L0,${windowY}L${windowX},${windowY}L${windowX},0Z
+ M${highlightBoxX},${highlightBoxY} h${highlightBoxWidth} a${normalizedRadius},${normalizedRadius} 0 0 1 ${normalizedRadius},${normalizedRadius} v${highlightBoxHeight} a${normalizedRadius},${normalizedRadius} 0 0 1 -${normalizedRadius},${normalizedRadius} h-${highlightBoxWidth} a${normalizedRadius},${normalizedRadius} 0 0 1 -${normalizedRadius},-${normalizedRadius} v-${highlightBoxHeight} a${normalizedRadius},${normalizedRadius} 0 0 1 ${normalizedRadius},-${normalizedRadius} z`;
+}
+
+export function destroyOverlay() {
+ const overlaySvg = getState('__overlaySvg');
+ if (overlaySvg) {
+ overlaySvg.remove();
+ }
+}
+
+export function destroyOverlayBtn() {
+ const overlayBtn = getState('__overlaySkipBtn');
+ if (overlayBtn) {
+ overlayBtn.remove();
+ }
+}
diff --git a/frontend/packages/driver/src/popover.tsx b/frontend/packages/driver/src/popover.tsx
new file mode 100644
index 000000000..c7ac9457e
--- /dev/null
+++ b/frontend/packages/driver/src/popover.tsx
@@ -0,0 +1,737 @@
+import { ReactNode } from 'react';
+import { Config, DriverHook, getConfig } from './config';
+import { DriveStep } from './driver';
+import { emit } from './emitter';
+import { onDriverClick } from './events';
+import { State, getState, setState } from './state';
+import { bringInView, getFocusableElements } from './utils';
+import { createRoot } from 'react-dom/client';
+
+export type Side = 'top' | 'right' | 'bottom' | 'left' | 'over';
+export type Alignment = 'start' | 'center' | 'end';
+export type AllowedButtons = 'next' | 'previous' | 'close';
+
+export type Popover = {
+ title?: string;
+ description?: string;
+ side?: Side;
+ align?: Alignment;
+
+ showButtons?: AllowedButtons[];
+ showProgress?: boolean;
+ disableButtons?: AllowedButtons[];
+
+ popoverClass?: string;
+
+ // Button texts
+ progressText?: string;
+ doneBtnText?: string;
+ nextBtnText?: string;
+ prevBtnText?: string;
+
+ // Called after the popover is rendered
+ onPopoverRender?: (popover: PopoverDOM, opts: { config: Config; state: State }) => void;
+
+ // Button callbacks
+ onNextClick?: DriverHook;
+ onPrevClick?: DriverHook;
+ onCloseClick?: DriverHook;
+
+ // custom config
+ isShowButtons?: boolean;
+ borderRadius?: string;
+ PopoverBody?: ReactNode;
+};
+
+export type PopoverDOM = {
+ wrapper: HTMLElement;
+ arrow: HTMLElement;
+ title: HTMLElement;
+ description: HTMLElement;
+ footer: HTMLElement;
+ progress: HTMLElement;
+ previousButton: HTMLButtonElement;
+ nextButton: HTMLButtonElement;
+ closeButton: HTMLButtonElement;
+ footerButtons: HTMLElement;
+ popoverBody: HTMLElement;
+};
+
+export function hidePopover() {
+ const popover = getState('popover');
+ if (!popover) {
+ return;
+ }
+
+ if (getConfig('allowPreviousStep')) {
+ popover.wrapper.style.display = 'block';
+ popover.wrapper.style.opacity = '0.4';
+ } else {
+ popover.wrapper.style.display = 'none';
+ }
+
+ const popoverInfoElement = document.getElementById('popover-info');
+ if (popoverInfoElement) {
+ const parentElement = popoverInfoElement.parentElement;
+ if (parentElement) {
+ parentElement.removeChild(popoverInfoElement);
+ }
+ }
+}
+
+export function renderPopover(element: Element, step: DriveStep) {
+ let popover: PopoverDOM;
+
+ if (!getConfig('allowPreviousStep')) {
+ let temp = getState('popover');
+ if (temp) {
+ document.body.removeChild(temp.wrapper);
+ }
+ }
+
+ // render custom body
+ const { PopoverBody } = step.popover || {};
+ popover = createPopover(PopoverBody);
+ document.body.appendChild(popover.wrapper);
+
+ // important react render popover body done
+ requestAnimationFrame(() => {
+ repositionPopover(element, step);
+ });
+
+ const {
+ title,
+ description,
+ showButtons,
+ disableButtons,
+ showProgress,
+ nextBtnText = getConfig('nextBtnText') || 'Next →',
+ prevBtnText = getConfig('prevBtnText') || '← Previous',
+ progressText = getConfig('progressText') || '{current} of {total}',
+ isShowButtons = getConfig('isShowButtons'),
+ borderRadius
+ } = step.popover || {};
+
+ popover.nextButton.textContent = nextBtnText;
+ popover.previousButton.textContent = prevBtnText;
+ popover.progress.textContent = progressText;
+
+ if (title) {
+ popover.title.textContent = title;
+ popover.title.style.display = 'block';
+ } else {
+ popover.title.style.display = 'none';
+ }
+
+ if (description) {
+ popover.description.textContent = description;
+ popover.description.style.display = 'block';
+ } else {
+ popover.description.style.display = 'none';
+ }
+
+ const showButtonsConfig: AllowedButtons[] = showButtons || getConfig('showButtons')!;
+ const showProgressConfig = showProgress || getConfig('showProgress') || false;
+ const showFooter = isShowButtons
+ ? showButtonsConfig?.includes('next') ||
+ showButtonsConfig?.includes('previous') ||
+ showProgressConfig
+ : false;
+
+ popover.closeButton.style.display = showButtonsConfig.includes('close') ? 'block' : 'none';
+
+ if (showFooter) {
+ popover.footer.style.display = 'flex';
+
+ popover.progress.style.display = showProgressConfig ? 'block' : 'none';
+ popover.nextButton.style.display = showButtonsConfig.includes('next') ? 'block' : 'none';
+ popover.previousButton.style.display = showButtonsConfig.includes('previous')
+ ? 'block'
+ : 'none';
+ } else {
+ popover.footer.style.display = 'none';
+ }
+
+ const disabledButtonsConfig: AllowedButtons[] =
+ disableButtons || getConfig('disableButtons')! || [];
+ if (disabledButtonsConfig?.includes('next')) {
+ popover.nextButton.disabled = true;
+ popover.nextButton.classList.add('driver-popover-btn-disabled');
+ }
+
+ if (disabledButtonsConfig?.includes('previous')) {
+ popover.previousButton.disabled = true;
+ popover.previousButton.classList.add('driver-popover-btn-disabled');
+ }
+
+ if (disabledButtonsConfig?.includes('close')) {
+ popover.closeButton.disabled = true;
+ popover.closeButton.classList.add('driver-popover-btn-disabled');
+ }
+
+ // Reset the popover position
+ const popoverWrapper = popover.wrapper;
+ popoverWrapper.style.display = 'block';
+ popoverWrapper.style.left = '';
+ popoverWrapper.style.top = '';
+ popoverWrapper.style.bottom = '';
+ popoverWrapper.style.right = '';
+ popoverWrapper.style.borderRadius = borderRadius || '';
+
+ popoverWrapper.id = 'driver-popover-content';
+ popoverWrapper.setAttribute('role', 'dialog');
+ popoverWrapper.setAttribute('aria-labelledby', 'driver-popover-title');
+ popoverWrapper.setAttribute('aria-describedby', 'driver-popover-description');
+
+ // Reset the classes responsible for the arrow position
+ const popoverArrow = popover.arrow;
+ popoverArrow.className = 'driver-popover-arrow';
+
+ // Reset any custom classes on the popover
+ const customPopoverClass = step.popover?.popoverClass || getConfig('popoverClass') || '';
+ popoverWrapper.className = `driver-popover ${customPopoverClass}`.trim();
+
+ // Handles the popover button clicks
+ onDriverClick(
+ popover.wrapper,
+ (e) => {
+ const target = e.target as HTMLElement;
+
+ const onNextClick = step.popover?.onNextClick || getConfig('onNextClick');
+ const onPrevClick = step.popover?.onPrevClick || getConfig('onPrevClick');
+ const onCloseClick = step.popover?.onCloseClick || getConfig('onCloseClick');
+
+ if (target.classList.contains('driver-popover-next-btn')) {
+ // If the user has provided a custom callback, call it
+ // otherwise, emit the event.
+ if (onNextClick) {
+ return onNextClick(element, step, {
+ config: getConfig(),
+ state: getState()
+ });
+ } else {
+ return emit('nextClick');
+ }
+ }
+
+ if (target.classList.contains('driver-popover-prev-btn')) {
+ if (onPrevClick) {
+ return onPrevClick(element, step, {
+ config: getConfig(),
+ state: getState()
+ });
+ } else {
+ return emit('prevClick');
+ }
+ }
+
+ if (target.classList.contains('driver-popover-close-btn')) {
+ if (onCloseClick) {
+ return onCloseClick(element, step, {
+ config: getConfig(),
+ state: getState()
+ });
+ } else {
+ return emit('closeClick');
+ }
+ }
+
+ return undefined;
+ },
+ (target) => {
+ // Only prevent the default action if we're clicking on a driver button
+ // This allows us to have links inside the popover title and description
+ return (
+ !popover?.description.contains(target) &&
+ !popover?.title.contains(target) &&
+ typeof target.className === 'string' &&
+ target.className.includes('driver-popover')
+ );
+ }
+ );
+
+ setState('popover', popover);
+
+ const onPopoverRender = step.popover?.onPopoverRender || getConfig('onPopoverRender');
+ if (onPopoverRender) {
+ onPopoverRender(popover, {
+ config: getConfig(),
+ state: getState()
+ });
+ }
+
+ repositionPopover(element, step);
+ bringInView(popoverWrapper);
+
+ // Focus on the first focusable element in active element or popover
+ const isToDummyElement = element.classList.contains('driver-dummy-element');
+ const focusableElement = getFocusableElements([
+ popoverWrapper,
+ ...(isToDummyElement ? [] : [element])
+ ]);
+ if (focusableElement.length > 0) {
+ focusableElement[0].focus();
+ }
+}
+
+type PopoverDimensions = {
+ width: number;
+ height: number;
+ realWidth: number;
+ realHeight: number;
+};
+
+function getPopoverDimensions(): PopoverDimensions | undefined {
+ const popover = getState('popover');
+ if (!popover?.wrapper) {
+ return;
+ }
+
+ const boundingClientRect = popover.wrapper.getBoundingClientRect();
+
+ const stagePadding = getConfig('stagePadding') || 0;
+ const popoverOffset = getConfig('popoverOffset') || 0;
+
+ return {
+ width: boundingClientRect.width + stagePadding + popoverOffset,
+ height: boundingClientRect.height + stagePadding + popoverOffset,
+
+ realWidth: boundingClientRect.width,
+ realHeight: boundingClientRect.height
+ };
+}
+
+function calculateTopForLeftRight(
+ alignment: Alignment,
+ config: {
+ elementDimensions: DOMRect;
+ popoverDimensions: PopoverDimensions;
+ popoverPadding: number;
+ popoverArrowDimensions: { width: number; height: number };
+ }
+): number {
+ const { elementDimensions, popoverDimensions, popoverPadding, popoverArrowDimensions } = config;
+
+ if (alignment === 'start') {
+ return Math.max(
+ Math.min(
+ elementDimensions.top - popoverPadding,
+ window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width
+ ),
+ popoverArrowDimensions.width
+ );
+ }
+
+ if (alignment === 'end') {
+ return Math.max(
+ Math.min(
+ elementDimensions.top -
+ popoverDimensions?.realHeight +
+ elementDimensions.height +
+ popoverPadding,
+ window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
+ ),
+ popoverArrowDimensions.width
+ );
+ }
+
+ if (alignment === 'center') {
+ return Math.max(
+ Math.min(
+ elementDimensions.top + elementDimensions.height / 2 - popoverDimensions?.realHeight / 2,
+ window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
+ ),
+ popoverArrowDimensions.width
+ );
+ }
+
+ return 0;
+}
+
+// Calculate the left placement for top and bottom sides
+function calculateLeftForTopBottom(
+ alignment: Alignment,
+ config: {
+ elementDimensions: DOMRect;
+ popoverDimensions: PopoverDimensions;
+ popoverPadding: number;
+ popoverArrowDimensions: { width: number; height: number };
+ }
+): number {
+ const { elementDimensions, popoverDimensions, popoverPadding, popoverArrowDimensions } = config;
+
+ if (alignment === 'start') {
+ return Math.max(
+ Math.min(
+ elementDimensions.left - popoverPadding,
+ window.innerWidth - popoverDimensions!.realWidth - popoverArrowDimensions.width
+ ),
+ popoverArrowDimensions.width
+ );
+ }
+
+ if (alignment === 'end') {
+ return Math.max(
+ Math.min(
+ elementDimensions.left -
+ popoverDimensions?.realWidth +
+ elementDimensions.width +
+ popoverPadding,
+ window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
+ ),
+ popoverArrowDimensions.width
+ );
+ }
+
+ if (alignment === 'center') {
+ return Math.max(
+ Math.min(
+ elementDimensions.left + elementDimensions.width / 2 - popoverDimensions?.realWidth / 2,
+ window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
+ ),
+ popoverArrowDimensions.width
+ );
+ }
+
+ return 0;
+}
+
+export function repositionPopover(element: Element, step: DriveStep) {
+ const popover = getState('popover');
+ if (!popover) {
+ return;
+ }
+
+ const { align = 'start', side = 'left' } = step?.popover || {};
+
+ // Configure the popover positioning
+ const requiredAlignment: Alignment = align;
+ const requiredSide: Side = element.id === 'driver-dummy-element' ? 'over' : side;
+ const popoverPadding = getConfig('stagePadding') || 0;
+
+ const popoverDimensions = getPopoverDimensions()!;
+
+ const popoverArrowDimensions = popover.arrow.getBoundingClientRect();
+ const elementDimensions = element.getBoundingClientRect();
+
+ const topValue = elementDimensions.top - popoverDimensions!.height;
+ let isTopOptimal = topValue >= 0;
+
+ const bottomValue = window.innerHeight - (elementDimensions.bottom + popoverDimensions!.height);
+ let isBottomOptimal = bottomValue >= 0;
+
+ const leftValue = elementDimensions.left - popoverDimensions!.width;
+ let isLeftOptimal = leftValue >= 0;
+
+ const rightValue = window.innerWidth - (elementDimensions.right + popoverDimensions!.width);
+ let isRightOptimal = rightValue >= 0;
+
+ const noneOptimal = !isTopOptimal && !isBottomOptimal && !isLeftOptimal && !isRightOptimal;
+ let popoverRenderedSide: Side = requiredSide;
+
+ if (requiredSide === 'top' && isTopOptimal) {
+ isRightOptimal = isLeftOptimal = isBottomOptimal = false;
+ } else if (requiredSide === 'bottom' && isBottomOptimal) {
+ isRightOptimal = isLeftOptimal = isTopOptimal = false;
+ } else if (requiredSide === 'left' && isLeftOptimal) {
+ isRightOptimal = isTopOptimal = isBottomOptimal = false;
+ } else if (requiredSide === 'right' && isRightOptimal) {
+ isLeftOptimal = isTopOptimal = isBottomOptimal = false;
+ }
+
+ if (requiredSide === 'over') {
+ const leftToSet = window.innerWidth / 2 - popoverDimensions!.realWidth / 2;
+ const topToSet = window.innerHeight / 2 - popoverDimensions!.realHeight / 2;
+
+ popover.wrapper.style.left = `${leftToSet}px`;
+ popover.wrapper.style.right = `auto`;
+ popover.wrapper.style.top = `${topToSet}px`;
+ popover.wrapper.style.bottom = `auto`;
+ } else if (noneOptimal) {
+ const leftValue = window.innerWidth / 2 - popoverDimensions?.realWidth! / 2;
+ const bottomValue = 10;
+
+ popover.wrapper.style.left = `${leftValue}px`;
+ popover.wrapper.style.right = `auto`;
+ popover.wrapper.style.bottom = `${bottomValue}px`;
+ popover.wrapper.style.top = `auto`;
+ } else if (isLeftOptimal) {
+ const leftToSet = Math.min(
+ leftValue,
+ window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
+ );
+
+ const topToSet = calculateTopForLeftRight(requiredAlignment, {
+ elementDimensions,
+ popoverDimensions,
+ popoverPadding,
+ popoverArrowDimensions
+ });
+
+ popover.wrapper.style.left = `${leftToSet}px`;
+ popover.wrapper.style.top = `${topToSet}px`;
+ popover.wrapper.style.bottom = `auto`;
+ popover.wrapper.style.right = 'auto';
+
+ popoverRenderedSide = 'left';
+ } else if (isRightOptimal) {
+ const rightToSet = Math.min(
+ rightValue,
+ window.innerWidth - popoverDimensions?.realWidth - popoverArrowDimensions.width
+ );
+ const topToSet = calculateTopForLeftRight(requiredAlignment, {
+ elementDimensions,
+ popoverDimensions,
+ popoverPadding,
+ popoverArrowDimensions
+ });
+
+ popover.wrapper.style.right = `${rightToSet}px`;
+ popover.wrapper.style.top = `${topToSet}px`;
+ popover.wrapper.style.bottom = `auto`;
+ popover.wrapper.style.left = 'auto';
+
+ popoverRenderedSide = 'right';
+ } else if (isTopOptimal) {
+ const topToSet = Math.min(
+ topValue,
+ window.innerHeight - popoverDimensions!.realHeight - popoverArrowDimensions.width
+ );
+ let leftToSet = calculateLeftForTopBottom(requiredAlignment, {
+ elementDimensions,
+ popoverDimensions,
+ popoverPadding,
+ popoverArrowDimensions
+ });
+
+ popover.wrapper.style.top = `${topToSet}px`;
+ popover.wrapper.style.left = `${leftToSet}px`;
+ popover.wrapper.style.bottom = `auto`;
+ popover.wrapper.style.right = 'auto';
+
+ popoverRenderedSide = 'top';
+ } else if (isBottomOptimal) {
+ const bottomToSet = Math.min(
+ bottomValue,
+ window.innerHeight - popoverDimensions?.realHeight - popoverArrowDimensions.width
+ );
+
+ let leftToSet = calculateLeftForTopBottom(requiredAlignment, {
+ elementDimensions,
+ popoverDimensions,
+ popoverPadding,
+ popoverArrowDimensions
+ });
+
+ popover.wrapper.style.left = `${leftToSet}px`;
+ popover.wrapper.style.bottom = `${bottomToSet}px`;
+ popover.wrapper.style.top = `auto`;
+ popover.wrapper.style.right = 'auto';
+
+ popoverRenderedSide = 'bottom';
+ }
+
+ // Popover stays on the screen if the element scrolls out of the visible area.
+ // Render the arrow again to make sure it's in the correct position
+ // e.g. if element scrolled out of the screen to the top, the arrow should be rendered
+ // pointing to the top. If the element scrolled out of the screen to the bottom,
+ // the arrow should be rendered pointing to the bottom.
+ if (!noneOptimal) {
+ renderPopoverArrow(requiredAlignment, popoverRenderedSide, element);
+ } else {
+ popover.arrow.classList.add('driver-popover-arrow-none');
+ }
+}
+
+function renderPopoverArrow(alignment: Alignment, side: Side, element: Element) {
+ const popover = getState('popover');
+ if (!popover) {
+ return;
+ }
+
+ const elementDimensions = element.getBoundingClientRect();
+ const popoverDimensions = getPopoverDimensions()!;
+ const popoverArrow = popover.arrow;
+
+ const popoverWidth = popoverDimensions.width;
+ const windowWidth = window.innerWidth;
+ const elementWidth = elementDimensions.width;
+ const elementLeft = elementDimensions.left;
+
+ const popoverHeight = popoverDimensions.height;
+ const windowHeight = window.innerHeight;
+ const elementTop = elementDimensions.top;
+ const elementHeight = elementDimensions.height;
+
+ // Remove all arrow classes
+ popoverArrow.className = 'driver-popover-arrow';
+
+ let arrowSide = side;
+ let arrowAlignment = alignment;
+
+ if (side === 'top') {
+ if (elementLeft + elementWidth <= 0) {
+ arrowSide = 'right';
+ arrowAlignment = 'end';
+ } else if (elementLeft + elementWidth - popoverWidth <= 0) {
+ arrowSide = 'top';
+ arrowAlignment = 'start';
+ }
+ if (elementLeft >= windowWidth) {
+ arrowSide = 'left';
+ arrowAlignment = 'end';
+ } else if (elementLeft + popoverWidth >= windowWidth) {
+ arrowSide = 'top';
+ arrowAlignment = 'end';
+ }
+ } else if (side === 'bottom') {
+ if (elementLeft + elementWidth <= 0) {
+ arrowSide = 'right';
+ arrowAlignment = 'start';
+ } else if (elementLeft + elementWidth - popoverWidth <= 0) {
+ arrowSide = 'bottom';
+ arrowAlignment = 'start';
+ }
+ if (elementLeft >= windowWidth) {
+ arrowSide = 'left';
+ arrowAlignment = 'start';
+ } else if (elementLeft + popoverWidth >= windowWidth) {
+ arrowSide = 'bottom';
+ arrowAlignment = 'end';
+ }
+ } else if (side === 'left') {
+ if (elementTop + elementHeight <= 0) {
+ arrowSide = 'bottom';
+ arrowAlignment = 'end';
+ } else if (elementTop + elementHeight - popoverHeight <= 0) {
+ arrowSide = 'left';
+ arrowAlignment = 'start';
+ }
+
+ if (elementTop >= windowHeight) {
+ arrowSide = 'top';
+ arrowAlignment = 'end';
+ } else if (elementTop + popoverHeight >= windowHeight) {
+ arrowSide = 'left';
+ arrowAlignment = 'end';
+ }
+ } else if (side === 'right') {
+ if (elementTop + elementHeight <= 0) {
+ arrowSide = 'bottom';
+ arrowAlignment = 'start';
+ } else if (elementTop + elementHeight - popoverHeight <= 0) {
+ arrowSide = 'right';
+ arrowAlignment = 'start';
+ }
+
+ if (elementTop >= windowHeight) {
+ arrowSide = 'top';
+ arrowAlignment = 'start';
+ } else if (elementTop + popoverHeight >= windowHeight) {
+ arrowSide = 'right';
+ arrowAlignment = 'end';
+ }
+ } else {
+ }
+
+ if (!arrowSide) {
+ popoverArrow.classList.add('driver-popover-arrow-none');
+ } else {
+ popoverArrow.classList.add(`driver-popover-arrow-side-${arrowSide}`);
+ popoverArrow.classList.add(`driver-popover-arrow-align-${arrowAlignment}`);
+ }
+}
+
+function createPopover(popoverBodyComponent?: ReactNode): PopoverDOM {
+ const wrapper = document.createElement('div');
+ wrapper.classList.add('driver-popover');
+
+ const arrow = document.createElement('div');
+ arrow.classList.add('driver-popover-arrow');
+
+ const title = document.createElement('header');
+ title.id = 'driver-popover-title';
+ title.classList.add('driver-popover-title');
+ title.style.display = 'none';
+ title.innerText = 'Popover Title';
+
+ const description = document.createElement('div');
+ description.id = 'driver-popover-description';
+ description.classList.add('driver-popover-description');
+ description.style.display = 'none';
+ description.innerText = 'Popover description is here';
+
+ const popoverBody = document.createElement('div');
+ popoverBody.id = 'driver-popover-body';
+ popoverBody.classList.add('driver-popover-body');
+ popoverBody.style.display = 'block';
+ description.innerText = 'Popover body is here';
+ if (popoverBodyComponent) {
+ createRoot(popoverBody).render(popoverBodyComponent);
+ }
+
+ const closeButton = document.createElement('button');
+ closeButton.type = 'button';
+ closeButton.classList.add('driver-popover-close-btn');
+ closeButton.setAttribute('aria-label', 'Close');
+ closeButton.innerHTML = '×';
+
+ const footer = document.createElement('footer');
+ footer.classList.add('driver-popover-footer');
+
+ const progress = document.createElement('span');
+ progress.classList.add('driver-popover-progress-text');
+ progress.innerText = '';
+
+ const footerButtons = document.createElement('span');
+ footerButtons.classList.add('driver-popover-navigation-btns');
+
+ const previousButton = document.createElement('button');
+ previousButton.type = 'button';
+ previousButton.classList.add('driver-popover-prev-btn');
+ previousButton.innerHTML = '← Previous';
+
+ const nextButton = document.createElement('button');
+ nextButton.type = 'button';
+ nextButton.classList.add('driver-popover-next-btn');
+ nextButton.innerHTML = 'Next →';
+
+ footerButtons.appendChild(previousButton);
+ footerButtons.appendChild(nextButton);
+ footer.appendChild(progress);
+ footer.appendChild(footerButtons);
+
+ wrapper.appendChild(closeButton);
+ wrapper.appendChild(arrow);
+ wrapper.appendChild(title);
+ wrapper.appendChild(description);
+ wrapper.appendChild(popoverBody);
+ wrapper.appendChild(footer);
+
+ return {
+ wrapper,
+ arrow,
+ title,
+ description,
+ footer,
+ previousButton,
+ nextButton,
+ closeButton,
+ footerButtons,
+ progress,
+ popoverBody
+ };
+}
+
+export function destroyPopover() {
+ const popover = getState('popover');
+ if (!popover) {
+ return;
+ }
+
+ // popover.wrapper.parentElement?.removeChild(popover.wrapper);
+ const popovers = document.querySelectorAll('.driver-popover');
+ popovers.forEach((popover) => {
+ popover.parentElement?.removeChild(popover);
+ });
+}
diff --git a/frontend/packages/driver/src/state.ts b/frontend/packages/driver/src/state.ts
new file mode 100644
index 000000000..9d044f82d
--- /dev/null
+++ b/frontend/packages/driver/src/state.ts
@@ -0,0 +1,46 @@
+import { StageDefinition } from './overlay';
+import { PopoverDOM } from './popover';
+import { DriveStep } from './driver';
+
+export type State = {
+ isInitialized?: boolean;
+
+ activeIndex?: number;
+ activeElement?: Element;
+ activeStep?: DriveStep;
+ previousElement?: Element;
+ previousStep?: DriveStep;
+
+ popover?: PopoverDOM;
+
+ // actual values considering the animation
+ // and delays. These are used to determine
+ // the positions etc.
+ __previousElement?: Element;
+ __activeElement?: Element;
+ __previousStep?: DriveStep;
+ __activeStep?: DriveStep;
+
+ __activeOnDestroyed?: Element;
+ __resizeTimeout?: number;
+ __transitionCallback?: () => void;
+ __activeStagePosition?: StageDefinition;
+ __overlaySvg?: SVGSVGElement;
+ __overlaySkipBtn?: HTMLDivElement;
+};
+
+let currentState: State = {};
+
+export function setState(key: K, value: State[K]) {
+ currentState[key] = value;
+}
+
+export function getState(): State;
+export function getState(key: K): State[K];
+export function getState(key?: K) {
+ return key ? currentState[key] : currentState;
+}
+
+export function resetState() {
+ currentState = {};
+}
diff --git a/frontend/packages/driver/src/utils.ts b/frontend/packages/driver/src/utils.ts
new file mode 100644
index 000000000..75a884481
--- /dev/null
+++ b/frontend/packages/driver/src/utils.ts
@@ -0,0 +1,65 @@
+import { getConfig } from "./config";
+
+export function easeInOutQuad(elapsed: number, initialValue: number, amountOfChange: number, duration: number): number {
+ if ((elapsed /= duration / 2) < 1) {
+ return (amountOfChange / 2) * elapsed * elapsed + initialValue;
+ }
+ return (-amountOfChange / 2) * (--elapsed * (elapsed - 2) - 1) + initialValue;
+}
+
+export function getFocusableElements(parentEls: Element[] | HTMLElement[]) {
+ const focusableQuery =
+ 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';
+
+ return parentEls
+ .flatMap(parentEl => {
+ const isParentFocusable = parentEl.matches(focusableQuery);
+ const focusableEls: HTMLElement[] = Array.from(parentEl.querySelectorAll(focusableQuery));
+
+ return [...(isParentFocusable ? [parentEl as HTMLElement] : []), ...focusableEls];
+ })
+ .filter(el => {
+ return getComputedStyle(el).pointerEvents !== "none" && isElementVisible(el);
+ });
+}
+
+export function bringInView(element: Element) {
+ if (!element || isElementInView(element)) {
+ return;
+ }
+
+ const shouldSmoothScroll = getConfig("smoothScroll");
+
+ element.scrollIntoView({
+ // Removing the smooth scrolling for elements which exist inside the scrollable parent
+ // This was causing the highlight to not properly render
+ behavior: !shouldSmoothScroll || hasScrollableParent(element) ? "auto" : "smooth",
+ inline: "center",
+ block: "center",
+ });
+}
+
+function hasScrollableParent(e: Element) {
+ if (!e || !e.parentElement) {
+ return;
+ }
+
+ const parent = e.parentElement as HTMLElement & { scrollTopMax?: number };
+
+ return parent.scrollHeight > parent.clientHeight;
+}
+
+function isElementInView(element: Element) {
+ const rect = element.getBoundingClientRect();
+
+ return (
+ rect.top >= 0 &&
+ rect.left >= 0 &&
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
+ rect.right <= (window.innerWidth || document.documentElement.clientWidth)
+ );
+}
+
+export function isElementVisible(el: HTMLElement) {
+ return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
+}
diff --git a/frontend/packages/driver/tsconfig.json b/frontend/packages/driver/tsconfig.json
new file mode 100644
index 000000000..0a3133f16
--- /dev/null
+++ b/frontend/packages/driver/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"],
+ "extends": "../../tsconfig.web.json"
+}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 29fffb646..6e05cd14f 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -78,6 +78,9 @@ importers:
'@kubernetes/client-node':
specifier: ^0.18.1
version: 0.18.1
+ '@sealos/driver':
+ specifier: workspace:^
+ version: link:../packages/driver
'@sealos/ui':
specifier: workspace:^
version: link:../packages/ui
@@ -270,6 +273,22 @@ importers:
specifier: ^2.6.2
version: 2.6.2
+ packages/driver:
+ dependencies:
+ react:
+ specifier: ^18.2.0
+ version: 18.2.0
+ react-dom:
+ specifier: ^18.2.0
+ version: 18.2.0(react@18.2.0)
+ devDependencies:
+ '@types/react':
+ specifier: 18.2.37
+ version: 18.2.37
+ '@types/react-dom':
+ specifier: 18.0.11
+ version: 18.0.11
+
packages/ui:
dependencies:
react:
@@ -415,6 +434,9 @@ importers:
'@next/font':
specifier: 13.1.6
version: 13.1.6
+ '@sealos/driver':
+ specifier: workspace:^
+ version: link:../../packages/driver
'@tanstack/react-query':
specifier: ^4.35.3
version: 4.36.1(react-dom@18.2.0)(react@18.2.0)
diff --git a/frontend/providers/applaunchpad/next.config.js b/frontend/providers/applaunchpad/next.config.js
index dfc8ca814..abe4dce2b 100644
--- a/frontend/providers/applaunchpad/next.config.js
+++ b/frontend/providers/applaunchpad/next.config.js
@@ -1,8 +1,8 @@
/** @type {import('next').NextConfig} */
-const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
-const { i18n } = require('./next-i18next.config');
-const analyzer = process.env === 'production' ? [new BundleAnalyzerPlugin()] : [];
-const path = require('path');
+const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
+const { i18n } = require('./next-i18next.config')
+const analyzer = process.env === 'production' ? [new BundleAnalyzerPlugin()] : []
+const path = require('path')
const nextConfig = {
i18n,
output: 'standalone',
@@ -15,13 +15,14 @@ const nextConfig = {
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack']
}
- ]);
- config.plugins = [...config.plugins, ...analyzer];
- return config;
+ ])
+ config.plugins = [...config.plugins, ...analyzer]
+ return config
},
+ transpilePackages: ['@sealos/driver'],
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../')
}
-};
+}
-module.exports = nextConfig;
+module.exports = nextConfig
diff --git a/frontend/providers/applaunchpad/package.json b/frontend/providers/applaunchpad/package.json
index b12909491..c426a1521 100644
--- a/frontend/providers/applaunchpad/package.json
+++ b/frontend/providers/applaunchpad/package.json
@@ -20,6 +20,7 @@
"@emotion/styled": "^11.11.0",
"@kubernetes/client-node": "^0.18.1",
"@next/font": "13.1.6",
+ "@sealos/driver": "workspace:^",
"@tanstack/react-query": "^4.35.3",
"ansi_up": "^5.2.1",
"axios": "^1.5.1",
diff --git a/frontend/providers/applaunchpad/public/locales/en/common.json b/frontend/providers/applaunchpad/public/locales/en/common.json
index 8cb013d45..9bc6b2773 100644
--- a/frontend/providers/applaunchpad/public/locales/en/common.json
+++ b/frontend/providers/applaunchpad/public/locales/en/common.json
@@ -161,5 +161,22 @@
"Surplus": "Surplus"
},
"Store At Least One": "Store At Least One",
- "Network port conflict": "Network port conflict"
-}
+ "Network port conflict": "Network port conflict",
+ "skip": "skip",
+ "Can help you deploy any Docker image": "Can help you deploy any Docker image",
+ "Second-level domain name tips": "After turning it on, you will be automatically assigned an external second-level domain name.",
+ "Click the Deploy Application button": "Click the Deploy Application button",
+ "Click on any shadow to skip": "Click on any shadow to skip",
+ "Click here to visit the website": "Click here to visit the website",
+ "You can enter the container through the terminal": "You can enter the container through the terminal",
+ "Adjust application configuration": "Adjust application configuration",
+ "You have successfully deployed an application": "You have successfully deployed an application",
+ "receive": "receive",
+ "Balance": "Balance",
+ "First time completion guide benefits": "First time completion guide benefits",
+ "Go to recharge": "Recharge",
+ "let me think again": "let me think again",
+ "Configurable number of instances or automatic horizontal scaling": "Configurable number of instances or automatic horizontal scaling",
+ "gift time tip": "Within {{time}} hours ",
+ "gift amount tip": "Recharge {{amount}} and get {{gift}} free"
+}
\ No newline at end of file
diff --git a/frontend/providers/applaunchpad/public/locales/zh/common.json b/frontend/providers/applaunchpad/public/locales/zh/common.json
index 0f1c1dfd0..e17909b2f 100644
--- a/frontend/providers/applaunchpad/public/locales/zh/common.json
+++ b/frontend/providers/applaunchpad/public/locales/zh/common.json
@@ -203,5 +203,22 @@
"user": {
"Insufficient account balance": "账号余额不足~"
},
- "Store At Least One": "存储最少保留一个"
-}
+ "Store At Least One": "存储最少保留一个",
+ "skip": "跳过",
+ "Can help you deploy any Docker image": "可以帮您部署任意 Docker 镜像",
+ "Second-level domain name tips": "开启后将自动为您分配一个外网二级域名",
+ "Click the Deploy Application button": "点击部署应用按钮",
+ "Click on any shadow to skip": "点击任意阴影跳过",
+ "Click here to visit the website": "点击这里访问网站",
+ "You can enter the container through the terminal": "可以通过终端进入容器",
+ "Adjust application configuration": "调整应用配置",
+ "You have successfully deployed an application": "您已成功部署一个应用",
+ "receive": "获得",
+ "Balance": "余额",
+ "First time completion guide benefits": "首次完成引导福利",
+ "Go to recharge": "去充值",
+ "let me think again": "我再想想",
+ "Configurable number of instances or automatic horizontal scaling": "可配置实例数或自动横向伸缩",
+ "gift time tip": "{{time}} 小时内",
+ "gift amount tip": "充值 {{amount}} 赠送 {{gift}} "
+}
\ No newline at end of file
diff --git a/frontend/providers/applaunchpad/src/api/platform.ts b/frontend/providers/applaunchpad/src/api/platform.ts
index 51f70d97e..367fb910e 100644
--- a/frontend/providers/applaunchpad/src/api/platform.ts
+++ b/frontend/providers/applaunchpad/src/api/platform.ts
@@ -1,8 +1,9 @@
-import { GET, POST } from '@/services/request';
import type { Response as InitDataType } from '@/pages/api/platform/getInitData';
-import type { UserQuotaItemType, userPriceType } from '@/types/user';
-import { AuthCnamePrams } from './params';
+import { GET, POST } from '@/services/request';
import { EnvResponse } from '@/types';
+import type { AccountCRD, UserQuotaItemType, userPriceType } from '@/types/user';
+import { AuthCnamePrams } from './params';
+import { UpdateUserGuideParams } from '@/pages/api/guide/updateGuide';
export const getResourcePrice = () => GET('/api/platform/resourcePrice');
@@ -17,3 +18,10 @@ export const getUserQuota = () =>
export const postAuthCname = (data: AuthCnamePrams) => POST('/api/platform/authCname', data);
export const getPlatformEnv = () => GET('/api/platform/getEnv');
+
+export const updateDesktopGuide = (payload: UpdateUserGuideParams) =>
+ POST('/api/guide/updateGuide', payload);
+
+export const getUserAccount = () => GET('/api/guide/getAccount');
+
+export const getPriceBonus = () => GET('/api/guide/getBonus');
diff --git a/frontend/providers/applaunchpad/src/constants/account.ts b/frontend/providers/applaunchpad/src/constants/account.ts
new file mode 100644
index 000000000..00f0180fe
--- /dev/null
+++ b/frontend/providers/applaunchpad/src/constants/account.ts
@@ -0,0 +1,4 @@
+export const GUIDE_LAUNCHPAD_CREATE_KEY = 'frontend.guide.launchpad.create';
+export const GUIDE_LAUNCHPAD_GIFT_KEY = 'activity.beginner-guide.launchpad.giveAmount';
+
+export const GUIDE_LAUNCHPAD_DETAIL_KEY = 'frontend.guide.launchpad.detail';
diff --git a/frontend/providers/applaunchpad/src/hooks/useDetailDriver.tsx b/frontend/providers/applaunchpad/src/hooks/useDetailDriver.tsx
new file mode 100644
index 000000000..b967c77bd
--- /dev/null
+++ b/frontend/providers/applaunchpad/src/hooks/useDetailDriver.tsx
@@ -0,0 +1,346 @@
+import { getPlatformEnv, getPriceBonus, getUserAccount, updateDesktopGuide } from '@/api/platform';
+import { GUIDE_LAUNCHPAD_DETAIL_KEY, GUIDE_LAUNCHPAD_GIFT_KEY } from '@/constants/account';
+import { Flex, FlexProps, Icon, Text } from '@chakra-ui/react';
+import { DriveStep, driver } from '@sealos/driver';
+import { useTranslation } from 'next-i18next';
+import { useEffect, useMemo, useState } from 'react';
+import { sealosApp } from 'sealos-desktop-sdk/app';
+import { DriverStarIcon } from './useDriver';
+import { formatMoney } from '@/utils/tools';
+
+export default function useDriver() {
+ const { t, i18n } = useTranslation();
+ const [showGiftStep, setShowGiftStep] = useState(false);
+ const [activity, setActivity] = useState({
+ balance: 8,
+ limitDuration: '1',
+ amount: '8',
+ giftAmount: '8'
+ });
+
+ const PopoverBodyInfo = (props: FlexProps) => {
+ return (
+
+
+ {t('Click on any shadow to skip')}
+
+
+
+
+
+ );
+ };
+
+ const baseSteps: DriveStep[] = [
+ {
+ element: '.driver-detail-network-public',
+ popover: {
+ side: 'left',
+ align: 'start',
+ borderRadius: '12px 12px 0px 12px',
+ PopoverBody: (
+
+
+
+ {t('Click here to visit the website')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.driver-detail-terminal',
+ popover: {
+ side: 'left',
+ align: 'center',
+ borderRadius: '12px 12px 0px 12px',
+ PopoverBody: (
+
+
+
+ {t('You can enter the container through the terminal')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.driver-detail-update-button',
+ popover: {
+ side: 'bottom',
+ align: 'start',
+ borderRadius: '12px 12px 0px 12px',
+ PopoverBody: (
+
+
+
+ {t('Adjust application configuration')}
+
+
+
+ )
+ }
+ }
+ ];
+
+ const giftStep: DriveStep[] = [
+ {
+ popover: {
+ borderRadius: '12px 12px 12px 12px',
+ PopoverBody: (
+
+
+
+ {t('You have successfully deployed an application')}
+
+ {t('receive')}
+
+ {activity.balance}
+
+
+
+ {t('Balance')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('First time completion guide benefits')}
+
+
+
+ {t('gift time tip', { time: activity.limitDuration })}
+
+
+ {t('gift amount tip', {
+ amount: activity.amount,
+ gift: activity.giftAmount
+ })}
+
+
+ {
+ console.log('充值');
+ driverObj.destroy();
+ openCostCenterApp();
+ }}
+ >
+ {t('Go to recharge')}
+
+ {
+ driverObj.destroy();
+ }}
+ >
+ {t('let me think again')}
+
+
+ ),
+ onPopoverRender: () => {
+ const svg = driverObj.getState('__overlaySvg');
+ if (svg) {
+ const pathElement = svg.querySelector('path');
+ if (pathElement) {
+ pathElement.style.pointerEvents = 'none';
+ }
+ }
+ }
+ }
+ }
+ ];
+
+ const driverConfig = useMemo(() => {
+ return {
+ disableActiveInteraction: true,
+ showProgress: false,
+ allowClose: false,
+ allowClickMaskNextStep: true,
+ allowPreviousStep: false,
+ isShowButtons: false,
+ allowKeyboardControl: false,
+ overlaySkipButton: t('skip') || 'skip',
+ steps: showGiftStep ? [...baseSteps, ...giftStep] : baseSteps,
+ onDestroyed: () => {
+ console.log('onDestroyed Detail');
+ updateGuideStatus();
+ },
+ interceptSkipButtonClick: () => {
+ const skipButton = driverObj.getState('__overlaySkipBtn');
+ skipButton?.remove();
+ if (driverObj.isLastStep()) {
+ driverObj.destroy();
+ } else {
+ driverObj.drive(3);
+ }
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [showGiftStep]);
+
+ const driverObj = driver(driverConfig);
+
+ const openCostCenterApp = () => {
+ sealosApp.runEvents('openDesktopApp', {
+ appKey: 'system-costcenter',
+ query: {
+ openRecharge: 'true'
+ }
+ });
+ };
+
+ const updateGuideStatus = () => {
+ updateDesktopGuide({
+ activityType: 'beginner-guide',
+ phase: 'launchpad',
+ phasePage: 'detail',
+ shouldSendGift: false
+ }).catch((err) => {
+ console.log(err);
+ });
+ };
+
+ useEffect(() => {
+ const handleUserGuide = async () => {
+ try {
+ const systemEnv = await getPlatformEnv();
+ const userAccount = await getUserAccount();
+
+ const bonus = await getPriceBonus();
+ if (bonus?.data?.activities) {
+ const strategy = JSON.parse(bonus.data?.activities);
+ const activity = {
+ balance: formatMoney(
+ strategy?.['beginner-guide']?.phases?.launchpad?.giveAmount || 8000000
+ ),
+ limitDuration: strategy?.[
+ 'beginner-guide'
+ ]?.phases?.launchpad?.RechargeDiscount?.limitDuration?.replace('h', ''),
+ amount: Object.entries(
+ strategy?.['beginner-guide']?.phases?.launchpad?.RechargeDiscount?.specialDiscount
+ )[0][0],
+ giftAmount:
+ Object.entries(
+ strategy?.['beginner-guide']?.phases?.launchpad?.RechargeDiscount?.specialDiscount
+ )[0][1] + ''
+ };
+ setActivity(activity);
+ }
+
+ if (systemEnv.guideEnabled && userAccount?.metadata?.annotations) {
+ const showGiftStep = !!userAccount.metadata.annotations?.[GUIDE_LAUNCHPAD_GIFT_KEY];
+ const isGuided = !!userAccount.metadata.annotations?.[GUIDE_LAUNCHPAD_DETAIL_KEY];
+ if (!isGuided) {
+ setShowGiftStep(showGiftStep);
+ startGuide();
+ }
+ }
+ } catch (error) {
+ console.log(error);
+ }
+ };
+ handleUserGuide();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const startGuide = () => {
+ driverObj.drive();
+ };
+
+ const closeGuide = () => {
+ driverObj.destroy();
+ };
+
+ return { startGuide, closeGuide };
+}
diff --git a/frontend/providers/applaunchpad/src/hooks/useDriver.tsx b/frontend/providers/applaunchpad/src/hooks/useDriver.tsx
new file mode 100644
index 000000000..fa7622fbb
--- /dev/null
+++ b/frontend/providers/applaunchpad/src/hooks/useDriver.tsx
@@ -0,0 +1,214 @@
+import { getPlatformEnv, getUserAccount, updateDesktopGuide } from '@/api/platform';
+import { GUIDE_LAUNCHPAD_CREATE_KEY } from '@/constants/account';
+import { AppEditType } from '@/types/app';
+import { Flex, FlexProps, Icon, Text } from '@chakra-ui/react';
+import { driver } from '@sealos/driver';
+import { useTranslation } from 'next-i18next';
+import { useEffect, useState } from 'react';
+import { UseFormReturn } from 'react-hook-form';
+
+export function DriverStarIcon() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function useDriver({
+ formHook
+}: {
+ formHook: UseFormReturn;
+}) {
+ const { t } = useTranslation();
+ const [isGuided, setIsGuided] = useState(true);
+
+ useEffect(() => {
+ const handleUserGuide = async () => {
+ try {
+ const systemEnv = await getPlatformEnv();
+ const userAccount = await getUserAccount();
+ if (systemEnv.guideEnabled && userAccount?.metadata?.annotations) {
+ const isGuided = !!userAccount.metadata.annotations?.[GUIDE_LAUNCHPAD_CREATE_KEY];
+ if (!isGuided) {
+ startGuide();
+ formHook.setValue('appName', 'hello-world-guide');
+ }
+ setIsGuided(isGuided);
+ }
+ } catch (error) {
+ console.log(error);
+ }
+ };
+ handleUserGuide();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const updateGuideStatus = () => {
+ updateDesktopGuide({
+ activityType: 'beginner-guide',
+ phase: 'launchpad',
+ phasePage: 'create',
+ shouldSendGift: false
+ }).catch((err) => {
+ console.log(err);
+ });
+ };
+
+ const PopoverBodyInfo = (props: FlexProps) => {
+ return (
+
+
+ {t('Click on any shadow to skip')}
+
+
+
+
+
+ );
+ };
+
+ const driverObj = driver({
+ showProgress: false,
+ allowClose: false,
+ allowClickMaskNextStep: true,
+ allowPreviousStep: false,
+ isShowButtons: false,
+ allowKeyboardControl: false,
+ overlaySkipButton: t('skip') || 'skip',
+ steps: [
+ {
+ element: '.driver-deploy-image',
+ popover: {
+ side: 'right',
+ align: 'center',
+ borderRadius: '0px 12px 12px 12px',
+ PopoverBody: (
+
+
+
+ {t('Can help you deploy any Docker image')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.driver-deploy-instance',
+ popover: {
+ side: 'top',
+ align: 'start',
+ borderRadius: '12px 12px 12px 0px',
+ PopoverBody: (
+
+
+
+ {t('Configurable number of instances or automatic horizontal scaling')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.driver-deploy-network-switch',
+ popover: {
+ side: 'top',
+ align: 'start',
+ borderRadius: '12px 12px 12px 0px',
+ PopoverBody: (
+
+
+
+ {t('Second-level domain name tips')}
+
+
+
+ )
+ }
+ },
+ {
+ element: '.driver-deploy-button',
+ popover: {
+ side: 'left',
+ align: 'center',
+ borderRadius: '12px 12px 0px 12px',
+ PopoverBody: (
+
+
+
+ {t('Click the Deploy Application button')}
+
+
+ ),
+ onPopoverRender: () => {
+ const svg = driverObj.getState('__overlaySvg');
+ if (svg) {
+ const pathElement = svg.querySelector('path');
+ if (pathElement) {
+ pathElement.style.pointerEvents = 'none';
+ }
+ }
+ }
+ }
+ }
+ ],
+ onDestroyed: () => {
+ updateGuideStatus();
+ }
+ });
+
+ const startGuide = () => {
+ driverObj.drive();
+ };
+
+ const closeGuide = () => {
+ driverObj.destroy();
+ };
+
+ return { driverObj, startGuide, closeGuide, isGuided };
+}
diff --git a/frontend/providers/applaunchpad/src/pages/_app.tsx b/frontend/providers/applaunchpad/src/pages/_app.tsx
index 305ec0b7b..3e49c3484 100644
--- a/frontend/providers/applaunchpad/src/pages/_app.tsx
+++ b/frontend/providers/applaunchpad/src/pages/_app.tsx
@@ -16,10 +16,10 @@ import NProgress from 'nprogress'; //nprogress module
import { useEffect, useState } from 'react';
import { EVENT_NAME } from 'sealos-desktop-sdk';
import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app';
-
import { getPlatformEnv } from '@/api/platform';
import '@/styles/reset.scss';
import 'nprogress/nprogress.css';
+import '@sealos/driver/src/driver.css';
//Binding events.
Router.events.on('routeChangeStart', () => NProgress.start());
@@ -174,6 +174,19 @@ const App = ({ Component, pageProps }: AppProps) => {
+
diff --git a/frontend/providers/applaunchpad/src/pages/api/guide/getAccount.ts b/frontend/providers/applaunchpad/src/pages/api/guide/getAccount.ts
new file mode 100644
index 000000000..4fc2b9933
--- /dev/null
+++ b/frontend/providers/applaunchpad/src/pages/api/guide/getAccount.ts
@@ -0,0 +1,36 @@
+import { authSession } from '@/services/backend/auth';
+import { jsonRes } from '@/services/backend/response';
+import { ApiResp } from '@/services/kubernet';
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const kubeconfig = await authSession(req.headers);
+ const domain = process.env.SEALOS_DOMAIN;
+ console.log(`https://${domain}/api/v1alpha/account/getAccount`);
+
+ const response = await fetch(`https://${domain}/api/v1alpha/account/getAccount`, {
+ method: 'GET',
+ headers: {
+ Authorization: encodeURIComponent(kubeconfig)
+ }
+ });
+ const result: {
+ code: number;
+ data: any;
+ message: string;
+ } = await response.json();
+
+ if (result.code !== 200) {
+ return jsonRes(res, { code: result.code, message: 'desktop api is err' });
+ } else {
+ return jsonRes(res, { data: result.data });
+ }
+ } catch (err: any) {
+ console.log(err);
+ jsonRes(res, {
+ code: 500,
+ error: err
+ });
+ }
+}
diff --git a/frontend/providers/applaunchpad/src/pages/api/guide/getBonus.ts b/frontend/providers/applaunchpad/src/pages/api/guide/getBonus.ts
new file mode 100644
index 000000000..fefa154be
--- /dev/null
+++ b/frontend/providers/applaunchpad/src/pages/api/guide/getBonus.ts
@@ -0,0 +1,24 @@
+import { authSession } from '@/services/backend/auth';
+import { getK8s } from '@/services/backend/kubernetes';
+import { jsonRes } from '@/services/backend/response';
+import { ApiResp } from '@/services/kubernet';
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const { k8sCore, namespace } = await getK8s({
+ kubeconfig: await authSession(req.headers)
+ });
+
+ const result = await k8sCore.readNamespacedConfigMap('recharge-gift', 'sealos');
+
+ jsonRes(res, {
+ data: result.body
+ });
+ } catch (err: any) {
+ jsonRes(res, {
+ code: 500,
+ error: err
+ });
+ }
+}
diff --git a/frontend/providers/applaunchpad/src/pages/api/guide/updateGuide.ts b/frontend/providers/applaunchpad/src/pages/api/guide/updateGuide.ts
new file mode 100644
index 000000000..c95135fb7
--- /dev/null
+++ b/frontend/providers/applaunchpad/src/pages/api/guide/updateGuide.ts
@@ -0,0 +1,56 @@
+import { authSession } from '@/services/backend/auth';
+import { jsonRes } from '@/services/backend/response';
+import { ApiResp } from '@/services/kubernet';
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+export type UpdateUserGuideParams = {
+ activityType: 'beginner-guide';
+ phase: 'launchpad' | 'database' | 'template' | 'terminal';
+ phasePage: 'create' | 'detail' | 'index';
+ shouldSendGift: boolean;
+};
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const { activityType, phase, phasePage, shouldSendGift } = req.body as UpdateUserGuideParams;
+
+ if (!activityType || !phase || !phasePage)
+ return jsonRes(res, { code: 400, message: 'Bad Request: Invalid parameters' });
+ const kubeconfig = await authSession(req.headers);
+ const domain = process.env.SEALOS_DOMAIN;
+
+ const payload = {
+ activityType,
+ phase,
+ phasePage,
+ shouldSendGift
+ };
+
+ const response = await fetch(`https://${domain}/api/v1alpha/account/updateGuide`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: encodeURIComponent(kubeconfig)
+ },
+ body: JSON.stringify(payload)
+ });
+
+ const result: {
+ code: number;
+ data: any;
+ message: string;
+ } = await response.json();
+
+ if (result.code !== 200) {
+ return jsonRes(res, { code: result.code, message: 'desktop api is err' });
+ } else {
+ return jsonRes(res, { data: result.data });
+ }
+ } catch (err: any) {
+ console.log(err);
+ jsonRes(res, {
+ code: 500,
+ error: err
+ });
+ }
+}
diff --git a/frontend/providers/applaunchpad/src/pages/api/platform/getEnv.ts b/frontend/providers/applaunchpad/src/pages/api/platform/getEnv.ts
index 0a378bd91..d1f61f0e6 100644
--- a/frontend/providers/applaunchpad/src/pages/api/platform/getEnv.ts
+++ b/frontend/providers/applaunchpad/src/pages/api/platform/getEnv.ts
@@ -6,7 +6,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
jsonRes(res, {
data: {
- domain: process.env.SEALOS_DOMAIN || 'cloud.sealos.io'
+ domain: process.env.SEALOS_DOMAIN || 'cloud.sealos.io',
+ guideEnabled: process.env.GUIDE_ENABLED === 'true'
}
});
}
diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/AppMainInfo.tsx b/frontend/providers/applaunchpad/src/pages/app/detail/components/AppMainInfo.tsx
index c99f5a2c2..599f48337 100644
--- a/frontend/providers/applaunchpad/src/pages/app/detail/components/AppMainInfo.tsx
+++ b/frontend/providers/applaunchpad/src/pages/app/detail/components/AppMainInfo.tsx
@@ -110,7 +110,7 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => {
-
+ |
-
+
diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/index.tsx b/frontend/providers/applaunchpad/src/pages/app/detail/index.tsx
index e8f195c06..0337b9d02 100644
--- a/frontend/providers/applaunchpad/src/pages/app/detail/index.tsx
+++ b/frontend/providers/applaunchpad/src/pages/app/detail/index.tsx
@@ -1,20 +1,22 @@
-import React, { useMemo, useState } from 'react';
+import useDetailDriver from '@/hooks/useDetailDriver';
+import { useLoading } from '@/hooks/useLoading';
+import { useToast } from '@/hooks/useToast';
+import { MOCK_APP_DETAIL } from '@/mock/apps';
+import { useAppStore } from '@/store/app';
+import { useGlobalStore } from '@/store/global';
+import { serviceSideProps } from '@/utils/i18n';
import { Box, Flex, useTheme } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
-import { useAppStore } from '@/store/app';
-import { useToast } from '@/hooks/useToast';
-import { useLoading } from '@/hooks/useLoading';
-import { useGlobalStore } from '@/store/global';
-import Header from './components/Header';
-import AppBaseInfo from './components/AppBaseInfo';
-import Pods from './components/Pods';
import dynamic from 'next/dynamic';
-import { MOCK_APP_DETAIL } from '@/mock/apps';
-import { serviceSideProps } from '@/utils/i18n';
+import React, { useMemo, useState } from 'react';
+import AppBaseInfo from './components/AppBaseInfo';
+import Header from './components/Header';
+import Pods from './components/Pods';
const AppMainInfo = dynamic(() => import('./components/AppMainInfo'), { ssr: false });
const AppDetail = ({ appName }: { appName: string }) => {
+ const { startGuide } = useDetailDriver();
const theme = useTheme();
const { toast } = useToast();
const { Loading } = useLoading();
@@ -29,7 +31,7 @@ const AppDetail = ({ appName }: { appName: string }) => {
const [podsLoaded, setPodsLoaded] = useState(false);
const [showSlider, setShowSlider] = useState(false);
- const { refetch } = useQuery(['setAppDetail'], () => setAppDetail(appName), {
+ const { refetch, isSuccess } = useQuery(['setAppDetail'], () => setAppDetail(appName), {
onError(err) {
toast({
title: String(err),
diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx
index d405aafc1..0d0021b15 100644
--- a/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx
+++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx
@@ -413,7 +413,7 @@ const Form = ({
{/* image */}
-
+
{t('Export')} Yaml
-
diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx
index 5e2c15f63..3df46448e 100644
--- a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx
+++ b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx
@@ -1,35 +1,37 @@
-import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
-import { useRouter } from 'next/router';
-import { Flex, Box } from '@chakra-ui/react';
-import type { YamlItemType } from '@/types';
-import {
- json2DeployCr,
- json2Service,
- json2Ingress,
- json2ConfigMap,
- json2Secret,
- json2HPA
-} from '@/utils/deployYaml2Json';
-import { useForm } from 'react-hook-form';
-import { defaultEditVal, editModeMap } from '@/constants/editApp';
import { postDeployApp, putApp } from '@/api/app';
+import { updateDesktopGuide } from '@/api/platform';
+import { noGpuSliderKey } from '@/constants/app';
+import { defaultEditVal, editModeMap } from '@/constants/editApp';
import { useConfirm } from '@/hooks/useConfirm';
+import useDriver from '@/hooks/useDriver';
+import { useLoading } from '@/hooks/useLoading';
+import { useToast } from '@/hooks/useToast';
+import { useAppStore } from '@/store/app';
+import { useGlobalStore } from '@/store/global';
+import { useUserStore } from '@/store/user';
+import type { YamlItemType } from '@/types';
import type { AppEditType, DeployKindsType } from '@/types/app';
import { adaptEditAppData } from '@/utils/adapt';
-import { useToast } from '@/hooks/useToast';
-import { useQuery } from '@tanstack/react-query';
-import { useAppStore } from '@/store/app';
-import { useLoading } from '@/hooks/useLoading';
-import { useGlobalStore } from '@/store/global';
-import Header from './components/Header';
-import Form from './components/Form';
-import Yaml from './components/Yaml';
-import dynamic from 'next/dynamic';
+import {
+ json2ConfigMap,
+ json2DeployCr,
+ json2HPA,
+ json2Ingress,
+ json2Secret,
+ json2Service
+} from '@/utils/deployYaml2Json';
import { serviceSideProps } from '@/utils/i18n';
-import { getErrText, patchYamlListV1, patchYamlList } from '@/utils/tools';
+import { getErrText, patchYamlListV1 } from '@/utils/tools';
+import { Box, Flex } from '@chakra-ui/react';
+import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
-import { noGpuSliderKey } from '@/constants/app';
-import { useUserStore } from '@/store/user';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import Form from './components/Form';
+import Header from './components/Header';
+import Yaml from './components/Yaml';
const ErrorModal = dynamic(() => import('./components/ErrorModal'));
@@ -87,10 +89,8 @@ export const formData2Yamls = (
const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => {
const { t } = useTranslation();
-
const crOldYamls = useRef([]);
const oldAppEditData = useRef();
-
const { toast } = useToast();
const { Loading, setIsLoading } = useLoading();
const router = useRouter();
@@ -123,6 +123,8 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) =>
const formHook = useForm({
defaultValues: defaultEditVal
});
+ const { isGuided, closeGuide } = useDriver({ formHook });
+
const realTimeForm = useRef(defaultEditVal);
// watch form change, compute new yaml
@@ -169,6 +171,16 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) =>
}
router.replace(`/app/detail?name=${formHook.getValues('appName')}`);
+ if (!isGuided) {
+ updateDesktopGuide({
+ activityType: 'beginner-guide',
+ phase: 'launchpad',
+ phasePage: 'create',
+ shouldSendGift: true
+ }).catch((err) => {
+ console.log(err);
+ });
+ }
toast({
title: t(applySuccess),
status: 'success'
@@ -193,7 +205,8 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) =>
t,
applySuccess,
userSourcePrice?.gpu,
- refetchPrice
+ refetchPrice,
+ isGuided
]
);
const submitError = useCallback(() => {
@@ -292,7 +305,8 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) =>
title={title}
yamlList={yamlList}
applyBtnText={applyBtnText}
- applyCb={() =>
+ applyCb={() => {
+ closeGuide();
formHook.handleSubmit((data) => {
const parseYamls = formData2Yamls(
data,
@@ -339,8 +353,8 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) =>
}
openConfirm(() => submitSuccess(parseYamls))();
- }, submitError)()
- }
+ }, submitError)();
+ }}
/>
diff --git a/frontend/providers/applaunchpad/src/styles/global.scss b/frontend/providers/applaunchpad/src/styles/global.scss
index eb0539cad..f2b7fdabb 100644
--- a/frontend/providers/applaunchpad/src/styles/global.scss
+++ b/frontend/providers/applaunchpad/src/styles/global.scss
@@ -24,3 +24,8 @@
white-space: nowrap;
overflow: hidden;
}
+
+#driver-popover-content {
+ padding: 14px;
+ max-width: 600px;
+}
diff --git a/frontend/providers/applaunchpad/src/types/index.d.ts b/frontend/providers/applaunchpad/src/types/index.d.ts
index d2d23f428..59ae1b9fa 100644
--- a/frontend/providers/applaunchpad/src/types/index.d.ts
+++ b/frontend/providers/applaunchpad/src/types/index.d.ts
@@ -24,4 +24,5 @@ declare global {
export type EnvResponse = {
domain: string;
+ guideEnabled: boolean;
};
diff --git a/frontend/providers/applaunchpad/src/types/user.d.ts b/frontend/providers/applaunchpad/src/types/user.d.ts
index ae67d54ea..3fa463b60 100644
--- a/frontend/providers/applaunchpad/src/types/user.d.ts
+++ b/frontend/providers/applaunchpad/src/types/user.d.ts
@@ -35,3 +35,20 @@ export type UserQuotaItemType = {
used: number;
limit: number;
};
+
+export type AccountCRD = {
+ apiVersion: string;
+ kind: string;
+ metadata: {
+ annotations: Record;
+ creationTimestamp: string;
+ generation: number;
+ };
+ status: {
+ activityBonus: number;
+ balance: number;
+ deductionBalance: number;
+ encryptBalance: string;
+ encryptDeductionBalance: string;
+ };
+};
diff --git a/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts b/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts
index c87163609..51668832a 100644
--- a/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts
+++ b/frontend/providers/applaunchpad/src/utils/deployYaml2Json.ts
@@ -20,8 +20,6 @@ export const json2DeployCr = (
handleType: 'edit' | 'create' = 'create',
crYamlList?: DeployKindsType[]
) => {
- console.log(data, 'form data');
-
const totalStorage = data.storeList.reduce((acc, item) => acc + item.value, 0);
const metadata = {
diff --git a/frontend/providers/applaunchpad/src/utils/tools.ts b/frontend/providers/applaunchpad/src/utils/tools.ts
index d2f7059f3..30b051580 100644
--- a/frontend/providers/applaunchpad/src/utils/tools.ts
+++ b/frontend/providers/applaunchpad/src/utils/tools.ts
@@ -480,3 +480,7 @@ export const getErrText = (err: any, def = '') => {
msg && console.log('error =>', msg);
return msg;
};
+
+export const formatMoney = (mone: number) => {
+ return mone / 1000000;
+};
diff --git a/frontend/providers/applaunchpad/src/utils/user.ts b/frontend/providers/applaunchpad/src/utils/user.ts
index bbe29b48d..6065f52eb 100644
--- a/frontend/providers/applaunchpad/src/utils/user.ts
+++ b/frontend/providers/applaunchpad/src/utils/user.ts
@@ -1,4 +1,6 @@
+// use client;
import yaml from 'js-yaml';
+
export const getUserKubeConfig = () => {
let kubeConfig: string =
process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_MOCK_USER || '' : '';
diff --git a/frontend/providers/applaunchpad/tsconfig.json b/frontend/providers/applaunchpad/tsconfig.json
index 470ce06c9..57ce4138a 100644
--- a/frontend/providers/applaunchpad/tsconfig.json
+++ b/frontend/providers/applaunchpad/tsconfig.json
@@ -1,11 +1,22 @@
{
"compilerOptions": {
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"baseUrl": ".",
"paths": {
- "@/*": ["./src/*"]
+ "@/*": [
+ "./src/*"
+ ]
}
},
- "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
+ "include": [
+ "next-env.d.ts",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "src/**/*.d.ts"
+ ],
"extends": "../../tsconfig.web.json"
-}
+}
\ No newline at end of file
diff --git a/frontend/providers/template/src/pages/deploy/components/Header.tsx b/frontend/providers/template/src/pages/deploy/components/Header.tsx
index a0ade710c..1da513e95 100644
--- a/frontend/providers/template/src/pages/deploy/components/Header.tsx
+++ b/frontend/providers/template/src/pages/deploy/components/Header.tsx
@@ -21,9 +21,9 @@ import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import { MouseEvent, useCallback, useMemo } from 'react';
-const MdPart = `](https://cloud.sealos.io/?openapp=system-fastdeploy%3FtemplateName%3Dfastgpt`;
+const MdPart = `](https://cloud.sealos.io/?openapp=system-template%3FtemplateName%3Dfastgpt`;
-const HtmlPart = ` `;
+const HtmlPart = ` `;
const Header = ({
appName,
@@ -70,7 +70,7 @@ const Header = ({
};
const copyTemplateLink = () => {
- const str = `https://${cloudDomain}/?openapp=system-fastdeploy%3FtemplateName%3D${appName}`;
+ const str = `https://${cloudDomain}/?openapp=system-template%3FtemplateName%3D${appName}`;
copyData(str);
};
@@ -85,8 +85,7 @@ const Header = ({
h={'80px'}
borderRadius={'8px'}
backgroundColor={'#FBFBFC'}
- border={' 1px solid rgba(255, 255, 255, 0.50)'}
- >
+ border={' 1px solid rgba(255, 255, 255, 0.50)'}>
@@ -103,8 +102,7 @@ const Header = ({
_hover={{
background: '#F4F6F8'
}}
- onClick={(e) => goGithub(e, templateDetail?.spec?.gitRepo)}
- >
+ onClick={(e) => goGithub(e, templateDetail?.spec?.gitRepo)}>
{t('Home Page')}
@@ -121,8 +119,7 @@ const Header = ({
alignItems={'center'}
_hover={{
background: '#F4F6F8'
- }}
- >
+ }}>
{t('Share')}
@@ -137,8 +134,7 @@ const Header = ({
w="60px"
flexDirection={'column'}
justifyContent={'center'}
- alignItems={'center'}
- >
+ alignItems={'center'}>
@@ -154,8 +150,7 @@ const Header = ({
copyData(HtmlPart)}
- >
+ onClick={() => copyData(HtmlPart)}>
@@ -167,8 +162,7 @@ const Header = ({
flexDirection={'column'}
alignItems={'center'}
ml="auto"
- onClick={() => copyData(MdPart)}
- >
+ onClick={() => copyData(MdPart)}>
@@ -194,8 +188,7 @@ const Header = ({
mt={'8px'}
fontSize={'12px'}
color={'5A646E'}
- fontWeight={400}
- >
+ fontWeight={400}>
{templateDetail?.spec?.description}
@@ -210,8 +203,7 @@ const Header = ({
bg={'myWhite.600'}
borderColor={'myGray.200'}
variant={'base'}
- onClick={handleExportYaml}
- >
+ onClick={handleExportYaml}>
{t('Export')} Yaml
diff --git a/frontend/providers/template/src/pages/deploy/index.tsx b/frontend/providers/template/src/pages/deploy/index.tsx
index a9c994ac3..0af72834b 100644
--- a/frontend/providers/template/src/pages/deploy/index.tsx
+++ b/frontend/providers/template/src/pages/deploy/index.tsx
@@ -136,7 +136,7 @@ export default function EditApp({ appName }: { appName?: string }) {
setCached(JSON.stringify({ ...formHook.getValues(), cachedKey: templateName }));
const _name = encodeURIComponent(`?templateName=${templateName}&sealos_inside=true`);
const _domain = platformEnvs?.SEALOS_CLOUD_DOMAIN;
- const href = `https://${_domain}/?openapp=system-fastdeploy${_name}`;
+ const href = `https://${_domain}/?openapp=system-template${_name}`;
return window.open(href, '_self');
}
const yamls = yamlList.map((item) => item.value);
@@ -231,7 +231,7 @@ export default function EditApp({ appName }: { appName?: string }) {
);
const copyTemplateLink = () => {
- const str = `https://${platformEnvs?.SEALOS_CLOUD_DOMAIN}/?openapp=system-fastdeploy%3FtemplateName%3D${appName}`;
+ const str = `https://${platformEnvs?.SEALOS_CLOUD_DOMAIN}/?openapp=system-template%3FtemplateName%3D${appName}`;
copyData(str);
};
@@ -256,8 +256,7 @@ export default function EditApp({ appName }: { appName?: string }) {
overflow={'auto'}
position={'relative'}
borderRadius={'12px'}
- background={'linear-gradient(180deg, #FFF 0%, rgba(255, 255, 255, 0.70) 100%)'}
- >
+ background={'linear-gradient(180deg, #FFF 0%, rgba(255, 255, 255, 0.70) 100%)'}>
+ backdropBlur={'100px'}>
+ cursor={'pointer'}>
+ }}>
router.push('/')}
- >
+ onClick={() => router.push('/')}>
router.push('/')}>
@@ -308,8 +303,7 @@ export default function EditApp({ appName }: { appName?: string }) {
+ color={router.pathname === '/deploy' ? '#262A32' : '#7B838B'}>
{data?.templateYaml?.metadata?.name}
@@ -320,8 +314,7 @@ export default function EditApp({ appName }: { appName?: string }) {
flexDirection={'column'}
width={'100%'}
flexGrow={1}
- backgroundColor={'rgba(255, 255, 255, 0.90)'}
- >
+ backgroundColor={'rgba(255, 255, 255, 0.90)'}>
|