diff --git a/.gitignore b/.gitignore index 52f3fcf21..a0f19970c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,3 @@ .vscode .DS_Store -.vscode/extensions.json -.vscode/settings.json -.idea -frontend/techpick/.env.production -frontend/techpick/.env.development -frontend/techpick-extension/.env.development -frontend/techpick-extension/.env.production +.idea \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index af5823b76..7cd95b789 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -19,13 +19,6 @@ logs npm-debug.log* yarn-debug.log* yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local # Editor directories and files .vscode/* @@ -37,3 +30,13 @@ dist-ssr *.njsproj *.sln *.sw? + +# Build output +dist/ + +# Dependencies +node_modules/ + +# ENV +.env.development +.env.production \ No newline at end of file diff --git a/frontend/techpick/package.json b/frontend/techpick/package.json index fb6a972d9..654e12282 100644 --- a/frontend/techpick/package.json +++ b/frontend/techpick/package.json @@ -27,6 +27,8 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-visually-hidden": "^1.1.0", "@sentry/nextjs": "8", "@tanstack/react-query": "^5.59.0", diff --git a/frontend/techpick/public/video/multiSelectPickMove.gif b/frontend/techpick/public/video/multiSelectPickMove.gif new file mode 100644 index 000000000..6c350b34f Binary files /dev/null and b/frontend/techpick/public/video/multiSelectPickMove.gif differ diff --git a/frontend/techpick/public/video/recommendPickMove.gif b/frontend/techpick/public/video/recommendPickMove.gif new file mode 100644 index 000000000..3bce6f161 Binary files /dev/null and b/frontend/techpick/public/video/recommendPickMove.gif differ diff --git a/frontend/techpick/src/app/(signed)/mypage/page.css.ts b/frontend/techpick/src/app/(signed)/mypage/page.css.ts index d98dc73f0..c38601c3e 100644 --- a/frontend/techpick/src/app/(signed)/mypage/page.css.ts +++ b/frontend/techpick/src/app/(signed)/mypage/page.css.ts @@ -8,13 +8,6 @@ export const myPageLayoutStyle = style({ backgroundColor: colorVars.gold2, }); -export const buttonSectionLayout = style({ - display: 'flex', - justifyContent: 'flex-end', - alignItems: 'center', - padding: '8px', -}); - export const logoutButtonStyle = style({ width: '120px', height: '32px', @@ -28,4 +21,27 @@ export const logoutButtonStyle = style({ ':hover': { backgroundColor: colorVars.red3, }, + + ':focus': { + backgroundColor: colorVars.red3, + }, +}); + +export const myPageContentContainerLayoutStyle = style({ + display: 'flex', + alignItems: 'start', + justifyContent: 'space-between', +}); + +export const tutorialReplaySwitchLayoutStyle = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '16px 0', +}); + +export const tutorialReplaySwitchLabelStyle = style({ + fontSize: '12px', + cursor: 'pointer', + flexShrink: 0, }); diff --git a/frontend/techpick/src/app/(signed)/mypage/page.tsx b/frontend/techpick/src/app/(signed)/mypage/page.tsx index a52c8207d..c71b07f6b 100644 --- a/frontend/techpick/src/app/(signed)/mypage/page.tsx +++ b/frontend/techpick/src/app/(signed)/mypage/page.tsx @@ -3,11 +3,14 @@ import { postLogout } from '@/apis/postLogout'; import MyPageContentContainer from '@/components/MyPage/MyPageContentContainer'; import MyPageShareFolderContent from '@/components/MyPage/MyPageShareFolderContent'; +import { TutorialReplaySwitch } from '@/components/TutorialReplaySwitch'; import { ROUTES } from '@/constants'; import { - buttonSectionLayout, logoutButtonStyle, + myPageContentContainerLayoutStyle, myPageLayoutStyle, + tutorialReplaySwitchLabelStyle, + tutorialReplaySwitchLayoutStyle, } from './page.css'; export default function MyPage() { @@ -22,17 +25,29 @@ export default function MyPage() { return (
- -
내 계정 정보
-
+
+ +
+
내 계정 정보
+ +
+
+
+ + +
+
+ -
- -
); } diff --git a/frontend/techpick/src/app/(signed)/recommend/page.tsx b/frontend/techpick/src/app/(signed)/recommend/page.tsx index f7d722e84..e5c5a3d63 100644 --- a/frontend/techpick/src/app/(signed)/recommend/page.tsx +++ b/frontend/techpick/src/app/(signed)/recommend/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { getSuggestionRankingPicks } from '@/apis/getSuggestionRankingPicks'; import { FolderContentLayout } from '@/components/FolderContentLayout'; import { RecommendedPickCarousel } from '@/components/RecommendedPickCarousel/RecommendedPickCarousel'; +import { TutorialDialog } from '@/components/TutorialDialog'; import { useClearSelectedPickIdsOnMount, useFetchTagList, @@ -57,6 +58,8 @@ export default function RecommendPage() { return ( + +

🔥HOT TREND!🔥

diff --git a/frontend/techpick/src/components/FolderTree/folderTreeHeader.css.ts b/frontend/techpick/src/components/FolderTree/folderTreeHeader.css.ts index 882cedac2..6b703b793 100644 --- a/frontend/techpick/src/components/FolderTree/folderTreeHeader.css.ts +++ b/frontend/techpick/src/components/FolderTree/folderTreeHeader.css.ts @@ -20,4 +20,5 @@ export const folderTreeHeaderTitleLayout = style({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', + height: '30px', }); diff --git a/frontend/techpick/src/components/TutorialDialog.tsx b/frontend/techpick/src/components/TutorialDialog.tsx new file mode 100644 index 000000000..9b08d40b1 --- /dev/null +++ b/frontend/techpick/src/components/TutorialDialog.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; +import * as Dialog from '@radix-ui/react-dialog'; +import * as Tabs from '@radix-ui/react-tabs'; +import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; +import { IS_TUTORIAL_SEEN_LOCAL_STORAGE_KEY } from '@/constants'; +import { useDisclosure, useLocalStorage } from '@/hooks'; +import { Gap } from './Gap'; +import { + dialogCloseButtonStyle, + dialogContent, + overlayStyle, + pointTextStyle, + tabContentDescriptionStyle, + tabContentStyle, + tabListStyle, + tabTriggerButtonStyle, + tabTriggerLayoutStyle, +} from './tutorialDialog.css'; + +const tutorialStepList = ['tutorial-step-1', 'tutorial-step-2'] as const; +type TutorialStepType = (typeof tutorialStepList)[number]; + +export function TutorialDialog() { + const [tutorialStep, setTutorialStep] = useState( + tutorialStepList[0] + ); + const prevButtonRef = useRef(null); + const closeButtonRef = useRef(null); + const { isOpen, onClose, onOpen } = useDisclosure(); + + const { + storedValue: isTutorialSeen, + setValue: setIsTutorialSeen, + isStoredValueLoad, + } = useLocalStorage(IS_TUTORIAL_SEEN_LOCAL_STORAGE_KEY, false); + + const onValueChange = (value: string) => { + setTutorialStep(value as TutorialStepType); + }; + + const handleMouseEnter = (ref: React.RefObject) => { + ref.current?.focus(); + }; + + const onCloseTutorial = () => { + setIsTutorialSeen(true); + onClose(); + }; + + useEffect( + function openTutorialForFirstTimeUser() { + if (isStoredValueLoad && !isTutorialSeen) { + onOpen(); + } + }, + [isStoredValueLoad, isTutorialSeen, onOpen] + ); + + return ( + + + + + + 튜토리얼 + 튜토리얼 설명입니다. + + + + +

+ 📌 추천 페이지에서 + 원하는 걸 저장할 수 + 있어요! +

+ + + + GIF 설명 +
+ + +

+ 저장한 북마크를 쉽게 + 이동할 수 있어요! +

+ + + + GIF 설명 +
+ + + {tutorialStep === tutorialStepList[0] ? ( + + + + ) : ( +
+ handleMouseEnter(prevButtonRef)} + asChild + > + + + +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/techpick/src/components/TutorialReplaySwitch.tsx b/frontend/techpick/src/components/TutorialReplaySwitch.tsx new file mode 100644 index 000000000..5dcb185b7 --- /dev/null +++ b/frontend/techpick/src/components/TutorialReplaySwitch.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as Switch from '@radix-ui/react-switch'; +import { IS_TUTORIAL_SEEN_LOCAL_STORAGE_KEY } from '@/constants'; +import { useLocalStorage } from '@/hooks'; +import { switchRoot, switchThumb } from './tutorialReplaySwitch.css'; + +export function TutorialReplaySwitch({ + labelTargetId, +}: TutorialReplaySwitchProps) { + const { setValue: setIsTutorialSeen } = useLocalStorage( + IS_TUTORIAL_SEEN_LOCAL_STORAGE_KEY, + false + ); + + return ( + { + setIsTutorialSeen(!isTutorialSeen); + }} + id={labelTargetId} + > + + + ); +} + +interface TutorialReplaySwitchProps { + labelTargetId?: string; +} diff --git a/frontend/techpick/src/components/tutorialDialog.css.ts b/frontend/techpick/src/components/tutorialDialog.css.ts new file mode 100644 index 000000000..849af2969 --- /dev/null +++ b/frontend/techpick/src/components/tutorialDialog.css.ts @@ -0,0 +1,109 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { colorVars } from 'techpick-shared'; + +const contentShow = keyframes({ + from: { + opacity: '0', + transform: 'translate(-50%, -48%) scale(0.96)', + }, + to: { + opacity: '1', + transform: 'translate(-50%, -50%) scale(1)', + }, +}); + +export const overlayStyle = style({ + position: 'fixed', + inset: '0', + animation: 'overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1)', + backgroundColor: colorVars.sand8, + opacity: 0.5, +}); + +export const dialogContent = style({ + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + width: 'auto', + height: 'auto', + borderRadius: '8px', + boxShadow: ` + hsl(206 22% 7% / 35%) 0px 10px 38px -10px, + hsl(206 22% 7% / 20%) 0px 10px 20px -15px + `, + padding: '24px', + backgroundColor: colorVars.gold4, + animation: `${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`, +}); + +export const tabListStyle = style({ + position: 'absolute', + top: '0', + right: '0px', +}); + +export const tabTriggerButtonStyle = style({ + width: '56px', + height: '32px', + border: '1px solid', + borderColor: colorVars.orange8, + borderRadius: '4px', + backgroundColor: colorVars.orange1, + color: colorVars.primary, + cursor: 'pointer', + transition: 'background-color 0.3s ease', + + ':hover': { + backgroundColor: colorVars.orange3, + }, + + ':focus': { + backgroundColor: colorVars.orange3, + }, + + selectors: { + '&[data-state="open"]': { + backgroundColor: colorVars.orange3, + }, + }, +}); + +export const tabContentDescriptionStyle = style({ + height: '32px', + fontSize: '18px', + textDecoration: 'underline', + textDecorationColor: colorVars.primary, + textUnderlineOffset: '4px', +}); + +export const pointTextStyle = style({ color: colorVars.primary }); + +export const dialogCloseButtonStyle = style({ + width: '56px', + height: '32px', + border: '1px solid', + borderColor: colorVars.orange8, + borderRadius: '4px', + backgroundColor: colorVars.orange1, + color: colorVars.primary, + cursor: 'pointer', + transition: 'background-color 0.3s ease', + + ':hover': { + backgroundColor: colorVars.orange3, + }, + + ':focus': { + backgroundColor: colorVars.orange3, + }, +}); + +export const tabContentStyle = style({ + position: 'relative', +}); + +export const tabTriggerLayoutStyle = style({ display: 'flex', gap: '8px' }); diff --git a/frontend/techpick/src/components/tutorialReplaySwitch.css.ts b/frontend/techpick/src/components/tutorialReplaySwitch.css.ts new file mode 100644 index 000000000..d9e9ed274 --- /dev/null +++ b/frontend/techpick/src/components/tutorialReplaySwitch.css.ts @@ -0,0 +1,43 @@ +import { style } from '@vanilla-extract/css'; +import { colorVars } from 'techpick-shared'; + +// 스위치 루트 스타일 +export const switchRoot = style({ + position: 'relative', + flexShrink: '0', + width: '42px', + height: '25px', + borderRadius: '9999px', + border: 'none', + boxShadow: `0 2px 10px ${colorVars.gold7}`, + backgroundColor: colorVars.gold3, + WebkitTapHighlightColor: 'rgba(0, 0, 0, 0)', + cursor: 'pointer', + + ':focus': { + boxShadow: `0 0 0 2px ${colorVars.gold8}`, + }, + selectors: { + '&[data-state="checked"]': { + backgroundColor: colorVars.gold5, + }, + }, +}); + +// 스위치 썸 스타일 +export const switchThumb = style({ + display: 'block', + width: '21px', + height: '21px', + borderRadius: '9999px', + boxShadow: `0 2px 2px ${colorVars.gold7}`, + backgroundColor: colorVars.gold1, + transition: 'transform 100ms', + transform: 'translateX(2px)', + willChange: 'transform', + selectors: { + '&[data-state="checked"]': { + transform: 'translateX(19px)', + }, + }, +}); diff --git a/frontend/techpick/src/constants/index.ts b/frontend/techpick/src/constants/index.ts index 381ead73a..054be70fb 100644 --- a/frontend/techpick/src/constants/index.ts +++ b/frontend/techpick/src/constants/index.ts @@ -3,4 +3,5 @@ export const UNKNOWN_FOLDER_ID = -9999; export { ROUTES } from './route'; export { COLOR_LIST } from './colorList'; export { ERROR_MESSAGE_JSON } from './errorMessageJson'; +export { IS_TUTORIAL_SEEN_LOCAL_STORAGE_KEY } from './isTutorialSeenLocalStorageKey'; export { NON_EXIST_FOLDER_ID } from './nonExistFolderId'; diff --git a/frontend/techpick/src/constants/isTutorialSeenLocalStorageKey.ts b/frontend/techpick/src/constants/isTutorialSeenLocalStorageKey.ts new file mode 100644 index 000000000..6e12e4f74 --- /dev/null +++ b/frontend/techpick/src/constants/isTutorialSeenLocalStorageKey.ts @@ -0,0 +1 @@ +export const IS_TUTORIAL_SEEN_LOCAL_STORAGE_KEY = 'isTutorialSeen'; diff --git a/frontend/techpick/src/hooks/index.ts b/frontend/techpick/src/hooks/index.ts index 8397f7aad..2ce8ca4bc 100644 --- a/frontend/techpick/src/hooks/index.ts +++ b/frontend/techpick/src/hooks/index.ts @@ -11,3 +11,4 @@ export { useGetDragOverStyle } from './useGetDragOverStyle'; export { useFetchPickRecordByFolderId } from './useFetchPickRecordByFolderId'; export { useDisclosure } from './useDisclosure'; export { useRecommendPickToFolderDndMonitor } from './useRecommendPickToFolderDndMonitor'; +export { useLocalStorage } from './useLocalStorage'; diff --git a/frontend/techpick/src/hooks/useLocalStorage.ts b/frontend/techpick/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..4384e1419 --- /dev/null +++ b/frontend/techpick/src/hooks/useLocalStorage.ts @@ -0,0 +1,54 @@ +'use client'; + +import { useState, useEffect, Dispatch, SetStateAction } from 'react'; +import { getItemFromLocalStorage, setItemToLocalStorage } from '@/utils'; + +type SetValue = Dispatch>; + +/** + * @description useLocalStorage의 storedValue가 제대로 로드됐는지 확인하기 위해선 + * isStoredValueLoad가 true인지 획인해야합니다. + * + * 이렇게 한 이유는 nextjs의 hydration failed error때문입니다. + * @returns + */ +export function useLocalStorage(key: string, initialValue: T) { + const [storedValue, setStoredValue] = useState(undefined); + const [isStoredValueLoad, setIsStoredValueLoad] = useState(false); + + useEffect(() => { + try { + const item = getItemFromLocalStorage(key); + if (item !== null) { + setStoredValue(item); + } else { + setStoredValue(initialValue); + } + } catch { + setStoredValue(initialValue); + } finally { + setIsStoredValueLoad(true); + } + }, [key, initialValue]); + + const setValue: SetValue = (value) => { + try { + const valueToStore = + value instanceof Function ? value(storedValue as T) : value; + setStoredValue(valueToStore); + setItemToLocalStorage(key, valueToStore); + } catch { + // 에러 처리 + } + }; + + if (!isStoredValueLoad) { + return { isStoredValueLoad, setValue, storedValue: null }; + } + + return { + storedValue, + setValue, + isStoredValueLoad, + }; +} diff --git a/frontend/techpick/src/utils/getItemFromLocalStorage.ts b/frontend/techpick/src/utils/getItemFromLocalStorage.ts new file mode 100644 index 000000000..ce56501a1 --- /dev/null +++ b/frontend/techpick/src/utils/getItemFromLocalStorage.ts @@ -0,0 +1,10 @@ +export const getItemFromLocalStorage = (key: string) => { + const item = localStorage.getItem(key); + + if (!item) { + return undefined; + } + + const parseItem: ItemType | undefined = JSON.parse(item); + return parseItem; +}; diff --git a/frontend/techpick/src/utils/index.ts b/frontend/techpick/src/utils/index.ts index cd5bbd4c7..992706ee6 100644 --- a/frontend/techpick/src/utils/index.ts +++ b/frontend/techpick/src/utils/index.ts @@ -17,3 +17,5 @@ export { getFolderLinkByType } from './getFolderLinkByType'; export { getOrderedPickListByFolderId } from './getOrderedPickListByFolderId'; export { createSearchSelectOptions } from './createSearchSelectOptions'; export { isRecommendPickDraggableObject } from './isRecommendPickDraggableObject'; +export { getItemFromLocalStorage } from './getItemFromLocalStorage'; +export { setItemToLocalStorage } from './setItemToLocalStorage'; diff --git a/frontend/techpick/src/utils/setItemToLocalStorage.ts b/frontend/techpick/src/utils/setItemToLocalStorage.ts new file mode 100644 index 000000000..2208d1f06 --- /dev/null +++ b/frontend/techpick/src/utils/setItemToLocalStorage.ts @@ -0,0 +1,4 @@ +export const setItemToLocalStorage = (key: string, item: unknown) => { + const jsonItem = JSON.stringify(item); + localStorage.setItem(key, jsonItem); +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d7233370e..9254d4570 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4070,6 +4070,57 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-switch@npm:^1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-switch@npm:1.1.1" + dependencies: + "@radix-ui/primitive": "npm:1.1.0" + "@radix-ui/react-compose-refs": "npm:1.1.0" + "@radix-ui/react-context": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.0.0" + "@radix-ui/react-use-controllable-state": "npm:1.1.0" + "@radix-ui/react-use-previous": "npm:1.1.0" + "@radix-ui/react-use-size": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/8b61aa3bf80d3a2037d67495cf5de9e1ffc0d0843edc0cde5adc1ff1a9b99b0a6b63a85951c79769ab5a44d484611d90dc85933a86d71f28028caa53d8db177b + languageName: node + linkType: hard + +"@radix-ui/react-tabs@npm:^1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-tabs@npm:1.1.1" + dependencies: + "@radix-ui/primitive": "npm:1.1.0" + "@radix-ui/react-context": "npm:1.1.1" + "@radix-ui/react-direction": "npm:1.1.0" + "@radix-ui/react-id": "npm:1.1.0" + "@radix-ui/react-presence": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.0.0" + "@radix-ui/react-roving-focus": "npm:1.1.0" + "@radix-ui/react-use-controllable-state": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/86fa6beda5ac5fbc6cede483e198641fbba0b1e4ad30db3488fbfefdf460ca4e35d765f5b22f73ded1849252b2432cfa755783218f282721462f90f2ad1adf30 + languageName: node + linkType: hard + "@radix-ui/react-use-callback-ref@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-use-callback-ref@npm:1.1.0" @@ -15493,6 +15544,8 @@ __metadata: "@radix-ui/react-select": "npm:^2.1.2" "@radix-ui/react-separator": "npm:^1.1.0" "@radix-ui/react-slot": "npm:^1.1.0" + "@radix-ui/react-switch": "npm:^1.1.1" + "@radix-ui/react-tabs": "npm:^1.1.1" "@radix-ui/react-visually-hidden": "npm:^1.1.0" "@sentry/nextjs": "npm:8" "@storybook/addon-essentials": "npm:^8.2.9"