diff --git a/.yarn/cache/@esbuild-darwin-x64-npm-0.17.19-30afb0190b-8.zip b/.yarn/cache/@esbuild-darwin-x64-npm-0.17.19-30afb0190b-8.zip new file mode 100644 index 00000000..ce0c4aa9 Binary files /dev/null and b/.yarn/cache/@esbuild-darwin-x64-npm-0.17.19-30afb0190b-8.zip differ diff --git a/.yarn/cache/@esbuild-darwin-x64-npm-0.18.13-e38db97cb2-8.zip b/.yarn/cache/@esbuild-darwin-x64-npm-0.18.13-e38db97cb2-8.zip new file mode 100644 index 00000000..f29465c7 Binary files /dev/null and b/.yarn/cache/@esbuild-darwin-x64-npm-0.18.13-e38db97cb2-8.zip differ diff --git a/.yarn/cache/@next-swc-darwin-x64-npm-13.5.6-3c6ecf4082-8.zip b/.yarn/cache/@next-swc-darwin-x64-npm-13.5.6-3c6ecf4082-8.zip new file mode 100644 index 00000000..0dfaf447 Binary files /dev/null and b/.yarn/cache/@next-swc-darwin-x64-npm-13.5.6-3c6ecf4082-8.zip differ diff --git a/public/images/dna/careerCard/A.png b/public/images/dna/careerCard/A.png new file mode 100644 index 00000000..d80388b0 Binary files /dev/null and b/public/images/dna/careerCard/A.png differ diff --git a/public/images/dna/careerCard/A.webp b/public/images/dna/careerCard/A.webp new file mode 100644 index 00000000..c135599a Binary files /dev/null and b/public/images/dna/careerCard/A.webp differ diff --git a/public/images/dna/careerCard/B.png b/public/images/dna/careerCard/B.png new file mode 100644 index 00000000..8cb406a5 Binary files /dev/null and b/public/images/dna/careerCard/B.png differ diff --git a/public/images/dna/careerCard/B.webp b/public/images/dna/careerCard/B.webp new file mode 100644 index 00000000..68ee6e81 Binary files /dev/null and b/public/images/dna/careerCard/B.webp differ diff --git a/public/images/dna/careerCard/C.png b/public/images/dna/careerCard/C.png new file mode 100644 index 00000000..9b8026b7 Binary files /dev/null and b/public/images/dna/careerCard/C.png differ diff --git a/public/images/dna/careerCard/C.webp b/public/images/dna/careerCard/C.webp new file mode 100644 index 00000000..ddd29988 Binary files /dev/null and b/public/images/dna/careerCard/C.webp differ diff --git a/public/images/dna/careerCard/D.png b/public/images/dna/careerCard/D.png new file mode 100644 index 00000000..c66a71a5 Binary files /dev/null and b/public/images/dna/careerCard/D.png differ diff --git a/public/images/dna/careerCard/D.webp b/public/images/dna/careerCard/D.webp new file mode 100644 index 00000000..06792c9c Binary files /dev/null and b/public/images/dna/careerCard/D.webp differ diff --git a/public/images/dna/careerCard/E.png b/public/images/dna/careerCard/E.png new file mode 100644 index 00000000..9a3a1f9e Binary files /dev/null and b/public/images/dna/careerCard/E.png differ diff --git a/public/images/dna/careerCard/E.webp b/public/images/dna/careerCard/E.webp new file mode 100644 index 00000000..da467574 Binary files /dev/null and b/public/images/dna/careerCard/E.webp differ diff --git a/public/images/dna/careerCard/F.png b/public/images/dna/careerCard/F.png new file mode 100644 index 00000000..b821d6c6 Binary files /dev/null and b/public/images/dna/careerCard/F.png differ diff --git a/public/images/dna/careerCard/F.webp b/public/images/dna/careerCard/F.webp new file mode 100644 index 00000000..e2a31e7e Binary files /dev/null and b/public/images/dna/careerCard/F.webp differ diff --git a/public/images/gallery/imgUploadCareerCard.png b/public/images/gallery/imgUploadCareerCard.png new file mode 100644 index 00000000..91450b3d Binary files /dev/null and b/public/images/gallery/imgUploadCareerCard.png differ diff --git a/public/images/profile/profile-basic.png b/public/images/profile/profile-basic.png new file mode 100644 index 00000000..f471dcf9 Binary files /dev/null and b/public/images/profile/profile-basic.png differ diff --git a/src/components/bottomBar/BottomBar.stories.tsx b/src/components/bottomBar/BottomBar.stories.tsx new file mode 100644 index 00000000..86d06340 --- /dev/null +++ b/src/components/bottomBar/BottomBar.stories.tsx @@ -0,0 +1,18 @@ +import { type Meta } from '@storybook/react'; + +import BottomBar from './BottomBar'; + +const meta: Meta = { + title: 'BottomBar', + component: BottomBar, +}; + +export default meta; + +export function Default() { + return ( +
+ +
+ ); +} diff --git a/src/components/bottomBar/BottomBar.tsx b/src/components/bottomBar/BottomBar.tsx new file mode 100644 index 00000000..a126cc68 --- /dev/null +++ b/src/components/bottomBar/BottomBar.tsx @@ -0,0 +1,118 @@ +import { type ComponentType } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { css, type Theme, useTheme } from '@emotion/react'; + +import HomeIcon from '~/components/bottomBar/HomeIcon'; +import MypageIcon from '~/components/bottomBar/MypageIcon'; +import QuestionIcon from '~/components/bottomBar/QuestionIcon'; +import useGetSurveyIdByUserStatus from '~/hooks/api/surveys/useGetSurveyIdByUserStatus'; + +interface TabItem { + text: string; + path: string; + icon: ComponentType<{ color?: string }>; +} + +const BottomBar = () => { + const theme = useTheme(); + const router = useRouter(); + + const currentPath = router.pathname; + const { data, 생성한_질문폼이_있는가 } = useCheckSurveyId(); + + const TAB_ITEMS: TabItem[] = [ + { + text: '홈', + path: '/gallery', + icon: HomeIcon, + }, + { + text: '질문폼', + path: 생성한_질문폼이_있는가 ? '/result' : '/survey/base', + icon: QuestionIcon, + }, + { + text: '내 명함', + path: `/dna/${data?.survey_id}`, + icon: MypageIcon, + }, + ]; + + const getIsSelected = (path: string | string[]) => { + if (Array.isArray(path)) return path.includes(currentPath); + + return path === currentPath; + }; + + const getIconColor = (path: string | string[]) => { + const isSelected = getIsSelected(path); + if (isSelected) return theme.colors.primary_300; + + return theme.colors.gray_300; + }; + + return ( + + ); +}; + +export default BottomBar; + +const BottomBarCss = (theme: Theme) => css` + position: fixed; + bottom: 0; + left: 0; + + display: flex; + gap: 86px; + align-items: center; + justify-content: center; + + width: 100vw; + height: 73px; + padding-top: 10px; + + background-color: ${theme.colors.white}; +`; + +const IconBoxCss = (theme: Theme) => css` + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + + text-decoration: none; + + span { + font-size: 12px; + font-weight: 400; + font-style: normal; + color: ${theme.colors.gray_300}; + text-align: center; + } +`; + +const selectedCss = (theme: Theme) => css` + span { + color: ${theme.colors.primary_300}; + } +`; + +const useCheckSurveyId = () => { + const { status } = useSession(); + + const { isLoading, data } = useGetSurveyIdByUserStatus({ enabled: status === 'authenticated' }); + const 생성한_질문폼이_있는가 = Boolean(data?.survey_id); + + return { isLoading, data, 생성한_질문폼이_있는가 }; +}; diff --git a/src/components/bottomBar/HomeIcon.tsx b/src/components/bottomBar/HomeIcon.tsx new file mode 100644 index 00000000..cb07d563 --- /dev/null +++ b/src/components/bottomBar/HomeIcon.tsx @@ -0,0 +1,21 @@ +import Svg from '~/components/svg/Svg'; + +interface Props { + color?: string; +} + +const HomeIcon = ({ color }: Props) => { + return ( + + + + + ); +}; + +export default HomeIcon; diff --git a/src/components/bottomBar/MypageIcon.tsx b/src/components/bottomBar/MypageIcon.tsx new file mode 100644 index 00000000..f2d711ce --- /dev/null +++ b/src/components/bottomBar/MypageIcon.tsx @@ -0,0 +1,20 @@ +import Svg from '~/components/svg/Svg'; + +interface Props { + color?: string; +} + +const MypageIcon = ({ color }: Props) => { + return ( + + + + ); +}; + +export default MypageIcon; diff --git a/src/components/bottomBar/QuestionIcon.tsx b/src/components/bottomBar/QuestionIcon.tsx new file mode 100644 index 00000000..ab6670f0 --- /dev/null +++ b/src/components/bottomBar/QuestionIcon.tsx @@ -0,0 +1,22 @@ +import Svg from '~/components/svg/Svg'; + +interface Props { + color?: string; +} + +const QuestionIcon = ({ color }: Props) => { + return ( + + + + + + ); +}; + +export default QuestionIcon; diff --git a/src/components/header/MobileHeader.tsx b/src/components/header/MobileHeader.tsx new file mode 100644 index 00000000..e0afa45d --- /dev/null +++ b/src/components/header/MobileHeader.tsx @@ -0,0 +1,63 @@ +import { css, type Theme } from '@emotion/react'; + +import { MenuBarIcon } from '~/components/icons/MenuIcon'; +import SideMenu from '~/components/sideMenu/SideMenu'; +import useBoolean from '~/hooks/common/useBoolean'; +import { HEAD_1_BOLD } from '~/styles/typo'; + +// NOTE: MobileHeader 임시 네이밍, 추후 수정 필요 +interface Props { + title: string; +} + +function MobileHeader(props: Props) { + const [isSideMenuOpen, toggleSideMenu] = useBoolean(false); + + return ( + <> +
+

