;
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 |
68 | >
69 | );
70 | };
71 |
72 | export default NotSendedView;
73 |
--------------------------------------------------------------------------------
/src/components/common/slider/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useRef, WheelEvent, MouseEvent } from 'react';
2 | import * as style from './style.css';
3 | import { isAuthorizedState } from '@/stores/auth';
4 | import { useRecoilValue } from 'recoil';
5 |
6 | export type SliderProps = {
7 | headerText: string;
8 | children: ReactNode[];
9 | };
10 |
11 | const Slider = ({ headerText, children, ...props }: SliderProps) => {
12 | const sliderTargetRef = useRef(null);
13 | const isAuthorized = useRecoilValue(isAuthorizedState);
14 |
15 | let isMouseDown = false;
16 | let startX = 0;
17 | let scrollLeft = 0;
18 |
19 | const handleWheel = (e: WheelEvent) => {
20 | if (e.deltaY !== 0) return;
21 |
22 | if (!sliderTargetRef.current) return;
23 | sliderTargetRef.current.scrollLeft += e.deltaY;
24 | };
25 |
26 | const handleMouseDown = (e: MouseEvent) => {
27 | if (!sliderTargetRef.current) return;
28 |
29 | isMouseDown = true;
30 | startX = e.clientX - sliderTargetRef.current.offsetLeft;
31 | scrollLeft = sliderTargetRef.current?.scrollLeft as number;
32 | sliderTargetRef.current.style.cursor = 'grabbing';
33 | };
34 |
35 | const handleMouseUp = () => {
36 | if (!sliderTargetRef.current) return;
37 |
38 | isMouseDown = false;
39 | sliderTargetRef.current.style.cursor = 'grab';
40 | };
41 |
42 | const handleMouseMove = (e: MouseEvent) => {
43 | e.preventDefault();
44 |
45 | if (!isMouseDown) return;
46 | if (!sliderTargetRef.current) return;
47 |
48 | const curLocation = e.clientX - sliderTargetRef.current.offsetLeft;
49 | const diff = (curLocation - startX) * 2;
50 | sliderTargetRef.current.scrollLeft = scrollLeft - diff;
51 | };
52 |
53 | return (
54 |
55 |
{headerText}
56 |
64 | {children}
65 |
66 |
67 | );
68 | };
69 |
70 | export default Slider;
71 |
--------------------------------------------------------------------------------
/src/components/dailyBriefing/ContentsCountChart.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Chart as ChartJS,
3 | CategoryScale,
4 | LinearScale,
5 | PointElement,
6 | LineElement,
7 | Title,
8 | Tooltip,
9 | Filler,
10 | Legend,
11 | } from 'chart.js';
12 | import { Line } from 'react-chartjs-2';
13 | import { Card, Heading } from '@/components/common';
14 | import * as style from './style.css';
15 | import { contentIncreaseData } from '@/types/dailyBriefing';
16 |
17 | ChartJS.register(
18 | CategoryScale,
19 | LinearScale,
20 | PointElement,
21 | LineElement,
22 | Title,
23 | Tooltip,
24 | Filler,
25 | Legend
26 | );
27 |
28 | type contentsCountChartProps = {
29 | data: contentIncreaseData[];
30 | };
31 |
32 | const ContentsCountChart = ({ data }: contentsCountChartProps) => {
33 | const chartData = {
34 | labels: data.map(({ date }) => date),
35 | datasets: [
36 | {
37 | fill: true,
38 | label: '추가된 콘텐츠 수',
39 | data: data.map(({ contentIncrease }) => contentIncrease),
40 | borderColor: 'rgb(53, 162, 235)',
41 | backgroundColor: 'rgba(53, 162, 235, 0.5)',
42 | tension: 0.5,
43 | },
44 | ],
45 | };
46 |
47 | const sortedData = data
48 | .slice()
49 | .sort((a, b) => b.contentIncrease - a.contentIncrease)
50 | .map(({ contentIncrease }) => contentIncrease);
51 |
52 | const max = sortedData[0];
53 | const min = sortedData[data.length - 1];
54 |
55 | const options = {
56 | responsive: true,
57 | plugins: {
58 | legend: {
59 | position: 'top' as const,
60 | },
61 | },
62 | scales: {
63 | y: {
64 | min: min === 0 ? 0 : min,
65 | max: max === 0 ? undefined : max,
66 | ticks: {
67 | stepSize: max - min < 10 ? 1 : undefined,
68 | },
69 | },
70 | },
71 | };
72 |
73 | return (
74 |
79 |
80 | 콘텐츠 수
81 |
82 |
83 |
84 |
85 |
86 | );
87 | };
88 |
89 | export default ContentsCountChart;
90 |
--------------------------------------------------------------------------------
/src/components/signup/WorkInfo.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Divider, Dropdown, Heading } from '@/components/common';
2 | import { CAREERS, JOBS } from '@/utils/constants/signup';
3 | import { useEffect, useState } from 'react';
4 | import * as style from './style.css';
5 |
6 | export type WorkInfoProps = {
7 | nickname: string;
8 | inputs: {
9 | job: string;
10 | career: string;
11 | setJob: (item: string) => void;
12 | setCareer: (item: string) => void;
13 | };
14 | slideDirection: 'left' | 'right';
15 | onPrevClick: () => void;
16 | onNextClick: () => void;
17 | };
18 |
19 | const WorkInfo = ({
20 | nickname,
21 | inputs,
22 | slideDirection,
23 | onPrevClick,
24 | onNextClick,
25 | }: WorkInfoProps) => {
26 | const { job, career, setJob, setCareer } = inputs;
27 | const [ableSubmit, setAbleSubmit] = useState(false);
28 |
29 | useEffect(() => {
30 | setAbleSubmit(job && career ? true : false);
31 | }, [job, career]);
32 |
33 | return (
34 |
35 |
36 | {nickname}님은
37 |
38 | 현재 어떤 일을 하고 계시나요?
39 |
40 |
41 |
74 |
75 | );
76 | };
77 |
78 | export default WorkInfo;
79 |
--------------------------------------------------------------------------------
/src/pages/creatorDetail/index.tsx:
--------------------------------------------------------------------------------
1 | import { getCreatorInfo } from '@/api/creator';
2 |
3 | import BackToTop from '@/components/backToTop';
4 | import ButtonGroup from '@/components/buttonGroup';
5 | import { Spinner } from '@/components/common';
6 | import DetailContents from '@/components/detailContents';
7 | import CreatorInfo from '@/components/detailContents/creatorInfo';
8 |
9 | import { isAuthorizedState } from '@/stores/auth';
10 | import { isHomeScrolledState } from '@/stores/scroll';
11 | import { selectedTabState } from '@/stores/tab';
12 | import { useRecoilValue, useSetRecoilState } from 'recoil';
13 |
14 | import { creator } from '@/types/contents';
15 |
16 | import { useQuery } from '@tanstack/react-query';
17 | import { useEffect, useState } from 'react';
18 | import { useParams } from 'react-router-dom';
19 |
20 | import * as style from './style.css';
21 |
22 | const FILTER = ['recent', 'popular'];
23 |
24 | const CreatorDetailPage = () => {
25 | const { creatorId } = useParams() as { creatorId: string };
26 | const [selectedFilter, setSelectedFilter] = useState(FILTER[0]);
27 | const setIsHomeScrolled = useSetRecoilState(isHomeScrolledState);
28 | const setTabState = useSetRecoilState(selectedTabState);
29 | const isAuthorized = useRecoilValue(isAuthorizedState);
30 |
31 | const {
32 | data: creatorInfo,
33 | isError,
34 | refetch,
35 | } = useQuery(
36 | ['creatorInfo', +creatorId],
37 | () => getCreatorInfo(+creatorId),
38 | {
39 | refetchOnWindowFocus: false,
40 | onSuccess: () => {
41 | setIsHomeScrolled(true);
42 | },
43 | }
44 | );
45 |
46 | useEffect(() => {
47 | setTabState('none');
48 | }, []);
49 |
50 | useEffect(() => {
51 | refetch();
52 | }, [isAuthorized]);
53 |
54 | if (!creatorInfo) return ;
55 | if (isError) return 에러
;
56 |
57 | return (
58 |
59 |
60 |
65 |
66 |
67 |
68 | );
69 | };
70 |
71 | export default CreatorDetailPage;
72 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "final",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview",
10 | "storybook": "start-storybook -p 6006",
11 | "build-storybook": "build-storybook"
12 | },
13 | "dependencies": {
14 | "@aws-sdk/client-s3": "^3.289.0",
15 | "@fortawesome/fontawesome-free": "^6.3.0",
16 | "@react-oauth/google": "^0.8.0",
17 | "@tanstack/react-query": "^4.24.6",
18 | "@tanstack/react-query-devtools": "^4.24.6",
19 | "@vanilla-extract/css": "^1.9.5",
20 | "@vanilla-extract/css-utils": "^0.1.3",
21 | "@vanilla-extract/dynamic": "^2.0.3",
22 | "@vanilla-extract/recipes": "^0.3.0",
23 | "axios": "^1.3.2",
24 | "chart.js": "^4.2.1",
25 | "dotenv": "^16.0.3",
26 | "react": "^18.2.0",
27 | "react-chartjs-2": "^5.2.0",
28 | "react-dom": "^18.2.0",
29 | "react-intersection-observer": "^9.4.3",
30 | "react-router-dom": "^6.8.1",
31 | "react-scatter-graphy": "^0.4.3",
32 | "recoil": "^0.7.6",
33 | "vercel": "^28.16.12"
34 | },
35 | "devDependencies": {
36 | "@babel/core": "^7.20.12",
37 | "@storybook/addon-actions": "^6.5.16",
38 | "@storybook/addon-essentials": "^6.5.16",
39 | "@storybook/addon-interactions": "^6.5.16",
40 | "@storybook/addon-links": "^6.5.16",
41 | "@storybook/builder-vite": "^0.4.0",
42 | "@storybook/react": "^6.5.16",
43 | "@storybook/testing-library": "^0.0.13",
44 | "@types/react": "^18.0.27",
45 | "@types/react-dom": "^18.0.10",
46 | "@typescript-eslint/eslint-plugin": "^5.52.0",
47 | "@typescript-eslint/parser": "^5.52.0",
48 | "@vanilla-extract/vite-plugin": "^3.8.0",
49 | "@vitejs/plugin-react": "^3.1.0",
50 | "babel-loader": "^8.3.0",
51 | "eslint-config-prettier": "^8.6.0",
52 | "eslint-plugin-prettier": "^4.2.1",
53 | "eslint-plugin-react": "^7.32.2",
54 | "husky": "^8.0.3",
55 | "lint-staged": "^13.1.1",
56 | "msw": "^1.0.1",
57 | "prettier": "^2.8.4",
58 | "storybook": "^6.5.16",
59 | "typescript": "^4.9.3",
60 | "vercel": "^28.16.15",
61 | "vite": "^4.1.0"
62 | },
63 | "msw": {
64 | "workerDirectory": "public"
65 | },
66 | "lint-staged": {
67 | "**/*.{js,ts,jsx,tsx}": [
68 | "eslint --fix",
69 | "prettier --write"
70 | ]
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/signup/BasicInfo.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Divider, Dropdown, Heading, Input } from '@/components/common';
2 | import { BIRTH_YEARS, GENDERS } from '@/utils/constants/signup';
3 | import { useEffect, useState } from 'react';
4 | import * as style from './style.css';
5 |
6 | export type BasicInfoProps = {
7 | email: string;
8 | inputs: {
9 | nickname: string;
10 | birthYear: string;
11 | gender: string;
12 | setNickname: (item: string) => void;
13 | setBirthYear: (item: string) => void;
14 | setGender: (item: string) => void;
15 | };
16 | slideDirection: 'left' | 'right';
17 | onNextClick: () => void;
18 | };
19 |
20 | const BasicInfo = ({
21 | email,
22 | inputs,
23 | slideDirection,
24 | onNextClick,
25 | }: BasicInfoProps) => {
26 | const { nickname, birthYear, gender, setNickname, setBirthYear, setGender } =
27 | inputs;
28 | const [ableSubmit, setAbleSubmit] = useState(false);
29 |
30 | useEffect(() => {
31 | setAbleSubmit(nickname && birthYear && gender ? true : false);
32 | }, [nickname, birthYear, gender]);
33 |
34 | return (
35 |
36 |
37 | HyperLink에서 회원님을
38 |
39 | 어떻게 불러드리면 좋을까요?
40 |
41 |
42 |
74 |
75 | );
76 | };
77 |
78 | export default BasicInfo;
79 |
--------------------------------------------------------------------------------
/src/components/common/tab/index.tsx:
--------------------------------------------------------------------------------
1 | import { isAuthorizedState } from '@/stores/auth';
2 | import { lastTabState } from '@/stores/lastTab';
3 | import { isLoginModalVisibleState } from '@/stores/modal';
4 | import { selectedTabState } from '@/stores/tab';
5 | import { CSSProperties } from 'react';
6 | import { useNavigate } from 'react-router';
7 | import { useRecoilValue, useSetRecoilState } from 'recoil';
8 | import Authorized from '../header/userNav/Authorized';
9 | import * as style from './style.css';
10 |
11 | export type TabProps = {
12 | items: string[];
13 | originalItems: { [key: string]: string };
14 | type?: 'header' | 'modal';
15 | onClick: (item: string) => void;
16 | style?: CSSProperties;
17 | };
18 |
19 | const Tab = ({
20 | items,
21 | originalItems,
22 | type = 'header',
23 | onClick,
24 | ...props
25 | }: TabProps) => {
26 | const tabState = useRecoilValue(selectedTabState);
27 | const isAuthorized = useRecoilValue(isAuthorizedState);
28 | const setIsLoginModalVisible = useSetRecoilState(isLoginModalVisibleState);
29 | const navigate = useNavigate();
30 | const setLastTabState = useSetRecoilState(lastTabState);
31 |
32 | const handleClick = (item: string) => {
33 | if (item === '구독 피드') {
34 | if (!isAuthorized) {
35 | setIsLoginModalVisible(true);
36 | setLastTabState('SUBSCRIPTIONS');
37 | return;
38 | }
39 | setLastTabState('SUBSCRIPTIONS');
40 | }
41 | if (item === '크리에이터') {
42 | navigate('/creatorList');
43 | onClick(item);
44 | return;
45 | }
46 | if (item === '실시간 최신 트렌드') {
47 | setLastTabState('RECENT_CONTENT');
48 | }
49 | if (item === '실시간 인기 트렌드') {
50 | setLastTabState('POPULAR_CONTENT');
51 | }
52 | onClick(item);
53 | navigate('/');
54 | };
55 |
56 | return (
57 |
62 | {items.map((item) => {
63 | return (
64 | - handleClick(item)}
71 | >
72 | {item}
73 |
74 | );
75 | })}
76 |
77 | );
78 | };
79 |
80 | export default Tab;
81 |
--------------------------------------------------------------------------------
/src/components/common/dropdown/index.tsx:
--------------------------------------------------------------------------------
1 | import { Icon, Text } from '@/components/common';
2 | import useDropdown from '@/hooks/useDropdown';
3 | import * as variants from '@/styles/variants.css';
4 | import { useEffect, useState } from 'react';
5 | import * as style from './style.css';
6 |
7 | export type DropdownProps = {
8 | placeholder?: string;
9 | value: string;
10 | items: string[];
11 | label?: string;
12 | onItemClick: (item: string) => void;
13 | };
14 |
15 | const Dropdown = ({
16 | placeholder,
17 | value,
18 | items,
19 | label,
20 | onItemClick,
21 | }: DropdownProps) => {
22 | const { isVisible, ref, handleVisibility } = useDropdown();
23 | const [chosenItem, setChosenItem] = useState(value);
24 |
25 | const handleItemClick = (item: string) => {
26 | onItemClick(item);
27 | handleVisibility();
28 | };
29 |
30 | useEffect(() => {
31 | setChosenItem(value);
32 | }, [value]);
33 |
34 | return (
35 |
38 | {label &&
{label}}
39 |
44 | {value ? (
45 | <>
46 | {value}
47 |
48 | >
49 | ) : (
50 | <>
51 | {placeholder}
52 |
53 | >
54 | )}
55 |
56 |
57 |
58 | {items.map((item) => {
59 | return (
60 | - handleItemClick(item)}
66 | >
67 | {item}
68 | {chosenItem === item && (
69 |
70 | )}
71 |
72 | );
73 | })}
74 |
75 |
76 |
77 | );
78 | };
79 |
80 | export default Dropdown;
81 |
--------------------------------------------------------------------------------
/src/components/detailContents/creatorInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import { postSubscribeResponse } from '@/api/subscribe';
2 | import { Avatar, Button, Heading } from '@/components/common';
3 | import { isAuthorizedState } from '@/stores/auth';
4 | import { isLoginModalVisibleState } from '@/stores/modal';
5 | import { creator } from '@/types/contents';
6 | import { useMutation, useQueryClient } from '@tanstack/react-query';
7 | import { useEffect, useState } from 'react';
8 | import { useRecoilValue, useSetRecoilState } from 'recoil';
9 | import * as style from './style.css';
10 |
11 | const CreatorInfo = ({
12 | creatorId,
13 | profileImgUrl,
14 | creatorName,
15 | subscriberAmount,
16 | creatorDescription,
17 | isSubscribed,
18 | }: creator) => {
19 | const [userSubscribe, setUserSubscribe] = useState(isSubscribed);
20 | const isAuthorized = useRecoilValue(isAuthorizedState);
21 | const setIsLoginModalVisible = useSetRecoilState(isLoginModalVisibleState);
22 |
23 | const queryClient = useQueryClient();
24 | const subScribeMutation = useMutation({
25 | mutationFn: () => postSubscribeResponse(creatorId),
26 |
27 | onSuccess: () => queryClient.invalidateQueries(['creatorInfo', creatorId]),
28 | });
29 |
30 | const handleSubscribeClick = () => {
31 | if (!isAuthorized) {
32 | setIsLoginModalVisible(true);
33 | return;
34 | }
35 | setUserSubscribe(!userSubscribe);
36 | subScribeMutation.mutate();
37 | };
38 |
39 | useEffect(() => {
40 | setUserSubscribe(isSubscribed);
41 | }, [isSubscribed, isAuthorized]);
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
50 | {creatorName}
51 |
52 |
53 | 구독자 {subscriberAmount}명
54 |
55 |
56 |
{creatorDescription}
57 |
58 |
59 |
66 |
67 | );
68 | };
69 |
70 | export default CreatorInfo;
71 |
--------------------------------------------------------------------------------