diff --git a/frontend/desktop/next.config.js b/frontend/desktop/next.config.js index 777b4e2d3..aca0b008a 100644 --- a/frontend/desktop/next.config.js +++ b/frontend/desktop/next.config.js @@ -15,7 +15,7 @@ const nextConfig = withPWA({ reactStrictMode: false, swcMinify: isProduction, output: 'standalone', - transpilePackages: ['@sealos/ui', 'sealos-desktop-sdk'], + transpilePackages: ['@sealos/ui', 'sealos-desktop-sdk', '@sealos/driver'], experimental: { outputFileTracingRoot: path.join(__dirname, '../') } diff --git a/frontend/desktop/package.json b/frontend/desktop/package.json index b0db1e93b..97a8bd3a1 100644 --- a/frontend/desktop/package.json +++ b/frontend/desktop/package.json @@ -25,6 +25,7 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@kubernetes/client-node": "^0.18.1", + "@sealos/driver": "workspace:^", "@sealos/ui": "workspace:^", "@tanstack/react-query": "^4.35.3", "axios": "^1.5.1", diff --git a/frontend/desktop/public/icons/driverStar.svg b/frontend/desktop/public/icons/driverStar.svg new file mode 100644 index 000000000..506fa8bea --- /dev/null +++ b/frontend/desktop/public/icons/driverStar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/desktop/public/images/driver-bg.png b/frontend/desktop/public/images/driver-bg.png new file mode 100644 index 000000000..c239453f4 Binary files /dev/null and b/frontend/desktop/public/images/driver-bg.png differ diff --git a/frontend/desktop/public/locales/en/common.json b/frontend/desktop/public/locales/en/common.json index 821f7ec6a..35ebe0c12 100644 --- a/frontend/desktop/public/locales/en/common.json +++ b/frontend/desktop/public/locales/en/common.json @@ -100,7 +100,21 @@ "Purchase Link Error": "Purchase Link Error", "You have not purchased the License": "You have not purchased the License", "App Info": "App Info", - "Click anywhere to continue": "Click on any blank space to continue", "Jump Over": "Jump Over", - "Detail": "Detail" -} \ No newline at end of file + "Detail": "Detail", + "Hello, welcome": "Hello, welcome to", + "Quick application switching floating ball": "Quick application switching floating ball", + "You can use the kubectl command directly from the terminal": "You can use the kubectl command directly from the terminal", + "Help you enable high availability database": "Help you enable high availability database", + "Launch various third-party applications with one click": "Launch various third-party applications with one click", + "You can view fees through the fee center": "You can view fees through the fee center", + "deploy an application": "Let’s deploy an application~", + "spend": "spend", + "Completed the deployment of an nginx for the first time": "Completed the deployment of an nginx for the first time", + "Next time": "Next", + "start immediately": "Start", + "Click on any shadow to skip": "Click on any shadow to skip", + "Start your Sealos journey": "Start your Sealos journey", + "gift amount": "Reward {{amount}} balance.", + "Recharge Amount": "Recharge Amount" +} diff --git a/frontend/desktop/public/locales/zh/common.json b/frontend/desktop/public/locales/zh/common.json index 343790912..5c2bd7082 100644 --- a/frontend/desktop/public/locales/zh/common.json +++ b/frontend/desktop/public/locales/zh/common.json @@ -24,8 +24,6 @@ "confirmNewPassword": "确认新密码", "passwordMismatch": "密码不一致", "passwordChangeSuccess": "密码修改成功", - "passwordMismatch": "密码不一致", - "newPassword": "新密码", "Verify password": "确认密码", "change": "修改", "Invalid username or password": "用户名或密码错误", @@ -96,7 +94,20 @@ "Purchase Link Error": "购买链接错误", "You have not purchased the License": "您还没有购买 License", "App Info": "应用信息", - "Click anywhere to continue": "点击任意空白继续", "Jump Over": "跳过", - "Detail": "详情" + "Detail": "详情", + "Hello, welcome": "您好, 欢迎来到", + "Quick application switching floating ball": "快捷应用切换悬浮球", + "You can use the kubectl command directly from the terminal": "您可通过终端直接使用 kubectl 命令", + "Help you enable high availability database": "帮您启用高可用数据库", + "Launch various third-party applications with one click": "一键启动各种第三方应用", + "You can view fees through the fee center": "您可通过费用中心查看费用", + "deploy an application": "来部署一个应用吧~", + "spend": "花", + "Completed the deployment of an nginx for the first time": "部署一个 nginx ,首次完成 将", + "Next time": "下次吧", + "start immediately": "立即开始", + "Click on any shadow to skip": "点击任意阴影跳过", + "Start your Sealos journey": "开始您的 Sealos 之旅", + "gift amount": "赠送 {{amount}} 余额." } \ No newline at end of file diff --git a/frontend/desktop/src/api/platform.ts b/frontend/desktop/src/api/platform.ts index 5497c8316..b9b4d96f4 100644 --- a/frontend/desktop/src/api/platform.ts +++ b/frontend/desktop/src/api/platform.ts @@ -1,4 +1,6 @@ import request from '@/services/request'; +import { ApiResp } from '@/types'; +import { AccountCRD } from '@/types/user'; // handle baidu export const uploadConvertData = (newType: number[], url?: string) => { @@ -14,3 +16,26 @@ export const uploadConvertData = (newType: number[], url?: string) => { main_url }); }; + +export const updateDesktopGuide = () => { + return request.post('/api/account/updateGuide'); +}; + +export const getUserAccount = () => { + return request.get('/api/account/getAccount'); +}; + +export const getSystemEnv = () => { + return request.get('/api/platform/getEnv'); +}; + +export const getPriceBonus = () => { + return request.get< + any, + ApiResp<{ + steps: string; + ratios: string; + activities: string; + }> + >('/api/price/bonus'); +}; diff --git a/frontend/desktop/src/components/desktop_content/index.tsx b/frontend/desktop/src/components/desktop_content/index.tsx index b600162f7..80d07305e 100644 --- a/frontend/desktop/src/components/desktop_content/index.tsx +++ b/frontend/desktop/src/components/desktop_content/index.tsx @@ -1,19 +1,20 @@ import AppWindow from '@/components/app_window'; import MoreButton from '@/components/more_button'; +import UserMenu from '@/components/user_menu'; import useAppStore from '@/stores/app'; import { TApp } from '@/types'; -import { Box, Flex, Grid, GridItem, Image, Text } from '@chakra-ui/react'; +import { Box, Flex, Grid, GridItem, Image, Text, Icon, FlexProps, Button } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import dynamic from 'next/dynamic'; import { MouseEvent, useCallback, useEffect, useState } from 'react'; import { createMasterAPP, masterApp } from 'sealos-desktop-sdk/master'; import IframeWindow from './iframe_window'; import styles from './index.module.scss'; +import useDriver from '@/hooks/useDriver'; const TimeComponent = dynamic(() => import('./time'), { ssr: false }); -import UserMenu from '@/components/user_menu'; export default function DesktopContent(props: any) { const { t, i18n } = useTranslation(); @@ -66,6 +67,8 @@ export default function DesktopContent(props: any) { return masterApp?.addEventListen('openDesktopApp', openDesktopApp); }, [openDesktopApp]); + const { UserGuide, showGuide } = useDriver({ openDesktopApp }); + return ( + {showGuide ? ( + <> + + + + ) : ( + <> + )} {/* desktop apps */} handleDoubleClick(e, item)} > - {/* opened apps */} {runningInfo.map((process) => { return ( diff --git a/frontend/desktop/src/components/floating_button/index.tsx b/frontend/desktop/src/components/floating_button/index.tsx index 35ce17f9c..946adf1e8 100644 --- a/frontend/desktop/src/components/floating_button/index.tsx +++ b/frontend/desktop/src/components/floating_button/index.tsx @@ -169,7 +169,7 @@ export default function Index(props: any) { >
{ 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 = () => ( + + driver + + + {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://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy%3FtemplateName%3Dfastgpt`; +const MdPart = `![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-template%3FtemplateName%3Dfastgpt`; -const HtmlPart = `Deploy on Sealos`; +const HtmlPart = `Deploy on Sealos`; 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