{props.title}

+ +
+
+ + + ); +} + +export default MobileHeader; + +const menuButtonCss = css` + height: 48px; +`; + +const headerCss = (theme: Theme) => css` + position: fixed; + z-index: ${theme.zIndex.aboveDefault}; + top: 0; + right: 0; + left: 0; + + display: flex; + align-items: center; + justify-content: space-between; + + width: 100vw; + max-width: ${theme.size.maxWidth}; + height: 60px; + margin-inline: auto; + padding: 0 20px; + + background-color: ${theme.colors.white}; + + h1 { + ${HEAD_1_BOLD}; + } +`; + +const blankCss = css` + width: 100%; + height: 60px; +`; diff --git a/src/components/icons/AlignUpdatedIcon.tsx b/src/components/icons/AlignUpdatedIcon.tsx new file mode 100644 index 00000000..efc7950a --- /dev/null +++ b/src/components/icons/AlignUpdatedIcon.tsx @@ -0,0 +1,25 @@ +import { type ComponentProps } from 'react'; + +import type Svg from '../svg/Svg'; + +function AlignUpdatedIcon({ width = 21, height = 20, color = '#1D2942', ...rest }: ComponentProps) { + return ( + + + + ); +} + +export default AlignUpdatedIcon; diff --git a/src/components/icons/BookmarkIcon.tsx b/src/components/icons/BookmarkIcon.tsx index c471e00d..77688f4e 100644 --- a/src/components/icons/BookmarkIcon.tsx +++ b/src/components/icons/BookmarkIcon.tsx @@ -8,14 +8,21 @@ interface Props extends ComponentProps { isBookmarked: boolean; } -const BookmarkIcon = ({ size = 32, isBookmarked = false, ...rest }: Props) => { +const BookmarkIcon = ({ size = 32, isBookmarked = false, color, ...rest }: Props) => { + const fillColor = (() => { + if (color) return color; + if (isBookmarked) return colors.primary_200; + + return colors.gray_300; + })(); + return ( - + ); diff --git a/src/components/icons/CheckCircleIcon.tsx b/src/components/icons/CheckCircleIcon.tsx new file mode 100644 index 00000000..5160f862 --- /dev/null +++ b/src/components/icons/CheckCircleIcon.tsx @@ -0,0 +1,18 @@ +import { type ComponentProps } from 'react'; + +import Svg from '~/components/svg/Svg'; + +function CheckCircleIcon({ width = 24, height = 24, color = '#CDE7AC', ...rest }: ComponentProps) { + return ( + + + + ); +} + +export default CheckCircleIcon; diff --git a/src/components/icons/MenuIcon.tsx b/src/components/icons/MenuIcon.tsx index a011202c..55e9662e 100644 --- a/src/components/icons/MenuIcon.tsx +++ b/src/components/icons/MenuIcon.tsx @@ -16,3 +16,17 @@ const MenuIcon = ({ size = 24, color = '#B0B0B0', ...rest }: ComponentProps) => { + return ( + + + + ); +}; diff --git a/src/components/icons/MinusCircleIcon.tsx b/src/components/icons/MinusCircleIcon.tsx new file mode 100644 index 00000000..e59e3ac8 --- /dev/null +++ b/src/components/icons/MinusCircleIcon.tsx @@ -0,0 +1,18 @@ +import React, { type ComponentProps } from 'react'; + +import Svg from '~/components/svg/Svg'; + +function MinusCircleIcon({ width = 24, height = 24, color = '#F85B81', ...rest }: ComponentProps) { + return ( + + + + ); +} + +export default MinusCircleIcon; diff --git a/src/components/icons/ThreeDotsIcon.tsx b/src/components/icons/ThreeDotsIcon.tsx index 377a6041..f69fcad5 100644 --- a/src/components/icons/ThreeDotsIcon.tsx +++ b/src/components/icons/ThreeDotsIcon.tsx @@ -4,12 +4,27 @@ import Svg from '../svg/Svg'; const ThreeDotsIcon = ({ color = '#677089', size = 24, ...rest }: ComponentProps) => { return ( - + + + ); diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx index 6e42a894..b477075b 100644 --- a/src/components/modal/Modal.tsx +++ b/src/components/modal/Modal.tsx @@ -1,5 +1,5 @@ import { type ComponentProps, type MouseEvent, type PropsWithChildren } from 'react'; -import { css, type Theme } from '@emotion/react'; +import { css, type Interpolation, type Theme } from '@emotion/react'; import { m } from 'framer-motion'; import Header from '~/components/header/Header'; @@ -13,6 +13,7 @@ interface Props { * 외부영역 클릭시 호출될 함수 */ onClickOutside?: VoidFunction; + blurOverrideCss?: Interpolation; } /** @@ -26,13 +27,14 @@ const Modal = ({ mode, onClickOutside, children, + blurOverrideCss, }: PropsWithChildren & ComponentProps) => { useScrollLock({ lock: isShowing }); return (
- +
{children}
@@ -52,7 +54,7 @@ const dialogPositionCss = css` height: 100vh; `; -const ModalBlur = ({ onClickOutside }: Pick) => { +const ModalBlur = ({ onClickOutside, blurOverrideCss }: Pick) => { const onClickOutsideDefault = (e: MouseEvent) => { if (e.target !== e.currentTarget) return; if (onClickOutside) onClickOutside(); @@ -61,7 +63,7 @@ const ModalBlur = ({ onClickOutside }: Pick) => { return ( void; + }[] = [ + { + id: 'bookmark-card', + label: '저장한 명함', + href: '/gallery/bookmarks', + }, + { + id: 'logout', + label: '로그아웃', + action: () => { + // 실 환경에서 되는지 체크 + logOutHandler(); + router.replace('/'); + }, + }, + // { + // id: 'terms', + // label: '약관 및 정책', + // href: '#', + // }, + // { + // id: 'suggest', + // label: '건의하기', + // href: '#', + // }, + // { + // id: 'withdraw', + // label: '회원탈퇴', + // href: '#', + // }, + ]; + + return ( +
+
    + {MENU_LIST.map((menu) => { + if (menu.href) { + return ( + +
  • + + {menu.label} +
  • + + ); + } + if (menu.action) { + return ( + + ); + } + + return null; + })} +
+
+ ); +} + +export default MenuSection; + +const menuListCss = css` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const menuItemCss = css` + cursor: pointer; + + display: flex; + gap: 8px; + align-items: center; + + padding: 8px 20px; + + color: #fff; + + span { + display: inline-block; + text-decoration: none; + } +`; diff --git a/src/components/sideMenu/ProfileSection.tsx b/src/components/sideMenu/ProfileSection.tsx new file mode 100644 index 00000000..3ad926e4 --- /dev/null +++ b/src/components/sideMenu/ProfileSection.tsx @@ -0,0 +1,111 @@ +import Image from 'next/image'; +import { css, type Theme } from '@emotion/react'; + +import { useGetLogin } from '~/hooks/api/user/useGetLogined'; +import { DETAIL, HEAD_2_BOLD } from '~/styles/typo'; + +const PROFILE_BASIC_URL = '/images/profile/profile-basic.png'; + +function ProfileSection() { + const { data: userInfo, isFetching } = useGetLogin(); + + if (isFetching) { + return ; + } + + return ( +
+
+ 프로필 이미지 +
+

{userInfo?.nickname}

+

{userInfo?.email}

+
+ ); +} + +const profileSectionCss = css` + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; +`; + +const profileImageCss = css` + width: 64px; + height: 64px; + background-color: #fff; + border-radius: 50%; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +const nicknameCss = css` + ${HEAD_2_BOLD}; + margin-top: 8px; + color: #fff; +`; + +const emailCss = (theme: Theme) => css` + ${DETAIL}; + height: 32px; + padding: 6px 12px; + + color: ${theme.colors.gray_200}; + + background-color: ${theme.colors.secondary_300}; + border-radius: 8px; +`; + +export default ProfileSection; + +function ProfileSectionSkeleton() { + return ( +
+
+
+
+
+ ); +} + +const skeletonCss = (theme: Theme) => css` + min-width: 64px; + background-color: ${theme.colors.secondary_300}; + animation: pulse 1.5s infinite; + + @keyframes pulse { + 0% { + opacity: 0.5; + } + + 50% { + opacity: 0.8; + } + + 100% { + opacity: 0.5; + } + } +`; diff --git a/src/components/sideMenu/SideMenu.tsx b/src/components/sideMenu/SideMenu.tsx new file mode 100644 index 00000000..2b84dc20 --- /dev/null +++ b/src/components/sideMenu/SideMenu.tsx @@ -0,0 +1,89 @@ +import { css, type Theme } from '@emotion/react'; +import { AnimatePresence, m } from 'framer-motion'; + +import { MenuBarIcon } from '~/components/icons/MenuIcon'; +import MenuSection from '~/components/sideMenu/MenuSection'; +import ProfileSection from '~/components/sideMenu/ProfileSection'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +function SideMenu(props: Props) { + return ( + + {props.isOpen && ( + <> + + + +
+ +
+ + + )} +
+ ); +} + +export default SideMenu; + +const containerCss = (theme: Theme) => css` + position: fixed; + z-index: ${theme.zIndex.fixed}; + top: 0; + right: 0; + bottom: 0; + + width: 260px; + height: 100vh; + padding-top: 46px; + + background-color: ${theme.colors.secondary_200}; +`; + +const menuButtonCss = css` + position: relative; + top: -18px; + + width: 50px; + height: 50px; + padding: 13px; +`; + +const dividerCss = (theme: Theme) => css` + width: calc(100% - 40px); + height: 1px; + margin: 24px 20px 42px; + + background-color: ${theme.colors.gray_500}; + border: none; +`; + +const backdropCss = (theme: Theme) => css` + position: fixed; + z-index: ${theme.zIndex.belowFixed}; + inset: 0; + + width: 100vw; + height: 100vh; + + background: rgb(0 0 0 / 50%); +`; diff --git a/src/constants/dnaImage.ts b/src/constants/dnaImage.ts new file mode 100644 index 00000000..e1c00b6e --- /dev/null +++ b/src/constants/dnaImage.ts @@ -0,0 +1,29 @@ +import { type Group } from '~/utils/resultLogic'; + +// src/pages/dna/LoadedDna.tsx와 동일 +interface Image { + webp: string; + png: string; +} + +const DNA_IMAGE_BASE_URL = '/images/dna/result'; + +export const DNA_IMAGE_BY_GROUP: Record = { + A: { webp: `${DNA_IMAGE_BASE_URL}/A_dna.webp`, png: `${DNA_IMAGE_BASE_URL}/A_dna.png` }, + B: { webp: `${DNA_IMAGE_BASE_URL}/B_dna.webp`, png: `${DNA_IMAGE_BASE_URL}/B_dna.png` }, + C: { webp: `${DNA_IMAGE_BASE_URL}/C_dna.webp`, png: `${DNA_IMAGE_BASE_URL}/C_dna.png` }, + D: { webp: `${DNA_IMAGE_BASE_URL}/D_dna.webp`, png: `${DNA_IMAGE_BASE_URL}/D_dna.png` }, + E: { webp: `${DNA_IMAGE_BASE_URL}/E_dna.webp`, png: `${DNA_IMAGE_BASE_URL}/E_dna.png` }, + F: { webp: `${DNA_IMAGE_BASE_URL}/F_dna.webp`, png: `${DNA_IMAGE_BASE_URL}/F_dna.png` }, +} as const; + +const CAREER_CARD_IMAGE_BASE_URL = '/images/dna/careerCard'; + +export const CAREER_CARD_IMAGE_BY_GROUP: Record = { + A: { webp: `${CAREER_CARD_IMAGE_BASE_URL}/A.webp`, png: `${CAREER_CARD_IMAGE_BASE_URL}/A.png` }, + B: { webp: `${CAREER_CARD_IMAGE_BASE_URL}/B.webp`, png: `${CAREER_CARD_IMAGE_BASE_URL}/B.png` }, + C: { webp: `${CAREER_CARD_IMAGE_BASE_URL}/C.webp`, png: `${CAREER_CARD_IMAGE_BASE_URL}/C.png` }, + D: { webp: `${CAREER_CARD_IMAGE_BASE_URL}/D.webp`, png: `${CAREER_CARD_IMAGE_BASE_URL}/D.png` }, + E: { webp: `${CAREER_CARD_IMAGE_BASE_URL}/E.webp`, png: `${CAREER_CARD_IMAGE_BASE_URL}/E.png` }, + F: { webp: `${CAREER_CARD_IMAGE_BASE_URL}/F.webp`, png: `${CAREER_CARD_IMAGE_BASE_URL}/F.png` }, +} as const; diff --git a/src/constants/storage.ts b/src/constants/storage.ts index b6fb0efc..6d3ed821 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -4,4 +4,5 @@ export const LOCAL_STORAGE_KEY = { surveyCustomQuestions: 'nlascq', surveyCreateSurveyRequest: 'nlascsr', resultRevisit: 'nlarv', + galleryCardLead: 'nlagcl', } as const; diff --git a/src/features/gallery/Card.tsx b/src/features/gallery/Card.tsx new file mode 100644 index 00000000..f3e75c0d --- /dev/null +++ b/src/features/gallery/Card.tsx @@ -0,0 +1,291 @@ +import Image from 'next/image'; +import { css, type Theme } from '@emotion/react'; + +import Softskill from '~/components/graphic/softskills/Softskill'; +import BookmarkIcon from '~/components/icons/BookmarkIcon'; +import Pill, { type Color } from '~/components/pill/Pill'; +import { CAREER_CARD_IMAGE_BY_GROUP } from '~/constants/dnaImage'; +import useBookmark from '~/features/gallery/useBookmark'; +import useDnaInfo from '~/hooks/dna/useDnaInfo'; +import { type SurveyType, type TargetType } from '~/remotes/gallery'; +import { BODY_1, BODY_2_REGULAR, DETAIL, HEAD_1_BOLD, HEAD_3_SEMIBOLD } from '~/styles/typo'; + +interface Props { + survey: SurveyType; + target: TargetType; + isBookmarked: boolean; + + listRefetch?: () => void; + isMine?: boolean; + isPreview?: boolean; +} + +function Card({ isBookmarked, survey, target, isMine, isPreview, listRefetch }: Props) { + const { group } = useDnaInfo(survey.survey_id); + + const viewTendencies = survey.tendencies.slice(0, 3); + + const { cancelBookmark, addBookmark } = useBookmark({ + surveyId: survey.survey_id, + refetch: listRefetch, + }); + + const onBookmarkClick = () => { + isBookmarked ? cancelBookmark() : addBookmark(); + }; + + if (!group) return ; + + return ( +
+
+
+
+

+ {target.nickname} + + {isMine && ME} +

+

{target.job}

+
+
+ {viewTendencies.map((tendency, idx) => ( + + + {tendency.name.replaceAll('_', ' ')} + + ))} +
+
+ + + DNA 이미지 + +
+
+
+

+ 받은 피드백 {survey.feedback_count} +

+
+ {survey.feedbacks.map((feedback) => ( +

{feedback}

+ ))} +
+
+ +
+
+ ); +} + +export default Card; + +interface BookmarkButtonProps { + bookmarked_count: number; + isBookmarked: boolean; + onClick: () => void; + blocked?: boolean; +} + +function BookmarkButton(props: BookmarkButtonProps) { + return ( + + ); +} + +const COLOR_INDEX: Color[] = ['bluegreen', 'pink', 'skyblue', 'yellowgreen', 'purple']; + +const topBoxCss = (theme: Theme) => css` + position: relative; + z-index: ${theme.zIndex.default}; + + overflow: hidden; + + width: 100%; + height: 228px; + padding: 30px 24px 48px; + + background-color: #dce9fb; + + > div { + z-index: ${theme.zIndex.above(theme.zIndex.aboveDefault)}; + } + + img { + z-index: ${theme.zIndex.aboveDefault}; + } +`; + +const feedbackWrapperCss = (theme: Theme) => css` + display: flex; + flex-direction: column; + gap: 18px; + padding: 22px 24px; + + h3 { + ${HEAD_3_SEMIBOLD}; + margin-bottom: 10px; + color: ${theme.colors.black}; + } + + p { + ${BODY_2_REGULAR}; + color: ${theme.colors.gray_500}; + } +`; + +const bookmarkWrapperCss = (theme: Theme) => css` + display: flex; + justify-content: flex-end; + + > div { + display: flex; + gap: 4px; + align-items: center; + + padding: 6px 12px; + + color: ${theme.colors.gray_400}; + + background-color: ${theme.colors.gray_50}; + border-radius: 22px; + } + + span { + ${BODY_1}; + position: relative; + top: 1px; + } + + svg * { + transition: all 0.2s ease-in-out; + } +`; + +const topInnerCss = (theme: Theme) => css` + position: relative; + z-index: ${theme.zIndex.default}; + + display: flex; + flex-direction: column; + gap: 20px; + align-items: flex-start; + + h2 { + ${HEAD_1_BOLD}; + display: flex; + align-items: center; + } + + p { + ${BODY_1}; + } +`; + +const isMineCss = (theme: Theme) => css` + ${DETAIL}; + margin-left: 6px; + padding: 3px 10px; + + color: ${theme.colors.primary_100}; + + background-color: ${theme.colors.secondary_200}; + border-radius: 24px; +`; + +const tagWrapperCss = css` + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: 200px; +`; + +const tagItemCss = (theme: Theme) => css` + display: flex; + gap: 6px; + align-items: center; + justify-content: center; + + padding: 4px 10px; + + background-color: ${theme.colors.white}; + border-radius: 6px; + + ${BODY_1}; +`; + +const imageCss = (theme: Theme) => css` + position: absolute; + z-index: ${theme.zIndex.belowDefault}; + top: 0; + right: 0; +`; + +const sectionCss = css` + position: relative; + + overflow: hidden; + + width: 100%; + margin: 0 auto; + + text-decoration: none; + + border-radius: 20px; + box-shadow: 0 4px 16px 0 rgb(0 0 0 / 16%); +`; + +export function CardSkeleton() { + return
; +} + +const skeletonCardCss = css` + height: 100%; + min-height: 393px; + + background-color: #dce9fb; + border-radius: 20px; + + animation: pulse 1.5s infinite; + + @keyframes pulse { + 0% { + background-color: #dce9fb; + } + + 50% { + background-color: #e3f1fc; + } + + 100% { + background-color: #dce9fb; + } + } +`; diff --git a/src/features/gallery/FilterTab.tsx b/src/features/gallery/FilterTab.tsx new file mode 100644 index 00000000..9720e7fa --- /dev/null +++ b/src/features/gallery/FilterTab.tsx @@ -0,0 +1,78 @@ +import { css, type Theme } from '@emotion/react'; + +import AlignUpdatedIcon from '~/components/icons/AlignUpdatedIcon'; +import type Svg from '~/components/svg/Svg'; +import { type FilterType } from '~/remotes/gallery'; + +const TABS: { + title: string; + id: FilterType; + Icon?: typeof Svg; +}[] = [ + { + title: '업데이트순', + id: 'update', + Icon: AlignUpdatedIcon, + }, + { + title: '저장 많은 순', + id: 'bookmark', + }, +]; + +interface Props { + filterTab: FilterType; + setFilterTab: (id: FilterType) => void; +} + +function FilterTab(props: Props) { + return ( +
+ {TABS.map((tab, idx) => ( + <> + {idx !== 0 &&
} + + + ))} +
+ ); +} + +export default FilterTab; + +const tabItemCss = (theme: Theme, isActive: boolean) => css` + display: inline-flex; + gap: 4px; + align-items: center; + + padding: 8px; + + color: ${isActive ? theme.colors.black : theme.colors.gray_300}; + + transition: color 0.2s ease-in-out; +`; + +const filterWrapperCss = (theme: Theme) => css` + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; + + hr { + display: block; + + width: 1px; + height: 18px; + + background-color: ${theme.colors.gray_50}; + border: none; + } +`; diff --git a/src/features/gallery/PublishMyCard/CardPublishBottomSheet.tsx b/src/features/gallery/PublishMyCard/CardPublishBottomSheet.tsx new file mode 100644 index 00000000..7dd56059 --- /dev/null +++ b/src/features/gallery/PublishMyCard/CardPublishBottomSheet.tsx @@ -0,0 +1,103 @@ +import { css, type Theme } from '@emotion/react'; + +import BottomSheet from '~/components/bottomSheet/BottomSheet'; +import Button from '~/components/button/Button'; +import { XCircleButton } from '~/components/button/CircleButton'; +import BottomSheetHandleIcon from '~/components/icons/BottomSheetHandleIcon'; +import Card, { CardSkeleton } from '~/features/gallery/Card'; +import { fixedBottomCss } from '~/features/survey/styles'; +import useGetGalleryPreview from '~/hooks/api/gallery/useGetGalleryPreview'; +import useScrollLock from '~/hooks/common/useScrollLock'; +import { type JobType } from '~/remotes/gallery'; +import { BODY_1, DETAIL, HEAD_1 } from '~/styles/typo'; + +interface Props { + isShowing: boolean; + onClose: () => void; + + job: JobType; + onSubmit: () => void; +} + +function CardPublishBottomSheet(props: Props) { + const { data } = useGetGalleryPreview({ + enabled: props.isShowing, + }); + + useScrollLock({ lock: props.isShowing }); + + return ( + + +
+
+

내 커리어 명함 게시하기

+

명함 갤러리에 게시 후 서로의 명함을 저장해보세요

+
+
+
미리보기
+ {data ? ( + + ) : ( + + )} +
+
+ + +
+
+
+ ); +} + +export default CardPublishBottomSheet; + +const innerCss = css` + overflow-y: auto; + + width: 100%; + min-height: fit-content; + max-height: calc(100vh - 12px); + padding: 47px 20px 94px; +`; + +const headingCss = (theme: Theme) => css` + margin-bottom: 35px; + text-align: center; + + h1 { + ${HEAD_1}; + color: ${theme.colors.black}; + } + + p { + ${BODY_1}; + margin-top: 4px; + color: ${theme.colors.gray_500}; + } +`; + +const tagCss = (theme: Theme) => css` + ${DETAIL}; + width: fit-content; + margin-bottom: 18px; + margin-inline: auto; + padding: 4px 10px; + + color: ${theme.colors.gray_500}; + + background-color: ${theme.colors.gray_50}; + border-radius: 8px; +`; + +const bottomCss = css` + display: flex; + justify-content: space-between; +`; + +const submitButtonCss = css` + width: 50%; +`; diff --git a/src/features/gallery/PublishMyCard/InducePublishCard.tsx b/src/features/gallery/PublishMyCard/InducePublishCard.tsx new file mode 100644 index 00000000..1da67d6a --- /dev/null +++ b/src/features/gallery/PublishMyCard/InducePublishCard.tsx @@ -0,0 +1,85 @@ +import Image from 'next/image'; +import { css, type Theme, useTheme } from '@emotion/react'; +import { m } from 'framer-motion'; + +import Button from '~/components/button/Button'; +import XIcon from '~/components/icons/XIcon'; +import { defaultFadeInVariants } from '~/constants/motions'; +import { useGetLogin } from '~/hooks/api/user/useGetLogined'; +import { BODY_1, BODY_2_REGULAR, HEAD_1_BOLD } from '~/styles/typo'; + +interface Props { + onClose: () => void; + onSubmit: () => void; +} + +function InducePublishCard(props: Props) { + const { data: userInfo, isFetching, isError } = useGetLogin(); + const theme = useTheme(); + + if (isFetching || isError) return null; + + return ( + +
+

{userInfo?.nickname} 님

+

커리어 명함을 게시해보세요.

+
+ 명함 게시하기 + + +
+ ); +} + +export default InducePublishCard; + +const buttonCss = css` + ${BODY_2_REGULAR}; + width: calc(100% - 64px); + height: 42px; + margin: 0 34px; +`; + +const containerCss = (theme: Theme) => css` + position: relative; + + width: 100%; + padding: 49px 24px 24px; + + background-color: ${theme.colors.gray_50}; + border-radius: 20px; + + > hgroup { + margin-bottom: 36px; + + h2 { + ${HEAD_1_BOLD}; + color: ${theme.colors.black}; + } + + p { + ${BODY_1}; + color: ${theme.colors.gray_400}; + } + } +`; + +const imageCss = css` + position: absolute; + top: 49px; + right: 24px; +`; + +const closeButtonCss = css` + position: absolute; + top: 0; + right: 0; + + width: fit-content; + padding: 10px; +`; diff --git a/src/features/gallery/PublishMyCard/JobSelectModal.tsx b/src/features/gallery/PublishMyCard/JobSelectModal.tsx new file mode 100644 index 00000000..90ba6073 --- /dev/null +++ b/src/features/gallery/PublishMyCard/JobSelectModal.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import Image from 'next/image'; +import { css, type Theme, useTheme } from '@emotion/react'; +import { m } from 'framer-motion'; + +import Button from '~/components/button/Button'; +import XIcon from '~/components/icons/XIcon'; +import Modal from '~/components/modal/Modal'; +import { defaultFadeInVariants } from '~/constants/motions'; +import Selection from '~/features/gallery/Selection'; +import { type JobType } from '~/remotes/gallery'; +import { BODY_1, HEAD_1 } from '~/styles/typo'; + +const JOB_CHOICES: { + label: string; + value: JobType; +}[] = [ + { + label: '기획자', + value: 'PM', + }, + { + label: '개발자', + value: 'DEVELOPER', + }, + { + label: '디자이너', + value: 'DESIGNER', + }, + { + label: '기타', + value: 'OTHERS', + }, +]; + +interface Props { + isShowing: boolean; + onClose: () => void; + + onSubmit: (job: JobType) => void; +} + +function JobSelectModal(props: Props) { + const theme = useTheme(); + const [selected, setSelected] = useState('PM'); + + const onSubmit = () => { + props.onSubmit(selected); + }; + + return ( + + +
+

직무 선택

+

명함을 만들 직무를 선택하세요

+
+
+ {JOB_CHOICES.map((job) => ( + setSelected(job.value)} checked={selected === job.value}> + {job.label} + + ))} +
+
+ +
+ 명함 게시하기 + +
+
+ ); +} + +export default JobSelectModal; + +const modalContentCss = (theme: Theme) => css` + position: relative; + + display: flex; + flex-direction: column; + gap: 25px; + + width: calc(100vw - 40px); + max-width: 334px; + padding: 42px 20px; + + background-color: ${theme.colors.white}; + border-radius: 20px; +`; + +const uploadCardImageCss = css` + position: absolute; + top: 42px; + right: 20px; +`; + +const modalHeaderCss = (theme: Theme) => css` + text-align: left; + + h1 { + ${HEAD_1}; + color: ${theme.colors.black}; + } + + p { + ${BODY_1}; + color: ${theme.colors.gray_400}; + } +`; + +const modalBodyCss = css` + display: flex; + flex-direction: column; + gap: 7px; +`; + +const modalButtonCss = css` + width: 167px; + margin-inline: auto; +`; + +const closeButtonCss = css` + position: absolute; + top: 0; + right: 0; + + width: fit-content; + padding: 10px; +`; diff --git a/src/features/gallery/PublishMyCard/index.tsx b/src/features/gallery/PublishMyCard/index.tsx new file mode 100644 index 00000000..167ff3e1 --- /dev/null +++ b/src/features/gallery/PublishMyCard/index.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { AnimatePresence } from 'framer-motion'; + +import CheckCircleIcon from '~/components/icons/CheckCircleIcon'; +import WarningIcon from '~/components/icons/WarningIcon'; +import Toast from '~/components/toast/Toast'; +import useToast from '~/components/toast/useToast'; +import { LOCAL_STORAGE_KEY } from '~/constants/storage'; +import CardPublishBottomSheet from '~/features/gallery/PublishMyCard/CardPublishBottomSheet'; +import InducePublishCard from '~/features/gallery/PublishMyCard/InducePublishCard'; +import JobSelectModal from '~/features/gallery/PublishMyCard/JobSelectModal'; +import usePostGallery from '~/hooks/api/gallery/usePostGallery'; +import { type JobType } from '~/remotes/gallery'; + +type OpenStateType = 'job-select' | 'publish-card' | 'initial'; + +interface Props { + onSubmit: () => void; +} + +function PublishMyCard(props: Props) { + const { fireToast } = useToast(); + + const { isOpen: isCardOpen, onClose: onCardClose } = useCardOpenState(); + + const [step, setStep] = useState('initial'); + const [selectJob, setSelectJob] = useState('PM'); + + const { mutate } = usePostGallery({ + onSuccess: () => { + setStep('initial'); + + fireToast({ + content: ( + <> + + 내 커리어 명함을 게시했어요 + + ), + }); + + props.onSubmit(); + }, + onError: () => { + // TODO: 에러 처리 (지금은 임시) + fireToast({ + content: ( + <> + + 오류가 발생했습니다. 다시 시도해주세요. + + ), + higherThanCTA: true, + }); + }, + }); + + const onSubmit = () => { + mutate({ job: selectJob }); + }; + + return ( + + {isCardOpen ? setStep('job-select')} onClose={onCardClose} /> : null} + setStep('initial')} + onSubmit={(job) => { + setSelectJob(job); + setStep('publish-card'); + }} + /> + {selectJob && ( + setStep('initial')} + job={selectJob} + onSubmit={onSubmit} + /> + )} + + ); +} + +export default PublishMyCard; + +const useCardOpenState = () => { + const [isOpen, setIsOpen] = useState(false); + + const onClose = () => { + setIsOpen(false); + localStorage.setItem(LOCAL_STORAGE_KEY.galleryCardLead, 'true'); + }; + + useEffect(() => { + const isGalleryCardLead = localStorage.getItem(LOCAL_STORAGE_KEY.galleryCardLead); + if (!isGalleryCardLead) { + setIsOpen(true); + } + }, []); + + return { isOpen, onClose }; +}; diff --git a/src/features/gallery/Selection.tsx b/src/features/gallery/Selection.tsx new file mode 100644 index 00000000..f29b3e0b --- /dev/null +++ b/src/features/gallery/Selection.tsx @@ -0,0 +1,80 @@ +import { type InputHTMLAttributes } from 'react'; +import { css, type Theme } from '@emotion/react'; + +const Selection = ({ children, ...rest }: InputHTMLAttributes) => { + return ( + + ); +}; + +export default Selection; + +const labelCss = (theme: Theme) => css` + width: 100%; + height: 56px; + background-color: ${theme.colors.gray_50}; + + & > input:checked + span { + background-color: ${theme.colors.primary_100}; + + & > span { + background-color: ${theme.colors.primary_200}; + } + } +`; + +const inputCss = css` + display: none; + appearance: none; +`; + +const wrapperSpanCss = (theme: Theme) => css` + cursor: pointer; + + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + height: 100%; + padding: 15.5px 16px; + + font-size: 18px; + font-weight: 400; + line-height: 140%; + color: ${theme.colors.gray_500}; + + border-radius: 8px; + + transition: background-color 0.3s ${theme.transition.defaultEasing}; +`; + +const checkboxSpanCss = (theme: Theme) => css` + display: flex; + align-items: center; + justify-content: center; + + width: 24px; + height: 24px; + + background-color: ${theme.colors.gray_300}; + border-radius: 50%; + + transition: background-color 0.3s ${theme.transition.defaultEasing}; + + &::after { + content: ''; + + width: 10px; + height: 10px; + + background-color: #f7f8f9; + border-radius: 50%; + } +`; diff --git a/src/features/gallery/Tab.tsx b/src/features/gallery/Tab.tsx new file mode 100644 index 00000000..1c74e583 --- /dev/null +++ b/src/features/gallery/Tab.tsx @@ -0,0 +1,76 @@ +import { css, type Theme } from '@emotion/react'; + +import { type PositionType } from '~/remotes/gallery'; + +const TABS: { + title: string; + id: PositionType; +}[] = [ + { + title: '전체', + id: 'ALL', + }, + { + title: '기획자', + id: 'PM', + }, + { + title: '개발자', + id: 'DEVELOPER', + }, + { + title: '디자이너', + id: 'DESIGNER', + }, + { + title: '기타', + id: 'OTHERS', + }, +]; + +interface Props { + activeTab: PositionType; + onClick: (id: PositionType) => void; +} + +function Tab(props: Props) { + return ( +
+ {TABS.map((tab) => ( + props.onClick(tab.id)} + /> + ))} +
+ ); +} + +export default Tab; + +const tabContainerCss = css` + padding: 0 7px; +`; + +interface TabItemProps { + title: string; + isActive: boolean; + onClick: () => void; +} + +function TabItem(props: TabItemProps) { + return ( + + ); +} + +const itemCss = (theme: Theme, isActive: boolean) => css` + padding: 0 10px 14px; + color: ${isActive ? theme.colors.gray_500 : theme.colors.gray_300}; + border-bottom: 2px solid ${isActive ? theme.colors.gray_500 : 'transparent'}; + transition: border-bottom 0.2s ease-in-out, color 0.2s ease-in-out; +`; diff --git a/src/features/gallery/useBookmark.tsx b/src/features/gallery/useBookmark.tsx new file mode 100644 index 00000000..0adf90f0 --- /dev/null +++ b/src/features/gallery/useBookmark.tsx @@ -0,0 +1,36 @@ +import CheckCircleIcon from '~/components/icons/CheckCircleIcon'; +import Toast from '~/components/toast/Toast'; +import useToast from '~/components/toast/useToast'; +import { useAddBookmark, useCancelBookmark } from '~/hooks/api/gallery/usePostBookmark'; + +const useBookmark = ({ surveyId, refetch }: { surveyId: string; refetch?: () => void }) => { + const { fireToast } = useToast(); + + const { mutate: addBookmark } = useAddBookmark(surveyId, { + onSuccess: () => { + fireToast({ + content: ( + <> + + 명함을 저장했어요 + + ), + }); + + refetch?.(); + }, + }); + + const { mutate: cancelBookmark } = useCancelBookmark(surveyId, { + onSuccess: () => { + refetch?.(); + }, + }); + + return { + cancelBookmark, + addBookmark, + }; +}; + +export default useBookmark; diff --git a/src/features/review/steps/choice/Checkbox.tsx b/src/features/review/steps/choice/Checkbox.tsx index 7606d0f5..6417ed82 100644 --- a/src/features/review/steps/choice/Checkbox.tsx +++ b/src/features/review/steps/choice/Checkbox.tsx @@ -1,6 +1,7 @@ import { type InputHTMLAttributes } from 'react'; import { css, type Theme } from '@emotion/react'; +// TODO : src/components/selection/Selection.tsx 으로 대체 const Checkbox = ({ children, ...rest }: InputHTMLAttributes) => { return (