diff --git a/package.json b/package.json index f5c915ff..41ffad32 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-dom": "^19.2.0", "react-error-boundary": "^6.1.2", "react-hook-form": "^7.71.1", + "react-joyride": "^3.2.0", "react-markdown": "^10.1.0", "react-remove-scroll": "^2.7.2", "react-router-dom": "^7.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 887a02e4..4aff611c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: react-hook-form: specifier: ^7.71.1 version: 7.71.1(react@19.2.4) + react-joyride: + specifier: ^3.2.0 + version: 3.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -73,7 +76,7 @@ importers: version: 4.3.6 zustand: specifier: ^5.0.10 - version: 5.0.11(@types/react@19.2.14)(react@19.2.4) + version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@commitlint/cli': specifier: ^20.3.1 @@ -532,6 +535,24 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@formatjs/ecma402-abstract@2.3.6': resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} @@ -547,6 +568,17 @@ packages: '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@gilbarbara/deep-equal@0.4.1': + resolution: {integrity: sha512-QF2BGeQjsa59T59XvFdR3is5jrl28Eg0J6giXAC5919bcqvR8XP4B+07tpbs6Y6/IQd4FBncaL2WVXIBgSxt4w==} + + '@gilbarbara/hooks@0.11.0': + resolution: {integrity: sha512-CIVazdxqFRplUfm9wZL3/0X1TURJekhPMWGFdWzEmyJrGPiotX2yxA1KiB8N7VnhawIaMtb2Apnda4Y6DRwi2Q==} + peerDependencies: + react: 16.8 - 19 + + '@gilbarbara/types@0.2.2': + resolution: {integrity: sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ==} + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -2799,6 +2831,9 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-lite@2.0.0: + resolution: {integrity: sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g==} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -3668,12 +3703,24 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-innertext@1.1.5: + resolution: {integrity: sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==} + peerDependencies: + '@types/react': '>=0.0.0 <=99' + react: '>=0.0.0 <=99' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-joyride@3.2.0: + resolution: {integrity: sha512-UrA/XWdWElMLNnjZt2eWmPFmpHl1IZ2CeI9XhS+PuEVvfqHMD9U4tavCq1csDAGVoj0YEGfaCDmhZwgNc9yE2g==} + peerDependencies: + react: 16.8 - 19 + react-dom: 16.8 - 19 + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -3841,6 +3888,12 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scroll@3.0.1: + resolution: {integrity: sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==} + + scrollparent@2.1.0: + resolution: {integrity: sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -4224,6 +4277,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -4328,6 +4385,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} @@ -4963,6 +5025,25 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/deepmerge@3.2.1': {} + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.12': {} + '@formatjs/ecma402-abstract@2.3.6': dependencies: '@formatjs/fast-memoize': 2.2.7 @@ -4989,6 +5070,17 @@ snapshots: dependencies: tslib: 2.8.1 + '@gilbarbara/deep-equal@0.4.1': {} + + '@gilbarbara/hooks@0.11.0(react@19.2.4)': + dependencies: + '@gilbarbara/deep-equal': 0.4.1 + react: 19.2.4 + + '@gilbarbara/types@0.2.2': + dependencies: + type-fest: 4.41.0 + '@hookform/resolvers@5.2.2(react-hook-form@7.71.1(react@19.2.4))': dependencies: '@standard-schema/utils': 0.3.0 @@ -7503,6 +7595,8 @@ snapshots: is-hexadecimal@2.0.1: {} + is-lite@2.0.0: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -8503,10 +8597,32 @@ snapshots: dependencies: react: 19.2.4 + react-innertext@1.1.5(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/react': 19.2.14 + react: 19.2.4 + react-is@16.13.1: {} react-is@17.0.2: {} + react-joyride@3.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@fastify/deepmerge': 3.2.1 + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@gilbarbara/deep-equal': 0.4.1 + '@gilbarbara/hooks': 0.11.0(react@19.2.4) + '@gilbarbara/types': 0.2.2 + is-lite: 2.0.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-innertext: 1.1.5(@types/react@19.2.14)(react@19.2.4) + scroll: 3.0.1 + scrollparent: 2.1.0 + use-sync-external-store: 1.6.0(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/hast': 3.0.4 @@ -8727,6 +8843,10 @@ snapshots: scheduler@0.27.0: {} + scroll@3.0.1: {} + + scrollparent@2.1.0: {} + semver@5.7.2: {} semver@6.3.1: {} @@ -9172,6 +9292,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -9307,6 +9429,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + util@0.12.5: dependencies: inherits: 2.0.4 @@ -9553,9 +9679,10 @@ snapshots: zod@4.3.6: {} - zustand@5.0.11(@types/react@19.2.14)(react@19.2.4): + zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) zwitch@2.0.4: {} diff --git a/src/components/common/OnboardingTooltip.tsx b/src/components/common/OnboardingTooltip.tsx new file mode 100644 index 00000000..2527b2a0 --- /dev/null +++ b/src/components/common/OnboardingTooltip.tsx @@ -0,0 +1,68 @@ +import type { TooltipRenderProps } from "react-joyride"; + +import Button from "@/components/common/button/Button"; + +export default function OnboardingTooltip({ + continuous, + index, + size, + step, + isLastStep, + backProps, + primaryProps, + skipProps, + tooltipProps, +}: TooltipRenderProps) { + return ( +
+
+
+ {step.title && ( +

+ {step.title} +

+ )} + + {index + 1} / {size} + +
+ +

+ {step.content} +

+ +
+ {!isLastStep ? ( + + ) : ( + + )} + +
+ {index > 0 && ( + + )} + {continuous && ( + + )} +
+
+
+
+ ); +} diff --git a/src/components/common/OnboardingTour.tsx b/src/components/common/OnboardingTour.tsx new file mode 100644 index 00000000..0b8e4010 --- /dev/null +++ b/src/components/common/OnboardingTour.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from "react"; +import { Joyride } from "react-joyride"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useOnboardingTour } from "@/hooks/common/useOnboardingTour"; + +import OnboardingTooltip from "@/components/common/OnboardingTooltip"; + +interface IOnboardingTourProps { + autoStart?: boolean; +} + +export default function OnboardingTour({ + autoStart = false, +}: IOnboardingTourProps) { + const { run, startTour, handleEvent, steps, myRole } = useOnboardingTour(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const tourStarted = useRef(false); + + useEffect(() => { + if (!autoStart || tourStarted.current) return; + if ((myRole === "ADMIN" || myRole === null) && pathname !== "/workspace") { + navigate("/workspace", { replace: true }); + return; + } + if (myRole === "MEMBER" && pathname !== "/dashboard") { + navigate("/dashboard", { replace: true }); + return; + } + tourStarted.current = true; + startTour(); + }, [autoStart, myRole, pathname, navigate, startTour]); + + return ( + + ); +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index a9948500..328035d6 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -52,7 +52,7 @@ function getMainItemClass(isActive: boolean, isCollapsed: boolean) { "flex cursor-pointer items-center rounded-2xl px-3 font-body2 transition-colors duration-200", isCollapsed ? "h-[55px] w-[55px] mx-auto flex justify-center" - : "h-[55px] gap-4 px-3", + : "h-[55px] w-full gap-4 px-3", isActive ? "bg-primary-400 text-surface-100" : "text-text-auth-sub hover:bg-surface-200", @@ -171,7 +171,7 @@ export default function Sidebar() { transition={{ type: "spring", stiffness: 320, damping: 34 }} >
-
+
@@ -206,7 +206,10 @@ export default function Sidebar() { setOpenId, )} > -
+
s.myRole); + const [run, setRun] = useState(false); + + const startTour = useCallback(() => { + setRun(true); + }, []); + + const handleEvent = useCallback((data: EventData) => { + const { status, type } = data; + + const isFinished = status === STATUS.FINISHED || status === STATUS.SKIPPED; + const isError = type === EVENTS.TARGET_NOT_FOUND; + + if (isFinished || isError) { + setRun(false); + localStorage.setItem(ONBOARDING_KEY, "true"); + } + }, []); + + const steps = + myRole === "MEMBER" + ? MEMBER_STEPS + : myRole === "ADMIN" + ? ADMIN_STEPS + : ADMIN_STEPS; + + return { run, startTour, handleEvent, steps, myRole }; +} diff --git a/src/layout/main/MainLayout.tsx b/src/layout/main/MainLayout.tsx index 7d90a980..d9935e4f 100644 --- a/src/layout/main/MainLayout.tsx +++ b/src/layout/main/MainLayout.tsx @@ -18,6 +18,7 @@ import { import { useCoreQuery } from "@/hooks/customQuery"; +import OnboardingTour from "@/components/common/OnboardingTour"; import Sidebar from "@/components/sidebar/Sidebar"; import { getMyInfo } from "@/api/auth/auth"; @@ -157,10 +158,16 @@ export default function MainLayout() { return (
+ {(myRole !== null || + (workspaces !== undefined && workspaces.length === 0)) && ( + + )}
-
+
diff --git a/src/pages/auth/Login.tsx b/src/pages/auth/Login.tsx index 8556e8af..f6451813 100644 --- a/src/pages/auth/Login.tsx +++ b/src/pages/auth/Login.tsx @@ -43,7 +43,12 @@ export default function Login() { const onSubmit: SubmitHandler = (data) => { useLogin.mutate(data, { onSuccess: () => { - navigate(getSafeReturnUrl(returnUrl), { replace: true }); + const isFirstLogin = !localStorage.getItem("hasSeenOnboarding"); + if (isFirstLogin) { + navigate("/workspace", { replace: true }); + } else { + navigate(getSafeReturnUrl(returnUrl), { replace: true }); + } }, onError: (error) => { toast.error(error.message ?? "로그인에 실패했습니다."); diff --git a/src/pages/auth/RedirectPage.tsx b/src/pages/auth/RedirectPage.tsx index 44cd3af3..b9a7e41c 100644 --- a/src/pages/auth/RedirectPage.tsx +++ b/src/pages/auth/RedirectPage.tsx @@ -40,7 +40,12 @@ export default function RedirectPage() { setAccessToken(accessToken); toast.success("소셜 로그인되었습니다."); - navigate(getSafeReturnUrl(storedReturnUrl), { replace: true }); + const isFirstLogin = !localStorage.getItem("hasSeenOnboarding"); + if (isFirstLogin) { + navigate("/workspace", { replace: true }); + } else { + navigate(getSafeReturnUrl(storedReturnUrl), { replace: true }); + } } else { toast.error("소셜 로그인에 실패했습니다. 다시 시도해주세요."); navigate(buildPathWithReturnUrl("/login", storedReturnUrl), { diff --git a/src/pages/integration/PlatformIntegrationsPage.tsx b/src/pages/integration/PlatformIntegrationsPage.tsx index 7528f036..13939c36 100644 --- a/src/pages/integration/PlatformIntegrationsPage.tsx +++ b/src/pages/integration/PlatformIntegrationsPage.tsx @@ -202,6 +202,7 @@ export default function PlatformIntegrationsPage() {
  • 워크스페이스 생성하기 diff --git a/src/styles/utilities.css b/src/styles/utilities.css index ea483844..9169b129 100644 --- a/src/styles/utilities.css +++ b/src/styles/utilities.css @@ -130,6 +130,12 @@ 0 4px 20px color-mix(in srgb, var(--color-text-400) 6%, transparent); } + .shadow-tooltip { + box-shadow: + 0 4px 6px color-mix(in srgb, var(--color-text-400) 6%, transparent), + 0 12px 32px color-mix(in srgb, var(--color-text-400) 14%, transparent); + } + /* UI 트랜지션 */ .transition-ui-smooth { transition-property: transform, opacity, background-color, border-color, color, @@ -238,6 +244,28 @@ animation: none; } } + + /* 온보딩 툴팁 등장 애니메이션 */ + @keyframes tooltip-enter { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } + } + + .animate-tooltip-enter { + animation: tooltip-enter 160ms var(--ease-out) forwards; + } + + @media (prefers-reduced-motion: reduce) { + .animate-tooltip-enter { + animation: none; + } + } } /* 플랫폼 상세 테이블 스크롤바 — @theme 팔레트만 사용 */