├── src ├── vite-env.d.ts ├── utils │ ├── constants │ │ ├── storage.ts │ │ ├── category.ts │ │ ├── categories.ts │ │ └── signup.ts │ ├── object.ts │ ├── scroll.ts │ ├── date.ts │ ├── color.ts │ ├── storage.ts │ ├── coordinates.ts │ └── weeklyViews.ts ├── components │ ├── myPage │ │ ├── index.ts │ │ ├── ErrorView │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ └── MyInfo │ │ │ └── style.css.ts │ ├── signup │ │ ├── index.ts │ │ ├── style.css.ts │ │ ├── WorkInfo.tsx │ │ └── BasicInfo.tsx │ ├── history │ │ ├── NoDataFound.tsx │ │ ├── index.tsx │ │ └── style.css.ts │ ├── mainContents │ │ └── style.css.ts │ ├── modal │ │ ├── index.ts │ │ ├── certification │ │ │ ├── NotSendedView │ │ │ │ ├── style.css.ts │ │ │ │ └── index.tsx │ │ │ ├── style.css.ts │ │ │ ├── SendedView │ │ │ │ └── style.css.ts │ │ │ └── index.tsx │ │ ├── myInfo │ │ │ └── style.css.ts │ │ ├── category │ │ │ └── style.css.ts │ │ └── login │ │ │ └── style.css.ts │ ├── dailyBriefing │ │ ├── index.tsx │ │ ├── Ranking.tsx │ │ ├── Summary.tsx │ │ ├── CategoryChart.tsx │ │ ├── style.css.ts │ │ └── ContentsCountChart.tsx │ ├── buttonGroup │ │ ├── style.css.ts │ │ └── index.tsx │ ├── admin │ │ ├── index.tsx │ │ ├── pagination │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── weeklyViews │ │ │ └── style.css.ts │ │ ├── creators │ │ │ └── style.css.ts │ │ ├── companies │ │ │ ├── style.css.ts │ │ │ └── companyName.tsx │ │ ├── contents │ │ │ └── style.css.ts │ │ ├── addCompany │ │ │ └── style.css.ts │ │ └── addCreator │ │ │ └── style.css.ts │ ├── common │ │ ├── spinner │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── divider │ │ │ ├── index.tsx │ │ │ └── style.css.ts │ │ ├── tooltip │ │ │ ├── tooltipPortal.tsx │ │ │ ├── tooltipBox.tsx │ │ │ └── index.tsx │ │ ├── heading │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── header │ │ │ ├── userNav │ │ │ │ ├── NotAuthorized.tsx │ │ │ │ └── index.tsx │ │ │ └── SearchBar.tsx │ │ ├── floatingActionButton │ │ │ ├── index.tsx │ │ │ └── style.css.ts │ │ ├── icon │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── card │ │ │ ├── index.tsx │ │ │ └── style.css.ts │ │ ├── avatar │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── text │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── badge │ │ │ ├── index.tsx │ │ │ └── style.css.ts │ │ ├── table │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── modal │ │ │ ├── ModalPortal.tsx │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── index.ts │ │ ├── button │ │ │ └── index.tsx │ │ ├── Image │ │ │ └── index.tsx │ │ ├── slider │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── tab │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ ├── input │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ │ └── dropdown │ │ │ └── index.tsx │ ├── cardList │ │ ├── index.tsx │ │ └── style.css.ts │ ├── searchResult │ │ ├── style.css.ts │ │ └── searchInfo.tsx │ ├── backToTop │ │ └── index.tsx │ ├── cardItem │ │ ├── content │ │ │ ├── style.css.ts │ │ │ ├── CardModal.tsx │ │ │ ├── recommendationBanner │ │ │ │ └── style.css.ts │ │ │ └── cardTop │ │ │ │ └── style.css.ts │ │ └── creator │ │ │ └── style.css.ts │ ├── main │ │ ├── style.css.ts │ │ └── index.tsx │ ├── layout │ │ └── index.tsx │ ├── detailContents │ │ └── creatorInfo │ │ │ ├── style.css.ts │ │ │ └── index.tsx │ └── recommendedCreators │ │ └── index.tsx ├── stores │ ├── searchKeyword.ts │ ├── lastTab.ts │ ├── scroll.ts │ ├── searchBar.ts │ ├── tab.ts │ ├── selectedCategory.ts │ ├── modal.ts │ └── auth.ts ├── pages │ ├── history │ │ ├── style.css.ts │ │ └── index.tsx │ ├── error │ │ ├── style.css.ts │ │ └── index.tsx │ ├── admin │ │ ├── style.css.ts │ │ └── index.tsx │ ├── creatorList │ │ └── style.css.ts │ ├── myPage │ │ ├── style.css.ts │ │ └── index.tsx │ ├── searchResult │ │ └── style.css.ts │ ├── index.ts │ ├── creatorDetail │ │ ├── style.css.ts │ │ └── index.tsx │ ├── signup │ │ └── style.css.ts │ ├── dailyBriefing │ │ └── style.css.ts │ └── home │ │ └── style.css.ts ├── types │ ├── myInfo.ts │ ├── positions.ts │ ├── dailyBriefing.ts │ ├── admin.ts │ └── contents.ts ├── api │ ├── subscribe.ts │ ├── dailyBriefing.ts │ ├── like.ts │ ├── notRecommend.ts │ ├── bookmark.ts │ ├── view.ts │ ├── creator.ts │ ├── creatorList.ts │ ├── specificCreator.ts │ ├── searchContents.ts │ ├── attentionCategory.ts │ ├── companies.ts │ ├── member.ts │ ├── history.ts │ ├── core.ts │ ├── mainContents.ts │ └── s3Image.ts ├── __mocks__ │ ├── handlers │ │ ├── specificCreator.ts │ │ ├── subscriptions.ts │ │ ├── like.ts │ │ ├── view.ts │ │ ├── subscribe.ts │ │ ├── mainContents.ts │ │ ├── notRecommend.ts │ │ ├── creator.ts │ │ ├── bookmark.ts │ │ ├── attentionCategory.ts │ │ ├── member.ts │ │ ├── index.ts │ │ ├── companies.ts │ │ ├── recommendedCreators.ts │ │ ├── creatorList.ts │ │ └── dailyBriefing.ts │ └── worker.ts ├── hooks │ ├── useInput.ts │ ├── useDropdown.ts │ ├── useClickAway.ts │ └── infiniteQuery │ │ ├── useCreatorListInfiniteQuery.ts │ │ ├── useMainContentsInfinitelQuery.ts │ │ ├── useSearchContentsInfiniteQuery.ts │ │ ├── useSpecificCreatorInfiniteQuery.ts │ │ └── useHistoryInfiniteQuery.ts ├── stories │ └── components │ │ ├── common │ │ ├── Divider.stories.tsx │ │ ├── Card.stories.tsx │ │ ├── Badge.stories.tsx │ │ ├── Banner.stories.tsx │ │ ├── Header.stories.tsx │ │ ├── Tab.stories.tsx │ │ ├── Avatar.stories.tsx │ │ ├── Spinner.stories.tsx │ │ ├── Icon.stories.tsx │ │ ├── Text.stories.tsx │ │ ├── Dropdown.stories.tsx │ │ ├── Button.stories.tsx │ │ ├── Image.stories.tsx │ │ └── Input.stories.tsx │ │ └── modal │ │ └── login.stories.tsx ├── styles │ ├── medias.css.ts │ ├── variants.css.ts │ ├── keyframes.css.ts │ ├── tokens.css.ts │ └── global.css.ts ├── main.tsx └── App.tsx ├── .storybook ├── preview-head.html ├── preview.cjs └── main.cjs ├── .vercel ├── project.json └── README.txt ├── .husky └── pre-commit ├── public ├── favicon.ico ├── assets │ ├── googleLogo.png │ ├── hyperlink.png │ ├── defaultProfileImage.png │ └── user.svg └── vite.svg ├── .env.example ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ └── 이슈-템플릿.md └── workflows │ └── production.yaml ├── tsconfig.node.json ├── vite.config.ts ├── .prettierrc ├── .gitignore ├── .eslintrc.json ├── tsconfig.json ├── index.html └── package.json /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/utils/constants/storage.ts: -------------------------------------------------------------------------------- 1 | export const WEEKLY_VIEWS = 'WEEKLY_VIEWS'; 2 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vercel/project.json: -------------------------------------------------------------------------------- 1 | {"projectId":"prj_rAWEHoGmcLt89Iw3VSCznL2vmPrk","orgId":"RTr1PHrekvMsVW829Wj0MT01"} -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint-staged -q 5 | 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-web-devcourse/Team-JJINSA-HyperLink-FE/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/assets/googleLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-web-devcourse/Team-JJINSA-HyperLink-FE/HEAD/public/assets/googleLogo.png -------------------------------------------------------------------------------- /public/assets/hyperlink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-web-devcourse/Team-JJINSA-HyperLink-FE/HEAD/public/assets/hyperlink.png -------------------------------------------------------------------------------- /src/components/myPage/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MyInfo } from './MyInfo'; 2 | export { default as ErrorView } from './ErrorView'; 3 | -------------------------------------------------------------------------------- /public/assets/defaultProfileImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prgrms-web-devcourse/Team-JJINSA-HyperLink-FE/HEAD/public/assets/defaultProfileImage.png -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export const getKeyByValue = (obj: { [key: string]: string }, value: string) => 2 | Object.keys(obj).find((key) => obj[key] === value); 3 | -------------------------------------------------------------------------------- /src/stores/searchKeyword.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const searchKeywordState = atom({ 4 | key: 'searchKeyword', 5 | default: '', 6 | }); 7 | -------------------------------------------------------------------------------- /src/utils/constants/category.ts: -------------------------------------------------------------------------------- 1 | export const CATEGORY: { [key: string]: string } = { 2 | develop: '개발', 3 | beauty: '패션 / 뷰티', 4 | finance: '경제 / 금융', 5 | }; 6 | -------------------------------------------------------------------------------- /src/pages/history/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | export const wrapper = style([ 4 | { 5 | padding: '0 10rem', 6 | }, 7 | ]); 8 | -------------------------------------------------------------------------------- /src/stores/lastTab.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const lastTabState = atom({ 4 | key: 'lastTab', 5 | default: 'RECENT_CONTENT', 6 | }); 7 | -------------------------------------------------------------------------------- /src/utils/constants/categories.ts: -------------------------------------------------------------------------------- 1 | export const CATEGORIES: { [key: string]: string } = { 2 | develop: '개발', 3 | beauty: '패션 / 뷰티', 4 | finance: '경제 / 금융', 5 | }; 6 | -------------------------------------------------------------------------------- /src/stores/scroll.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const isHomeScrolledState = atom({ 4 | key: 'isHomeScrolled', 5 | default: false, 6 | }); 7 | -------------------------------------------------------------------------------- /src/stores/searchBar.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const isSearchBarVisibleState = atom({ 4 | key: 'isSearchBarVisible', 5 | default: false, 6 | }); 7 | -------------------------------------------------------------------------------- /src/stores/tab.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const selectedTabState = atom({ 4 | key: 'selectedTab', 5 | default: 'RECENT_CONTENT', 6 | }); 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_GOOGLE_CLIENT_ID=구글 oauth client id 2 | VITE_BASE_URL=local용 test url 3 | VITE_AWS_ACCESS_KEY_ID=aws access key id 4 | VITE_AWS_SECRET_ACCESS_KEY=aws secret access key 5 | -------------------------------------------------------------------------------- /src/components/signup/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BasicInfo } from './BasicInfo'; 2 | export { default as CategoryInfo } from './CategoryInfo'; 3 | export { default as WorkInfo } from './WorkInfo'; 4 | -------------------------------------------------------------------------------- /src/types/myInfo.ts: -------------------------------------------------------------------------------- 1 | export type myInfo = { 2 | email: string; 3 | nickname: string; 4 | career: string; 5 | careerYear: string; 6 | profileUrl: string; 7 | companyName?: string; 8 | }; 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 💡 이슈 번호 2 | close #이슈 번호 3 | 4 | ## 📖 작업 내용 5 | - [] 작업한 내용 및 설명 6 | - [] 작업한 내용 및 설명 7 | 8 | ## ✅ PR 포인트 9 | - 봐야하는 부분 10 | - 궁금한 점 11 | 12 | ## 📸 스크린샷 13 | -------------------------------------------------------------------------------- /src/components/history/NoDataFound.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from '../common'; 2 | 3 | const NoDataFound = () => { 4 | return 🗑️ 아직 아무것도 없네요!; 5 | }; 6 | 7 | export default NoDataFound; 8 | -------------------------------------------------------------------------------- /src/components/mainContents/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as variants from '@/styles/variants.css'; 2 | import { style } from '@vanilla-extract/css'; 3 | 4 | export const fetching = style({ color: variants.color.primary }); 5 | -------------------------------------------------------------------------------- /src/components/history/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as BookmarkContent } from './BookmarkContent'; 2 | export { default as HistoryContent } from './HistoryContent'; 3 | export { default as NoDataFound } from './NoDataFound'; 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/이슈-템플릿.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 이슈 템플릿 3 | about: '예시: [이슈명(한글)] (라벨)' 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📕 작업 설명 11 | > 내용 12 | 13 | ## 📖 진행 사항 14 | - [ ] 할 일1 15 | - [ ] 할 일2 16 | -------------------------------------------------------------------------------- /src/components/modal/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LoginModal } from '@/components/modal/login'; 2 | export { default as MyInfoModal } from '@/components/modal/myInfo'; 3 | export { default as CategoryModal } from '@/components/modal/category'; 4 | -------------------------------------------------------------------------------- /src/utils/scroll.ts: -------------------------------------------------------------------------------- 1 | export const scrollTo = ( 2 | element: HTMLDivElement | (Window & typeof globalThis), 3 | to: number 4 | ) => 5 | element.scrollTo({ 6 | top: to, 7 | left: 0, 8 | behavior: 'smooth', 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | export const isSameDate = (date1: Date, date2: Date) => { 2 | return ( 3 | date1.getFullYear() === date2.getFullYear() && 4 | date1.getMonth() === date2.getMonth() && 5 | date1.getDate() === date2.getDate() 6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /src/api/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from '@/api/core'; 2 | 3 | export const postSubscribeResponse = async (creatorId: number) => { 4 | const response = await axiosInstance.post(`/creators/${creatorId}/subscribe`); 5 | return response; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/dailyBriefing/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Ranking } from './Ranking'; 2 | export { default as Summary } from './Summary'; 3 | export { default as CategoryChart } from './CategoryChart'; 4 | export { default as ContentsCountChart } from './ContentsCountChart'; 5 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/specificCreator.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { data } from '../data'; 3 | 4 | export const specificCreatorHandler = [ 5 | rest.get('/creators', (req, res, ctx) => { 6 | return res(ctx.status(200), ctx.delay(500), ctx.json(data)); 7 | }), 8 | ]; 9 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { data } from '../data'; 3 | 4 | export const subscriptionContentsHandlers = [ 5 | rest.get('/subscriptions', (req, res, ctx) => { 6 | return res(ctx.status(200), ctx.delay(500), ctx.json(data)); 7 | }), 8 | ]; 9 | -------------------------------------------------------------------------------- /src/api/dailyBriefing.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from '@/api/core'; 2 | import { dailyBriefing } from '@/types/dailyBriefing'; 3 | 4 | export const getDailyBriefingData = async () => { 5 | const response: dailyBriefing = await axiosInstance.get(`/daily-briefing`); 6 | return response; 7 | }; 8 | -------------------------------------------------------------------------------- /src/components/buttonGroup/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | 4 | export const filterButtonGroup = style([ 5 | utils.flexAlignCenter, 6 | { 7 | margin: '2rem 0', 8 | gap: '1rem', 9 | }, 10 | ]); 11 | -------------------------------------------------------------------------------- /src/components/admin/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as AddCreator } from './addCreator'; 2 | export { default as Companies } from './companies'; 3 | export { default as Contents } from './contents'; 4 | export { default as Creators } from './creators'; 5 | export { default as WeeklyViews } from './weeklyViews'; 6 | -------------------------------------------------------------------------------- /src/components/common/spinner/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | 4 | export const spinner = style([ 5 | utils.positionAbsolute, 6 | { 7 | top: '50%', 8 | left: '50%', 9 | transform: 'translate(-50%, -50%)', 10 | }, 11 | ]); 12 | -------------------------------------------------------------------------------- /src/stores/selectedCategory.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const selectedCategoryState = atom({ 4 | key: 'category', 5 | default: 'all', 6 | }); 7 | 8 | export const selectedFilterHistoryPageState = atom({ 9 | key: 'selectedFilterHistoryPage', 10 | default: 'bookmark', 11 | }); 12 | -------------------------------------------------------------------------------- /.storybook/preview.cjs: -------------------------------------------------------------------------------- 1 | import '@fortawesome/fontawesome-free/css/all.css'; 2 | import '@/styles/global.css'; 3 | 4 | export const parameters = { 5 | actions: { argTypesRegex: '^on[A-Z].*' }, 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/, 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/admin/pagination/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import { style } from '@vanilla-extract/css'; 3 | 4 | export const container = style([ 5 | utils.flexJustifyCenter, 6 | { 7 | gap: '1.5rem', 8 | }, 9 | ]); 10 | 11 | export const iconButton = style([utils.cursorPointer]); 12 | -------------------------------------------------------------------------------- /src/components/admin/weeklyViews/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | 4 | export const container = style([utils.flexColumn, { gap: '2rem' }]); 5 | 6 | export const spinnerWrapper = style([ 7 | utils.positionRelative, 8 | { height: '30rem' }, 9 | ]); 10 | -------------------------------------------------------------------------------- /src/components/common/divider/index.tsx: -------------------------------------------------------------------------------- 1 | import * as style from './style.css'; 2 | 3 | export type DividerProps = { 4 | type?: 'horizontal' | 'vertical'; 5 | }; 6 | 7 | const Divider = ({ type = 'horizontal' }: DividerProps) => { 8 | return
; 9 | }; 10 | 11 | export default Divider; 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; 4 | 5 | export default defineConfig({ 6 | plugins: [react(), vanillaExtractPlugin()], 7 | resolve: { 8 | alias: [{ find: '@', replacement: '/src' }], 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/pages/error/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | 4 | export const wrapper = style([ 5 | utils.flexColumn, 6 | utils.flexCenter, 7 | { 8 | paddingTop: '14rem', 9 | paddingBottom: '4rem', 10 | textAlign: 'center', 11 | rowGap: '3rem', 12 | }, 13 | ]); 14 | -------------------------------------------------------------------------------- /src/components/myPage/ErrorView/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | 4 | export const wrapper = style([ 5 | utils.flexColumn, 6 | utils.flexCenter, 7 | { 8 | paddingTop: '14rem', 9 | paddingBottom: '4rem', 10 | textAlign: 'center', 11 | rowGap: '3rem', 12 | }, 13 | ]); 14 | -------------------------------------------------------------------------------- /src/types/positions.ts: -------------------------------------------------------------------------------- 1 | export type positions = 2 | | 'top-start' 3 | | 'top' 4 | | 'top-end' 5 | | 'right-start' 6 | | 'right' 7 | | 'right-end' 8 | | 'bottom-start' 9 | | 'bottom' 10 | | 'bottom-end' 11 | | 'left-start' 12 | | 'left' 13 | | 'left-end'; 14 | 15 | export type coordinates = { 16 | left: number; 17 | top: number; 18 | }; 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "arrowParens": "always", 10 | "endOfLine": "lf", 11 | "bracketSpacing": true, 12 | "requirePragma": false, 13 | "insertPragma": false, 14 | "proseWrap": "preserve" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/cardList/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import * as style from './style.css'; 3 | 4 | const CardList = ({ 5 | type, 6 | children, 7 | }: { 8 | type: 'creator' | 'content'; 9 | children: ReactNode; 10 | }) => { 11 | return
{children}
; 12 | }; 13 | 14 | export default CardList; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env 27 | -------------------------------------------------------------------------------- /src/components/modal/certification/NotSendedView/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import { style } from '@vanilla-extract/css'; 3 | 4 | export const modalInputWrapper = style([ 5 | utils.flexColumn, 6 | { 7 | gap: '1.2rem', 8 | }, 9 | ]); 10 | 11 | export const textWrapper = style({ 12 | textAlign: 'right', 13 | marginTop: '-1rem', 14 | }); 15 | -------------------------------------------------------------------------------- /src/pages/admin/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | import * as medias from '@/styles/medias.css'; 4 | 5 | export const container = style([ 6 | utils.flexColumn, 7 | medias.medium({ 8 | padding: '6rem 10rem', 9 | }), 10 | { width: '60rem', padding: '6rem 0', gap: '4rem', margin: '0 auto' }, 11 | ]); 12 | -------------------------------------------------------------------------------- /src/components/searchResult/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as variants from '@/styles/variants.css'; 3 | 4 | export const searchInfo = style({ 5 | marginBottom: '4.2rem', 6 | }); 7 | 8 | export const resultStats = style({ 9 | fontSize: variants.fontSize.medium, 10 | color: variants.color.font.secondary, 11 | marginBottom: '1.4rem', 12 | }); 13 | -------------------------------------------------------------------------------- /src/api/like.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from '@/api/core'; 2 | 3 | export const postLikeResponse = async (contentId: number, type: boolean) => { 4 | try { 5 | const response = await axiosInstance.post(`/like/${contentId}`, { 6 | addLike: `${!type}`, 7 | }); 8 | 9 | return response; 10 | } catch (error) { 11 | console.error('like error api', error); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/common/tooltip/tooltipPortal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | const TooltipPortal = ({ children }: { children: ReactNode | ReactNode[] }) => { 5 | const tooltipRoot = document.getElementById('tooltip-root'); 6 | 7 | return ReactDOM.createPortal(children, tooltipRoot as Element); 8 | }; 9 | 10 | export default TooltipPortal; 11 | -------------------------------------------------------------------------------- /src/hooks/useInput.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | const useInput = (initialValue = '') => { 4 | const [value, setValue] = useState(initialValue); 5 | 6 | const handleChange = useCallback((v: string) => { 7 | setValue(v); 8 | }, []); 9 | 10 | return { 11 | value, 12 | onChange: handleChange, 13 | }; 14 | }; 15 | 16 | export default useInput; 17 | -------------------------------------------------------------------------------- /src/api/notRecommend.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from '@/api/core'; 2 | 3 | export const postNotRecommendResponse = async (creatorId: number) => { 4 | try { 5 | const response = await axiosInstance.post( 6 | `/creators/${creatorId}/not-recommend` 7 | ); 8 | 9 | return response; 10 | } catch (error) { 11 | console.error('not-recommend error api', error); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/history/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import * as utils from '@/styles/utils.css'; 4 | 5 | export const wrapper = style([ 6 | utils.positionRelative, 7 | { 8 | minHeight: 'calc(100vh - 7.1rem)', 9 | }, 10 | ]); 11 | 12 | export const fetching = style({ color: variants.color.primary }); 13 | -------------------------------------------------------------------------------- /src/stores/modal.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const isLoginModalVisibleState = atom({ 4 | key: 'isLoginModalVisible', 5 | default: false, 6 | }); 7 | 8 | export const isMyInfoModalVisibleState = atom({ 9 | key: 'isMyInfoModalVisible', 10 | default: false, 11 | }); 12 | 13 | export const isCategoryModalVisibleState = atom({ 14 | key: 'isCategoryModalVisible', 15 | default: false, 16 | }); 17 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/like.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | export const likeHandlers = [ 4 | rest.post('/like', async (req, res, ctx) => { 5 | const { contentId } = req.params; 6 | const { addLike } = await req.json(); 7 | 8 | if (!contentId) { 9 | return res(ctx.status(400)); 10 | } 11 | 12 | return res( 13 | ctx.status(200), 14 | ctx.delay(1000), 15 | ctx.json({ addLike: addLike }) 16 | ); 17 | }), 18 | ]; 19 | -------------------------------------------------------------------------------- /src/api/bookmark.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from '@/api/core'; 2 | 3 | export const postBookmarkResponse = async ( 4 | contentId: number, 5 | type: boolean 6 | ) => { 7 | const isBookmarked = type ? 0 : 1; 8 | 9 | try { 10 | const response = await axiosInstance.post( 11 | `/bookmark/${contentId}?type=${isBookmarked}` 12 | ); 13 | 14 | return response; 15 | } catch (error) { 16 | console.error('bookmark error api', error); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | export const hexToRGB = (hex: string, alpha: number) => { 2 | const r = parseInt(hex.slice(1, 3), 16), 3 | g = parseInt(hex.slice(3, 5), 16), 4 | b = parseInt(hex.slice(5, 7), 16); 5 | const rgb = [r, g, b]; 6 | 7 | if (alpha) { 8 | return `rgba(${rgb.join(',')}, ${alpha})`; 9 | } 10 | 11 | return `rgba(${rgb.join(',')})`; 12 | }; 13 | 14 | export const generateRandomHex = () => 15 | `#${Math.random().toString(16).substr(-6)}`; 16 | -------------------------------------------------------------------------------- /src/pages/creatorList/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as medias from '@/styles/medias.css'; 2 | import * as utils from '@/styles/utils.css'; 3 | import { style } from '@vanilla-extract/css'; 4 | 5 | export const wrapper = style([ 6 | { 7 | padding: '0.7rem 10rem', 8 | }, 9 | medias.large({ padding: '0 6rem' }), 10 | medias.medium({ padding: '0 4rem' }), 11 | ]); 12 | 13 | export const buttonWrapper = style([ 14 | utils.flexAlignCenter, 15 | utils.flexJustifySpaceBetween, 16 | ]); 17 | -------------------------------------------------------------------------------- /src/api/view.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from '@/api/core'; 2 | 3 | type viewProps = { 4 | contentId: number; 5 | pageType: number; 6 | }; 7 | 8 | export const patchViewResponse = async (viewData: viewProps) => { 9 | try { 10 | const response = await axiosInstance.patch( 11 | `/contents/${viewData.contentId}/view?search=${viewData.pageType}` 12 | ); 13 | 14 | return response; 15 | } catch (error) { 16 | console.error('View error api', error); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/common/heading/style.css.ts: -------------------------------------------------------------------------------- 1 | import { recipe } from '@vanilla-extract/recipes'; 2 | import * as tokens from '@/styles/tokens.css'; 3 | 4 | export const heading = recipe({ 5 | variants: { 6 | level: { 7 | 1: tokens.typography.heading1, 8 | 2: tokens.typography.heading2, 9 | 3: tokens.typography.heading3, 10 | 4: tokens.typography.heading4, 11 | 5: tokens.typography.heading5, 12 | 6: tokens.typography.heading6, 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/admin/creators/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import { style } from '@vanilla-extract/css'; 3 | 4 | export const container = style([ 5 | utils.fullWidth, 6 | utils.flexColumn, 7 | { 8 | gap: '2rem', 9 | }, 10 | ]); 11 | 12 | export const ellipsis = style([ 13 | utils.textOverflowEllipsis, 14 | { maxWidth: '14rem' }, 15 | ]); 16 | 17 | export const spinnerWrapper = style([ 18 | utils.positionRelative, 19 | { height: '30rem' }, 20 | ]); 21 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/view.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | export const viewHandlers = [ 4 | rest.patch('/contents/:contentId/view', (req, res, ctx) => { 5 | const { contentId } = req.params; 6 | const viewType = req.url.searchParams.get('search'); 7 | if (!contentId || !viewType) { 8 | return res(ctx.status(400)); 9 | } 10 | 11 | return res( 12 | ctx.status(200), 13 | ctx.delay(500), 14 | ctx.json({ data: 'view success' }) 15 | ); 16 | }), 17 | ]; 18 | -------------------------------------------------------------------------------- /src/api/creator.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from '@/api/core'; 2 | import { creator, recommendedCreators } from '@/types/contents'; 3 | 4 | export const getCreatorInfo = async (creatorId: number) => { 5 | const response: creator = await axiosInstance.get(`/creators/${creatorId}`); 6 | return response; 7 | }; 8 | 9 | export const getRecommendedCreators = async () => { 10 | const response: recommendedCreators = await axiosInstance.get( 11 | '/creators/recommend' 12 | ); 13 | 14 | return response; 15 | }; 16 | -------------------------------------------------------------------------------- /src/pages/myPage/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | import { medium } from '@/styles/medias.css'; 4 | 5 | export const wrapper = style([ 6 | utils.flexColumn, 7 | medium({ 8 | padding: '3rem 0', 9 | width: '30rem', 10 | minWidth: '30rem', 11 | gap: '2rem', 12 | }), 13 | { 14 | margin: '0 auto', 15 | padding: '10rem', 16 | width: '60rem', 17 | minWidth: '60rem', 18 | gap: '3rem', 19 | }, 20 | ]); 21 | -------------------------------------------------------------------------------- /src/components/common/header/userNav/NotAuthorized.tsx: -------------------------------------------------------------------------------- 1 | import Button from '../../button'; 2 | import { useSetRecoilState } from 'recoil'; 3 | import { isLoginModalVisibleState } from '@/stores/modal'; 4 | 5 | const NotAuthorized = () => { 6 | const setIsLoginModalVisible = useSetRecoilState(isLoginModalVisibleState); 7 | 8 | return ( 9 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/stories/components/common/Tab.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Tab } from '@/components/common'; 2 | import { TabProps } from '@/components/common/tab'; 3 | 4 | export default { 5 | title: 'Components/Common/Tab', 6 | component: Tab, 7 | argTypes: { 8 | type: { 9 | defaultValue: 'header', 10 | control: 'inline-radio', 11 | options: ['header', 'modal'], 12 | description: 'Tab type', 13 | }, 14 | }, 15 | }; 16 | 17 | export const Default = (args: TabProps) => { 18 | const handleClick = (item: string) => { 19 | console.log(item); 20 | }; 21 | 22 | return ( 23 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/common/floatingActionButton/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { recipe } from '@vanilla-extract/recipes'; 4 | 5 | export const wrapper = recipe({ 6 | base: [ 7 | utils.positionFixed, 8 | utils.borderRadiusRound, 9 | utils.flexCenter, 10 | utils.cursorPointer, 11 | { 12 | right: '3rem', 13 | bottom: '3rem', 14 | 15 | backgroundColor: variants.color.white, 16 | width: '4rem', 17 | height: '4rem', 18 | 19 | boxShadow: '0 0.3rem 0.6rem rgba(0, 0, 0, 0.2)', 20 | }, 21 | ], 22 | variants: { 23 | visible: { 24 | true: { 25 | visibility: 'visible', 26 | }, 27 | false: { 28 | visibility: 'hidden', 29 | }, 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/common/badge/index.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react'; 2 | import * as style from './style.css'; 3 | 4 | export type BadgeProps = { 5 | text?: string; 6 | top?: number; 7 | right?: number; 8 | style?: CSSProperties; 9 | }; 10 | 11 | // header AlramIcon badge, 알람 모달 badge / 데일리 브리핑 new badge 12 | const Badge = ({ text = 'new', top = 0, right = 0, ...props }: BadgeProps) => { 13 | const position = { 14 | top, 15 | right, 16 | }; 17 | 18 | return text ? ( 19 | 23 | {text} 24 | 25 | ) : ( 26 | 30 | ); 31 | }; 32 | 33 | export default Badge; 34 | -------------------------------------------------------------------------------- /src/components/detailContents/creatorInfo/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | import * as variants from '@/styles/variants.css'; 4 | 5 | export const creator = style([ 6 | utils.flexJustifySpaceBetween, 7 | utils.flexAlignCenter, 8 | ]); 9 | 10 | export const info = style([utils.flexAlignCenter]); 11 | 12 | export const detail = style({ 13 | marginLeft: '2rem', 14 | }); 15 | 16 | export const header = style([ 17 | utils.flex, 18 | { 19 | alignItems: 'baseline', 20 | }, 21 | ]); 22 | 23 | export const subscriber = style({ 24 | fontSize: variants.fontSize.small, 25 | color: variants.color.font.secondary, 26 | }); 27 | 28 | export const description = style({ 29 | fontSize: variants.fontSize.small, 30 | color: variants.color.font.primary, 31 | }); 32 | -------------------------------------------------------------------------------- /src/styles/variants.css.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalTheme } from '@vanilla-extract/css'; 2 | 3 | export const color = createGlobalTheme(':root', { 4 | primary: '#3772ff', 5 | primaryDimmed: '#3772ff30', 6 | secondary: '#004BFF', 7 | white: '#fff', 8 | bg: { 9 | tab: '#eff0f2', 10 | select: '#dddddd80', 11 | }, 12 | border: '#c2c6cc', 13 | font: { 14 | primary: '#2a282f', 15 | secondary: '#787878', 16 | }, 17 | icon: '#9a9a9a', 18 | disabled: { 19 | font: '#7D8398', 20 | bg: '#7D839830', 21 | }, 22 | }); 23 | 24 | export const fontSize = createGlobalTheme(':root', { 25 | xSmall: '1.2rem', 26 | small: '1.4rem', 27 | medium: '1.6rem', 28 | large: '1.8rem', 29 | xLarge: '2.3rem', 30 | huge: '6rem', 31 | }); 32 | 33 | export const font = createGlobalTheme(':root', { 34 | default: 'Pretendard Variable', 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/common/card/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { recipe } from '@vanilla-extract/recipes'; 4 | 5 | export const CardWrapper = recipe({ 6 | base: [ 7 | utils.borderRadius, 8 | utils.overflowHidden, 9 | utils.fullWidth, 10 | { 11 | minWidth: '25rem', 12 | backgroundColor: variants.color.white, 13 | }, 14 | ], 15 | variants: { 16 | type: { 17 | default: { 18 | padding: '2.4rem', 19 | }, 20 | creator: { 21 | height: '14.6rem', 22 | boxShadow: '0 0.3rem 0.6rem rgba(0, 0, 0, 0.2)', 23 | }, 24 | content: [ 25 | utils.positionRelative, 26 | { 27 | boxShadow: '0 0.3rem 0.6rem rgba(0, 0, 0, 0.2)', 28 | }, 29 | ], 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/api/companies.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from '@/api/core'; 2 | import { creators } from '@/types/contents'; 3 | 4 | type verificationCompany = { 5 | companyEmail: string; 6 | authNumber: number; 7 | }; 8 | 9 | export const sendCompanyEmail = async (companyEmail: string) => { 10 | try { 11 | const response: creators = await axiosInstance.post(`/companies/auth`, { 12 | companyEmail, 13 | }); 14 | 15 | return response; 16 | } catch (error) { 17 | console.error(error); 18 | } 19 | }; 20 | 21 | export const verificationCompany = async ( 22 | verificationCompany: verificationCompany 23 | ) => { 24 | try { 25 | const response: creators = await axiosInstance.post( 26 | `/companies/verification`, 27 | verificationCompany 28 | ); 29 | 30 | return response; 31 | } catch (error) { 32 | console.error(error); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/stories/components/common/Avatar.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from '@/components/common'; 2 | import { AvatarProps } from '@/components/common/avatar'; 3 | 4 | export default { 5 | title: 'Components/Common/Avatar', 6 | component: Avatar, 7 | argTypes: { 8 | src: { 9 | type: { name: 'string', require: true }, 10 | defaultValue: 'https://avatars.githubusercontent.com/u/60571418?v=4', 11 | control: { type: 'text' }, 12 | }, 13 | shape: { 14 | defaultValue: 'circle', 15 | control: 'inline-radio', 16 | options: ['circle', 'round', 'square'], 17 | }, 18 | size: { 19 | defaultValue: 'medium', 20 | control: 'inline-radio', 21 | options: ['xSmall', 'small', 'medium', 'large', 'xLarge'], 22 | }, 23 | }, 24 | }; 25 | 26 | export const Default = (args: AvatarProps) => { 27 | return ; 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/common/header/userNav/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilValue, useSetRecoilState } from 'recoil'; 2 | import Authorized from './Authorized'; 3 | import { Button } from '@/components/common'; 4 | import { isAuthorizedState } from '@/stores/auth'; 5 | import { isLoginModalVisibleState } from '@/stores/modal'; 6 | import * as style from '../style.css'; 7 | 8 | const UserNav = () => { 9 | const isAuthorized = useRecoilValue(isAuthorizedState); 10 | const setIsLoginModalVisible = useSetRecoilState(isLoginModalVisibleState); 11 | 12 | return ( 13 |
14 | {isAuthorized ? ( 15 | 16 | ) : ( 17 |
24 | ); 25 | }; 26 | 27 | export default UserNav; 28 | -------------------------------------------------------------------------------- /src/hooks/useDropdown.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | const useDropdown = (initialState = false) => { 4 | const [isVisible, setIsVisible] = useState(initialState); 5 | 6 | const ref = useRef(null); 7 | 8 | const handleVisibility = () => { 9 | setIsVisible(!isVisible); 10 | }; 11 | 12 | const handleClick = (e: MouseEvent) => { 13 | const target = e.target as HTMLDivElement; 14 | if (ref.current && !ref.current.contains(target)) { 15 | setIsVisible(!isVisible); 16 | } 17 | }; 18 | 19 | useEffect(() => { 20 | if (isVisible) { 21 | window.addEventListener('click', handleClick); 22 | } 23 | 24 | return () => { 25 | window.removeEventListener('click', handleClick); 26 | }; 27 | }, [isVisible]); 28 | 29 | return { isVisible, ref, handleVisibility }; 30 | }; 31 | 32 | export default useDropdown; 33 | -------------------------------------------------------------------------------- /src/styles/keyframes.css.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from '@vanilla-extract/css'; 2 | 3 | export const leftSlideIn = keyframes({ 4 | '0%': { opacity: 0, transform: 'translateX(-20rem)' }, 5 | '100%': { opacity: 1, transform: 'translateY(0)' }, 6 | }); 7 | 8 | export const rightSlideIn = keyframes({ 9 | '0%': { opacity: 0, transform: 'translateX(20rem)' }, 10 | '100%': { opacity: 1, transform: 'translateY(0)' }, 11 | }); 12 | 13 | export const bouncing = keyframes({ 14 | '0%': { transform: 'translateY(-3rem)' }, 15 | '50%': { transform: 'translateY(0)' }, 16 | '100%': { transform: 'translateY(-3rem)' }, 17 | }); 18 | 19 | export const fadeIn = keyframes({ 20 | '0%': { opacity: 0 }, 21 | '100%': { opacity: 1 }, 22 | }); 23 | 24 | export const rightSlideOut = keyframes({ 25 | '0%': { opacity: 1, transform: 'translate(0)' }, 26 | '100%': { opacity: 0, transform: 'translate(20rem)' }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/attentionCategory.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | export const myAttentionCategory = { 4 | attentionCategory: ['develop', 'beauty', 'finance'], 5 | }; 6 | 7 | export const attentionCategoryHandler = [ 8 | rest.get('/attention-category', (req, res, ctx) => { 9 | if (!req.headers.all().authorization) { 10 | return res(ctx.status(401)); 11 | } 12 | 13 | return res( 14 | ctx.status(200), 15 | ctx.json(myAttentionCategory.attentionCategory) 16 | ); 17 | }), 18 | 19 | rest.put('/attention-category/update', async (req, res, ctx) => { 20 | if (!req.headers.all().authorization) { 21 | return res(ctx.status(401)); 22 | } 23 | 24 | const { attentionCategory } = await req.json(); 25 | myAttentionCategory.attentionCategory = attentionCategory; 26 | 27 | return res(ctx.status(200), ctx.json(myAttentionCategory)); 28 | }), 29 | ]; 30 | -------------------------------------------------------------------------------- /src/components/common/table/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { globalStyle, style } from '@vanilla-extract/css'; 4 | import { recipe } from '@vanilla-extract/recipes'; 5 | 6 | export const table = style([ 7 | utils.fullWidth, 8 | { 9 | border: `0.2rem solid ${variants.color.font.primary}`, 10 | borderSpacing: '0', 11 | }, 12 | ]); 13 | 14 | export const th = recipe({ 15 | variants: { 16 | version: { 17 | small: { 18 | width: '8rem', 19 | }, 20 | }, 21 | }, 22 | }); 23 | 24 | globalStyle(`${table} td, th`, { 25 | textAlign: 'center', 26 | borderBottom: `0.2rem solid ${variants.color.font.primary}`, 27 | borderRight: `0.2rem solid ${variants.color.font.primary}`, 28 | }); 29 | 30 | globalStyle(`${table} th`, { 31 | padding: '1.4rem 0', 32 | background: 'lightgray', 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/common/spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@/components/common'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { CSSProperties } from 'react'; 4 | import * as style from './style.css'; 5 | 6 | export type SpinnerProps = { 7 | size?: 'xSmall' | 'small' | 'medium' | 'large' | 'xLarge' | 'huge'; 8 | color?: string; 9 | loading?: boolean; 10 | style?: CSSProperties; 11 | }; 12 | 13 | const Spinner = ({ 14 | size = 'medium', 15 | color = variants.color.icon, 16 | loading = true, 17 | ...props 18 | }: SpinnerProps) => { 19 | return loading ? ( 20 |
21 | 29 |
30 | ) : null; 31 | }; 32 | 33 | export default Spinner; 34 | -------------------------------------------------------------------------------- /src/components/searchResult/searchInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useSetRecoilState } from 'recoil'; 3 | import { Heading } from '@/components/common'; 4 | import { isHomeScrolledState } from '@/stores/scroll'; 5 | import * as style from './style.css'; 6 | 7 | type searchInfoProps = { 8 | keyword: string; 9 | searchResultCount: number; 10 | }; 11 | 12 | const SearchInfo = ({ keyword, searchResultCount }: searchInfoProps) => { 13 | const setIsHomeScrolled = useSetRecoilState(isHomeScrolledState); 14 | 15 | useEffect(() => { 16 | setIsHomeScrolled(true); 17 | }, []); 18 | 19 | return ( 20 |
21 |

총 {searchResultCount}개의 검색 결과

22 | 23 | '{keyword}'에 대한 검색 결과 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default SearchInfo; 30 | -------------------------------------------------------------------------------- /src/styles/tokens.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | 3 | const heading1 = style({ 4 | fontSize: '4rem', 5 | fontWeight: 800, 6 | lineHeight: '150%', 7 | }); 8 | 9 | const heading2 = style({ 10 | fontSize: '3.6rem', 11 | fontWeight: 800, 12 | lineHeight: '110%', 13 | }); 14 | 15 | const heading3 = style({ 16 | fontSize: '3.2rem', 17 | fontWeight: 700, 18 | lineHeight: '140%', 19 | }); 20 | 21 | const heading4 = style({ 22 | fontSize: '2.8rem', 23 | fontWeight: 600, 24 | lineHeight: '150%', 25 | }); 26 | 27 | const heading5 = style({ 28 | fontSize: '2.4rem', 29 | fontWeight: 600, 30 | lineHeight: '150%', 31 | }); 32 | 33 | const heading6 = style({ 34 | fontSize: '2rem', 35 | fontWeight: 600, 36 | lineHeight: '150%', 37 | }); 38 | 39 | export const typography = { 40 | heading1, 41 | heading2, 42 | heading3, 43 | heading4, 44 | heading5, 45 | heading6, 46 | }; 47 | -------------------------------------------------------------------------------- /src/api/member.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from './core'; 2 | import { myInfo } from '@/types/myInfo'; 3 | 4 | export type myNewInfo = { 5 | nickname: string; 6 | career: string; 7 | careerYear: string; 8 | }; 9 | 10 | export const getMyInfo = async () => { 11 | const response: myInfo = await axiosInstance.get('/members/mypage'); 12 | return response; 13 | }; 14 | 15 | export const updateProfileImage = async (profileImgUrl: string) => { 16 | try { 17 | const response = await axiosInstance.put('/members/profile-image', { 18 | profileImgUrl, 19 | }); 20 | 21 | return response; 22 | } catch (error) { 23 | console.error(error); 24 | } 25 | }; 26 | 27 | export const updateMyInfo = async (myNewInfo: myNewInfo) => { 28 | try { 29 | const response = await axiosInstance.put('/members/update', myNewInfo); 30 | 31 | return response; 32 | } catch (error) { 33 | console.error(error); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/member.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | export const myInfo = { 4 | email: 'rldnd5555@gmail.com', 5 | nickname: '초보자', 6 | career: 'develop', 7 | careerYear: 'ten', 8 | profileUrl: 9 | 'https://lh3.googleusercontent.com/a/AEdFTp6KQBBQ-S4iulsmKXKrkDCYJlMQATyZKXT-zg1Z', 10 | companyName: 'kakao', 11 | }; 12 | 13 | export const memberHandlers = [ 14 | rest.get('/members/mypage', (req, res, ctx) => { 15 | if (!req.headers.all().authorization) { 16 | return res(ctx.status(400)); 17 | } 18 | 19 | return res(ctx.status(200), ctx.json(myInfo)); 20 | }), 21 | 22 | rest.put('/members/profile-image', async (req, res, ctx) => { 23 | if (!req.headers.all().authorization) { 24 | return res(ctx.status(400)); 25 | } 26 | 27 | const { profileUrl } = await req.json(); 28 | myInfo.profileUrl = profileUrl; 29 | 30 | return res(ctx.status(200), ctx.json(myInfo)); 31 | }), 32 | ]; 33 | -------------------------------------------------------------------------------- /src/components/common/table/index.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '@/components/common'; 2 | import { ReactNode } from 'react'; 3 | import * as style from './style.css'; 4 | 5 | type TableProps = { 6 | columns: string[]; 7 | children: ReactNode; 8 | }; 9 | 10 | const Table = ({ columns, children }: TableProps) => { 11 | return ( 12 | 13 | 14 | 15 | {columns.map((column, index) => ( 16 | 26 | ))} 27 | 28 | 29 | {children} 30 |
22 | 23 | {column} 24 | 25 |
31 | ); 32 | }; 33 | 34 | export default Table; 35 | -------------------------------------------------------------------------------- /src/components/common/modal/ModalPortal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback, useEffect, useMemo } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | type ModalProps = { 5 | children: ReactNode; 6 | onClose: () => void; 7 | }; 8 | 9 | const ModalPortal = ({ children, onClose }: ModalProps) => { 10 | const el = useMemo(() => document.createElement('div'), []); 11 | 12 | const handleESCPress = useCallback( 13 | (e: KeyboardEvent) => { 14 | if (e.key !== 'Escape') return; 15 | onClose(); 16 | }, 17 | [onClose] 18 | ); 19 | 20 | useEffect(() => { 21 | document.body.appendChild(el); 22 | document.addEventListener('keyup', handleESCPress, false); 23 | 24 | return () => { 25 | document.body.removeChild(el); 26 | document.removeEventListener('keyup', handleESCPress, false); 27 | }; 28 | }); 29 | 30 | return createPortal(children, el); 31 | }; 32 | 33 | export default ModalPortal; 34 | -------------------------------------------------------------------------------- /src/api/history.ts: -------------------------------------------------------------------------------- 1 | import { axiosInstance } from './core'; 2 | import { histories } from '@/types/contents'; 3 | 4 | export const getBookmarkContents = async (pageParam: number) => { 5 | const response: histories = await axiosInstance.get( 6 | `/bookmark?page=${pageParam}&size=10` 7 | ); 8 | 9 | return { 10 | // 실제 데이터 11 | content_page: response.contents, 12 | // 반환 값에 현재 페이지를 넘겨주자 13 | current_page: pageParam, 14 | // 페이지가 마지막인지 알려주는 서버에서 넘겨준 true/false 값 15 | isLast: !response.hasNext, 16 | }; 17 | }; 18 | 19 | export const getHistoryContents = async (pageParam: number) => { 20 | const response: histories = await axiosInstance.get( 21 | `/history?page=${pageParam}&size=10` 22 | ); 23 | 24 | return { 25 | // 실제 데이터 26 | content_page: response.contents, 27 | // 반환 값에 현재 페이지를 넘겨주자 28 | current_page: pageParam, 29 | // 페이지가 마지막인지 알려주는 서버에서 넘겨준 true/false 값 30 | isLast: !response.hasNext, 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/pages/error/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Heading } from '@/components/common'; 2 | import { useLocation, useNavigate } from 'react-router-dom'; 3 | import * as style from './style.css'; 4 | import alert from '/assets/alert.svg'; 5 | 6 | const ErrorPage = () => { 7 | const { state } = useLocation(); 8 | const navigate = useNavigate(); 9 | 10 | return ( 11 |
12 | not-found page 13 | 14 | {!state && ( 15 | <> 16 | 주소가 잘못되었거나 17 |
더 이상 제공되지 않는 페이지입니다. 18 | 19 | )} 20 | {state && state.status === '401' && '접근 권한이 없는 페이지입니다.'} 21 |
22 |
30 | ); 31 | }; 32 | 33 | export default ErrorPage; 34 | -------------------------------------------------------------------------------- /src/components/signup/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as keyframes from '@/styles/keyframes.css'; 2 | import * as utils from '@/styles/utils.css'; 3 | import { style } from '@vanilla-extract/css'; 4 | import { recipe } from '@vanilla-extract/recipes'; 5 | 6 | export const wrapper = recipe({ 7 | variants: { 8 | slideDirection: { 9 | left: { animation: `500ms ${keyframes.leftSlideIn}` }, 10 | right: { animation: `500ms ${keyframes.rightSlideIn}` }, 11 | }, 12 | }, 13 | }); 14 | 15 | export const form = style([ 16 | utils.flexColumn, 17 | { 18 | gap: '2.4rem', 19 | paddingTop: '1rem', 20 | }, 21 | ]); 22 | 23 | export const buttonContainer = style([ 24 | utils.flex, 25 | utils.fullWidth, 26 | { 27 | paddingTop: '4rem', 28 | gap: '1rem', 29 | }, 30 | ]); 31 | 32 | export const categoryContainer = style([ 33 | { 34 | display: 'grid', 35 | gridTemplateColumns: '1fr 1fr 1fr', 36 | gap: '1.6rem', 37 | padding: '4rem 2.4rem', 38 | }, 39 | ]); 40 | -------------------------------------------------------------------------------- /src/stories/components/common/Spinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from '@/components/common'; 2 | import { SpinnerProps } from '@/components/common/spinner'; 3 | import * as variants from '@/styles/variants.css'; 4 | 5 | export default { 6 | title: 'Components/Common/Spinner', 7 | component: Spinner, 8 | argTypes: { 9 | size: { 10 | defaultValue: 'huge', 11 | control: 'inline-radio', 12 | options: ['xSmall', 'small', 'medium', 'large', 'xLarge', 'huge'], 13 | description: 'spinner icon size', 14 | }, 15 | color: { 16 | defaultValue: variants.color.bg.select, 17 | control: 'color', 18 | type: 'string', 19 | description: 'spinner icon color', 20 | }, 21 | loading: { 22 | defaultValue: true, 23 | control: 'boolean', 24 | type: 'boolean', 25 | description: 'spinner visible', 26 | }, 27 | }, 28 | }; 29 | 30 | export const Default = (args: SpinnerProps) => { 31 | return ; 32 | }; 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 13 | 20 | HyperLink 21 | 22 | 23 |
24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/common/badge/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { recipe } from '@vanilla-extract/recipes'; 4 | 5 | export const badge = recipe({ 6 | base: [ 7 | utils.positionAbsolute, 8 | utils.overflowHidden, 9 | { 10 | display: 'inline-flex', 11 | color: variants.color.white, 12 | backgroundColor: variants.color.primary, 13 | transform: 'translate(50%, -50%)', 14 | }, 15 | ], 16 | variants: { 17 | type: { 18 | text: [ 19 | utils.textAlignCenter, 20 | { 21 | padding: '0 0.6rem', 22 | borderRadius: '2rem', 23 | right: '-2rem', 24 | fontSize: '0.8rem', 25 | fontWeight: 600, 26 | }, 27 | ], 28 | dot: { 29 | padding: 0, 30 | width: '0.75rem', 31 | height: '0.75rem', 32 | borderRadius: '50%', 33 | overflow: 'hidden', 34 | }, 35 | }, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/common/avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import ImageComponent from '@/components/common/Image'; 2 | import { CSSProperties } from 'react'; 3 | import * as style from './style.css'; 4 | import user from '/assets/user.svg'; 5 | 6 | export type AvatarProps = { 7 | src: string; 8 | shape?: 'circle' | 'round' | 'square'; 9 | size?: 'small' | 'medium' | 'large' | 'xLarge'; 10 | style?: CSSProperties; 11 | className?: string; 12 | }; 13 | 14 | const Avatar = ({ 15 | src, 16 | shape = 'circle', 17 | size = 'medium', 18 | ...props 19 | }: AvatarProps) => { 20 | return ( 21 |
27 | 36 |
37 | ); 38 | }; 39 | 40 | export default Avatar; 41 | -------------------------------------------------------------------------------- /src/utils/coordinates.ts: -------------------------------------------------------------------------------- 1 | import { positions } from '@/types/positions'; 2 | 3 | export const getCoordinates = ( 4 | rect: DOMRect, 5 | position: positions = 'bottom-end' 6 | ) => { 7 | const { left, top, width, height } = rect; 8 | 9 | switch (position) { 10 | case 'top-start': 11 | case 'left-start': 12 | return { left, top }; 13 | case 'top-end': 14 | case 'right-start': 15 | return { left: left + width, top }; 16 | case 'right-end': 17 | case 'bottom-end': 18 | return { left: left + width, top: top + height }; 19 | case 'bottom-start': 20 | case 'left-end': 21 | return { left, top: top + height }; 22 | case 'top': 23 | return { left: left + width / 2, top }; 24 | case 'right': 25 | return { left: left + width, top: top + height / 2 }; 26 | case 'bottom': 27 | return { left: left + width / 2, top: top + height }; 28 | case 'left': 29 | return { left, top: top + height / 2 }; 30 | default: 31 | return { left, top }; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/api/core.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const { VITE_BASE_URL } = import.meta.env; 4 | const API_BASE_URL = 5 | import.meta.env.MODE === 'development' ? VITE_BASE_URL : '/api'; 6 | 7 | const axiosApi = (options = {}) => { 8 | const instance = axios.create({ baseURL: API_BASE_URL, ...options }); 9 | 10 | instance.defaults.timeout = 2500; 11 | 12 | instance.interceptors.response.use( 13 | (response) => { 14 | return response.data; 15 | }, 16 | (error) => { 17 | console.error(error); 18 | return Promise.reject(error); 19 | } 20 | ); 21 | 22 | instance.interceptors.request.use( 23 | (config) => { 24 | // const token = 'token'; 25 | // if (config.headers && token) { 26 | // config.headers['Authorization'] = `bearer ${token}`; 27 | // } 28 | return config; 29 | }, 30 | (error) => { 31 | console.error(error); 32 | return Promise.reject(error); 33 | } 34 | ); 35 | 36 | return instance; 37 | }; 38 | 39 | export const axiosInstance = axiosApi(); 40 | -------------------------------------------------------------------------------- /src/components/modal/myInfo/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { style } from '@vanilla-extract/css'; 4 | 5 | export const myInfo = style([ 6 | utils.flexAlignCenter, 7 | { 8 | padding: '1rem', 9 | }, 10 | ]); 11 | 12 | export const myInfoDetail = style({ 13 | marginLeft: '1.5rem', 14 | }); 15 | 16 | export const nickname = style({ 17 | fontSize: variants.fontSize.small, 18 | fontWeight: '700', 19 | marginBottom: '0.2rem', 20 | }); 21 | 22 | export const career = style({ 23 | fontSize: variants.fontSize.xSmall, 24 | color: variants.color.font.secondary, 25 | }); 26 | 27 | export const email = style({ 28 | fontSize: variants.fontSize.xSmall, 29 | color: variants.color.font.secondary, 30 | }); 31 | 32 | export const menuItem = style({ 33 | fontSize: variants.fontSize.small, 34 | padding: '1rem', 35 | borderRadius: '0.4rem', 36 | 37 | ':hover': { 38 | fontWeight: '600', 39 | background: variants.color.bg.select, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /src/types/admin.ts: -------------------------------------------------------------------------------- 1 | export type pagination = { 2 | currentPage: number; 3 | totalPage: number; 4 | }; 5 | 6 | export type creator = { 7 | creatorId: number; 8 | name: string; 9 | profileImgUrl: string; 10 | description: string; 11 | categoryName: 'develop' | 'beauty' | 'finance'; 12 | }; 13 | 14 | export type creators = { 15 | creators: creator[]; 16 | } & pagination; 17 | 18 | export type content = { 19 | contentId: number; 20 | link: string; 21 | title: string; 22 | }; 23 | 24 | export type contents = { 25 | contents: content[]; 26 | } & pagination; 27 | 28 | export type company = { 29 | companyId: number; 30 | companyName: string; 31 | }; 32 | 33 | export type companies = { 34 | companies: company[]; 35 | } & pagination; 36 | 37 | type view = { 38 | categoryName: 'develop' | 'beauty' | 'finance'; 39 | viewCount: number; 40 | }; 41 | 42 | type onedayViews = { 43 | results: view[]; 44 | date: string; 45 | }; 46 | 47 | export type weeklyViews = { 48 | weeklyViewCounts: onedayViews[]; 49 | createdDate: string; 50 | }; 51 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export { adminHandlers } from './admin'; 2 | export { authHandlers } from './auth'; 3 | export { bookmarkHandlers } from './bookmark'; 4 | export { likeHandlers } from './like'; 5 | export { mainContentsHandlers } from './mainContents'; 6 | export { memberHandlers } from './member'; 7 | export { notRecommendHandlers } from './notRecommend'; 8 | export { searchContentsHandlers } from './searchContents'; 9 | export { specificCreatorHandler } from './specificCreator'; 10 | export { recommendedCreatorsHandler } from './recommendedCreators'; 11 | export { creatorListHandler } from './creatorList'; 12 | export { companiesHandler } from './companies'; 13 | export { subscriptionContentsHandlers } from './subscriptions'; 14 | export { viewHandlers } from './view'; 15 | export { creatorInfoHandler } from './creator'; 16 | export { attentionCategoryHandler } from './attentionCategory'; 17 | export { historyHandler } from './history'; 18 | export { subscribeHandlers } from './subscribe'; 19 | export { dailyBriefingDataHandler } from './dailyBriefing'; 20 | -------------------------------------------------------------------------------- /src/components/cardItem/content/CardModal.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react'; 2 | import * as style from './style.css'; 3 | import useClickAway from '@/hooks/useClickAway'; 4 | 5 | export type ModalProps = { 6 | children: ReactNode; 7 | isOpen: boolean; 8 | onClose: () => void; 9 | style?: CSSProperties; 10 | }; 11 | 12 | // 새로 ... 아이콘 모달 13 | const CardModal = ({ 14 | children, 15 | isOpen = false, 16 | onClose, 17 | ...props 18 | }: ModalProps) => { 19 | const ref = useClickAway((e: Event) => { 20 | if (e.target instanceof HTMLElement && e.target.tagName !== 'BUTTON') { 21 | onClose && onClose(); 22 | } 23 | }); 24 | 25 | return ( 26 |
27 |
36 | {children} 37 |
38 |
39 | ); 40 | }; 41 | 42 | export default CardModal; 43 | -------------------------------------------------------------------------------- /src/hooks/useClickAway.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, MutableRefObject } from 'react'; 2 | 3 | const EVENTS: Array = ['mouseup', 'touchstart']; 4 | 5 | const useClickAway = ( 6 | handler: (event: Event) => void 7 | ) => { 8 | const ref = useRef(null); 9 | const savedHandler = useRef(handler); 10 | 11 | useEffect(() => { 12 | savedHandler.current = handler; 13 | }, [handler]); 14 | 15 | useEffect(() => { 16 | const element: T | null = ref.current; 17 | if (!element) return; 18 | const handleEvent = (e: Event) => { 19 | !element.contains(e.target as Node) && savedHandler.current(e); 20 | }; 21 | 22 | EVENTS.forEach((event) => { 23 | document.addEventListener(event, handleEvent); 24 | }); 25 | 26 | return () => { 27 | EVENTS.forEach((event) => { 28 | document.removeEventListener(event, handleEvent); 29 | }); 30 | }; 31 | }, [ref]); 32 | 33 | return ref as unknown as MutableRefObject; 34 | }; 35 | 36 | export default useClickAway; 37 | -------------------------------------------------------------------------------- /src/pages/creatorDetail/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | import * as variants from '@/styles/variants.css'; 4 | 5 | export const wrapper = style([ 6 | { 7 | padding: '5rem 10rem 0 10rem', 8 | }, 9 | ]); 10 | 11 | export const creator = style([ 12 | utils.flexJustifySpaceBetween, 13 | utils.flexAlignCenter, 14 | ]); 15 | 16 | export const info = style([utils.flexAlignCenter]); 17 | 18 | export const detail = style({ 19 | marginLeft: '2rem', 20 | }); 21 | 22 | export const header = style([ 23 | utils.flex, 24 | { 25 | alignItems: 'baseline', 26 | }, 27 | ]); 28 | 29 | export const subscriber = style({ 30 | fontSize: variants.fontSize.small, 31 | color: variants.color.font.secondary, 32 | }); 33 | 34 | export const description = style({ 35 | fontSize: variants.fontSize.small, 36 | color: variants.color.font.primary, 37 | }); 38 | 39 | export const filterButtonGroup = style([ 40 | utils.flexAlignCenter, 41 | { 42 | margin: '2rem 0', 43 | gap: '1rem', 44 | }, 45 | ]); 46 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { RecoilRoot } from 'recoil'; 4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 6 | import { GoogleOAuthProvider } from '@react-oauth/google'; 7 | 8 | const { VITE_GOOGLE_CLIENT_ID } = import.meta.env; 9 | 10 | import App from './App'; 11 | import '@/styles/global.css'; 12 | import { worker } from '@/__mocks__/worker'; 13 | 14 | if (process.env.NODE_ENV === 'development') { 15 | worker.start(); 16 | } 17 | 18 | const queryClient = new QueryClient(); 19 | 20 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /src/stories/components/common/Icon.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@/components/common'; 2 | import { IconProps } from '@/components/common/icon'; 3 | import * as variants from '@/styles/variants.css'; 4 | 5 | export default { 6 | title: 'Components/Common/Icon', 7 | component: Icon, 8 | argTypes: { 9 | type: { 10 | defaultValue: 'solid', 11 | control: 'inline-radio', 12 | options: ['light', 'regular', 'solid', 'thin'], 13 | description: 'icon type', 14 | }, 15 | name: { 16 | defaultValue: 'xmark', 17 | type: 'string', 18 | description: 'icon name', 19 | }, 20 | size: { 21 | defaultValue: 'medium', 22 | control: 'inline-radio', 23 | options: ['xSmall', 'small', 'medium', 'large', 'xLarge'], 24 | description: 'icon size', 25 | }, 26 | color: { 27 | defaultValue: variants.color.icon, 28 | control: 'color', 29 | type: 'string', 30 | description: 'icon color', 31 | }, 32 | }, 33 | }; 34 | 35 | export const Default = (args: IconProps) => { 36 | return ; 37 | }; 38 | -------------------------------------------------------------------------------- /src/stories/components/common/Text.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '@/components/common'; 2 | import { TextProps } from '@/components/common/text'; 3 | 4 | export default { 5 | title: 'Components/Common/Text', 6 | component: Text, 7 | argTypes: { 8 | block: { 9 | defaultValue: false, 10 | type: 'boolean', 11 | }, 12 | paragraph: { 13 | defaultValue: false, 14 | type: 'boolean', 15 | }, 16 | size: { 17 | defaultValue: 'medium', 18 | control: 'inline-radio', 19 | options: ['xSmall', 'small', 'medium', 'large', 'xLarge'], 20 | description: 'text size', 21 | }, 22 | weight: { 23 | defaultValue: 400, 24 | control: 'inline-radio', 25 | options: [300, 400, 500, 600, 700, 800], 26 | description: 'text weight', 27 | }, 28 | color: { 29 | defaultValue: '#9a9a9a', 30 | control: 'color', 31 | type: 'string', 32 | description: 'text color', 33 | }, 34 | }, 35 | }; 36 | 37 | export const Default = (args: TextProps) => { 38 | return Text test; 39 | }; 40 | -------------------------------------------------------------------------------- /src/stores/auth.ts: -------------------------------------------------------------------------------- 1 | import { silentRefresh } from '@/api/auth'; 2 | import { atom, DefaultValue, selector } from 'recoil'; 3 | 4 | const authState = atom({ 5 | key: 'auth', 6 | default: (async () => { 7 | const response = await silentRefresh(); 8 | 9 | return { 10 | isAuthorized: response?.accessToken ? true : false, 11 | isAdmin: response?.admin ? true : false, 12 | }; 13 | })(), 14 | }); 15 | 16 | export const isAuthorizedState = selector({ 17 | key: 'isAuthorized', 18 | get: ({ get }) => get(authState).isAuthorized, 19 | set: ({ set, get }, newValue) => { 20 | if (!(newValue instanceof DefaultValue)) { 21 | set(authState, { 22 | ...get(authState), 23 | isAuthorized: newValue, 24 | }); 25 | } 26 | }, 27 | }); 28 | 29 | export const isAdminState = selector({ 30 | key: 'isAdmin', 31 | get: ({ get }) => get(authState).isAdmin, 32 | set: ({ set, get }, newValue) => { 33 | if (!(newValue instanceof DefaultValue)) { 34 | set(authState, { 35 | ...get(authState), 36 | isAdmin: newValue, 37 | }); 38 | } 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/companies.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | const CAMPANIES = { 4 | companyEmail: 'rldnd2637@kakao.co.kr', 5 | authNumber: '123456', 6 | }; 7 | 8 | export const companiesHandler = [ 9 | rest.post('/companies/auth', async (req, res, ctx) => { 10 | const { companyEmail } = await req.json(); 11 | 12 | if (!companyEmail) { 13 | return res(ctx.status(400)); 14 | } 15 | 16 | CAMPANIES.companyEmail = companyEmail; 17 | 18 | return res( 19 | ctx.status(200), 20 | ctx.json({ 21 | authNumber: CAMPANIES.authNumber, 22 | }) 23 | ); 24 | }), 25 | 26 | rest.post('/companies/verification', async (req, res, ctx) => { 27 | const { companyEmail, authNumber } = await req.json(); 28 | 29 | if (!companyEmail || !authNumber) { 30 | return res(ctx.status(400)); 31 | } 32 | 33 | if (authNumber !== CAMPANIES.authNumber) { 34 | return res( 35 | ctx.status(403), 36 | ctx.json({ 37 | message: '인증 코드가 유효하지 않습니다.', 38 | }) 39 | ); 40 | } 41 | 42 | return res(ctx.status(200)); 43 | }), 44 | ]; 45 | -------------------------------------------------------------------------------- /src/hooks/infiniteQuery/useCreatorListInfiniteQuery.ts: -------------------------------------------------------------------------------- 1 | import { getCreatorList } from '@/api/creatorList'; 2 | import { useInfiniteQuery } from '@tanstack/react-query'; 3 | 4 | export const useCreatorListInfiniteQuery = (category: string) => { 5 | const { 6 | data: getContents, 7 | fetchNextPage: getNextPage, 8 | isSuccess: getContentsIsSuccess, 9 | hasNextPage: getNextPageIsPossible, 10 | isFetchingNextPage, 11 | refetch, 12 | } = useInfiniteQuery( 13 | ['creatorList', category], 14 | ({ pageParam = 0 }) => getCreatorList(pageParam, category), 15 | { 16 | refetchOnWindowFocus: false, 17 | getNextPageParam: (lastPage) => { 18 | // lastPage는 콜백함수에서 리턴한 값을 의미 19 | // lastPage: 직전에 반환된 리턴값 20 | if (!lastPage.isLast) return lastPage.current_page + 1; 21 | // 마지막 페이지면 undefined가 리턴되어서 hasNextPage는 false가 됨 22 | return undefined; 23 | }, 24 | } 25 | ); 26 | 27 | return { 28 | getContents, 29 | getNextPage, 30 | getContentsIsSuccess, 31 | getNextPageIsPossible, 32 | isFetchingNextPage, 33 | refetch, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/constants/signup.ts: -------------------------------------------------------------------------------- 1 | export const STEPS = ['기본 정보', '직군 / 경력', '관심 카테고리']; 2 | 3 | export const CATEGORIES: { [key: string]: string } = { 4 | 개발: 'develop', 5 | '패션, 뷰티': 'beauty', 6 | '경제, 금융': 'finance', 7 | }; 8 | 9 | export const JOBS: { [key: string]: string } = { 10 | 개발: 'develop', 11 | '패션, 뷰티': 'beauty', 12 | '경제, 금융': 'finance', 13 | 기타: 'etc', 14 | }; 15 | 16 | export const CAREERS: { [key: string]: string } = { 17 | '1년 미만': 'lessThanOne', 18 | '1년': 'one', 19 | '2년': 'two', 20 | '3년': 'three', 21 | '4년': 'four', 22 | '5년': 'five', 23 | '6년': 'six', 24 | '7년': 'seven', 25 | '8년': 'eight', 26 | '9년': 'nine', 27 | '10년': 'ten', 28 | '10년 이상': 'moreThanTen', 29 | }; 30 | 31 | export const REVERSE_CAREERS: { [key: string]: string } = Object.entries( 32 | CAREERS 33 | ).reduce((acc: { [key: string]: string }, [key, value]) => { 34 | acc[value] = key; 35 | return acc; 36 | }, {}); 37 | 38 | export const GENDERS: { [key: string]: string } = { 남: 'man', 여: 'woman' }; 39 | 40 | export const BIRTH_YEARS = Array.from({ length: 100 }, (_, i) => 41 | (new Date().getFullYear() - i).toString() 42 | ); 43 | -------------------------------------------------------------------------------- /src/__mocks__/worker.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw'; 2 | import { 3 | adminHandlers, 4 | authHandlers, 5 | bookmarkHandlers, 6 | creatorListHandler, 7 | likeHandlers, 8 | mainContentsHandlers, 9 | memberHandlers, 10 | notRecommendHandlers, 11 | searchContentsHandlers, 12 | specificCreatorHandler, 13 | subscriptionContentsHandlers, 14 | viewHandlers, 15 | companiesHandler, 16 | creatorInfoHandler, 17 | attentionCategoryHandler, 18 | historyHandler, 19 | dailyBriefingDataHandler, 20 | recommendedCreatorsHandler 21 | } from './handlers'; 22 | 23 | export const worker = setupWorker( 24 | ...adminHandlers, 25 | ...authHandlers, 26 | ...recommendedCreatorsHandler, 27 | ...bookmarkHandlers, 28 | ...creatorListHandler, 29 | ...likeHandlers, 30 | ...mainContentsHandlers, 31 | ...memberHandlers, 32 | ...notRecommendHandlers, 33 | ...searchContentsHandlers, 34 | ...specificCreatorHandler, 35 | ...viewHandlers, 36 | ...companiesHandler, 37 | ...subscriptionContentsHandlers, 38 | ...creatorInfoHandler, 39 | ...attentionCategoryHandler, 40 | ...historyHandler, 41 | ...dailyBriefingDataHandler 42 | ); 43 | -------------------------------------------------------------------------------- /src/components/modal/category/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as medias from '@/styles/medias.css'; 2 | import * as utils from '@/styles/utils.css'; 3 | import { color } from '@/styles/variants.css'; 4 | import { style } from '@vanilla-extract/css'; 5 | 6 | export const modalContent = style([ 7 | utils.flexColumn, 8 | utils.flexJustifySpaceBetween, 9 | utils.positionAbsolute, 10 | utils.borderRadius, 11 | medias.small({ 12 | width: '100vw', 13 | minWidth: '100vw', 14 | right: '-2rem', 15 | }), 16 | { 17 | right: '5rem', 18 | gap: '4rem', 19 | padding: '3rem', 20 | width: '38rem', 21 | minWidth: '38rem', 22 | boxShadow: '0 0.3rem 0.6rem rgba(0, 0, 0, 0.2)', 23 | backgroundColor: color.white, 24 | }, 25 | ]); 26 | 27 | export const modalHeader = style([ 28 | utils.flexJustifySpaceBetween, 29 | { 30 | alignItems: 'flex-start', 31 | }, 32 | ]); 33 | 34 | export const modalSelectWrapper = style([ 35 | utils.grid, 36 | { 37 | gridTemplateColumns: 'repeat(3, 1fr)', 38 | gap: '1.2rem', 39 | }, 40 | ]); 41 | 42 | export const buttonWrapper = style({ 43 | paddingLeft: '66%', 44 | textAlign: 'right', 45 | }); 46 | -------------------------------------------------------------------------------- /src/pages/history/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilState, useSetRecoilState } from 'recoil'; 2 | import ButtonGroup from '@/components/buttonGroup'; 3 | import { BookmarkContent, HistoryContent } from '@/components/history'; 4 | import { selectedFilterHistoryPageState } from '@/stores/selectedCategory'; 5 | import * as style from './style.css'; 6 | import { selectedTabState } from '@/stores/tab'; 7 | import { useEffect } from 'react'; 8 | 9 | const CATEGORIES = ['bookmark', 'history']; 10 | 11 | const HistoryPage = () => { 12 | const [selectedFilter, setSelectedFilter] = useRecoilState( 13 | selectedFilterHistoryPageState 14 | ); 15 | 16 | const setTabState = useSetRecoilState(selectedTabState); 17 | 18 | useEffect(() => { 19 | setTabState('none'); 20 | }, []); 21 | 22 | return ( 23 |
24 | 29 | {selectedFilter === 'bookmark' ? : } 30 |
31 | ); 32 | }; 33 | 34 | export default HistoryPage; 35 | -------------------------------------------------------------------------------- /src/components/modal/login/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { style } from '@vanilla-extract/css'; 4 | 5 | export const wrapper = style([ 6 | utils.flexColumn, 7 | utils.flexJustifySpaceBetween, 8 | { 9 | padding: '3rem', 10 | height: '40rem', 11 | width: '37rem', 12 | }, 13 | ]); 14 | 15 | export const header = style([utils.flexJustifySpaceBetween]); 16 | 17 | export const logo = style([ 18 | utils.flexAlignCenter, 19 | { 20 | gap: '0.8rem', 21 | }, 22 | ]); 23 | 24 | export const content = style([ 25 | utils.flexColumn, 26 | { 27 | gap: '1rem', 28 | }, 29 | ]); 30 | 31 | export const button = style([ 32 | utils.flexCenter, 33 | utils.borderRadius, 34 | utils.fullWidth, 35 | { 36 | boxShadow: '0px 0.3rem 0.6rem #18181803', 37 | padding: '1.2rem 0', 38 | gap: '1rem', 39 | border: `0.2rem solid ${variants.color.disabled.bg}`, 40 | }, 41 | ]); 42 | 43 | export const icon = style([utils.cursorPointer]); 44 | 45 | export const googleLogo = style({ width: '2.4rem' }); 46 | 47 | export const bannerWrapper = style({ marginTop: '3rem' }); 48 | -------------------------------------------------------------------------------- /src/stories/components/common/Dropdown.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Dropdown } from '@/components/common'; 2 | import { DropdownProps } from '@/components/common/dropdown'; 3 | import { useState } from 'react'; 4 | 5 | export default { 6 | title: 'Components/Common/Dropdown', 7 | component: Dropdown, 8 | argTypes: { 9 | placeholder: { 10 | defaultValue: '값을 선택하세요.', 11 | type: 'string', 12 | }, 13 | label: { 14 | defaultValue: '', 15 | type: 'string', 16 | }, 17 | }, 18 | }; 19 | 20 | export const Default = (args: DropdownProps) => { 21 | const [value, setValue] = useState(''); 22 | const handleItemClick = (item: string) => { 23 | setValue(item); 24 | }; 25 | 26 | return ( 27 |
28 | 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/utils/weeklyViews.ts: -------------------------------------------------------------------------------- 1 | import { views } from '@/types/admin'; 2 | import { WEEKLY_VIEWS } from '@/utils/constants/storage'; 3 | import { isSameDate } from '@/utils/date'; 4 | import { getItem, setItem } from '@/utils/storage'; 5 | 6 | export const updateWeeklyViews = (yesterdayViews: views) => { 7 | const weeklyViews: views[] = getItem(WEEKLY_VIEWS, []); 8 | const today = new Date(); 9 | const yesterday = new Date(today.setDate(today.getDate() - 1)); 10 | 11 | if (weeklyViews.length === 7) { 12 | if ( 13 | !weeklyViews.some((views) => 14 | isSameDate(new Date(views.createdDate), yesterday) 15 | ) 16 | ) { 17 | weeklyViews.shift(); 18 | setItem(WEEKLY_VIEWS, [...weeklyViews, yesterdayViews]); 19 | } 20 | } else if (weeklyViews.length < 7) { 21 | if ( 22 | !weeklyViews.some((views) => 23 | isSameDate(new Date(views.createdDate), yesterday) 24 | ) && 25 | !weeklyViews.some((views) => 26 | isSameDate( 27 | new Date(views.createdDate), 28 | new Date(yesterdayViews.createdDate) 29 | ) 30 | ) 31 | ) { 32 | setItem(WEEKLY_VIEWS, [...weeklyViews, yesterdayViews]); 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/buttonGroup/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '../common'; 2 | import * as style from './style.css'; 3 | 4 | const CATEGORIES: { [key: string]: string } = { 5 | all: '전체', 6 | develop: '개발', 7 | beauty: '패션/뷰티', 8 | finance: '경제/금융', 9 | recent: '최신순', 10 | popular: '인기순', 11 | bookmark: '북마크', 12 | history: '히스토리', 13 | }; 14 | 15 | type ButtonGroupProps = { 16 | buttonData: string[]; 17 | selectedCategory: string; 18 | setSelectedCategory: (category: string) => void; 19 | }; 20 | 21 | const ButtonGroup = ({ 22 | buttonData, 23 | selectedCategory, 24 | setSelectedCategory, 25 | }: ButtonGroupProps) => { 26 | return ( 27 |
28 | {buttonData.map((category, idx) => { 29 | return ( 30 |
41 | ); 42 | }; 43 | 44 | export default ButtonGroup; 45 | -------------------------------------------------------------------------------- /src/components/main/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Text } from '@/components/common'; 2 | import SearchBar from '@/components/common/header/SearchBar'; 3 | 4 | import { isHomeScrolledState } from '@/stores/scroll'; 5 | import { useSetRecoilState } from 'recoil'; 6 | 7 | import { ScatterGraphy } from 'react-scatter-graphy'; 8 | 9 | import * as variants from '@/styles/variants.css'; 10 | import * as style from './style.css'; 11 | import hyperlink from '/assets/hyperlink.png'; 12 | 13 | type mainProps = { 14 | onScrollDown: () => void; 15 | }; 16 | 17 | const Main = ({ onScrollDown }: mainProps) => { 18 | const setIsHomeScrolled = useSetRecoilState(isHomeScrolledState); 19 | 20 | return ( 21 |
22 |
23 | 24 |
25 | setIsHomeScrolled(true)} 28 | /> 29 |
30 | 31 | 아래로 스크롤하여 컨텐츠 확인하기 32 |
33 |
34 | ); 35 | }; 36 | 37 | export default Main; 38 | -------------------------------------------------------------------------------- /src/components/common/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Avatar } from '@/components/common/avatar'; 2 | export { default as Badge } from '@/components/common/badge'; 3 | export { default as Button } from '@/components/common/button'; 4 | export { default as Card } from '@/components/common/card'; 5 | export { default as Divider } from '@/components/common/divider'; 6 | export { default as Dropdown } from '@/components/common/dropdown'; 7 | export { default as FAB } from '@/components/common/floatingActionButton'; 8 | export { default as Header } from '@/components/common/header'; 9 | export { default as Heading } from '@/components/common/heading'; 10 | export { default as Icon } from '@/components/common/icon'; 11 | export { default as Input } from '@/components/common/input'; 12 | export { default as Modal } from '@/components/common/modal'; 13 | export { default as Slider } from '@/components/common/slider'; 14 | export { default as Spinner } from '@/components/common/spinner'; 15 | export { default as Tab } from '@/components/common/tab'; 16 | export { default as Table } from '@/components/common/table'; 17 | export { default as Text } from '@/components/common/text'; 18 | export { default as Tooltip } from '@/components/common/tooltip'; 19 | -------------------------------------------------------------------------------- /src/stories/components/common/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/common'; 2 | import { ButtonProps } from '@/components/common/button'; 3 | 4 | export default { 5 | title: 'Components/Common/Button', 6 | component: Button, 7 | argTypes: { 8 | version: { 9 | defaultValue: 'blue', 10 | control: 'inline-radio', 11 | options: ['blue', 'blueInverted', 'gray', 'grayInverted', 'white'], 12 | }, 13 | shape: { 14 | defaultValue: 'round', 15 | control: 'inline-radio', 16 | options: ['round', 'circle'], 17 | }, 18 | fontSize: { 19 | defaultValue: 'small', 20 | control: 'inline-radio', 21 | options: ['small', 'medium', 'large'], 22 | }, 23 | paddingSize: { 24 | defaultValue: 'small', 25 | control: 'inline-radio', 26 | options: ['small', 'medium', 'full'], 27 | }, 28 | isBold: { 29 | defaultValue: false, 30 | type: 'boolean', 31 | }, 32 | text: { 33 | defaultValue: 'button', 34 | control: 'text', 35 | }, 36 | disabled: { 37 | defaultValue: false, 38 | type: 'boolean', 39 | }, 40 | }, 41 | }; 42 | 43 | export const Default = (args: ButtonProps) => { 44 | return 48 | ); 49 | }; 50 | 51 | export default Button; 52 | -------------------------------------------------------------------------------- /src/components/dailyBriefing/Ranking.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Heading, Text } from '@/components/common'; 2 | import { dataByCategorys } from '@/types/dailyBriefing'; 3 | import { CATEGORIES } from '@/utils/constants/categories'; 4 | import * as style from './style.css'; 5 | 6 | type rankingProps = { 7 | standardTime: string; 8 | data: dataByCategorys[]; 9 | }; 10 | 11 | const Ranking = ({ standardTime, data }: rankingProps) => { 12 | return ( 13 | 18 |
19 | 카테고리별 조회수 랭킹 20 | {standardTime}시 기준 21 |
22 |
    23 | {data.map(({ ranking, categoryName, count }) => ( 24 |
  • 25 |
    {ranking}
    26 |
    27 | {CATEGORIES[categoryName]} 28 | {count.toLocaleString()} 29 |
    30 |
  • 31 | ))} 32 |
33 |
34 | ); 35 | }; 36 | 37 | export default Ranking; 38 | -------------------------------------------------------------------------------- /src/components/common/Image/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, CSSProperties, useEffect, useState } from 'react'; 2 | 3 | export type ImageProps = { 4 | defaultImage: string; 5 | src: string; 6 | alt: string; 7 | block: boolean; 8 | width: string; 9 | height: string; 10 | objectFit: CSSProperties['objectFit']; 11 | style?: CSSProperties; 12 | }; 13 | 14 | const ImageComponent = ({ 15 | defaultImage, 16 | src, 17 | alt, 18 | block, 19 | width, 20 | height, 21 | objectFit = 'cover', 22 | ...props 23 | }: ImageProps) => { 24 | const [imgSrc, setImgSrc] = useState(src); 25 | const imgRef = useRef(null); 26 | 27 | useEffect(() => { 28 | const image = new Image(); 29 | image.src = imgSrc; 30 | image.onload = () => setImgSrc(src); 31 | }, [src]); 32 | 33 | const imageStyle: CSSProperties = { 34 | display: block ? 'block' : undefined, 35 | width, 36 | height, 37 | objectFit: objectFit as CSSProperties['objectFit'], // cover, fill, contain 38 | }; 39 | 40 | return ( 41 | {alt} setImgSrc(defaultImage)} 46 | style={{ 47 | ...imageStyle, 48 | ...props.style, 49 | }} 50 | /> 51 | ); 52 | }; 53 | 54 | export default ImageComponent; 55 | -------------------------------------------------------------------------------- /src/components/common/modal/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as keyframes from '@/styles/keyframes.css'; 2 | import * as utils from '@/styles/utils.css'; 3 | import * as variants from '@/styles/variants.css'; 4 | import { style } from '@vanilla-extract/css'; 5 | import { recipe } from '@vanilla-extract/recipes'; 6 | 7 | export const backgroundDimmed = style([ 8 | utils.positionFixed, 9 | utils.top0, 10 | utils.left0, 11 | { 12 | width: '100vw', 13 | height: '100vh', 14 | backgroundColor: 'rgba(0, 0, 0, 0.5)', 15 | zIndex: '1000', 16 | }, 17 | ]); 18 | 19 | export const modalContainer = recipe({ 20 | base: [ 21 | utils.borderRadius, 22 | { 23 | animation: `300ms ${keyframes.fadeIn}`, 24 | display: 'block', 25 | backgroundColor: variants.color.white, 26 | boxShadow: '0 0.3rem 0.6rem rgba(0, 0, 0, 0.2)', 27 | }, 28 | ], 29 | 30 | variants: { 31 | type: { 32 | center: [ 33 | utils.positionFixed, 34 | { 35 | top: '50%', 36 | right: '50%', 37 | transform: 'translate(50%, -50%)', 38 | }, 39 | ], 40 | icon: [ 41 | utils.positionAbsolute, 42 | utils.right0, 43 | { 44 | top: '4rem', 45 | zIndex: '10', 46 | }, 47 | ], 48 | }, 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /src/components/myPage/MyInfo/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as utils from '@/styles/utils.css'; 3 | 4 | export const avatarWrapper = style([ 5 | utils.flexCenter, 6 | { 7 | padding: '5rem', 8 | }, 9 | ]); 10 | 11 | export const input = style({ 12 | display: 'none', 13 | }); 14 | 15 | export const avatar = style([ 16 | utils.cursorPointer, 17 | { 18 | transition: 'filter 0.2s ease-in-out', 19 | boxShadow: '0 0.3rem 0.6rem rgba(0, 0, 0, 0.2)', 20 | }, 21 | ]); 22 | 23 | export const hoverAvatar = style({ 24 | filter: 'blur(0.5rem)', 25 | }); 26 | 27 | export const avatarText = style([ 28 | utils.cursorPointer, 29 | { 30 | position: 'absolute', 31 | transition: 'all 0.2s ease-in-out', 32 | opacity: '0', 33 | fontSize: '1.5rem', 34 | fontWeight: '600', 35 | }, 36 | ]); 37 | 38 | export const hoverText = style({ 39 | opacity: '1', 40 | }); 41 | export const dropdownWrapper = style([utils.flexColumn, { gap: '1rem' }]); 42 | 43 | export const companyText = style({ 44 | textAlign: 'right', 45 | 46 | textDecoration: 'underline', 47 | '@media': { 48 | 'screen and (max-width: 943px)': { 49 | marginTop: '-1rem', 50 | }, 51 | 'screen and (min-width: 943px)': { 52 | marginTop: '-2rem', 53 | }, 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /src/types/contents.ts: -------------------------------------------------------------------------------- 1 | export type content = { 2 | contentId: number; 3 | creatorId: number; 4 | } & contentData; 5 | 6 | export type contentData = { 7 | contentImgUrl: string; 8 | createdAt: string; 9 | creatorName: string; 10 | isBookmarked: boolean; 11 | isLiked: boolean; 12 | likeCount: number; 13 | link: string; 14 | title: string; 15 | viewCount: number; 16 | recommendations?: banner[]; 17 | }; 18 | 19 | export type banner = { 20 | bannerLogoImgUrl: string; 21 | bannerName: string; 22 | }; 23 | 24 | export type contents = { 25 | contents: content[]; 26 | hasNext: boolean; 27 | }; 28 | 29 | export type searchContents = { 30 | getContentsCommonResponse: contents; 31 | keyword: string; 32 | resultCount: number; 33 | }; 34 | 35 | export type creator = { 36 | creatorId: number; 37 | creatorName: string; 38 | subscriberAmount: number; 39 | creatorDescription: string; 40 | isSubscribed: boolean; 41 | profileImgUrl: string; 42 | }; 43 | 44 | export type creators = { 45 | creators: creator[]; 46 | hasNext: boolean; 47 | }; 48 | 49 | export type recommendedCreator = Omit; 50 | 51 | export type recommendedCreators = { 52 | creators: recommendedCreator[]; 53 | }; 54 | 55 | export type histories = { 56 | contents: content[]; 57 | hasNext: boolean; 58 | }; 59 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createBrowserRouter, 3 | createRoutesFromElements, 4 | Route, 5 | RouterProvider, 6 | } from 'react-router-dom'; 7 | 8 | import { 9 | HomePage, 10 | SignupPage, 11 | SearchResultPage, 12 | CreatorDetailPage, 13 | CreatorListPage, 14 | DailyBriefingPage, 15 | MyPage, 16 | ErrorPage, 17 | AdminPage, 18 | HistoryPage, 19 | } from '@/pages'; 20 | import Layout from './components/layout'; 21 | 22 | const router = createBrowserRouter( 23 | createRoutesFromElements( 24 | }> 25 | } /> 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | } /> 31 | } /> 32 | } /> 33 | } /> 34 | } /> 35 | 36 | ) 37 | ); 38 | 39 | const App = () => { 40 | return ; 41 | }; 42 | 43 | export default App; 44 | -------------------------------------------------------------------------------- /src/components/modal/certification/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Modal, Text } from '@/components/common'; 2 | import { useState } from 'react'; 3 | import NotSendedView from './NotSendedView'; 4 | import SendedView from './SendedView'; 5 | import * as style from './style.css'; 6 | 7 | export type CertificationModalProps = { 8 | isOpen: boolean; 9 | onClose: () => void; 10 | }; 11 | 12 | const CertificationModal = ({ isOpen, onClose }: CertificationModalProps) => { 13 | const [isSend, setIsSend] = useState(false); 14 | const [email, setEmail] = useState(''); 15 | 16 | return ( 17 | 18 |
19 |
20 | 21 | 소속 회사 이메일 인증 22 | 23 |
24 | 25 |
26 |
27 | {isSend ? ( 28 | setIsSend(false)} /> 29 | ) : ( 30 | setIsSend(true)} 32 | setEmail={(email) => setEmail(email)} 33 | /> 34 | )} 35 |
36 |
37 | ); 38 | }; 39 | 40 | export default CertificationModal; 41 | -------------------------------------------------------------------------------- /src/components/admin/companies/companyName.tsx: -------------------------------------------------------------------------------- 1 | import { modifyCompanyName } from '@/api/admin'; 2 | import { Input } from '@/components/common'; 3 | import useInput from '@/hooks/useInput'; 4 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 5 | 6 | type CompanyNameProps = { 7 | page: number; 8 | companyId: number; 9 | companyName: string; 10 | }; 11 | 12 | const TABLE_SIZE = 10; 13 | 14 | const CompanyName = ({ page, companyId, companyName }: CompanyNameProps) => { 15 | const queryClient = useQueryClient(); 16 | const name = useInput(companyName); 17 | 18 | const modifyCompanyNameMutation = useMutation({ 19 | mutationFn: modifyCompanyName, 20 | onSuccess: () => { 21 | queryClient.invalidateQueries({ 22 | queryKey: ['notUsingRecommendCompanies', page, TABLE_SIZE], 23 | }); 24 | alert('회사 이름이 변경되었습니다.'); 25 | }, 26 | }); 27 | 28 | const handleEnterPress = (companyId: number, companyName: string) => { 29 | modifyCompanyNameMutation.mutate({ companyId, companyName }); 30 | }; 31 | 32 | return ( 33 | 34 | handleEnterPress(companyId, name.value)} 38 | needResetWhenEnter={false} 39 | /> 40 | 41 | ); 42 | }; 43 | 44 | export default CompanyName; 45 | -------------------------------------------------------------------------------- /src/hooks/infiniteQuery/useMainContentsInfinitelQuery.ts: -------------------------------------------------------------------------------- 1 | import { getMainContents } from '@/api/mainContents'; 2 | import { selectedTabState } from '@/stores/tab'; 3 | import { useInfiniteQuery } from '@tanstack/react-query'; 4 | import { useRecoilValue } from 'recoil'; 5 | 6 | export const useMainContentsInfiniteQuery = (category: string) => { 7 | const tabState = useRecoilValue(selectedTabState); 8 | 9 | const { 10 | data: getContents, 11 | fetchNextPage: getNextPage, 12 | isSuccess: getContentsIsSuccess, 13 | hasNextPage: getNextPageIsPossible, 14 | isFetchingNextPage, 15 | refetch, 16 | status, 17 | } = useInfiniteQuery( 18 | ['mainContents', category], 19 | async ({ pageParam = 0 }) => 20 | await getMainContents(pageParam, category, tabState), 21 | { 22 | refetchOnWindowFocus: false, 23 | getNextPageParam: (lastPage) => { 24 | // lastPage는 콜백함수에서 리턴한 값을 의미 25 | // lastPage: 직전에 반환된 리턴값 26 | if (!lastPage.isLast) return lastPage.current_page + 1; 27 | // 마지막 페이지면 undefined가 리턴되어서 hasNextPage는 false가 됨 28 | return undefined; 29 | }, 30 | } 31 | ); 32 | 33 | return { 34 | getContents, 35 | getNextPage, 36 | getContentsIsSuccess, 37 | getNextPageIsPossible, 38 | isFetchingNextPage, 39 | refetch, 40 | status, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/pages/myPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Spinner } from '@/components/common'; 2 | import * as style from './style.css'; 3 | import { getMyInfo } from '@/api/member'; 4 | import { useQuery } from '@tanstack/react-query'; 5 | import { ErrorView, MyInfo } from '@/components/myPage'; 6 | import { myInfo } from '@/types/myInfo'; 7 | import { useRecoilValue, useSetRecoilState } from 'recoil'; 8 | import { isAuthorizedState } from '@/stores/auth'; 9 | import { isHomeScrolledState } from '@/stores/scroll'; 10 | import { useEffect } from 'react'; 11 | 12 | const MyPage = () => { 13 | const setIsHomeScrolled = useSetRecoilState(isHomeScrolledState); 14 | useEffect(() => { 15 | setIsHomeScrolled(false); 16 | }); 17 | 18 | const isAuthorized = useRecoilValue(isAuthorizedState); 19 | if (!isAuthorized) return ; 20 | 21 | const { data, isLoading, isError } = useQuery(['myInfo'], getMyInfo, { 22 | refetchOnWindowFocus: false, 23 | }); 24 | 25 | if (isError) return ; 26 | if (isLoading) return ; 27 | 28 | const myInfo = data as myInfo; 29 | 30 | return ( 31 | <> 32 |
33 | 내 정보 34 | 35 |
36 | 37 | ); 38 | }; 39 | 40 | export default MyPage; 41 | -------------------------------------------------------------------------------- /src/hooks/infiniteQuery/useSearchContentsInfiniteQuery.ts: -------------------------------------------------------------------------------- 1 | import { useSetRecoilState } from 'recoil'; 2 | import { useInfiniteQuery } from '@tanstack/react-query'; 3 | import { getSearchContents } from '@/api/searchContents'; 4 | import { searchKeywordState } from '@/stores/searchKeyword'; 5 | 6 | export const useSearchContentsInfiniteQuery = (keyword: string) => { 7 | const setSearchKeyword = useSetRecoilState(searchKeywordState); 8 | const { 9 | data: getContents, 10 | fetchNextPage: getNextPage, 11 | isSuccess: getContentsIsSuccess, 12 | hasNextPage: getNextPageIsPossible, 13 | status, 14 | isFetching, 15 | isFetchingNextPage, 16 | } = useInfiniteQuery( 17 | ['searchContents', keyword], 18 | ({ pageParam = 0 }) => getSearchContents(pageParam, keyword), 19 | { 20 | refetchOnWindowFocus: false, 21 | getNextPageParam: (lastPage) => { 22 | // lastPage는 콜백함수에서 리턴한 값을 의미 23 | // lastPage: 직전에 반환된 리턴값 24 | if (!lastPage.isLast) return lastPage.current_page + 1; 25 | // 마지막 페이지면 undefined가 리턴되어서 hasNextPage는 false가 됨 26 | return undefined; 27 | }, 28 | } 29 | ); 30 | 31 | setSearchKeyword(keyword); 32 | 33 | return { 34 | getContents, 35 | getNextPage, 36 | getContentsIsSuccess, 37 | getNextPageIsPossible, 38 | status, 39 | isFetching, 40 | isFetchingNextPage, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /.github/workflows/production.yaml: -------------------------------------------------------------------------------- 1 | name: Vercel Production Deployment 2 | env: 3 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 4 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 5 | on: 6 | push: 7 | branches-ignore: 8 | - main 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | - name: create vercel.json 18 | run: | 19 | touch vercel.json 20 | echo ' 21 | { 22 | "rewrites": [ 23 | { 24 | "source": "/api/:url*", 25 | "destination": "${{ secrets.API_BASE_URL }}/:url*" 26 | }, 27 | { 28 | "source": "/(.*)", 29 | "destination": "/" 30 | } 31 | ] 32 | } 33 | ' >> vercel.json 34 | - name: Install Vercel CLI 35 | run: npm install --global vercel@latest 36 | - name: Pull Vercel Environment Information 37 | run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} 38 | - name: Build Project Artifacts 39 | run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} 40 | - name: Deploy Project Artifacts to Vercel 41 | run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} 42 | -------------------------------------------------------------------------------- /src/api/s3Image.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from '@aws-sdk/client-s3'; 2 | import { PutObjectCommand } from '@aws-sdk/client-s3'; 3 | const { VITE_AWS_ACCESS_KEY_ID, VITE_AWS_SECRET_ACCESS_KEY } = import.meta.env; 4 | 5 | const region = 'ap-northeast-2'; 6 | const bucket = 'team-jjinsa-hyperlink-bucket'; 7 | 8 | const s3 = new AWS.S3({ 9 | region, 10 | credentials: { 11 | accessKeyId: VITE_AWS_ACCESS_KEY_ID, 12 | secretAccessKey: VITE_AWS_SECRET_ACCESS_KEY, 13 | }, 14 | }); 15 | 16 | type keyType = 'logo' | 'profile'; 17 | 18 | export const uploadFileToS3 = async (file: File, keyType: keyType) => { 19 | const fileType = file.type.split('/').pop(); 20 | const key = `${keyType}/${self.crypto.randomUUID()}.${fileType}`; 21 | const uploadImage = s3.send( 22 | new PutObjectCommand({ 23 | Bucket: bucket, 24 | Key: key, 25 | Body: file, 26 | }) 27 | ); 28 | 29 | try { 30 | await uploadImage; 31 | const url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`; 32 | return url; 33 | } catch (error) { 34 | console.error(error); 35 | } 36 | }; 37 | 38 | export const deleteFileFromS3 = async (fileUrl: string) => { 39 | const key = fileUrl.split('/').slice(-2).join('/'); 40 | 41 | const deleteImage = s3.deleteObject({ 42 | Bucket: bucket, 43 | Key: key, 44 | }); 45 | 46 | try { 47 | await deleteImage; 48 | } catch (error) { 49 | console.error(error); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/hooks/infiniteQuery/useSpecificCreatorInfiniteQuery.ts: -------------------------------------------------------------------------------- 1 | import { getSpecificCreator } from '@/api/specificCreator'; 2 | import { selectedCategoryState } from '@/stores/selectedCategory'; 3 | import { useInfiniteQuery } from '@tanstack/react-query'; 4 | import { useRecoilValue } from 'recoil'; 5 | 6 | export const useSpecificCreatorInfiniteQuery = ( 7 | creatorId: string, 8 | sortType: string 9 | ) => { 10 | const selectedCategory = useRecoilValue(selectedCategoryState); 11 | 12 | const { 13 | data: getContents, 14 | fetchNextPage: getNextPage, 15 | isSuccess: getContentsIsSuccess, 16 | hasNextPage: getNextPageIsPossible, 17 | isFetchingNextPage, 18 | refetch, 19 | isFetching, 20 | } = useInfiniteQuery( 21 | ['mainContents', selectedCategory], 22 | ({ pageParam = 0 }) => getSpecificCreator(pageParam, creatorId, sortType), 23 | { 24 | refetchOnWindowFocus: false, 25 | getNextPageParam: (lastPage) => { 26 | // lastPage는 콜백함수에서 리턴한 값을 의미 27 | // lastPage: 직전에 반환된 리턴값 28 | if (!lastPage.isLast) return lastPage.current_page + 1; 29 | // 마지막 페이지면 undefined가 리턴되어서 hasNextPage는 false가 됨 30 | return undefined; 31 | }, 32 | } 33 | ); 34 | 35 | return { 36 | getContents, 37 | getNextPage, 38 | getContentsIsSuccess, 39 | getNextPageIsPossible, 40 | isFetchingNextPage, 41 | refetch, 42 | isFetching, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/signup/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { style } from '@vanilla-extract/css'; 4 | import { recipe } from '@vanilla-extract/recipes'; 5 | 6 | export const wrapper = style([utils.flexCenter, { paddingTop: '11rem' }]); 7 | 8 | export const container = style([utils.flexColumn, { width: '44rem' }]); 9 | 10 | export const stepContainer = style([ 11 | utils.flexJustifySpaceBetween, 12 | { marginBottom: '2.4rem' }, 13 | ]); 14 | 15 | export const step = style([utils.flexAlignCenter, { gap: '0.8rem' }]); 16 | 17 | export const stepNumber = recipe({ 18 | base: [ 19 | utils.borderRadiusRound, 20 | utils.flexCenter, 21 | { 22 | border: `0.2rem solid ${variants.color.primary}`, 23 | width: '2.8rem', 24 | height: '2.8rem', 25 | fontSize: variants.fontSize.medium, 26 | color: variants.color.primary, 27 | backgroundColor: variants.color.white, 28 | }, 29 | ], 30 | variants: { 31 | type: { 32 | current: { 33 | fontWeight: '500', 34 | color: variants.color.white, 35 | backgroundColor: variants.color.primary, 36 | }, 37 | }, 38 | }, 39 | }); 40 | 41 | export const stepInfo = recipe({ 42 | base: { color: variants.color.font.secondary }, 43 | variants: { 44 | type: { 45 | current: { 46 | fontWeight: '700', 47 | color: variants.color.font.primary, 48 | }, 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/cardItem/content/recommendationBanner/style.css.ts: -------------------------------------------------------------------------------- 1 | import { recipe } from '@vanilla-extract/recipes'; 2 | import { style } from '@vanilla-extract/css'; 3 | import * as utils from '@/styles/utils.css'; 4 | import * as variants from '@/styles/variants.css'; 5 | 6 | export const flipAnimationContainer = style([utils.flex]); 7 | 8 | export const flipBanner = style([ 9 | utils.positionAbsolute, 10 | { 11 | objectFit: 'cover', 12 | opacity: 0, 13 | transition: 'all 1s ease-in-out', 14 | }, 15 | ]); 16 | 17 | export const activeFlipBanner = recipe({ 18 | base: [ 19 | utils.positionRelative, 20 | { 21 | objectFit: 'cover', 22 | opacity: 1, 23 | }, 24 | ], 25 | variants: { 26 | type: { 27 | avatar: { 28 | transform: 'rotateY(0deg)', 29 | }, 30 | text: { 31 | transform: 'rotateX(0deg)', 32 | }, 33 | }, 34 | }, 35 | }); 36 | 37 | export const previousFlipBanner = recipe({ 38 | base: [ 39 | { 40 | transition: 'all 1s ease-in-out', 41 | }, 42 | ], 43 | variants: { 44 | type: { 45 | avatar: { 46 | transform: 'rotateY(-180deg)', 47 | }, 48 | text: { 49 | transform: 'rotateX(-180deg)', 50 | }, 51 | }, 52 | }, 53 | }); 54 | 55 | export const recommendationName = style({ 56 | fontWeight: 600, 57 | fontSize: variants.fontSize.small, 58 | }); 59 | 60 | export const recommendationBanner = style({ 61 | fontWeight: 600, 62 | fontSize: variants.fontSize.xSmall, 63 | color: 'rgba(42,40,47,0.8)', 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/dailyBriefing/Summary.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useSetRecoilState } from 'recoil'; 3 | import { Card, Heading, Icon } from '@/components/common'; 4 | import { isHomeScrolledState } from '@/stores/scroll'; 5 | import { statistic } from '@/types/dailyBriefing'; 6 | import * as style from './style.css'; 7 | 8 | const TYPE: { [key: string]: string } = { 9 | members: '가입자 수', 10 | views: '방문자 수', 11 | }; 12 | 13 | type SummaryProps = { 14 | title: string; 15 | data: statistic; 16 | standardTime: string; 17 | color: string; 18 | }; 19 | 20 | const Summary = ({ title, data, standardTime, color }: SummaryProps) => { 21 | const setIsHomeScrolled = useSetRecoilState(isHomeScrolledState); 22 | 23 | useEffect(() => { 24 | setIsHomeScrolled(true); 25 | }, []); 26 | 27 | return ( 28 | 29 |
30 | {TYPE[title]} 31 | {standardTime}시 기준 32 |
33 |
34 | {data.totalCount.toLocaleString()} 35 |
36 | {data.increase.toLocaleString()} 37 | 43 |
44 |
45 |
46 | ); 47 | }; 48 | 49 | export default Summary; 50 | -------------------------------------------------------------------------------- /src/components/common/icon/index.tsx: -------------------------------------------------------------------------------- 1 | import * as variants from '@/styles/variants.css'; 2 | import { CSSProperties } from 'react'; 3 | import * as style from './style.css'; 4 | 5 | export type IconProps = { 6 | type?: 'light' | 'regular' | 'solid' | 'thin'; 7 | name?: string; 8 | size?: 'xSmall' | 'small' | 'medium' | 'large' | 'xLarge' | 'huge'; 9 | color?: string; 10 | isPointer?: boolean; 11 | className?: string; 12 | onClick?: () => void; 13 | style?: CSSProperties; 14 | }; 15 | 16 | /** 17 | * Font-awesome Icon Component 18 | * @param {'light' | 'regular' | 'solid' | 'thin'} type - Icon type(default: solid) 19 | * @param {string} name - Icon name(default: xmark) 20 | * @param {string} size - Icon size(default: medium(1.4rem)) expected to be one of ['xSmall', 'small', 'medium', 'large', 'xLarge', 'huge'] 21 | * @param {string} color - Icon color(default: #9a9a9a) 22 | * @param {string} className - Icon className 23 | * @param {func} onClick - Icon onClick event handler 24 | * @returns {Icon} Font-awesome icon 25 | */ 26 | 27 | const Icon = ({ 28 | type = 'solid', 29 | name = 'xmark', 30 | size = 'medium', 31 | color = variants.color.icon, 32 | isPointer = true, 33 | className = '', 34 | onClick, 35 | ...props 36 | }: IconProps) => { 37 | return ( 38 | 46 | ); 47 | }; 48 | 49 | export default Icon; 50 | -------------------------------------------------------------------------------- /src/components/admin/pagination/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Text } from '@/components/common'; 2 | import * as variants from '@/styles/variants.css'; 3 | import * as style from './style.css'; 4 | 5 | type PaginationProps = { 6 | currentPage: number; 7 | totalPage: number; 8 | page: number; 9 | setPage: (page: number) => void; 10 | }; 11 | 12 | const Pagination = ({ 13 | currentPage, 14 | totalPage, 15 | page, 16 | setPage, 17 | }: PaginationProps) => { 18 | return ( 19 |
20 | 23 | 30 | 31 | {currentPage} / {totalPage} 32 | 33 | 40 | 46 |
47 | ); 48 | }; 49 | 50 | export default Pagination; 51 | -------------------------------------------------------------------------------- /src/hooks/infiniteQuery/useHistoryInfiniteQuery.ts: -------------------------------------------------------------------------------- 1 | import { getHistoryContents, getBookmarkContents } from '@/api/history'; 2 | import { selectedCategoryState } from '@/stores/selectedCategory'; 3 | import { useInfiniteQuery } from '@tanstack/react-query'; 4 | import { useRecoilValue } from 'recoil'; 5 | 6 | const QUERY_KEY = { 7 | bookmark: 'bookmarkContents', 8 | history: 'historyContents', 9 | }; 10 | 11 | export const useHistoryInfiniteQuery = (key: keyof typeof QUERY_KEY) => { 12 | const selectedCategory = useRecoilValue(selectedCategoryState); 13 | 14 | const { 15 | data: getContents, 16 | fetchNextPage: getNextPage, 17 | isSuccess: getContentsIsSuccess, 18 | hasNextPage: getNextPageIsPossible, 19 | status, 20 | isFetching, 21 | isFetchingNextPage, 22 | } = useInfiniteQuery( 23 | ['mainContents', selectedCategory], 24 | ({ pageParam = 0 }) => 25 | key === 'bookmark' 26 | ? getBookmarkContents(pageParam) 27 | : getHistoryContents(pageParam), 28 | { 29 | refetchOnWindowFocus: false, 30 | getNextPageParam: (lastPage) => { 31 | // lastPage는 콜백함수에서 리턴한 값을 의미 32 | // lastPage: 직전에 반환된 리턴값 33 | if (!lastPage.isLast) return lastPage.current_page + 1; 34 | // 마지막 페이지면 undefined가 리턴되어서 hasNextPage는 false가 됨 35 | return undefined; 36 | }, 37 | } 38 | ); 39 | 40 | return { 41 | getContents, 42 | getNextPage, 43 | getContentsIsSuccess, 44 | getNextPageIsPossible, 45 | status, 46 | isFetching, 47 | isFetchingNextPage, 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/common/modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react'; 2 | import * as style from './style.css'; 3 | import useClickAway from '@/hooks/useClickAway'; 4 | import ModalPortal from './ModalPortal'; 5 | 6 | export type ModalProps = { 7 | children: ReactNode; 8 | type: 'center' | 'icon'; 9 | isOpen: boolean; 10 | style?: CSSProperties; 11 | onClose: () => void; 12 | }; 13 | 14 | // 센터 모달, header 아이콘 모달 15 | const Modal = ({ 16 | children, 17 | isOpen = false, 18 | onClose, 19 | type, 20 | ...props 21 | }: ModalProps) => { 22 | const ref = useClickAway((e: Event) => { 23 | if (e.target instanceof HTMLElement && !e.target.closest('button')) { 24 | onClose && onClose(); 25 | } 26 | }); 27 | 28 | return type === 'center' ? ( 29 | 30 |
34 |
40 | {children} 41 |
42 |
43 |
44 | ) : ( 45 |
46 |
52 | {children} 53 |
54 |
55 | ); 56 | }; 57 | 58 | export default Modal; 59 | -------------------------------------------------------------------------------- /src/components/recommendedCreators/index.tsx: -------------------------------------------------------------------------------- 1 | import { Slider, Spinner } from '@/components/common'; 2 | import CreatorCard from '../cardItem/creator'; 3 | import { useQuery } from '@tanstack/react-query'; 4 | import { recommendedCreators } from '@/types/contents'; 5 | import { getRecommendedCreators } from '@/api/creator'; 6 | import { RECOMMENDED_CREATORS } from '@/__mocks__/handlers/recommendedCreators'; 7 | import { useEffect } from 'react'; 8 | import { useRecoilValue } from 'recoil'; 9 | import { isAuthorizedState } from '@/stores/auth'; 10 | 11 | const RecommenedCreators = () => { 12 | const isAuthorized = useRecoilValue(isAuthorizedState); 13 | 14 | const { data: recommendedCreators, refetch } = useQuery( 15 | ['recommendedCreators'], 16 | getRecommendedCreators, 17 | { 18 | refetchOnWindowFocus: false, 19 | } 20 | ); 21 | 22 | useEffect(() => { 23 | refetch(); 24 | }, [isAuthorized]); 25 | 26 | if (!recommendedCreators) { 27 | return ( 28 | 29 | {RECOMMENDED_CREATORS.creators.map((creator) => ( 30 | 31 | ))} 32 | 33 | ); 34 | } 35 | 36 | return ( 37 | 38 | {recommendedCreators.creators.map((recommendedCreator) => ( 39 | 43 | ))} 44 | 45 | ); 46 | }; 47 | 48 | export default RecommenedCreators; 49 | -------------------------------------------------------------------------------- /src/stories/components/common/Input.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from '@/components/common'; 2 | import { InputProps } from '@/components/common/input'; 3 | import useInput from '@/hooks/useInput'; 4 | 5 | export default { 6 | title: 'Components/Common/Input', 7 | component: Input, 8 | argTypes: { 9 | version: { 10 | defaultValue: 'normal', 11 | control: 'inline-radio', 12 | options: ['normal', 'banner', 'header'], 13 | description: 'input version consists of [normal, banner, header]', 14 | }, 15 | type: { 16 | defaultValue: 'text', 17 | control: 'inline-radio', 18 | options: ['text', 'email'], 19 | description: 'input type consists of [text, email]', 20 | }, 21 | placeholder: { 22 | defaultValue: '', 23 | type: 'string', 24 | description: 'input placeholder', 25 | }, 26 | readOnly: { 27 | defaultValue: false, 28 | type: 'boolean', 29 | description: "input's readonly attribute", 30 | }, 31 | max: { 32 | defaultValue: undefined, 33 | type: 'number', 34 | description: "input's max length attribute", 35 | }, 36 | label: { 37 | defaultValue: '', 38 | type: 'string', 39 | description: "input's top left label", 40 | }, 41 | }, 42 | }; 43 | 44 | export const Default = (args: InputProps) => { 45 | const { value, onChange } = useInput(); 46 | const handleEnterPress = () => { 47 | console.log(value); 48 | }; 49 | return ( 50 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/common/text/index.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react'; 2 | import * as style from './style.css'; 3 | 4 | export type TextProps = { 5 | children: ReactNode; 6 | block?: boolean; 7 | paragraph?: boolean; 8 | size?: 'xSmall' | 'small' | 'medium' | 'large' | 'xLarge'; 9 | weight?: 300 | 400 | 500 | 600 | 700 | 800; 10 | underline?: boolean; 11 | color?: string; 12 | style?: CSSProperties; 13 | className?: string; 14 | }; 15 | 16 | /** 17 | * Text Component 18 | * @param {boolean} block - if block, tag is set to div 19 | * @param {boolean} paragraph - if paragraph, tag is set to p / if neither block nor paragraph, tag is set to span 20 | * @param {boolean} underline - if underline, text's decoration is set to underline 21 | * @param {union} size - text size (default: medium(1.6rem)) expected to be one of ['xSmall' | 'small' | 'medium' | 'large' | 'xLarge'] 22 | * @param {union} weight - text's font weight 23 | * @param {string} color - text color 24 | * @returns {Text} Text Component 25 | */ 26 | 27 | const Text = ({ 28 | children, 29 | block, 30 | paragraph, 31 | size = 'medium', 32 | weight = 400, 33 | underline, 34 | color, 35 | ...props 36 | }: TextProps) => { 37 | const Tag = block ? 'div' : paragraph ? 'p' : 'span'; 38 | const fontStyle = { 39 | textDecoration: underline ? 'underline' : undefined, 40 | textUnderlinePosition: underline ? 'under' : undefined, 41 | color, 42 | }; 43 | 44 | return ( 45 | 50 | {children} 51 | 52 | ); 53 | }; 54 | 55 | export default Text; 56 | -------------------------------------------------------------------------------- /src/components/common/tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | import TooltipBox from './tooltipBox'; 2 | import TooltipPortal from './tooltipPortal'; 3 | 4 | import { coordinates, positions } from '@/types/positions'; 5 | 6 | import { getCoordinates } from '@/utils/coordinates'; 7 | 8 | import { MouseEvent, ReactNode, useState } from 'react'; 9 | 10 | type TooltipProps = { 11 | children: ReactNode; 12 | message: string; 13 | position?: positions; 14 | type?: 'icon' | 'text'; 15 | }; 16 | 17 | const Tooltip = ({ 18 | children, 19 | message, 20 | position = 'bottom-end', 21 | type = 'icon', 22 | }: TooltipProps) => { 23 | const [coords, setCoords] = useState({ left: 0, top: 0 }); 24 | const [isTooltipVisible, setIsTooltipVisible] = useState(false); 25 | 26 | const handleMouseEnter = (e: MouseEvent) => { 27 | const target = e.target as HTMLElement; 28 | const rect = target.getBoundingClientRect(); 29 | const { offsetWidth, scrollWidth, offsetHeight, scrollHeight } = target; 30 | 31 | if ( 32 | type === 'text' && 33 | offsetWidth === scrollWidth && 34 | offsetHeight === scrollHeight 35 | ) { 36 | return; 37 | } 38 | 39 | setCoords(getCoordinates(rect, position)); 40 | setIsTooltipVisible(true); 41 | }; 42 | 43 | const handleMouseLeave = () => { 44 | setIsTooltipVisible(false); 45 | }; 46 | 47 | return ( 48 |
49 | {children} 50 | {isTooltipVisible && ( 51 | 52 | 53 | 54 | )} 55 |
56 | ); 57 | }; 58 | 59 | export default Tooltip; 60 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/recommendedCreators.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | export const RECOMMENDED_CREATORS = { 4 | creators: [ 5 | { 6 | creatorId: 1, 7 | creatorName: '개발바닥1', 8 | subscriberAmount: 13, 9 | creatorDescription: '개발에 대한 모든 지식을 공유합니다.', 10 | profileImgUrl: 11 | 'https://play-lh.googleusercontent.com/Yoaqip2U7E9EKghfvnZW1OeanfbjaL3Qqn5TGVDYAqkbXsL3TDNyEp_oBPH5vAPro38', 12 | }, 13 | { 14 | creatorId: 2, 15 | creatorName: '개발바닥2', 16 | subscriberAmount: 130, 17 | creatorDescription: '개발에 대한 모든 지식을 공유합니다.', 18 | profileImgUrl: 19 | 'https://play-lh.googleusercontent.com/Yoaqip2U7E9EKghfvnZW1OeanfbjaL3Qqn5TGVDYAqkbXsL3TDNyEp_oBPH5vAPro38', 20 | }, 21 | { 22 | creatorId: 3, 23 | creatorName: '개발바닥3', 24 | subscriberAmount: 123, 25 | creatorDescription: '개발에 대한 모든 지식을 공유합니다.', 26 | profileImgUrl: 27 | 'https://play-lh.googleusercontent.com/Yoaqip2U7E9EKghfvnZW1OeanfbjaL3Qqn5TGVDYAqkbXsL3TDNyEp_oBPH5vAPro38', 28 | }, 29 | { 30 | creatorId: 4, 31 | creatorName: '개발바닥4', 32 | subscriberAmount: 313, 33 | creatorDescription: '개발에 대한 모든 지식을 공유합니다.', 34 | profileImgUrl: 35 | 'https://play-lh.googleusercontent.com/Yoaqip2U7E9EKghfvnZW1OeanfbjaL3Qqn5TGVDYAqkbXsL3TDNyEp_oBPH5vAPro38', 36 | }, 37 | ], 38 | }; 39 | 40 | export const recommendedCreatorsHandler = [ 41 | rest.get('/creators/recommend', (req, res, ctx) => { 42 | if (!req.headers.all().authorization) { 43 | return res(ctx.status(401)); 44 | } 45 | 46 | return res( 47 | ctx.status(200), 48 | ctx.delay(1000), 49 | ctx.json(RECOMMENDED_CREATORS) 50 | ); 51 | }), 52 | ]; 53 | -------------------------------------------------------------------------------- /src/pages/dailyBriefing/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { recipe } from '@vanilla-extract/recipes'; 3 | import * as medias from '@/styles/medias.css'; 4 | import * as utils from '@/styles/utils.css'; 5 | 6 | export const wrapper = style([ 7 | utils.flexColumn, 8 | medias.large({ padding: '5rem 6rem' }), 9 | medias.medium({ padding: '5rem 2rem' }), 10 | { 11 | padding: '5rem 10rem', 12 | }, 13 | ]); 14 | 15 | export const cardContainer = style([ 16 | utils.flexJustifyCenter, 17 | medias.large({ flexDirection: 'column', padding: '0' }), 18 | medias.medium({ padding: '0' }), 19 | { 20 | padding: '0 6rem', 21 | gap: '5rem', 22 | }, 23 | ]); 24 | 25 | export const header = style({ 26 | marginBottom: '4rem', 27 | }); 28 | 29 | export const intro = style([utils.flexAlignCenter]); 30 | 31 | export const logo = style([ 32 | utils.flex, 33 | { 34 | marginRight: '1rem', 35 | }, 36 | ]); 37 | 38 | export const summaryGroup = style([ 39 | utils.flex, 40 | utils.fullWidth, 41 | medias.medium({ flexDirection: 'column', minWidth: '30rem' }), 42 | { 43 | gap: '3rem', 44 | }, 45 | ]); 46 | 47 | export const wrapColumn = recipe({ 48 | base: [ 49 | utils.flexColumn, 50 | medias.large({ width: '100%' }), 51 | { 52 | gap: '4rem', 53 | }, 54 | ], 55 | variants: { 56 | direction: { 57 | left: [ 58 | medias.large({ flexDirection: 'row' }), 59 | { 60 | width: '40%', 61 | '@media': { 62 | 'screen and (max-width: 976.98px)': { 63 | flexDirection: 'column', 64 | }, 65 | }, 66 | }, 67 | ], 68 | right: { 69 | width: '60%', 70 | }, 71 | }, 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /src/components/common/slider/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { recipe } from '@vanilla-extract/recipes'; 3 | import * as variants from '@/styles/variants.css'; 4 | import * as utils from '@/styles/utils.css'; 5 | 6 | export const slider = style([ 7 | utils.flexColumn, 8 | utils.positionRelative, 9 | utils.fullWidth, 10 | { 11 | // minWidth: '53.2rem', 12 | padding: '2.6rem 2.4rem 1.6rem', 13 | marginBottom: '2rem', 14 | background: 15 | 'linear-gradient(116.5deg, rgba(75, 128, 255, 0.95) 14.56%, rgba(13, 153, 255, 0.5) 88.34%)', 16 | borderRadius: '1.2rem', 17 | boxShadow: '0px 8px 16px rgba(17, 17, 17, 0.2)', 18 | }, 19 | ]); 20 | 21 | export const title = style({ 22 | fontSize: '2.4rem', 23 | fontWeight: '700', 24 | marginBottom: '1rem', 25 | color: variants.color.white, 26 | '@media': { 27 | 'screen and (max-width: 500px)': { 28 | fontSize: '2rem', 29 | }, 30 | }, 31 | }); 32 | 33 | export const sliderTarget = recipe({ 34 | base: [ 35 | utils.flex, 36 | { 37 | transition: 'all 100ms ease-in-out', 38 | paddingTop: '1.2rem', 39 | paddingBottom: '1rem', 40 | gap: '1.4rem', 41 | overflowX: 'auto', 42 | cursor: 'grab', 43 | 44 | '::-webkit-scrollbar': { 45 | height: '0.8rem', 46 | backgroundColor: 'transparent', 47 | }, 48 | '::-webkit-scrollbar-thumb': { 49 | padding: '10px 0', 50 | backgroundColor: '#3F435040', 51 | borderRadius: '0.3rem', 52 | }, 53 | '::-webkit-scrollbar-track': { 54 | backgroundColor: '#3F435025', 55 | }, 56 | }, 57 | ], 58 | variants: { 59 | authorized: { 60 | false: { 61 | filter: 'blur(1rem)', 62 | cursor: 'none', 63 | pointerEvents: 'none', 64 | }, 65 | }, 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /src/components/cardList/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import { recipe } from '@vanilla-extract/recipes'; 3 | 4 | export const listContainer = recipe({ 5 | base: [ 6 | utils.grid, 7 | { 8 | gridTemplateColumns: 'repeat(auto-fill, minmax(28.8rem, 1fr))', 9 | gridGap: '20px', 10 | justifyItems: 'center', 11 | height: 'fit-content', 12 | }, 13 | ], 14 | variants: { 15 | type: { 16 | content: { 17 | '@media': { 18 | 'screen and (max-width: 675px)': { 19 | gridTemplateColumns: 'repeat(auto-fill, minmax(26.8rem, 1fr))', 20 | }, 21 | 'screen and (min-width: 807px)': { 22 | gridTemplateColumns: 'repeat(auto-fill, minmax(28.8rem, 1fr))', 23 | }, 24 | 'screen and (min-width: 943px)': { 25 | gridTemplateColumns: 'repeat(3, minmax(30%))', 26 | }, 27 | 'screen and (min-width: 1272px)': { 28 | gridTemplateColumns: 'repeat(4, minmax(20%))', 29 | }, 30 | 'screen and (min-width: 1600px)': { 31 | gridTemplateColumns: 'repeat(auto-fill, minmax(28.8rem, 1fr))', 32 | }, 33 | }, 34 | }, 35 | creator: { 36 | '@media': { 37 | 'screen and (max-width: 400px)': { 38 | gridTemplateColumns: 'auto-fill', 39 | }, 40 | 'screen and (max-width: 675px)': { 41 | gridTemplateColumns: 'repeat(auto-fill, minmax(24rem, 1fr))', 42 | }, 43 | 'screen and (min-width: 1050px) and (max-width: 1120px)': { 44 | gridTemplateColumns: 'repeat(3, minmax(30%))', 45 | }, 46 | 'screen and (min-width: 1412px)': { 47 | gridTemplateColumns: 'repeat(auto-fill, minmax(28.8rem, 1fr))', 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/components/dailyBriefing/CategoryChart.tsx: -------------------------------------------------------------------------------- 1 | import { Doughnut } from 'react-chartjs-2'; 2 | import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; 3 | import { Card, Heading } from '@/components/common'; 4 | import { CATEGORIES } from '@/utils/constants/categories'; 5 | import { dataByCategorys } from '@/types/dailyBriefing'; 6 | import * as style from './style.css'; 7 | 8 | ChartJS.register(ArcElement, Tooltip, Legend); 9 | 10 | const options = { 11 | responsive: true, 12 | }; 13 | 14 | const backgroundColor = [ 15 | 'rgba(255, 99, 132, 0.2)', 16 | 'rgba(54, 162, 235, 0.2)', 17 | 'rgba(255, 206, 86, 0.2)', 18 | ]; 19 | 20 | const borderColor = [ 21 | 'rgba(255, 99, 132, 1)', 22 | 'rgba(54, 162, 235, 1)', 23 | 'rgba(255, 206, 86, 1)', 24 | ]; 25 | 26 | type categoryChartProps = { 27 | standardTime: string; 28 | data: dataByCategorys[]; 29 | }; 30 | 31 | const CategoryChart = ({ standardTime, data }: categoryChartProps) => { 32 | const chartData = { 33 | labels: data.map(({ categoryName }) => CATEGORIES[categoryName]), 34 | datasets: [ 35 | { 36 | data: data.map(({ count }) => count), 37 | backgroundColor, 38 | borderColor, 39 | borderWidth: 1, 40 | pointStyle: 'Rounded', 41 | }, 42 | ], 43 | }; 44 | 45 | return ( 46 | 51 |
52 | 관심 카테고리별 회원 수 53 | {standardTime}시 기준 54 |
55 |
56 | 61 |
62 |
63 | ); 64 | }; 65 | 66 | export default CategoryChart; 67 | -------------------------------------------------------------------------------- /src/components/common/tab/style.css.ts: -------------------------------------------------------------------------------- 1 | import { recipe } from '@vanilla-extract/recipes'; 2 | import * as utils from '@/styles/utils.css'; 3 | import * as variants from '@/styles/variants.css'; 4 | 5 | export const tabList = recipe({ 6 | base: [utils.flexAlignCenter], 7 | variants: { 8 | type: { 9 | header: { 10 | height: '6rem', 11 | }, 12 | modal: { 13 | height: '4rem', 14 | }, 15 | }, 16 | }, 17 | }); 18 | 19 | export const tabItem = recipe({ 20 | base: [ 21 | utils.cursorPointer, 22 | { 23 | color: variants.color.font.primary, 24 | padding: '0 1rem', 25 | margin: '0 0.5rem', 26 | whiteSpace: 'nowrap', 27 | }, 28 | ], 29 | variants: { 30 | type: { 31 | header: { 32 | fontSize: variants.fontSize.small, 33 | height: '6rem', 34 | lineHeight: '6rem', 35 | 36 | ':hover': { 37 | color: variants.color.primary, 38 | }, 39 | }, 40 | modal: { 41 | fontSize: variants.fontSize.small, 42 | height: '4rem', 43 | lineHeight: '4rem', 44 | color: variants.color.font.secondary, 45 | 46 | ':hover': { 47 | color: variants.color.font.primary, 48 | }, 49 | }, 50 | }, 51 | isClicked: { 52 | true: { 53 | fontWeight: '700', 54 | borderBottom: `0.3rem solid ${variants.color.primary}`, 55 | }, 56 | }, 57 | }, 58 | compoundVariants: [ 59 | { 60 | variants: { 61 | type: 'header', 62 | isClicked: true, 63 | }, 64 | style: { 65 | color: variants.color.primary, 66 | }, 67 | }, 68 | { 69 | variants: { 70 | type: 'modal', 71 | isClicked: true, 72 | }, 73 | style: { 74 | color: variants.color.font.primary, 75 | borderColor: variants.color.font.primary, 76 | }, 77 | }, 78 | ], 79 | }); 80 | -------------------------------------------------------------------------------- /src/components/common/input/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { style } from '@vanilla-extract/css'; 4 | import { recipe } from '@vanilla-extract/recipes'; 5 | 6 | export const inputContainer = recipe({ 7 | base: [ 8 | utils.flexAlignCenter, 9 | utils.borderRadius, 10 | utils.positionRelative, 11 | utils.fullWidth, 12 | { 13 | boxShadow: '0 0.3rem 1rem #18181810', 14 | border: `0.1rem solid ${variants.color.disabled.bg}`, 15 | padding: '1.2rem 1.6rem', 16 | fontSize: variants.fontSize.medium, 17 | background: variants.color.white, 18 | }, 19 | ], 20 | variants: { 21 | version: { 22 | normal: { height: '4.8rem' }, 23 | header: { 24 | height: '4rem', 25 | maxWidth: '60rem', 26 | borderRadius: '3.5rem', 27 | gap: '1.2rem', 28 | }, 29 | banner: [ 30 | utils.fullWidth, 31 | { 32 | height: '7rem', 33 | maxWidth: '80rem', 34 | borderRadius: '3.5rem', 35 | padding: '1.2rem 2.4rem', 36 | fontSize: variants.fontSize.xLarge, 37 | gap: '2rem', 38 | }, 39 | ], 40 | }, 41 | readOnly: { 42 | true: { 43 | backgroundColor: variants.color.disabled.bg, 44 | border: 'none', 45 | }, 46 | }, 47 | hasLabel: { 48 | true: { 49 | marginTop: '3rem', 50 | }, 51 | }, 52 | }, 53 | }); 54 | 55 | export const input = style([ 56 | utils.fullWidth, 57 | { 58 | color: variants.color.font.primary, 59 | 60 | ':read-only': { 61 | color: variants.color.disabled.font, 62 | }, 63 | 64 | '::placeholder': { 65 | color: variants.color.icon, 66 | }, 67 | }, 68 | ]); 69 | 70 | export const label = style([ 71 | utils.positionAbsolute, 72 | { 73 | top: '-2.4rem', 74 | left: '0.4rem', 75 | }, 76 | ]); 77 | -------------------------------------------------------------------------------- /src/styles/global.css.ts: -------------------------------------------------------------------------------- 1 | import * as variants from '@/styles/variants.css'; 2 | import { globalStyle } from '@vanilla-extract/css'; 3 | 4 | globalStyle('*, *:after, *:before', { 5 | boxSizing: 'border-box', 6 | fontSize: '100%', 7 | }); 8 | 9 | globalStyle('html', { 10 | fontSize: '10px', 11 | }); 12 | 13 | globalStyle('html, body, #root', { 14 | margin: 0, 15 | padding: 0, 16 | height: '100%', 17 | }); 18 | 19 | globalStyle('body', { 20 | lineHeight: '1.8rem', 21 | }); 22 | 23 | globalStyle( 24 | 'html, body, div, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, em, img, ins, kbd, q, s, samp, small, span, strike, strong, article, footer, header, main, nav, section, input, canvas', 25 | { 26 | margin: 0, 27 | padding: 0, 28 | border: 0, 29 | verticalAlign: 'baseline', 30 | fontFamily: variants.font.default, 31 | } 32 | ); 33 | 34 | globalStyle( 35 | 'article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section', 36 | { 37 | display: 'block', 38 | } 39 | ); 40 | 41 | globalStyle('ol, ul', { 42 | listStyle: 'none', 43 | padding: 0, 44 | margin: 0, 45 | }); 46 | 47 | globalStyle('h1, h2, h3, h4, h5, h6, p', { 48 | wordBreak: 'keep-all', 49 | whiteSpace: 'pre-wrap', 50 | letterSpacing: '-0.02rem', 51 | lineHeight: '1.8rem', 52 | }); 53 | 54 | globalStyle('span', { 55 | wordBreak: 'keep-all', 56 | whiteSpace: 'pre', 57 | letterSpacing: '-0.02rem', 58 | lineHeight: '1.8rem', 59 | }); 60 | 61 | globalStyle('a', { 62 | textDecoration: 'none', 63 | color: 'inherit', 64 | }); 65 | 66 | globalStyle('button, select, input, textarea', { 67 | border: 0, 68 | outline: 0, 69 | backgroundColor: 'transparent', 70 | fontFamily: variants.font.default, 71 | }); 72 | 73 | globalStyle('a, button', { 74 | cursor: 'pointer', 75 | }); 76 | 77 | globalStyle('button', { 78 | padding: 0, 79 | }); 80 | 81 | globalStyle('table', { 82 | borderCollapse: 'collapse', 83 | }); 84 | -------------------------------------------------------------------------------- /src/components/dailyBriefing/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import * as medias from '@/styles/medias.css'; 3 | import * as utils from '@/styles/utils.css'; 4 | import * as variants from '@/styles/variants.css'; 5 | 6 | export const title = style([ 7 | utils.flexJustifySpaceBetween, 8 | utils.flexAlignCenter, 9 | medias.large({ 10 | flexDirection: 'column', 11 | alignItems: 'flex-start', 12 | marginBottom: '2rem', 13 | }), 14 | { 15 | marginBottom: '1rem', 16 | }, 17 | ]); 18 | 19 | export const detail = style([ 20 | utils.flexJustifySpaceBetween, 21 | { 22 | alignItems: 'flex-end', 23 | }, 24 | ]); 25 | 26 | export const standardTime = style({ 27 | fontSize: variants.fontSize.xSmall, 28 | color: variants.color.border, 29 | }); 30 | 31 | export const rankList = style([ 32 | utils.flexColumn, 33 | medias.large({ 34 | marginTop: '3rem', 35 | }), 36 | ]); 37 | 38 | export const rankItem = style([ 39 | utils.flex, 40 | utils.flexAlignCenter, 41 | { 42 | padding: '0.6rem 0', 43 | }, 44 | ]); 45 | 46 | export const ranking = style([ 47 | utils.flexJustifyCenter, 48 | utils.flexAlignCenter, 49 | utils.borderRadiusRound, 50 | { 51 | flexShrink: 0, 52 | width: '2.8rem', 53 | height: '2.8rem', 54 | fontSize: variants.fontSize.small, 55 | fontWeight: '700', 56 | color: variants.color.white, 57 | background: variants.color.primary, 58 | marginRight: '1rem', 59 | }, 60 | ]); 61 | 62 | export const rankDesc = style([utils.flexJustifySpaceBetween, utils.fullWidth]); 63 | 64 | export const count = style({ 65 | fontSize: variants.fontSize.small, 66 | color: variants.color.font.secondary, 67 | }); 68 | 69 | export const chart = style([ 70 | utils.fullHeight, 71 | utils.flexJustifyCenter, 72 | medias.large({ margin: '3rem 2rem', minHeight: '40rem' }), 73 | medias.small({ minHeight: '20rem' }), 74 | { 75 | margin: '4rem', 76 | }, 77 | ]); 78 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/creatorList.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | const creatorListData = { 4 | creators: [ 5 | { 6 | creatorId: 1, 7 | creatorName: '카카오', 8 | subscriberAmount: 13, 9 | creatorDescription: '개발에 대한 모든 지식을 공유합니다.', 10 | isSubscribed: true, 11 | profileImgUrl: 'https://avatars.githubusercontent.com/u/60571418?v=4', 12 | }, 13 | { 14 | creatorId: 2, 15 | creatorName: '프로그래머스', 16 | subscriberAmount: 13, 17 | creatorDescription: 18 | '개발에 대한 모든 지식을 공유합니다.개발에 대한 모든 지식을 공유합니다.개발에 대한 모든 지식을 공유합니다.개발에 대한 모든 지식을 공유합니다.', 19 | isSubscribed: false, 20 | profileImgUrl: 21 | 'https://avatars.githubusercontent.com/u/88082564?s=100&v=4', 22 | }, 23 | { 24 | creatorId: 3, 25 | creatorName: '벨로퍼트', 26 | subscriberAmount: 13, 27 | creatorDescription: '개발에 대한 모든 지식을 공유합니다.', 28 | isSubscribed: false, 29 | profileImgUrl: 30 | 'https://avatars.githubusercontent.com/u/17202261?s=100&v=4', 31 | }, 32 | { 33 | creatorId: 4, 34 | creatorName: '원티드', 35 | subscriberAmount: 13, 36 | creatorDescription: '개발에 대한 모든 지식을 공유합니다.', 37 | isSubscribed: false, 38 | profileImgUrl: 39 | 'https://avatars.githubusercontent.com/u/100328104?s=200&v=4', 40 | }, 41 | { 42 | creatorId: 5, 43 | creatorName: '개발바닥', 44 | subscriberAmount: 13, 45 | creatorDescription: '개발에 대한 모든 지식을 공유합니다.', 46 | isSubscribed: false, 47 | profileImgUrl: '/favicon.ico', 48 | }, 49 | ], 50 | hasNext: true, 51 | }; 52 | 53 | export const creatorListHandler = [ 54 | rest.get('/creators', (req, res, ctx) => { 55 | const page = req.url.searchParams.get('page'), 56 | category = req.url.searchParams.get('category'); 57 | 58 | if (!page || !category) { 59 | return res(ctx.status(400)); 60 | } 61 | return res(ctx.status(200), ctx.delay(500), ctx.json(creatorListData)); 62 | }), 63 | ]; 64 | -------------------------------------------------------------------------------- /src/__mocks__/handlers/dailyBriefing.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | const DAILY_BRIEFING = { 4 | standardTime: '2023-02-17 18', 5 | dailyBriefing: { 6 | memberStatistics: { 7 | increase: 300, 8 | totalCount: 12400, 9 | }, 10 | viewStatistics: { 11 | increase: 1540, 12 | totalCount: 54920, 13 | }, 14 | viewByCategories: [ 15 | { 16 | categoryName: 'develop', 17 | count: 283, 18 | ranking: 3, 19 | }, 20 | { 21 | categoryName: 'beauty', 22 | count: 832, 23 | ranking: 1, 24 | }, 25 | { 26 | categoryName: 'finance', 27 | count: 425, 28 | ranking: 2, 29 | }, 30 | ], 31 | contentIncreaseForWeek: [ 32 | { 33 | date: '2023-03-01', 34 | contentIncrease: 44, 35 | }, 36 | { 37 | date: '2023-03-02', 38 | contentIncrease: 23, 39 | }, 40 | { 41 | date: '2023-03-03', 42 | contentIncrease: 63, 43 | }, 44 | { 45 | date: '2023-03-04', 46 | contentIncrease: 29, 47 | }, 48 | { 49 | date: '2023-03-05', 50 | contentIncrease: 16, 51 | }, 52 | { 53 | date: '2023-03-06', 54 | contentIncrease: 45, 55 | }, 56 | { 57 | date: '2023-03-07', 58 | contentIncrease: 55, 59 | }, 60 | ], 61 | memberCountByAttentionCategories: [ 62 | { 63 | categoryName: 'develop', 64 | count: 13, 65 | ranking: 3, 66 | }, 67 | { 68 | categoryName: 'beauty', 69 | count: 92, 70 | ranking: 1, 71 | }, 72 | { 73 | categoryName: 'finance', 74 | count: 55, 75 | ranking: 2, 76 | }, 77 | ], 78 | }, 79 | }; 80 | 81 | export const dailyBriefingDataHandler = [ 82 | rest.get('/daily-briefing', (req, res, ctx) => { 83 | return res(ctx.status(200), ctx.delay(500), ctx.json(DAILY_BRIEFING)); 84 | }), 85 | ]; 86 | -------------------------------------------------------------------------------- /src/components/cardItem/creator/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import { style } from '@vanilla-extract/css'; 4 | import { recipe } from '@vanilla-extract/recipes'; 5 | 6 | export const creatorCardContainer = style([ 7 | utils.flexColumn, 8 | { padding: '1.6rem', gap: '2.4rem' }, 9 | ]); 10 | 11 | export const creatorCardTop = style([utils.flexAlignCenter, { gap: '1rem' }]); 12 | 13 | export const topInfo = style({ 14 | flexGrow: 1, 15 | }); 16 | 17 | export const infoCreator = style([ 18 | utils.textOverflowEllipsis, 19 | utils.overflowHidden, 20 | { 21 | display: '-webkit-box', 22 | WebkitBoxOrient: 'vertical', 23 | whiteSpace: 'normal', 24 | WebkitLineClamp: 1, 25 | fontSize: variants.fontSize.medium, 26 | fontWeight: 600, 27 | lineHeight: '1.9rem', 28 | letterSpacing: '-0.04rem', 29 | }, 30 | ]); 31 | export const infoSubscriber = style({ 32 | fontWeight: 400, 33 | fontSize: variants.fontSize.xSmall, 34 | color: '#A8A6AC', 35 | }); 36 | 37 | export const topButton = recipe({ 38 | base: [ 39 | utils.borderRadius, 40 | { 41 | width: '7.3rem', 42 | border: '0.2rem solid #625F68', 43 | padding: '1rem 1.4rem', 44 | fontSize: variants.fontSize.small, 45 | fontWeight: 600, 46 | cursor: 'pointer', 47 | ':hover': { 48 | border: '0.2rem solid white', 49 | color: variants.color.white, 50 | backgroundColor: variants.color.primary, 51 | }, 52 | }, 53 | ], 54 | variants: { 55 | type: { 56 | true: { 57 | backgroundColor: variants.color.primary, 58 | color: variants.color.white, 59 | border: '0.2rem solid white', 60 | }, 61 | }, 62 | }, 63 | }); 64 | 65 | export const creatorCardBottom = style({ 66 | fontWeight: 400, 67 | fontSize: variants.fontSize.small, 68 | color: '#625F68', 69 | display: '-webkit-box', 70 | WebkitLineClamp: 2, 71 | WebkitBoxOrient: 'vertical', 72 | whiteSpace: 'normal', 73 | overflow: 'hidden', 74 | }); 75 | -------------------------------------------------------------------------------- /src/components/common/header/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from '@/components/common'; 2 | 3 | import useInput from '@/hooks/useInput'; 4 | 5 | import { isAuthorizedState } from '@/stores/auth'; 6 | import { isLoginModalVisibleState } from '@/stores/modal'; 7 | import { isSearchBarVisibleState } from '@/stores/searchBar'; 8 | import { selectedTabState } from '@/stores/tab'; 9 | 10 | import { useNavigate } from 'react-router-dom'; 11 | import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; 12 | 13 | import * as style from './style.css'; 14 | 15 | export type SearchBarProps = { 16 | version?: 'header' | 'banner'; 17 | onEnterPress?: () => void; 18 | }; 19 | 20 | const SearchBar = ({ version = 'header', onEnterPress }: SearchBarProps) => { 21 | const navigate = useNavigate(); 22 | const isAuthorized = useRecoilValue(isAuthorizedState); 23 | const setIsLoginModalVisible = useSetRecoilState(isLoginModalVisibleState); 24 | const setTabState = useSetRecoilState(selectedTabState); 25 | const [isSearchBarVisible, setIsSearhBarVisible] = useRecoilState( 26 | isSearchBarVisibleState 27 | ); 28 | 29 | const { value: keyword, onChange: handleKeywordChange } = useInput(''); 30 | const inputStyle = { 31 | margin: version === 'header' ? '0 1rem' : '0', 32 | }; 33 | 34 | const handleEnterPress = async () => { 35 | if (!isAuthorized) { 36 | setIsLoginModalVisible(true); 37 | return; 38 | } 39 | if (!keyword.trim().length) { 40 | alert('한 글자 이상 검색해주세요!'); 41 | return; 42 | } 43 | 44 | onEnterPress?.(); 45 | setIsSearhBarVisible(false); 46 | setTabState('none'); 47 | navigate(`/search/${keyword}`); 48 | }; 49 | 50 | return ( 51 |
52 | 61 |
62 | ); 63 | }; 64 | 65 | export default SearchBar; 66 | -------------------------------------------------------------------------------- /src/pages/home/style.css.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '@/styles/utils.css'; 2 | import * as variants from '@/styles/variants.css'; 3 | import * as medias from '@/styles/medias.css'; 4 | import { style } from '@vanilla-extract/css'; 5 | import { recipe } from '@vanilla-extract/recipes'; 6 | 7 | export const container = recipe({ 8 | base: [ 9 | { 10 | height: 'calc(100vh - 7.1rem)', 11 | overflowY: 'auto', 12 | padding: '0 10rem', 13 | '::-webkit-scrollbar': { 14 | display: 'none', 15 | }, 16 | }, 17 | medias.large({ padding: '0 6rem' }), 18 | medias.medium({ padding: '0 4rem' }), 19 | ], 20 | variants: { 21 | isScrolled: { 22 | true: { 23 | height: 'calc(100vh - 14.2rem)', 24 | }, 25 | }, 26 | }, 27 | }); 28 | 29 | export const banner = style([ 30 | utils.fullWidth, 31 | { 32 | height: 'calc(100vh - 7.1rem)', 33 | }, 34 | ]); 35 | 36 | export const content = style({ 37 | paddingBottom: '1rem', 38 | minHeight: 'calc(100vh - 7.1rem)', 39 | }); 40 | 41 | export const filterButtonGroup = style([ 42 | utils.flexAlignCenter, 43 | { 44 | margin: '2rem 0', 45 | gap: '1rem', 46 | }, 47 | ]); 48 | 49 | export const recommendCreatorWrapper = style([utils.positionRelative]); 50 | 51 | export const disabledCreatorText = style([ 52 | utils.positionAbsolute, 53 | utils.flexCenter, 54 | { 55 | fontSize: variants.fontSize.large, 56 | flexDirection: 'column', 57 | padding: '2rem', 58 | top: '50%', 59 | right: '50%', 60 | transform: 'translate(50%, -25%)', 61 | whiteSpace: 'nowrap', 62 | background: 'white', 63 | border: '1px solid rgba(17, 17, 17, 0.32)', 64 | boxShadow: 65 | '0px 0px 2px rgba(0, 0, 0, 0.12), 0px 20px 20px rgba(0, 0, 0, 0.08)', 66 | borderRadius: '8px', 67 | wordBreak: 'keep-all', 68 | '@media': { 69 | 'screen and (max-width: 500px)': { 70 | width: '70%', 71 | }, 72 | }, 73 | }, 74 | ]); 75 | 76 | export const toggleDisabledText = style({ 77 | '@media': { 78 | 'screen and (max-width: 425px)': { 79 | display: 'none', 80 | }, 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /src/components/cardItem/content/cardTop/style.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from '@vanilla-extract/css'; 2 | import { recipe } from '@vanilla-extract/recipes'; 3 | import * as utils from '@/styles/utils.css'; 4 | import * as variants from '@/styles/variants.css'; 5 | import { cardContainer } from '../style.css'; 6 | 7 | export const cardTop = style([ 8 | utils.positionRelative, 9 | { 10 | height: '21rem', 11 | }, 12 | ]); 13 | 14 | export const bookmarkWrapper = recipe({ 15 | base: [ 16 | utils.positionAbsolute, 17 | { 18 | opacity: 0, 19 | top: '1.6rem', 20 | right: '1.6rem', 21 | color: 'lightgray', 22 | ':hover': { 23 | cursor: 'pointer', 24 | color: 'white', 25 | }, 26 | selectors: { 27 | [`${cardContainer}:hover &`]: { 28 | opacity: 1, 29 | }, 30 | }, 31 | }, 32 | ], 33 | variants: { 34 | bookmark: { 35 | true: { 36 | opacity: 1, 37 | }, 38 | }, 39 | }, 40 | }); 41 | 42 | export const bookmarkIcon = style({ 43 | ':hover': { 44 | cursor: 'pointer', 45 | color: 'white', 46 | }, 47 | }); 48 | 49 | export const numberIconWrapper = style([ 50 | utils.flex, 51 | utils.positionAbsolute, 52 | { 53 | right: '1.6rem', 54 | bottom: '1.6rem', 55 | color: 'lightgray', 56 | }, 57 | ]); 58 | 59 | export const iconWrapper = recipe({ 60 | base: [ 61 | utils.flexCenter, 62 | { 63 | backgroundColor: 'rgba(0,0,0,0.5)', 64 | borderRadius: '0.4rem', 65 | padding: '0.4rem 0.8rem', 66 | gap: '0.5rem', 67 | }, 68 | ], 69 | variants: { 70 | bookmark: { 71 | true: { 72 | borderRadius: '50%', 73 | padding: '0.6rem 0.8rem', 74 | opacity: 1, 75 | ':hover': { 76 | color: variants.color.white, 77 | backgroundColor: variants.color.primary, 78 | }, 79 | }, 80 | }, 81 | heart: { 82 | true: { 83 | cursor: 'pointer', 84 | ':hover': { 85 | color: variants.color.white, 86 | }, 87 | }, 88 | }, 89 | eyes: { 90 | true: { 91 | marginLeft: '0.5rem', 92 | }, 93 | }, 94 | }, 95 | }); 96 | -------------------------------------------------------------------------------- /src/components/common/input/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Text } from '@/components/common'; 2 | import { ChangeEvent, CSSProperties, KeyboardEvent } from 'react'; 3 | import * as style from './style.css'; 4 | 5 | export type InputProps = { 6 | label?: string; 7 | version?: 'normal' | 'banner' | 'header'; 8 | type?: 'text' | 'email'; 9 | placeholder?: string; 10 | readOnly?: boolean; 11 | value?: string; 12 | max?: number; 13 | onChange?: (value: string) => void; 14 | onEnterPress?: () => void; 15 | needResetWhenEnter?: boolean; 16 | style?: CSSProperties; 17 | }; 18 | 19 | const Input = ({ 20 | label = '', 21 | version = 'normal', 22 | type = 'text', 23 | placeholder = '', 24 | readOnly = false, 25 | value = '', 26 | max, 27 | onChange, 28 | onEnterPress, 29 | needResetWhenEnter = true, 30 | ...props 31 | }: InputProps) => { 32 | const handleChange = (e: ChangeEvent) => { 33 | if (max && e.target.value.length > max) { 34 | e.target.value = e.target.value.slice(0, max); 35 | } 36 | 37 | onChange?.(e.target.value); 38 | }; 39 | 40 | const handleEnterPress = (e: KeyboardEvent) => { 41 | if (!onEnterPress || e.key !== 'Enter') { 42 | return; 43 | } 44 | 45 | onEnterPress(); 46 | if (needResetWhenEnter) { 47 | onChange?.(''); 48 | } 49 | }; 50 | 51 | return ( 52 |
60 | {version !== 'normal' && ( 61 | 62 | )} 63 | {label && {label}} 64 | 77 |
78 | ); 79 | }; 80 | 81 | export default Input; 82 | -------------------------------------------------------------------------------- /src/components/modal/certification/NotSendedView/index.tsx: -------------------------------------------------------------------------------- 1 | import { sendCompanyEmail } from '@/api/companies'; 2 | import { Input, Text, Button } from '@/components/common'; 3 | import useInput from '@/hooks/useInput'; 4 | import { useQuery } from '@tanstack/react-query'; 5 | import { useState } from 'react'; 6 | import * as style from './style.css'; 7 | 8 | type NotSendedViewProps = { 9 | setIsSendTrue: () => void; 10 | setEmail: (email: string) => void; 11 | }; 12 | 13 | const NotSendedView = ({ setIsSendTrue, setEmail }: NotSendedViewProps) => { 14 | const [isDisabled, setIsDisabled] = useState(false); 15 | const { value: email, onChange: handleEmailChange } = useInput(''); 16 | const [isError, setIsError] = useState(false); 17 | const { refetch } = useQuery( 18 | ['companiesAuth'], 19 | () => sendCompanyEmail(email), 20 | { enabled: false } 21 | ); 22 | 23 | const handleSubmit = () => { 24 | const emailValidationRegex = 25 | /^[A-Za-z0-9_!#$%&'*+\/=?`{|}~^.-]+@[A-Za-z0-9.-]+$/gm; 26 | const isValid = emailValidationRegex.test(email.trim()); 27 | 28 | if (isValid) { 29 | setIsDisabled(true); 30 | refetch(); 31 | setIsDisabled(false); 32 | setIsSendTrue(); 33 | setEmail(email); 34 | setIsError(false); 35 | return; 36 | } 37 | 38 | setIsError(true); 39 | }; 40 | return ( 41 | <> 42 |
43 | 50 | {isError && ( 51 |
52 | 53 | 이메일이 유효하지 않습니다 54 | 55 |
56 | )} 57 | 58 | * 해당 정보는 게시글 추천 용도로 사용됩니다. 59 |
* 인증된 메일은 모두 비공개로 관리됩니다. 60 |
61 |
62 |