diff --git a/.eslintrc.js b/.eslintrc.js index f9a1775a1..e07abaa7b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -68,6 +68,8 @@ module.exports = { '@typescript-eslint/no-var-requires': 'warn', '@typescript-eslint/no-use-before-define': 2, '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/camelcase': 'off', }, ignorePatterns: ['generated/**/*.tsx'], settings: { diff --git a/api/src/resources/auth/guards/preCreatorTwitch.filter.ts b/api/src/resources/auth/guards/preCreatorTwitch.filter.ts index 4908d7f46..ed09cce30 100644 --- a/api/src/resources/auth/guards/preCreatorTwitch.filter.ts +++ b/api/src/resources/auth/guards/preCreatorTwitch.filter.ts @@ -16,7 +16,7 @@ export class PreCreatorTwitchExceptionFilter implements ExceptionFilter { console.log('[PreCreatorTwitchExceptionFilter] ERROR => ', err); if (err.message) { - res.redirect(`${this.HOST}/creator/signup/pre-user?${err.message}&platform=twitch`); - } else res.redirect(`${this.HOST}/creator/signup/pre-user?error=error&platform=twitch`); + res.redirect(`${this.HOST}/regist/pre-user?${err.message}&platform=twitch`); + } else res.redirect(`${this.HOST}/regist/pre-user?error=error&platform=twitch`); } } diff --git a/api/src/resources/auth/login.controller.ts b/api/src/resources/auth/login.controller.ts index a44be4561..677e61ade 100644 --- a/api/src/resources/auth/login.controller.ts +++ b/api/src/resources/auth/login.controller.ts @@ -123,7 +123,7 @@ export class LoginController { const { creatorId, creatorName, accessToken } = req.user as any; res.redirect( [ - `${this.HOST}/creator/signup/pre-user`, + `${this.HOST}/regist/pre-user`, `?creatorId=${creatorId}`, `&creatorName=${creatorName}`, `&accessToken=${accessToken}`, diff --git a/api/src/resources/auth/strategies/marketerKakao.strategy.ts b/api/src/resources/auth/strategies/marketerKakao.strategy.ts index 5caac7fad..ef2d4e819 100644 --- a/api/src/resources/auth/strategies/marketerKakao.strategy.ts +++ b/api/src/resources/auth/strategies/marketerKakao.strategy.ts @@ -27,6 +27,6 @@ export class MarketerKakaoStrategy extends PassportStrategy(Strategy, 'kakao') { refreshToken: string, profile: KakaoProfile, ): Promise { - return this.authService.naverLogin(profile); + return this.authService.kakaoLogin(profile); } } diff --git a/api/src/resources/marketer/marketer.controller.ts b/api/src/resources/marketer/marketer.controller.ts index 50007494e..0d653de98 100644 --- a/api/src/resources/marketer/marketer.controller.ts +++ b/api/src/resources/marketer/marketer.controller.ts @@ -65,7 +65,7 @@ export class MarketerController { // * 광고주 생성 ( 소셜 플랫폼 로그인 시 ) @Post('/platform') createMarketerWithSocialLogin( - dto: CreateNewMarketerWithSocialLoginDto, + @Body() dto: CreateNewMarketerWithSocialLoginDto, ): Promise { return this.marketerService.createNewMarketerWithSocialLogin(dto); } diff --git a/api/src/resources/marketer/marketer.service.ts b/api/src/resources/marketer/marketer.service.ts index bc8ebdd23..528b6031d 100644 --- a/api/src/resources/marketer/marketer.service.ts +++ b/api/src/resources/marketer/marketer.service.ts @@ -113,7 +113,6 @@ export class MarketerService { const data = await this.marketerInfoRepo.findOne({ where: { marketerMail: dto.marketerMail, marketerName: dto.marketerName }, }); - return { marketerId: data.marketerId }; } diff --git a/client-next/.gitignore b/client-next/.gitignore new file mode 100644 index 000000000..ffa3941a7 --- /dev/null +++ b/client-next/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +#lint +.eslintrc + +# yarn +yarn.lock diff --git a/client-next/README.md b/client-next/README.md new file mode 100644 index 000000000..a93e32e6b --- /dev/null +++ b/client-next/README.md @@ -0,0 +1,64 @@ +# client-ts > client-next rewriting + +기존 client-ts에 client side 코드들을 +next.js를 기반으로 재구축 + +next에 cra migration을 하지 않고 초기 빌딩부터 CNA로 빌딩 + +연동서버는 api, 프론트는 client-next로 + +서버 시동은 기존과 동일, +프론트 시동도 기존과 동일 + +~~~bash +./client-next + +# 개발환경 +> yarn dev + +# 빌드 +> yarn build + +# 빌드 실행 +> yarn start +~~~ + +## Client-next 구조 +```bash +├── assets : 외부 자원 - 다운로드 폰트 +│ ├── fonts +├── atoms : Material-UI 기반 커스텀 컴포넌트 +│ ├── table +│ ├── avatar +│ └── ... +├── components : 각 page 하위 구성 컴포넌트들 +│ ├── mainpage +│ ├── mypage +│ ├── shared +│ └── temp +├── config : 설정값 +├── constants : 공유 상수값 +├── context : 마케터 컨텍스트 +├── pages : 라우팅 페이지 +│ ├── introduction +│ ├── mypage +│ ├── policy +│ └── ... +├── public : 이미지 및 manifest 등 구성자원 +│ ├── creatorList +│ ├── door +│ └── ... +├── source : 메인페이지 사용 텍스트 +├── store : zustand-store +├── style : 글로벌 CSS 및 메인페이지 CSS +│ ├── mainpage +│ └── ... +├── utils : utils 관련 파일 +│ ├── aws +│ ├── hooks +│ └── ... +├── next.config.js : next.js 환경설정 +├── theme.tsx : 공유 테마 파일 +└── tsconifg.json : 프로젝트 typescript 설정 +``` + diff --git a/client-next/assets/fonts/AppleSDGothicNeoB.ttf b/client-next/assets/fonts/AppleSDGothicNeoB.ttf new file mode 100644 index 000000000..ebd50e298 Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoB.ttf differ diff --git a/client-next/assets/fonts/AppleSDGothicNeoEB.ttf b/client-next/assets/fonts/AppleSDGothicNeoEB.ttf new file mode 100644 index 000000000..d293d595a Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoEB.ttf differ diff --git a/client-next/assets/fonts/AppleSDGothicNeoH.ttf b/client-next/assets/fonts/AppleSDGothicNeoH.ttf new file mode 100644 index 000000000..9a438b2b3 Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoH.ttf differ diff --git a/client-next/assets/fonts/AppleSDGothicNeoL.ttf b/client-next/assets/fonts/AppleSDGothicNeoL.ttf new file mode 100644 index 000000000..ec189fb83 Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoL.ttf differ diff --git a/client-next/assets/fonts/AppleSDGothicNeoM.ttf b/client-next/assets/fonts/AppleSDGothicNeoM.ttf new file mode 100644 index 000000000..b6eed02a9 Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoM.ttf differ diff --git a/client-next/assets/fonts/AppleSDGothicNeoR.ttf b/client-next/assets/fonts/AppleSDGothicNeoR.ttf new file mode 100644 index 000000000..4ab04daae Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoR.ttf differ diff --git a/client-next/assets/fonts/AppleSDGothicNeoSB.ttf b/client-next/assets/fonts/AppleSDGothicNeoSB.ttf new file mode 100644 index 000000000..eb8017ea9 Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoSB.ttf differ diff --git a/client-next/assets/fonts/AppleSDGothicNeoT.ttf b/client-next/assets/fonts/AppleSDGothicNeoT.ttf new file mode 100644 index 000000000..9b137fd47 Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoT.ttf differ diff --git a/client-next/assets/fonts/AppleSDGothicNeoUL.ttf b/client-next/assets/fonts/AppleSDGothicNeoUL.ttf new file mode 100644 index 000000000..7576e3d66 Binary files /dev/null and b/client-next/assets/fonts/AppleSDGothicNeoUL.ttf differ diff --git a/client-next/assets/fonts/NotoSansKR-Black.otf b/client-next/assets/fonts/NotoSansKR-Black.otf new file mode 100644 index 000000000..bdf4292f4 Binary files /dev/null and b/client-next/assets/fonts/NotoSansKR-Black.otf differ diff --git a/client-next/assets/fonts/NotoSansKR-Bold.otf b/client-next/assets/fonts/NotoSansKR-Bold.otf new file mode 100644 index 000000000..e58af2ac9 Binary files /dev/null and b/client-next/assets/fonts/NotoSansKR-Bold.otf differ diff --git a/client-next/assets/fonts/NotoSansKR-Light.otf b/client-next/assets/fonts/NotoSansKR-Light.otf new file mode 100644 index 000000000..032d62c54 Binary files /dev/null and b/client-next/assets/fonts/NotoSansKR-Light.otf differ diff --git a/client-next/assets/fonts/NotoSansKR-Medium.otf b/client-next/assets/fonts/NotoSansKR-Medium.otf new file mode 100644 index 000000000..295e02c6d Binary files /dev/null and b/client-next/assets/fonts/NotoSansKR-Medium.otf differ diff --git a/client-next/assets/fonts/NotoSansKR-Regular.otf b/client-next/assets/fonts/NotoSansKR-Regular.otf new file mode 100644 index 000000000..7226b1834 Binary files /dev/null and b/client-next/assets/fonts/NotoSansKR-Regular.otf differ diff --git a/client-next/assets/fonts/NotoSansKR-Thin.otf b/client-next/assets/fonts/NotoSansKR-Thin.otf new file mode 100644 index 000000000..785caa7ed Binary files /dev/null and b/client-next/assets/fonts/NotoSansKR-Thin.otf differ diff --git a/client-next/atoms/avatar/editableAvatar.tsx b/client-next/atoms/avatar/editableAvatar.tsx new file mode 100644 index 000000000..620b5a34a --- /dev/null +++ b/client-next/atoms/avatar/editableAvatar.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import classnames from 'classnames'; +import { Avatar, Button, CircularProgress, makeStyles, Typography } from '@material-ui/core'; +import { CameraAlt } from '@material-ui/icons'; +import { useDialog } from '../../utils/hooks'; + +const useStyles = makeStyles(theme => ({ + avatarButton: { + margin: theme.spacing(1, 2, 1, 0), + }, + avatar: { + width: theme.spacing(8), + height: theme.spacing(8), + }, + small: { + width: theme.spacing(5), + height: theme.spacing(5), + }, + avatarBackgroundOnHover: { + position: 'absolute', + backgroundColor: 'rgba(0, 0, 0, 0.7)', + width: '100%', + height: '100%', + }, + avatarBackdrop: { + width: '100%', + height: '100%', + position: 'absolute', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + visibility: 'hidden', + }, + backdropText: { + color: theme.palette.common.white, + }, +})); + +export interface EditableAvatarProps { + src?: string; + small?: boolean; + changeLoading?: boolean; + avatarClassName?: string; + onProfileImageChange: (e: React.FormEvent) => void; +} +export default function EditableAvatar({ + src, + small, + changeLoading, + avatarClassName, + onProfileImageChange, +}: EditableAvatarProps): JSX.Element { + const classes = useStyles(); + // 마우스 hover 감지를 위해 + const isAvatarHover = useDialog(); + + return ( + + ); +} diff --git a/client-next/atoms/banner/onadBanner.tsx b/client-next/atoms/banner/onadBanner.tsx new file mode 100644 index 000000000..502ea36b8 --- /dev/null +++ b/client-next/atoms/banner/onadBanner.tsx @@ -0,0 +1,70 @@ +import { ButtonBase } from '@material-ui/core'; +import * as React from 'react'; +import isVideo from '../../utils/isVideo'; + +export interface OnadBannerProps { + src: string; + alt?: string; + width?: string | number; + height?: string | number; + onClick?: (e: React.MouseEvent) => void; + onError?: (e: React.SyntheticEvent) => void; + [key: string]: any; +} + +export default function OnadBanner({ + src, + alt = '', + width = 320, + height = 160, + onClick, + onError, + ...rest +}: OnadBannerProps): JSX.Element { + if (isVideo(src)) { + if (onClick) { + return ( + + + + ); + } + return ( + + ); + } + + if (onClick) { + return ( + + {alt} + + ); + } + + return {alt}; +} diff --git a/client-next/atoms/banner/videoBanner.tsx b/client-next/atoms/banner/videoBanner.tsx new file mode 100644 index 000000000..bca29e46d --- /dev/null +++ b/client-next/atoms/banner/videoBanner.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +interface VideoProps + extends React.DetailedHTMLProps, HTMLVideoElement> { + src: string; + onError?: () => void; + [key: string]: any; +} +export default function VideoBanner({ onError, src, ...rest }: VideoProps): JSX.Element { + return ( + + ); +} diff --git a/client-next/atoms/bannerCarousel.jsx b/client-next/atoms/bannerCarousel.jsx new file mode 100644 index 000000000..d40270975 --- /dev/null +++ b/client-next/atoms/bannerCarousel.jsx @@ -0,0 +1,171 @@ +import { useState, useEffect } from 'react'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; +import MobileStepper from '@material-ui/core/MobileStepper'; +import ButtonBase from '@material-ui/core/Paper'; +import Button from '@material-ui/core/Button'; +import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft'; +import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; +import SwipeableViews from 'react-swipeable-views'; +import Check from '@material-ui/icons/Check'; +import Success from './typography/success'; +import OnadBanner from './banner/onadBanner'; + +const useStyles = makeStyles(theme => ({ + root: { + width: 320, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + height: 'auto', + }, + image: { + position: 'relative', + display: 'block', + overflow: 'hidden', + '&:hover, &$focusVisible': { + zIndex: 1, + '& $imageBackdrop': { + opacity: 0.35, + }, + '& $imageTitle': { + opacity: 1.0, + }, + }, + }, + focusVisible: {}, + imageSrc: { + position: 'relative', + left: 0, + right: 0, + top: 0, + bottom: 0, + backgroundSize: 'cover', + width: '100%', + height: 'auto', + maxHeight: 160, + }, + imageBackdrop: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + backgroundColor: theme.palette.common.black, + opacity: 0.1, + transition: theme.transitions.create('appear'), + }, + imageTitle: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + opacity: 0.1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: `${theme.spacing(2)}px ${theme.spacing(4)}px ${theme.spacing(1) + 6}px`, + }, +})); + +function BannerCarousel(props) { + const { steps, handleBannerId, registStep } = props; + const classes = useStyles(); + const theme = useTheme(); + const [activeStep, setActiveStep] = useState(0); + const [checkImage, setCheckImage] = useState({ step: 0, check: 0 }); + const maxSteps = steps.length; + + useEffect(() => { + setCheckImage({ step: 0, check: 0 }); + setActiveStep(0); + }, [registStep]); + + function handleNext() { + setCheckImage({}); + setActiveStep(prevActiveStep => prevActiveStep + 1); + } + + function handleBack() { + setCheckImage(0); + setActiveStep(prevActiveStep => prevActiveStep - 1); + } + + function handleStepChange(step) { + setActiveStep(step); + } + + // check가 안될 + const handleActiveStep = step => () => { + // check가 되어있었던 상태였다면. + if (checkImage.check) { + setCheckImage({ step: -1, check: 0 }); + handleBannerId(''); + } else { + setCheckImage({ step: activeStep, check: 1 }); + handleBannerId(step.bannerId); + } + }; + + return ( +
+ + {steps.map(step => ( + + + + + + + + + + ))} + + + Next + {theme.direction === 'rtl' ? : } + + } + backButton={ + + } + /> +
+ ); +} + +/** + * @description + 해당 캠페인의 배너를 저장하기 위해 배너를 보여주고 체크하는 컴포넌트 + + * @param {*} steps ? 배너 list가 저장된 array + * @param {*} handleBannerId ? 배너를 등록하는 Dialog를 띄우는 state + * @param {*} registStep ? 현재의 회원가입 진행상태, 다음 step으로 진행될 때, 선택된 옵션에 대한 렌더링을 위함. + * + * @author 박찬우 + */ + +export default BannerCarousel; diff --git a/client-next/atoms/button/button.style.ts b/client-next/atoms/button/button.style.ts new file mode 100644 index 000000000..a4eb287e8 --- /dev/null +++ b/client-next/atoms/button/button.style.ts @@ -0,0 +1,15 @@ +import { makeStyles } from '@material-ui/core/styles'; + +const useButtonStyles = makeStyles(() => ({ + button: { + border: 'none', + margin: '.3125rem 1px', + willChange: 'box-shadow, transform', + textAlign: 'center', + whiteSpace: 'nowrap', + verticalAlign: 'middle', + touchAction: 'manipulation', + }, +})); + +export default useButtonStyles; diff --git a/client-next/atoms/button/customButton.tsx b/client-next/atoms/button/customButton.tsx new file mode 100644 index 000000000..f0fbdc5a6 --- /dev/null +++ b/client-next/atoms/button/customButton.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +// material-ui components +import Button, { ButtonProps } from '@material-ui/core/Button'; +// Styles +import { CircularProgress } from '@material-ui/core'; +import useButtonStyles from './button.style'; + +interface CustomProps extends ButtonProps { + to?: string; + link?: any; + load?: boolean; +} + +function RegularButton({ + color = 'default', + size = 'large', + variant = 'contained', + onClick, + load, + link, + children, + to, + ...rest +}: CustomProps): JSX.Element { + const classes = useButtonStyles(); + const [isloading, setIsLoading] = React.useState(false); + + function handleClick(): void { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 1000); + } + + return ( + + ); +} + +export default RegularButton; diff --git a/client-next/atoms/card/card.style.ts b/client-next/atoms/card/card.style.ts new file mode 100644 index 000000000..0688df2f9 --- /dev/null +++ b/client-next/atoms/card/card.style.ts @@ -0,0 +1,24 @@ +import { Theme, makeStyles } from '@material-ui/core/styles'; +import { hexToRgb } from '../../styles/onad'; + +const useStyles = makeStyles((theme: Theme) => ({ + card: { + border: '0', + marginBottom: theme.spacing(3), + marginTop: theme.spacing(3), + width: '100%', + boxShadow: `0 1px 4px 0 rgba(${hexToRgb(theme.palette.common.black)}, 0.14)`, + position: 'relative', + display: 'flex', + flexDirection: 'column', + minWidth: '0', + wordWrap: 'break-word', + fontSize: '.875rem', + }, + cardProfile: { + marginTop: '20px', + textAlign: 'center', + }, +})); + +export default useStyles; diff --git a/client-next/atoms/card/card.tsx b/client-next/atoms/card/card.tsx new file mode 100644 index 000000000..09eafa971 --- /dev/null +++ b/client-next/atoms/card/card.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +// nodejs library that concatenates classes +import classNames from 'classnames'; +// @material-ui/core components +import Paper from '@material-ui/core/Paper'; +// style +import useCardStyles from './card.style'; + +interface CardProps { + className?: string | null; + profile?: boolean; + children: React.ReactNode; + [rest: string]: any; +} + +export default function Card({ className, children, profile, ...rest }: CardProps): JSX.Element { + const classes = useCardStyles(); + + const cardClasses = classNames({ + [classes.card]: true, + [classes.cardProfile]: profile, + [className || '']: className !== undefined, + }); + + return ( + + {children} + + ); +} diff --git a/client-next/atoms/card/cardBody.style.ts b/client-next/atoms/card/cardBody.style.ts new file mode 100644 index 000000000..8e02f3b5e --- /dev/null +++ b/client-next/atoms/card/cardBody.style.ts @@ -0,0 +1,18 @@ +import { makeStyles } from '@material-ui/core/styles'; + +const useCardBodyStyles = makeStyles({ + cardBody: { + padding: '0.9375rem 20px', + flex: '1 1 auto', + position: 'relative', + }, + cardBodyPlain: { + paddingLeft: '5px', + paddingRight: '5px', + }, + cardBodyProfile: { + marginTop: '15px', + }, +}); + +export default useCardBodyStyles; diff --git a/client-next/atoms/card/cardBody.tsx b/client-next/atoms/card/cardBody.tsx new file mode 100644 index 000000000..8c8bd21e3 --- /dev/null +++ b/client-next/atoms/card/cardBody.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +// nodejs library that concatenates classes +import classNames from 'classnames'; +// style +import useCardBodyStyles from './cardBody.style'; + +interface CardBodyProps { + className?: string | null; + children: React.ReactNode; + plain?: boolean; + profile?: boolean; + [rest: string]: any; +} +function CardBody({ className, children, plain, profile, ...rest }: CardBodyProps): JSX.Element { + const classes = useCardBodyStyles(); + const cardBodyClasses = classNames({ + [classes.cardBody]: true, + [classes.cardBodyPlain]: plain, + [classes.cardBodyProfile]: profile, + [className || '']: className !== undefined, + }); + return ( +
+ {children} +
+ ); +} + +export default CardBody; diff --git a/client-next/atoms/card/cardHeader.style.ts b/client-next/atoms/card/cardHeader.style.ts new file mode 100644 index 000000000..73b3b4f33 --- /dev/null +++ b/client-next/atoms/card/cardHeader.style.ts @@ -0,0 +1,77 @@ +import { Theme, makeStyles } from '@material-ui/core/styles'; + +const cardHeaderStyle = makeStyles((theme: Theme) => ({ + cardHeader: { + textAlign: 'center', + padding: '0.75rem 1.25rem', + marginBottom: '0', + borderBottom: 'none', + background: 'inherit', + color: theme.palette.common.white, + '&:not($cardHeaderIcon)': { + background: `linear-gradient(60deg, ${theme.palette.info.main}, ${theme.palette.info.main})`, + marginTop: '-20px', + padding: '10px', + margin: '0 15px', + position: 'relative', + color: theme.palette.common.white, + }, + '&$cardHeaderPlain,&$cardHeaderIcon,&$cardHeaderStats&': { + margin: '0 15px', + padding: '0', + position: 'relative', + color: theme.palette.common.white, + }, + '&$cardHeaderStats svg': { + fontSize: '36px', + lineHeight: '56px', + textAlign: 'center', + width: '36px', + height: '36px', + margin: '10px 10px 4px', + }, + '&$cardHeaderStats i,&$cardHeaderStats .material-icons': { + fontSize: '36px', + lineHeight: '56px', + width: '56px', + height: '56px', + textAlign: 'center', + overflow: 'unset', + marginBottom: '1px', + }, + '&$cardHeaderStats$cardHeaderIcon': { + textAlign: 'right', + }, + }, + cardHeaderPlain: { + marginLeft: '0px !important', + marginRight: '0px !important', + }, + cardHeaderStats: { + '& $cardHeaderIcon': { + textAlign: 'right', + }, + '& h1,& h2,& h3,& h4,& h5,& h6': { + margin: '0 !important', + }, + }, + cardHeaderIcon: { + background: 'transparent', + boxShadow: 'none', + '& i,& .material-icons': { + width: '33px', + height: '33px', + textAlign: 'center', + lineHeight: '33px', + }, + '& svg': { + width: '24px', + height: '24px', + textAlign: 'center', + lineHeight: '33px', + margin: '5px 4px 0px', + }, + }, +})); + +export default cardHeaderStyle; diff --git a/client-next/atoms/card/cardHeader.tsx b/client-next/atoms/card/cardHeader.tsx new file mode 100644 index 000000000..776209209 --- /dev/null +++ b/client-next/atoms/card/cardHeader.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +// nodejs library that concatenates classes +import classNames from 'classnames'; +// nodejs library to set properties for components + +// core components +import cardHeaderStyle from './cardHeader.style'; + +interface CardHeaderProps { + className?: string; + children: React.ReactNode; + plain?: boolean; + stats?: boolean; + icon?: boolean; + color?: string; +} + +function CardHeader({ + className, + children, + plain, + stats, + icon, + ...rest +}: CardHeaderProps): JSX.Element { + const classes = cardHeaderStyle(); + const cardHeaderClasses = classNames({ + [classes.cardHeader]: true, + [classes.cardHeaderPlain]: plain, + [classes.cardHeaderStats]: stats, + [classes.cardHeaderIcon]: icon, + [className || '']: className !== undefined, + }); + return ( +
+ {children} +
+ ); +} +export default CardHeader; diff --git a/client-next/atoms/card/cardIcon.style.ts b/client-next/atoms/card/cardIcon.style.ts new file mode 100644 index 000000000..28e21788c --- /dev/null +++ b/client-next/atoms/card/cardIcon.style.ts @@ -0,0 +1,20 @@ +import { Theme, makeStyles } from '@material-ui/core/styles'; + +const useCardIconStyle = makeStyles((theme: Theme) => ({ + cardIcon: { + background: `linear-gradient(60deg, ${theme.palette.info.main}, ${theme.palette.info.main})`, + padding: '15px', + marginTop: '-20px', + marginRight: '15px', + float: 'left', + }, + cardIcon2: { + backgroundColor: '#ffb74d', + padding: '15px', + marginTop: '-20px', + marginRight: '15px', + float: 'left', + }, +})); + +export default useCardIconStyle; diff --git a/client-next/atoms/card/cardIcon.tsx b/client-next/atoms/card/cardIcon.tsx new file mode 100644 index 000000000..b712f2610 --- /dev/null +++ b/client-next/atoms/card/cardIcon.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +// nodejs library that concatenates classes +import classNames from 'classnames'; +import useCardIconStyle from './cardIcon.style'; + +interface CardIconProps { + children: React.ReactNode; + className?: string | null; + [rest: string]: any; +} + +function CardIcon({ className, children, ...rest }: CardIconProps): JSX.Element { + const classes = useCardIconStyle(); + + const cardIconClasses = classNames({ + [classes.cardIcon]: true, + [className || '']: className !== undefined, + }); + + const cardIconClasses2 = classNames({ + [classes.cardIcon2]: true, + [className || '']: className !== undefined, + }); + + return ( +
+ {children} +
+ ); +} + +export default CardIcon; diff --git a/client-next/atoms/card/customCard.tsx b/client-next/atoms/card/customCard.tsx new file mode 100644 index 000000000..5e24fee57 --- /dev/null +++ b/client-next/atoms/card/customCard.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import makeStyles from '@material-ui/core/styles/makeStyles'; +import Card from './card'; +import CardIcon from './cardIcon'; +import CardHeader from './cardHeader'; +import CardBody from './cardBody'; + +const useStyles = makeStyles(theme => ({ + buttonWrapper: { + display: 'flex', + alignItems: 'center', + flexDirection: 'row-reverse', + padding: 5, + }, + root: { + marginTop: theme.spacing(4), + width: '100%', + heigth: '100%', + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + marginBotton: '20px', + }, +})); + +/** + * @param {} props iconComponent=왼쪽상단의 아이콘, buttonComponent=오른쪽 상단의 버튼 + */ +interface CustomCardProps { + iconComponent: React.ReactNode; + secondComponent?: React.ReactNode; + buttonComponent?: React.ReactNode; + children: React.ReactNode; + backgroundColor?: boolean; +} +export default function CustomCard({ + iconComponent, + secondComponent, + buttonComponent, + children, + backgroundColor, +}: CustomCardProps): JSX.Element { + const classes = useStyles(); + return ( + + + + {iconComponent} + + {secondComponent && {secondComponent}} + {buttonComponent && buttonComponent} + + {children} + + ); +} diff --git a/client-next/atoms/carousel/carousel.tsx b/client-next/atoms/carousel/carousel.tsx new file mode 100644 index 000000000..872de642f --- /dev/null +++ b/client-next/atoms/carousel/carousel.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import MobileStepper from '@material-ui/core/MobileStepper'; +import Button from '@material-ui/core/Button'; +import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft'; +import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; +import SwipeableViews from 'react-swipeable-views'; +import { autoPlay } from 'react-swipeable-views-utils'; + +const AutoPlaySwipeableViews = autoPlay(SwipeableViews); + +const useStyles = makeStyles(theme => ({ + root: { + maxWidth: 400, + flexGrow: 1, + }, + header: { + display: 'flex', + alignItems: 'center', + height: 50, + paddingLeft: theme.spacing(4), + backgroundColor: theme.palette.background.default, + }, + img: { + height: 255, + display: 'block', + maxWidth: 400, + overflow: 'hidden', + width: '100%', + }, +})); + +export interface ImageCarouselProps { + images: string[]; +} +export default function ImageCarousel({ images }: ImageCarouselProps): React.ReactElement { + const classes = useStyles(); + const [activeStep, setActiveStep] = React.useState(0); + const maxSteps = images.length; + + const handleNext = (): void => { + setActiveStep(prevActiveStep => prevActiveStep + 1); + }; + + const handleBack = (): void => { + setActiveStep(prevActiveStep => prevActiveStep - 1); + }; + + const handleStepChange = (step: number): void => { + setActiveStep(step); + }; + + return ( +
+ + {images.map((image, index) => ( +
+ {Math.abs(activeStep - index) <= 2 ? ( + {image} + ) : null} +
+ ))} +
+ 2 ? 'dots' : 'text'} + activeStep={activeStep} + nextButton={ + + } + backButton={ + + } + /> +
+ ); +} diff --git a/client-next/atoms/chart/chartFunction.ts b/client-next/atoms/chart/chartFunction.ts new file mode 100644 index 000000000..c9d52c268 --- /dev/null +++ b/client-next/atoms/chart/chartFunction.ts @@ -0,0 +1,245 @@ +/** ********************************************** + * ***************** 보조 함수 ********************* + * *********************************************** */ + +type dateDiffFunction = (d1: Date, d2: Date) => number | null; +/** + * @description 날짜간의 차이를 반환하는 함수 + * @param {Date} date1 날짜 차이를 구할 기준 + * @param {Date} date2 타겟 날짜 + * @returns {Number} 날짜차이 + * @author hwasurr + */ +const dateDiff: dateDiffFunction = (date1, date2) => { + if (date1 instanceof Date && date2 instanceof Date) { + return Math.ceil((date1.getTime() - date2.getTime()) / (1000 * 60 * 60 * 24)); + } + return null; +}; + +type monthDiffFunction = (d1: Date, d2: Date) => number; +/** + * @description 날짜간의 월의 차이를 반환하는 함수 + * @param {Date} date1 월 차이를 구할 기준 + * @param {Date} date2 타겟 날짜 + * @returns {Number} 월 차이 + * @author hwasurr + */ +const monthDiff: monthDiffFunction = (date1, date2) => { + let strTermCnt = 0; + // 년도가 같으면 단순히 월을 마이너스 한다. + // => 20090301-20090201 의 경우(윤달이 있는 경우) 아래 else의 로직으로는 정상적인 1이 리턴되지 않는다. + if (date2.getFullYear() === date1.getFullYear()) { + strTermCnt = date1.getMonth() * 1 - date2.getMonth() * 1; + } else { + strTermCnt = Math.round( + (date2.getTime() - date1.getTime()) / ((1000 * 60 * 60 * 24 * 365) / 12), + ); + } + return strTermCnt; +}; + +type datefyFunction = (dateObject: Date | string) => string; +/** + * @description 날짜를 문자열로 변환하는 함수 + * @param {number} distance + * @return {String} "%m월 %d일" 에 따르는 날짜 문자열 + * @author hwasurr + */ +const datefy: datefyFunction = dateObject => { + if (typeof dateObject === 'string') { + return dateObject.split(' ')[0]; + } + return `${dateObject.getMonth() + 1}월 ${dateObject.getDate()}일`; +}; + +type defaultDataPacket = { date: string; type: 'CPM' | 'CPC'; cash: number }; +type SetUpDataResult = { CPM: number[]; CPC: number[]; setUpLabels: string[] }; +/** + * @description 데이터를 셋업하는 함수 + * @param {array} dataPacket 로부터 api 서버로부터 넘겨받은 데이터. + * @param {function} dateDiffFunc 두 날짜간의 사이를 구할 함수 ( dateDiff or monthDiff ) + * @author hwasurr + */ +function setUpData( + dataPacket: T[], + dateDiffFunc: dateDiffFunction | monthDiffFunction, +): SetUpDataResult { + const DEFAULT_VALUE = 0; + const CPM = Array(); + const CPC = Array(); + const setUpLabels = Array(); + + dataPacket.forEach((obj, index) => { + if (setUpLabels.indexOf(datefy(obj.date)) === -1) { + // 처음보는 date + if (setUpLabels.length >= 1) { + const previousDate = setUpLabels[setUpLabels.length - 1]; + const currentDate = datefy(obj.date); + const datediff = dateDiffFunc(new Date(previousDate), new Date(currentDate)); + + if (datediff) { + // 앞의날짜와 지금날짜의 날짜차이가 있다면 ( 빈 데이터가 존재한다면 ) + if (datediff > 1) { + const emptyDate = new Date(previousDate); + for (let i = 1; i < datediff; i += 1) { + // 빠진 날짜만큼 반복 + emptyDate.setDate(emptyDate.getDate() - 1); + + setUpLabels.push(emptyDate.toISOString().split('T')[0]); + CPM.push(DEFAULT_VALUE); + CPC.push(DEFAULT_VALUE); + } + } + } + } + + setUpLabels.push(datefy(obj.date)); + + if (obj.type === 'CPM') { + CPM.push(obj.cash); + } else if (obj.type === 'CPC') { + CPC.push(obj.cash); + } + + // 다음 데이터 중, 지금의 날짜가 없으면 + if (index !== dataPacket.length) { + if (!dataPacket[index + 1] || datefy(dataPacket[index + 1].date) !== datefy(obj.date)) { + if (obj.type === 'CPM') { + CPC.push(DEFAULT_VALUE); + } else if (obj.type === 'CPC') { + CPM.push(DEFAULT_VALUE); + } + } + } + // 이미 돌았던 date + } else if (obj.type === 'CPM') { + CPM.push(obj.cash); + } else { + CPC.push(obj.cash); + } + }); + return { setUpLabels, CPM, CPC }; +} + +type CreateStackBarDataResult = { + CPM: number[]; + CPC: number[]; + labels: string[]; +}; +/** ************************************************ + * ***************** 실 사용 함수 ********************* + * *********************************************** */ +/** + * @param {[ {}, {}, ]} dataPacket api 요청으로 받아온 데이터. 데이터형은 다음과 같다. + * `[ { date: '09월 10일', cash: 9000, type: 'CPM' }, + * {date: '09월 10일', cash: 9000, type: 'CPC'}, {}, ... ]` + * @description StackedBar에 해당하는 데이터를 생성해주는 함수, 데이터가 15일 미만으로 존재할 시, 빈 데이터를 채워준다. + * @author hwasurr + */ +function createStackBarDataSet( + dataPacket: defaultDataPacket[], + DATE_RANGE = 15, +): CreateStackBarDataResult { + const today = new Date(); + if (dataPacket.length > 0) { + const { setUpLabels, CPM, CPC } = setUpData(dataPacket, dateDiff); + + const labels = setUpLabels.map(day => `${day.split('-')[1]}월 ${day.split('-')[2]}일`); + const firstTime = new Date(dataPacket[0].date.split('T')[0]); // 마지막 날짜 + const lastTime = new Date(dataPacket[dataPacket.length - 1].date.split('T')[0]); // 현재 날짜 수 + + // 오늘과 today, firstTime 사이의 빈 데이터가 있는경우 채운다. + const dDiff = dateDiff(today, firstTime); + if (dDiff) { + if (dDiff > 0) { + // 날짜 차이 만큼 앞에 데이터를 추가한다. + for (let i = dDiff; i > 1; i -= 1) { + firstTime.setDate(firstTime.getDate() + 1); + labels.unshift(datefy(firstTime)); + CPM.unshift(0); + CPC.unshift(0); + } + } + } + + // 2주일의 데이터보다 적다면, 한달(30일)의 데이터만큼 day를 채운다. + if (labels.length < DATE_RANGE) { + for (let i = DATE_RANGE - labels.length; i > 0; i -= 1) { + lastTime.setDate(lastTime.getDate() - 1); + labels.push(`${lastTime.getMonth() + 1}월 ${lastTime.getDate()}일`); + } + } + return { labels, CPM, CPC }; + } + const labels = []; + const CPM = []; + const CPC = []; + for (let i = DATE_RANGE; i > 0; i -= 1) { + today.setDate(today.getDate() - 1); + labels.push(`${today.getMonth() + 1}월 ${today.getDate()}일`); + CPM.push(0); + CPC.push(0); + } + + return { labels, CPC, CPM }; +} + +/** + * @description 월별 데이터를 받아와 차트용 데이터로 전처리하는 함수. + * @param { [ {}, {}, ...] } dataPacket DB로부터 가져온 데이터 + * @author hwasurr + */ +function createStackBarDataSetPerMonth(dataPacket: defaultDataPacket[]): CreateStackBarDataResult { + const MONTH_LENGTH = 12; + const today = new Date(); + if (dataPacket.length > 0) { + const { setUpLabels, CPM, CPC } = setUpData(dataPacket, monthDiff); + + const labels = setUpLabels + .map(day => `${day.split('-')[0]}년 ${day.split('-')[1]}월`) + .reverse(); + const firstTime = new Date(dataPacket[0].date.split('T')[0]); // 처음 날짜 + const lastTime = new Date(dataPacket[dataPacket.length - 1].date.split('T')[0]); // 마지막 날짜 + + // 오늘과 today, firstTime 사이의 빈 데이터가 있는경우 채운다. + if (monthDiff(today, firstTime) > 0) { + // 날짜 차이 만큼 앞에 데이터를 추가한다. + for (let i = monthDiff(today, firstTime); i > 0; i -= 1) { + labels.unshift(`${today.getFullYear()}년 ${today.getMonth() + 1}월`); + CPM.unshift(0); + CPC.unshift(0); + today.setMonth(today.getMonth() + 1); + } + } + + // 12개월 데이터보다 적다면, 12개월의 데이터만큼 month를 채운다. + if (labels.length < MONTH_LENGTH) { + for (let i = MONTH_LENGTH - labels.length; i > 0; i -= 1) { + lastTime.setMonth(lastTime.getMonth() - 1); + labels.push(`${lastTime.getFullYear()}년 ${lastTime.getMonth() + 1}월`); + CPM.unshift(0); + CPC.unshift(0); + } + } + + return { labels, CPM, CPC }; + } + + const labels = []; + const CPM = []; + const CPC = []; + for (let i = MONTH_LENGTH; i > 0; i -= 1) { + today.setDate(today.getDate() - 1); + labels.push(`${today.getMonth() + 1}월 ${today.getDate()}일`); + CPM.push(0); + CPC.push(0); + } + + return { labels, CPC, CPM }; +} + +export default { + createStackBarDataSet, + createStackBarDataSetPerMonth, +}; diff --git a/client-next/atoms/chart/chartTheme.ts b/client-next/atoms/chart/chartTheme.ts new file mode 100644 index 000000000..69cb313e3 --- /dev/null +++ b/client-next/atoms/chart/chartTheme.ts @@ -0,0 +1,146 @@ +// 차트 컬러 테마 +const chartTheme = { + main: [ + '#67b7dc', + '#6794dc', + '#6771dc', + '#8067dc', + '#a367dc', + '#c767dc', + '#dc67ce', + '#dc67ab', + '#dc6788', + '#dc6967', + '#dc8c67', + '#dcaf67', + '#67b7dc', + '#6794dc', + '#6771dc', + '#8067dc', + '#a367dc', + '#c767dc', + '#dc67ce', + '#dc67ab', + '#dc6788', + '#dc6967', + '#dc8c67', + '#dcaf67', + ], + hover: [ + '#67b7fe', + '#6794fc', + '#6771fc', + '#8067fc', + '#a367fc', + '#c767fc', + '#dc67ee', + '#dc67cb', + '#dc6799', + '#dc6987', + '#dc8c87', + '#dcaf87', + '#67b7fe', + '#6794fc', + '#6771fc', + '#8067fc', + '#a367fc', + '#c767fc', + '#dc67ee', + '#dc67cb', + '#dc6799', + '#dc6987', + '#dc8c87', + '#dcaf87', + ], + pie: [ + '#67b7dc', + '#dcaf87', + '#dc6788', + '#D1B6E1', + '#f9af4f', + '#d9ad6f', + '#58C9B9', + '#6794dc', + '#6771dc', + '#8067dc', + '#a367dc', + '#c767dc', + '#dc67ce', + '#dc67ab', + '#519D9E', + '#dc6967', + '#dc8c67', + '#dcaf67', + '#61a7bc', + '#69b7bc', + '#91C1C8', + ], +}; + +export const chartTheme2 = { + main: [ + '#9DC8C8', + '#58C9B9', + '#519D9E', + '#D1B6E1', + '#dcaf87', + '#69a7bc', + '#61b7bc', + '#9aC1C8', + '#9DC8C8', + '#58C9B9', + '#519D9E', + '#D1B6E1', + '#dcaf87', + '#69a7bc', + '#61b7bc', + '#9aC1C8', + '#9DC8C8', + '#58C9B9', + '#519D9E', + '#D1B6E1', + '#dcaf87', + '#69a7bc', + '#61b7bc', + '#9aC1C8', + '#58C9B9', + '#519D9E', + '#D1B6E1', + '#dcaf87', + '#f3ad3a', + '#d3ad3f', + ], + hover: [ + '#9aC8C8', + '#51C9B9', + '#598D9E', + '#D9c6E1', + '#d1af87', + '#61a7bc', + '#69b7bc', + '#91C1C8', + '#9aC8C8', + '#51C9B9', + '#598D9E', + '#D9c6E1', + '#d1af87', + '#61a7bc', + '#69b7bc', + '#91C1C8', + '#9aC8C8', + '#51C9B9', + '#598D9E', + '#D9c6E1', + '#d1af87', + '#61a7bc', + '#69b7bc', + '#91C1C8', + '#58C9B9', + '#519D9E', + '#D1B6E1', + '#dcaf87', + '#f9af4f', + '#d9ad6f', + ], +}; +export default chartTheme; diff --git a/client-next/atoms/chart/heatmap/clickHeatmap.tsx b/client-next/atoms/chart/heatmap/clickHeatmap.tsx new file mode 100644 index 000000000..466526c15 --- /dev/null +++ b/client-next/atoms/chart/heatmap/clickHeatmap.tsx @@ -0,0 +1,98 @@ +import dayjs from 'dayjs'; +import { useMemo } from 'react'; +import Heatmap from 'react-calendar-heatmap'; +import ReactTooltip from 'react-tooltip'; +import { useMarketerCampaignAnalysisHeatmap } from '../../../utils/hooks/query/useMarketerCampaignAnalysisHeatmap'; +import CenterLoading from '../../loading/centerLoading'; +import getMeanStd from './getMeanStd'; + +type ClickData = { count: number; date: string }; +interface ClickHeatmap { + campaignId: string; +} +export default function ClickHeatmap({ campaignId }: ClickHeatmap): JSX.Element { + const clickData = useMarketerCampaignAnalysisHeatmap(campaignId); + + const getTooltipDataAttrs = (value: ClickData): { 'data-tip': string } | null => { + // Temporary hack around null value.date issue + if (!value || !value.date) { + return null; + } + // Configuration for react-tooltip + return { + 'data-tip': `${dayjs(value.date).format('YYYY년 MM월 DD일')}, ${value.count}회 클릭`, + }; + }; + + const statistics = useMemo(() => { + if (clickData.data) { + return getMeanStd(clickData.data.map(d => d.count)); + } + return null; + }, [clickData.data]); + + function makeDateOptions(): { startDate: Date; endDate: Date } { + const today = new Date(); + const today2 = new Date(); + + today.setDate(today.getDate() - 250); + today2.setDate(today2.getDate() + 50); + const startDate = today; + const endDate = today2; + return { startDate, endDate }; + } + + return ( +
+ {clickData.isLoading && } + {!clickData.isLoading && clickData.data && ( + <> + { + if (!value || !statistics) { + return 'color-empty'; + } + if (value.count < statistics.mean - 2 * statistics.stddev) { + return 'color-github-0'; + } + if (value.count < statistics.mean - statistics.stddev) { + return 'color-github-1'; + } + if (value.count < statistics.mean) { + return 'color-github-2'; + } + if (value.count < statistics.mean + statistics.stddev) { + return 'color-github-3'; + } + return 'color-github-4'; + }} + /> + {clickData.data.length > 0 && ( + + )} + + )} +
+ ); +} diff --git a/client-next/atoms/chart/heatmap/getMeanStd.ts b/client-next/atoms/chart/heatmap/getMeanStd.ts new file mode 100644 index 000000000..04ad89527 --- /dev/null +++ b/client-next/atoms/chart/heatmap/getMeanStd.ts @@ -0,0 +1,32 @@ +/** + * 숫자로 구성된 배열을 인자로 넣으면, 평균과 표준편차를 반환하는 함수. + * @param {array} dataArray 평균과 표준편차를 구할 리스트 + * @return { mean: float, stddev: float } + */ +function getMeanStd(dataArray: number[]): { + mean: number; + stddev: number; +} { + let allCounts = 0; + dataArray.map(d => { + allCounts += d; + return null; + }); + + const avgCounts = allCounts / dataArray.length; + + let total = 0; + for (let i = 0; i < dataArray.length; i += 1) { + const deviation = dataArray[i] - avgCounts; + + total += deviation * deviation; + } + const stddev = Math.floor(Math.sqrt(total / (dataArray.length - 1))); + + return { + mean: avgCounts, + stddev, + }; +} + +export default getMeanStd; diff --git a/client-next/atoms/chart/pieChart.tsx b/client-next/atoms/chart/pieChart.tsx new file mode 100644 index 000000000..e128467be --- /dev/null +++ b/client-next/atoms/chart/pieChart.tsx @@ -0,0 +1,28 @@ +import { Pie, ChartComponentProps } from 'react-chartjs-2'; +import chartTheme from './chartTheme'; + +// Omit<타입T, string|number|symbol>는 +// T 타입에서 두번째 타입인자의 이름을 키값으로 하는 값을 제외한 나머지 타입을 반환한다. +interface PieChartProps extends Omit { + labels: Array; + data: Array; +} +export default function PieChart(props: PieChartProps): JSX.Element { + const { labels, data, ...rest } = props; + + return ( + + ); +} diff --git a/client-next/atoms/chart/reChartBar.tsx b/client-next/atoms/chart/reChartBar.tsx new file mode 100644 index 000000000..74be50771 --- /dev/null +++ b/client-next/atoms/chart/reChartBar.tsx @@ -0,0 +1,108 @@ +import { useMemo } from 'react'; +import * as React from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; +import { useTheme } from '@material-ui/core/styles'; +import makeBarChartData, { KeyMap, ChartDataBase } from '../../utils/chart/makeBarChartData'; + +interface ReChartBarProps { + data: ChartDataBase[]; + legend?: boolean; + containerHeight?: number; + chartHeight?: number; + chartWidth?: number; + xAxisDataKey?: string; + tooltipFormatter?: (value: any, name: any) => React.ReactNode; + tooltipLabelFormatter?: (label: any) => React.ReactNode; + legendFormatter?: (value: string) => string; + nopreprocessing?: boolean; + dataKey?: string[] | string; + labels?: any; + keyMap: KeyMap[]; +} + +export default function ReChartBar({ + data, + legend = true, + dataKey = ['cpm_amount', 'cpc_amount', 'cpa_amount'], + labels = ['배너광고', '클릭광고', '참여형광고'], + containerHeight = 400, + chartHeight = 300, + chartWidth = 500, + xAxisDataKey = 'date', + tooltipLabelFormatter = (label: string | number): string | number => label, + tooltipFormatter = (value: any, name: any): any => [ + typeof value === 'number' ? value.toLocaleString() : value, + labels[name], + ], + legendFormatter = (value: any): any => labels[value], + nopreprocessing = false, + keyMap, +}: ReChartBarProps): JSX.Element { + const theme = useTheme(); + + const preprocessed = useMemo(() => { + if (nopreprocessing) return data; + const _data = makeBarChartData(data, keyMap); + return _data; + }, [data, keyMap, nopreprocessing]); + + return ( +
+ + + + + + + {legend && ( + + )} + + + {dataKey instanceof Array && dataKey.length >= 2 ? ( + + ) : null} + {dataKey instanceof Array && dataKey.length >= 3 ? ( + + ) : null} + + +
+ ); +} diff --git a/client-next/atoms/chart/reChartBarT.tsx b/client-next/atoms/chart/reChartBarT.tsx new file mode 100644 index 000000000..68d46ad7f --- /dev/null +++ b/client-next/atoms/chart/reChartBarT.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'; +import { useTheme } from '@material-ui/core/styles'; + +interface ReChartBarProps { + data: Readonly[]; + legend?: boolean; + containerHeight?: number; + chartHeight?: number; + chartWidth?: number; + xAxisDataKey?: string; + yAxisDataKey?: string; + tooltipFormatter?: ( + value: string | number | Array, + name: string, + ) => React.ReactNode; + tooltipLabelFormatter?: (label: string | number) => React.ReactNode; + // legendFormatter?: (value: string | number | Array) => string; + nopreprocessing?: boolean; + dataKey?: string; +} + +export default function ReChartBar({ + data, + legend, + dataKey, + containerHeight = 400, + chartHeight = 300, + chartWidth = 500, + xAxisDataKey, + yAxisDataKey, + tooltipLabelFormatter = (label: string | number): string | number => label, + tooltipFormatter = (value: string | number | Array, name: string) => [ + value, + name, + ], + // legendFormatter = (value: string | number | Array): string => { + // if (value === 'cpm_amount') { return '배너광고'; } return '클릭광고'; + // }, + nopreprocessing = false, +}: ReChartBarProps): JSX.Element { + const theme = useTheme(); + + return ( +
+ + + + + + {legend && } + {dataKey && } + +
+ ); +} diff --git a/client-next/atoms/chart/reChartPie.tsx b/client-next/atoms/chart/reChartPie.tsx new file mode 100644 index 000000000..808fe16b5 --- /dev/null +++ b/client-next/atoms/chart/reChartPie.tsx @@ -0,0 +1,49 @@ +import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'; +import { nanoid } from 'nanoid'; +import COLORS from './chartTheme'; + +interface CustomPieChartProps { + dataKey: string; + nameKey: string; + data: Readonly[]; + height: number; + legend?: boolean; + activeIndex?: number; + onPieEnter?: (...args: any[]) => void; + tooltipLabelText?: string; + underText?: boolean; +} +// extends { value: any } +export default function CustomPieChart({ + dataKey, + nameKey, + legend, + data = [], + height = 400, +}: CustomPieChartProps): JSX.Element { + return ( +
+ + + + {data.map((entry, index) => ( + + ))} + + + + {legend ? : null} + + +
+ ); +} diff --git a/client-next/atoms/chart/reportStackedBar.tsx b/client-next/atoms/chart/reportStackedBar.tsx new file mode 100644 index 000000000..74aadb3c5 --- /dev/null +++ b/client-next/atoms/chart/reportStackedBar.tsx @@ -0,0 +1,119 @@ +/* eslint-disable max-len */ +import dayjs from 'dayjs'; +import _ from 'lodash'; +import { useCallback, useMemo } from 'react'; +import { useTheme } from '@material-ui/core/styles'; +import { ChartComponentProps, Bar } from 'react-chartjs-2'; + +interface DefaultDataType { + date: string; + value: number; + type: 'CPC' | 'CPM'; +} +interface ReportStackedBarProps extends Omit { + dataSet: any[]; + labelArray: string[]; + height?: number; +} +export default function ReportStackedBar({ + height = 70, + dataSet, + labelArray, + ...rest +}: ReportStackedBarProps): JSX.Element { + const theme = useTheme(); + + // 차트 데이터 + const setStackedBarData = useCallback( + (data: Array, _labelArray: string[]): any => { + const labels = Array(); + const array1 = Array(); + const array2 = Array(); + const grouped = _.groupBy( + data.map(x => ({ ...x, date: dayjs(x.date).format('M.DD') })), + 'date', + ); + + Object.keys(grouped).forEach(date => { + if (!labels.includes(date)) { + labels.push(date); + } + + const types = Array.from(new Set(grouped[date].map(x => x.type))); + + grouped[date].forEach(_row => { + // 판매/클릭 모두 있는 경우 + if (types.length === _labelArray.length) { + if (_row.type === _labelArray[0]) { + array1.push(_row.value); + } + if (_row.type === _labelArray[1]) { + array2.push(_row.value); + } + } else { + if (_row.type === _labelArray[0]) { + array1.push(_row.value); + array2.push(0); + } + if (_row.type === _labelArray[1]) { + array1.push(0); + array2.push(_row.value); + } + } + }); + }); + + const ChartjsBarData = { + labels, + datasets: [ + { + stack: '1', + label: _labelArray[0], + backgroundColor: theme.palette.primary.main, + borderColor: theme.palette.primary.main, + borderWidth: 1, + hoverBackgroundColor: theme.palette.primary.light, + hoverBorderColor: theme.palette.primary.light, + data: array1, + }, + { + stack: '1', + label: _labelArray[1], + backgroundColor: theme.palette.secondary.main, + borderColor: theme.palette.secondary.main, + borderWidth: 1, + hoverBackgroundColor: theme.palette.secondary.light, + hoverBorderColor: theme.palette.secondary.light, + data: array2, // 클릭 + }, + ], + }; + return ChartjsBarData; + }, + [ + theme.palette.primary.light, + theme.palette.primary.main, + theme.palette.secondary.light, + theme.palette.secondary.main, + ], + ); + + const preprocessedDataSet = useMemo( + () => setStackedBarData(dataSet, labelArray), + [dataSet, labelArray, setStackedBarData], + ); + + return ( + + ); +} diff --git a/client-next/atoms/chart/stackedBar.tsx b/client-next/atoms/chart/stackedBar.tsx new file mode 100644 index 000000000..33d34ae5e --- /dev/null +++ b/client-next/atoms/chart/stackedBar.tsx @@ -0,0 +1,81 @@ +import { useTheme, Theme } from '@material-ui/core/styles'; +import { Bar, ChartComponentProps } from 'react-chartjs-2'; +import chartFunctions from './chartFunction'; +import { chartTheme2 } from './chartTheme'; + +interface DefaultDataType { + date: string; + cash: number; + type: 'CPC' | 'CPM'; +} +// 차트 데이터 +const setStackedBarData = + (theme: Theme) => + ( + data: T[], + labelArray: string[], + type = 'day', + dateRange = 30, + ): any => { + let setupFunc; + if (type === 'day') { + setupFunc = chartFunctions.createStackBarDataSet; + } else { + setupFunc = chartFunctions.createStackBarDataSetPerMonth; + } + const { labels, CPM, CPC } = setupFunc(data, dateRange); + + const ChartjsBarData = { + labels, + datasets: [ + { + stack: '1', + label: labelArray[0], + borderWidth: 1, + data: CPM, + backgroundColor: theme.palette.action.disabled, + hovkerBackgroundColor: theme.palette.action.hover, + }, + { + stack: '1', + label: labelArray[1], + backgroundColor: chartTheme2.main, + borderColor: chartTheme2.main, + borderWidth: 1, + hoverBackgroundColor: chartTheme2.hover, + hoverBorderColor: chartTheme2.hover, + data: CPC, + }, + ], + }; + return ChartjsBarData; + }; + +interface StackedBarProps extends Omit { + dataSet: T[]; + labelArray?: string[]; + dateRange?: number; + type?: 'day' | 'month'; + height?: number; +} + +export default function StackedBar({ + dataSet, + type = 'day', + height = 70, + dateRange = 30, + labelArray = ['배너광고', '클릭광고'], + ...rest +}: StackedBarProps): JSX.Element { + const theme = useTheme(); + const preprocessedDataSet = setStackedBarData(theme)(dataSet, labelArray, type, dateRange); + + return ( + + ); +} diff --git a/client-next/atoms/checkbox/greenCheckBox.tsx b/client-next/atoms/checkbox/greenCheckBox.tsx new file mode 100644 index 000000000..a15745952 --- /dev/null +++ b/client-next/atoms/checkbox/greenCheckBox.tsx @@ -0,0 +1,26 @@ +import { Checkbox, CheckboxProps } from '@material-ui/core'; +import { withStyles, Theme } from '@material-ui/core/styles'; +// icons +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; + +const GreenCheckBox = withStyles((theme: Theme) => ({ + root: { + color: theme.palette.success.light, + '&$checked': { + color: theme.palette.success.main, + }, + margin: 0, + }, + checked: {}, +}))((props: CheckboxProps) => ( + } + checkedIcon={} + {...props} + /> +)); + +export default GreenCheckBox; diff --git a/client-next/atoms/chip/orderStatusChip.tsx b/client-next/atoms/chip/orderStatusChip.tsx new file mode 100644 index 000000000..a0139ffdb --- /dev/null +++ b/client-next/atoms/chip/orderStatusChip.tsx @@ -0,0 +1,52 @@ +import { Chip, makeStyles } from '@material-ui/core'; +import classnames from 'classnames'; +import { useMemo } from 'react'; +import * as React from 'react'; +import renderOrderStatus, { + 주문상태_상품준비, + 주문상태_출고준비, + 주문상태_배송완료, + 주문상태_출고완료, + OrderStatus, + 주문상태_구매확정, + 주문상태_주문취소, +} from '../../utils/render_funcs/renderOrderStatus'; + +const useStyles = makeStyles(theme => ({ + success: { + color: theme.palette.common.white, + backgroundColor: theme.palette.success.dark, + '&:hover': { backgroundColor: theme.palette.success.dark }, + }, + error: { + color: theme.palette.common.white, + backgroundColor: theme.palette.error.dark, + '&:hover': { backgroundColor: theme.palette.error.dark }, + }, +})); + +interface OrderStatusChipProps { + status: OrderStatus; +} + +export default function OrderStatusChip({ status }: OrderStatusChipProps): React.ReactElement { + const classes = useStyles(); + + const color = useMemo(() => { + if ([주문상태_상품준비, 주문상태_출고준비].includes(status)) return 'secondary'; + if (status === 주문상태_구매확정) return 'primary'; + return 'default'; + }, [status]); + + return ( + + ); +} diff --git a/client-next/atoms/dataText/dataText.tsx b/client-next/atoms/dataText/dataText.tsx new file mode 100644 index 000000000..2929416a4 --- /dev/null +++ b/client-next/atoms/dataText/dataText.tsx @@ -0,0 +1,52 @@ +import classnames from 'classnames'; +import { makeStyles, Typography } from '@material-ui/core'; +import { SvgIconProps } from '@material-ui/core/SvgIcon'; +import * as React from 'react'; + +const useStyles = makeStyles(theme => ({ + dataText: { + margin: theme.spacing(0.5, 0), + }, + bold: { + fontWeight: 'bold', + }, + success: { + color: theme.palette.success.dark, + }, +})); + +interface DataTextProps { + name: string; + value: React.ReactNode; + iconComponent?: (props: SvgIconProps) => JSX.Element; + iconColor?: SvgIconProps['color'] | 'success'; +} + +export default function DataText({ + name, + value, + iconComponent, + iconColor = 'inherit', +}: DataTextProps): React.ReactElement { + const classes = useStyles(); + const Icon = iconComponent; + return ( + + + {Icon ? ( + + ) : null} + {name} + + {': '} + {value} + + ); +} diff --git a/client-next/atoms/dialog/dialog.tsx b/client-next/atoms/dialog/dialog.tsx new file mode 100644 index 000000000..45f797865 --- /dev/null +++ b/client-next/atoms/dialog/dialog.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef } from 'react'; +import * as React from 'react'; +// material ui style helper, Theme type +import { withStyles, Theme } from '@material-ui/core/styles'; +// material ui core components +import Dialog from '@material-ui/core/Dialog'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import MuiDialogActions from '@material-ui/core/DialogActions'; +import IconButton from '@material-ui/core/IconButton'; +import Typography from '@material-ui/core/Typography'; +import Grid from '@material-ui/core/Grid'; +// icons +import CloseIcon from '@material-ui/icons/Close'; + +const DialogTitle = withStyles((theme: Theme) => ({ + root: { + margin: 0, + padding: theme.spacing(2), + backgroundColor: theme.palette.primary.main, + color: theme.palette.common.white, + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[300], + }, + title: { + [theme.breakpoints.down('xs')]: { + fontSize: '13px', + }, + [theme.breakpoints.up('sm')]: { + fontSize: '1.25rem', + }, + }, +}))( + ({ + children, + classes, + onClose, + }: { + children: React.ReactNode; + classes: any; + onClose: () => void; + }) => ( + + + + {children} + + + {onClose ? ( + + + + ) : null} + + + + ), +); + +const DialogContent = withStyles(theme => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent); + +const DialogActions = withStyles(theme => ({ + root: { + margin: 0, + padding: theme.spacing(1), + }, +}))(MuiDialogActions); + +interface CustomDialogProps { + title?: React.ReactNode; + open: boolean; + children: React.ReactNode; + buttons?: React.ReactNode; + onClose: () => void; + dialogContentRef?: React.RefObject; + disableScrollTop?: boolean; + [rest: string]: any; +} + +function CustomDialog({ + title, + open, + onClose, + buttons, + children, + disableScrollTop = false, + dialogContentRef, + ...rest +}: CustomDialogProps): JSX.Element { + const contentRef = useRef(null); + useEffect(() => { + if (dialogContentRef) { + if (dialogContentRef.current && !disableScrollTop) { + dialogContentRef.current.scrollTo(0, 0); + } + } else if (contentRef.current && !disableScrollTop) { + contentRef.current.scrollTo(0, 0); + } + }); + return ( + + {title ? {title} : null} + + {children} + + {buttons ? {buttons} : null} + + ); +} +export default CustomDialog; diff --git a/client-next/atoms/dialog/eventPopup.tsx b/client-next/atoms/dialog/eventPopup.tsx new file mode 100644 index 000000000..f9caffc4d --- /dev/null +++ b/client-next/atoms/dialog/eventPopup.tsx @@ -0,0 +1,104 @@ +// material-UI +import { + Checkbox, + Dialog, + DialogProps, + FormControlLabel, + IconButton, + makeStyles, + Typography, +} from '@material-ui/core'; +import { Close } from '@material-ui/icons'; +// 내부 소스 +// 프로젝트 내부 모듈 +import * as React from 'react'; +// 컴포넌트 +// util 계열 +// 스타일 + +const useStyles = makeStyles(theme => ({ + img: { + height: '100%', + minWidth: '100%', + overflowX: 'hidden', + position: 'relative', + }, + container: { + padding: theme.spacing(0, 2, 2), + textAlign: 'center', + color: theme.palette.common.white, + }, + closeButton: { textAlign: 'right', zIndex: 10 }, + closeIcon: { color: theme.palette.common.white }, + checkboxContainer: { + textAlign: 'right', + backgroundColor: theme.palette.common.white, + }, + checkbox: { + color: theme.palette.common.black, + margin: 0, + }, +})); + +interface EventPopupProps { + open: boolean; + onClose: () => void; + children: React.ReactNode; + backgroundImg: string; + noShowKey: string; + maxWidth?: DialogProps['maxWidth']; + disableCloseButton?: boolean; + disableFullWidth?: boolean; +} + +export default function EventPopup({ + open, + onClose, + children, + backgroundImg, + maxWidth = 'xs', + noShowKey, + disableCloseButton = false, + disableFullWidth = false, +}: EventPopupProps): React.ReactElement { + const classes = useStyles(); + function handleNoShowCheck(): void { + onClose(); + localStorage.setItem(noShowKey, new Date().toString()); + } + + return ( + +
+ {!disableCloseButton && ( +
+ + + +
+ )} + + {children} + +
+ + } + label={하루 동안 열지 않기} + /> +
+
+
+ ); +} diff --git a/client-next/atoms/editableInput/editableTextField.tsx b/client-next/atoms/editableInput/editableTextField.tsx new file mode 100644 index 000000000..2a4cbbce4 --- /dev/null +++ b/client-next/atoms/editableInput/editableTextField.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +// @material-ui/core +import { Button, makeStyles, TextField, TextFieldProps, Typography } from '@material-ui/core'; +import { useToggle } from '../../utils/hooks'; + +const useStyles = makeStyles(theme => ({ + label: { + fontWeight: 'bold', + }, + editable: {}, + value: { margin: theme.spacing(1, 0) }, + textField: { + maxWidth: 320, + margin: theme.spacing(1, 0), + }, + button: { + margin: theme.spacing(0, 1, 0, 0), + }, +})); + +export interface EditableTextFieldProps { + inputProps?: TextFieldProps['inputProps']; + label: string; + displayValue: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + onSubmit: (value: string) => void; + onReset: () => void; + helperText?: string; + textFieldProps?: TextFieldProps; + loading?: boolean; +} + +export default function EditableTextField({ + inputProps, + label, + displayValue, + value, + onChange, + onSubmit, + onReset, + helperText, + textFieldProps, + loading, +}: EditableTextFieldProps): JSX.Element { + const classes = useStyles(); + const editMode = useToggle(); + + function submit(e: React.FormEvent): void { + e.preventDefault(); + editMode.handleToggle(); + onSubmit(value); + } + + return ( +
+ {label} + {/* 이름 View 모드 */} + {!editMode.toggle ? ( +
+ {displayValue} + +
+ ) : ( +
+ {/* 편집 모드 */} + +
+ + +
+
+ )} +
+ ); +} diff --git a/client-next/atoms/grid/gridContainer.tsx b/client-next/atoms/grid/gridContainer.tsx new file mode 100644 index 000000000..d687df9a1 --- /dev/null +++ b/client-next/atoms/grid/gridContainer.tsx @@ -0,0 +1,20 @@ +import makeStyles from '@material-ui/core/styles/makeStyles'; +import Grid, { GridProps } from '@material-ui/core/Grid'; + +const useStyles = makeStyles({ + grid: { + margin: '0 -8px !important', + width: 'unset', + }, +}); + +function GridContainer({ children, ...rest }: GridProps): JSX.Element { + const classes = useStyles(); + return ( + + {children} + + ); +} + +export default GridContainer; diff --git a/client-next/atoms/grid/gridItem.tsx b/client-next/atoms/grid/gridItem.tsx new file mode 100644 index 000000000..803f999a2 --- /dev/null +++ b/client-next/atoms/grid/gridItem.tsx @@ -0,0 +1,19 @@ +import makeStyles from '@material-ui/core/styles/makeStyles'; +import Grid, { GridProps } from '@material-ui/core/Grid'; + +const useStyles = makeStyles({ + grid: { + padding: '0 8px !important', + }, +}); + +function GridItem({ children, ...rest }: GridProps): JSX.Element { + const classes = useStyles(); + return ( + + {children} + + ); +} + +export default GridItem; diff --git a/client-next/atoms/input/staticInput.tsx b/client-next/atoms/input/staticInput.tsx new file mode 100644 index 000000000..c1806f201 --- /dev/null +++ b/client-next/atoms/input/staticInput.tsx @@ -0,0 +1,21 @@ +import { Input, InputProps } from '@material-ui/core'; +import { withStyles, Theme } from '@material-ui/core/styles'; + +// 폰트 사이즈 고정인 input (PlatformRegistForm) +const StaticInput = withStyles((theme: Theme) => ({ + root: { + fontSize: '16px', + color: theme.palette.text.primary, + width: '300px', + [theme.breakpoints.down('xs')]: { + width: '100%', + fontSize: '16px', + margin: 0, + }, + }, + underline: { + color: theme.palette.text.primary, + }, +}))((props: InputProps) => ); + +export default StaticInput; diff --git a/client-next/atoms/loading/centerLoading.tsx b/client-next/atoms/loading/centerLoading.tsx new file mode 100644 index 000000000..f9757c2f0 --- /dev/null +++ b/client-next/atoms/loading/centerLoading.tsx @@ -0,0 +1,12 @@ +import { Box, CircularProgress } from '@material-ui/core'; + +interface CenterLoadingProps { + height?: number; +} +export default function CenterLoading({ height = 200 }: CenterLoadingProps): JSX.Element { + return ( + + + + ); +} diff --git a/client-next/atoms/progress/circularProgress.tsx b/client-next/atoms/progress/circularProgress.tsx new file mode 100644 index 000000000..020d45567 --- /dev/null +++ b/client-next/atoms/progress/circularProgress.tsx @@ -0,0 +1,42 @@ +import classnames from 'classnames'; +import MuiCircularProgress, { + CircularProgressProps as MuiCircularProgressProps, +} from '@material-ui/core/CircularProgress'; +import makeStyles from '@material-ui/core/styles/makeStyles'; + +const useStyles = makeStyles(theme => ({ + wrapper: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + big: { + marginTop: theme.spacing(15), + marginBottom: theme.spacing(15), + }, +})); + +interface CircularProgressProps extends MuiCircularProgressProps { + small?: boolean; +} + +export default function CircularProgress({ + small = false, + ...rest +}: CircularProgressProps): JSX.Element { + const classes = useStyles(); + + return ( +
+ {small ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/client-next/atoms/radio/greenRadio.tsx b/client-next/atoms/radio/greenRadio.tsx new file mode 100644 index 000000000..0b0f11832 --- /dev/null +++ b/client-next/atoms/radio/greenRadio.tsx @@ -0,0 +1,15 @@ +import { Radio, RadioProps } from '@material-ui/core'; +import { withStyles, Theme } from '@material-ui/core/styles'; + +const GreenRadio = withStyles((theme: Theme) => ({ + root: { + color: theme.palette.success.light, + '&$checked': { + color: theme.palette.success.main, + }, + margin: 0, + }, + checked: {}, +}))((props: RadioProps) => ); + +export default GreenRadio; diff --git a/client-next/atoms/selector/timeSelector.tsx b/client-next/atoms/selector/timeSelector.tsx new file mode 100644 index 000000000..94f5c20a7 --- /dev/null +++ b/client-next/atoms/selector/timeSelector.tsx @@ -0,0 +1,95 @@ +import classnames from 'classnames'; +import Grid from '@material-ui/core/Grid'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + table: { maxWidth: 960, borderCollapse: 'collapse' }, + legend: { + display: 'flex', + marginTop: theme.spacing(2), + justifyContent: 'center', + maxWidth: 960, + }, + legendItem: { + height: 20, + width: 60, + marginLeft: theme.spacing(1), + marginRight: theme.spacing(1), + }, + legendItemOn: { backgroundColor: theme.palette.primary.main }, + legendItemOff: { backgroundColor: theme.palette.action.disabled }, + thead: { + border: '1px', + padding: 'auto', + width: '40px', + textAlign: 'center', + }, + td: { + border: '1px', + borderColor: theme.palette.common.black, + borderStyle: 'solid', + padding: 'auto', + textAlign: 'center', + height: '3px', + backgroundColor: theme.palette.action.disabled, + }, + tdCheck: { + backgroundColor: theme.palette.primary.main, + }, + font: { opacity: '0' }, +})); + +interface SelectTimeDetailProps { + timeList: number[]; + onTimeSelect?: (timeIndex: number) => void; +} + +export default function TimeSelector(props: SelectTimeDetailProps): JSX.Element { + const { timeList, onTimeSelect } = props; + const classes = useStyles(); + + const times = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + ]; + + return ( + + {/* 송출 / 송출 안함에 대한 범례 */} + +
+ 송출 +
+ 송출안함 + + + {/* 송출 시간 선택 */} + + + + {times.map(index => ( + + ))} + + + + + {times.map((index: number) => ( + + ))} + + +
{`${index}`}
-1, + })} + key={index} + onClick={(): void => (onTimeSelect ? onTimeSelect(index) : undefined)} + role="gridcell" + style={{ cursor: onTimeSelect ? 'pointer' : 'default' }} + > + {index} +
+ + ); +} diff --git a/client-next/atoms/snackbar/snackbar.tsx b/client-next/atoms/snackbar/snackbar.tsx new file mode 100644 index 000000000..47cbce6b2 --- /dev/null +++ b/client-next/atoms/snackbar/snackbar.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +// @material-ui/core components +import Snack, { SnackbarProps as MuiSnackbarProps } from '@material-ui/core/Snackbar'; +import Alert, { AlertProps } from '@material-ui/lab/Alert'; + +interface SnackBarProps extends MuiSnackbarProps { + color?: AlertProps['color']; + alertProps?: AlertProps; + onClose: (event: React.SyntheticEvent, reason?: string) => void; +} + +function Snackbar({ + message, + color = 'success', + open, + onClose, + alertProps, + anchorOrigin, + ...rest +}: SnackBarProps): JSX.Element { + return ( + + + {message} + + + ); +} + +export default Snackbar; diff --git a/client-next/atoms/styledInput.tsx b/client-next/atoms/styledInput.tsx new file mode 100644 index 000000000..cfa9651f5 --- /dev/null +++ b/client-next/atoms/styledInput.tsx @@ -0,0 +1,21 @@ +import { Input, InputProps } from '@material-ui/core'; +import { withStyles, Theme } from '@material-ui/core/styles'; + +const StyledInput = withStyles((theme: Theme) => ({ + root: { + fontSize: '16px', + fontWeight: 700, + color: theme.palette.text.primary, + width: '300px', + [theme.breakpoints.down('xs')]: { + width: '100%', + fontSize: '12px', + margin: 0, + }, + }, + underline: { + color: theme.palette.text.primary, + }, +}))((props: InputProps) => ); + +export default StyledInput; diff --git a/client-next/atoms/styledItemText.tsx b/client-next/atoms/styledItemText.tsx new file mode 100644 index 000000000..8813c6f49 --- /dev/null +++ b/client-next/atoms/styledItemText.tsx @@ -0,0 +1,22 @@ +import { ListItemText } from '@material-ui/core'; +import { withStyles, Theme } from '@material-ui/core/styles'; + +// props를 이용한 정의 + +const StyledItemText = withStyles((theme: Theme) => ({ + root: { + color: theme.palette.text.primary, + fontWeight: 700, + [theme.breakpoints.down('sm')]: { + fontSize: '14px', + marginBottom: '8px', + }, + }, + primary: ({ fontSize, color }: { fontSize?: string; color?: string }) => ({ + fontSize: fontSize || '16px', + fontWeight: 700, + color: color || theme.palette.text.primary, + }), +}))(ListItemText); + +export default StyledItemText; diff --git a/client-next/atoms/styledSelectText.tsx b/client-next/atoms/styledSelectText.tsx new file mode 100644 index 000000000..d4d84cf6e --- /dev/null +++ b/client-next/atoms/styledSelectText.tsx @@ -0,0 +1,24 @@ +import { ListItemText, ListItemTextProps } from '@material-ui/core'; +import { withStyles, Theme } from '@material-ui/core/styles'; + +interface StyledSelectTextProps extends ListItemTextProps { + fontSize?: number | string; +} +const StyledSelectText = withStyles((theme: Theme) => ({ + primary: ({ fontSize, color }: { fontSize?: number | string; color?: string }) => ({ + fontSize: fontSize || '18px', + lineHeight: 2.0, + fontWeight: 700, + [theme.breakpoints.down('sm')]: { + fontSize: '16px', + }, + color: color || 'primary', + }), + secondary: (props: StyledSelectTextProps) => ({ + fontSize: props.fontSize ? props.fontSize : '13px', + fontWeight: 500, + color: props.color || 'secondary', + }), +}))(ListItemText); + +export default StyledSelectText; diff --git a/client-next/atoms/switch/campaignOnOffSwitch.tsx b/client-next/atoms/switch/campaignOnOffSwitch.tsx new file mode 100644 index 000000000..ec1d536f2 --- /dev/null +++ b/client-next/atoms/switch/campaignOnOffSwitch.tsx @@ -0,0 +1,144 @@ +/* eslint-disable max-len */ +import { Switch, SwitchProps, Tooltip } from '@material-ui/core'; +import * as React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { CampaignInterface } from '../../components/mypage/marketer/main/interfaces'; +import { useMarketerCampaignOnOffMutation } from '../../utils/hooks/mutation/useMarketerCampaignOnOffMutation'; +import { + CONFIRM_STATE_REJECTED, + CONFIRM_STATE_WAIT, +} from '../../utils/render_funcs/renderUrlConfirmState'; + +export interface CampaignOnOffSwitchProps extends SwitchProps { + campaign: CampaignInterface; + onSuccess: (data: any) => void; + onFail: (err: any) => void; + size?: SwitchProps['size']; + id?: SwitchProps['id']; + color?: SwitchProps['color']; + inventoryLoading?: boolean; +} + +const LIVE_BANNER_OPTION_TYPE = 1; // 생방송 라이브 배너광고 Option Type +const CPS_OPTION_TYPE = 3; // 상품판매형 CPS Option Type +export default function CampaignOnOffSwitch(props: CampaignOnOffSwitchProps): React.ReactElement { + const { + campaign, + onSuccess, + onFail, + size = 'small', + id = 'onoff-switch', + color = 'primary', + inventoryLoading, + } = props; + + // ********************************************** + // 로딩 처리를 위해 + const onOffMutation = useMarketerCampaignOnOffMutation(); + const handleSwitch = useCallback((): void => { + onOffMutation + .mutateAsync({ + onoffState: !campaign.onOff, + campaignId: campaign.campaignId, + }) + .then(({ data }) => onSuccess(data)) + .catch(err => onFail(err)); + }, [campaign.campaignId, campaign.onOff, onFail, onOffMutation, onSuccess]); + + // ********************************************** + // 캠페인 시작이 불가능할 때, 버튼 disable 처리 + const [onOffDisableReason, setOnOffDisableReason] = useState('현재 송출 불가'); + function handleOnOffDisable(reason: string): void { + setOnOffDisableReason(reason); + } + + const onOffDisabled = useMemo((): boolean => { + if (!campaign.onOff && !campaign.confirmState) { + handleOnOffDisable('캠페인의 배너가 승인되지 않았습니다.'); + return true; + } + // 생방송 라이브 배너광고의 경우 + if (campaign.optionType === LIVE_BANNER_OPTION_TYPE) { + if (!campaign.onOff && campaign.linkConfirmState === CONFIRM_STATE_REJECTED) { + handleOnOffDisable('캠페인의 랜딩페이지URL이 거절되었습니다.'); + return true; + } + + if (!campaign.onOff && campaign.linkConfirmState === CONFIRM_STATE_WAIT) { + handleOnOffDisable('캠페인의 랜딩페이지URL이 아직 승인되지 않았습니다.'); + return true; + } + } + // CPS 캠페인인 경우 + if (campaign.optionType === CPS_OPTION_TYPE) { + // 현재 off 상태이면서 온애드샵에 아직 업로드 되지 않았거나, 상품이 없거나, 온애드샵 사이트 URL이 아직 업로드 되지 않은 경우 + if ( + !campaign.onOff && + (!campaign.merchandiseUploadState || + !campaign.merchandiseId || + !campaign.merchandiseItemSiteUrl) + ) { + let message = '아직 온애드샵에 상품이 업로드되지 않았습니다.'; + if (campaign.merchandiseUploadState === 0 && campaign.merchandiseDenialReason) + message = '상품이 거절된 캠페인입니다.'; + handleOnOffDisable(message); + return true; + } + // off 상태이면서 상품의 남은 재고가 없는 경우 + if ( + !campaign.onOff && + campaign.merchandiseStock && + campaign.merchandiseSoldCount && + !(campaign.merchandiseStock - campaign.merchandiseSoldCount > 0) + ) { + handleOnOffDisable('상품 판매 재고를 모두 소진하였습니다.'); + return true; + } + } + return false; + }, [ + campaign.confirmState, + campaign.linkConfirmState, + campaign.merchandiseDenialReason, + campaign.merchandiseId, + campaign.merchandiseItemSiteUrl, + campaign.merchandiseSoldCount, + campaign.merchandiseStock, + campaign.merchandiseUploadState, + campaign.onOff, + campaign.optionType, + ]); + + const switchButton = useMemo( + () => ( + + ), + [ + campaign.onOff, + color, + handleSwitch, + id, + inventoryLoading, + onOffMutation.isLoading, + onOffDisabled, + size, + ], + ); + + if (onOffDisabled) { + return ( + +
{switchButton}
+
+ ); + } + + return switchButton; +} diff --git a/client-next/atoms/table/cashUsageTable.tsx b/client-next/atoms/table/cashUsageTable.tsx new file mode 100644 index 000000000..09bb65ce4 --- /dev/null +++ b/client-next/atoms/table/cashUsageTable.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { nanoid } from 'nanoid'; +// @material-ui/core components +import { Table, TableHead, TableRow, TableBody, TableCell, Button } from '@material-ui/core'; +// custom table component +import CustomTableFooter from './tableFooter'; +import useTableStyles from './table.style'; + +interface CustomTableProps { + tableHead: string[]; + tableData: string[][]; + handleDialogOpen?: (v?: any) => void; + perMonth?: boolean; + detailButton?: boolean; +} + +function CustomTable({ + tableHead, + tableData, + handleDialogOpen, + perMonth = false, + detailButton = false, +}: CustomTableProps): JSX.Element { + const classes = useTableStyles(); + + const [page, setPage] = React.useState(0); // 테이블 페이지 + const [rowsPerPage, setRowsPerPage] = React.useState(5); // 테이블 페이지당 행 + const emptyRows = rowsPerPage - Math.min(rowsPerPage, tableData.length - page * rowsPerPage); + // page handler + function handleChangeTablePage( + event: React.MouseEvent | null, + newPage: number, + ): void { + setPage(newPage); + } + // page per row handler + function handleChangeTableRowsPerPage( + event: React.ChangeEvent, + ): void { + setRowsPerPage(parseInt(event.target.value, 10)); + } + + return ( +
+ + + + {tableHead.map(value => ( + + {value} + + ))} + + + + + {tableData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((prop, i) => ( + + {prop.map(value => ( + + {!perMonth ? ( +
+ {value} +
+ ) : ( +
+ {/* {value.indexOf(',') >= 0 ? ( // 집행 금액 컬럼의 경우 + + ) : ( + {value} + )} */} + {value} +
+ )} +
+ ))} + {detailButton && ( + + + + )} +
+ ))} + + {emptyRows > 0 && ( + + + + )} +
+ + +
+
+ ); +} + +export default CustomTable; diff --git a/client-next/atoms/table/customDataGrid.tsx b/client-next/atoms/table/customDataGrid.tsx new file mode 100644 index 000000000..c07c7d842 --- /dev/null +++ b/client-next/atoms/table/customDataGrid.tsx @@ -0,0 +1,6 @@ +import { DataGrid, DataGridProps } from '@material-ui/data-grid'; +import { dataGridLocale } from './dataGridLocale.kr'; + +export default function CustomDataGrid(props: DataGridProps): JSX.Element { + return ; +} diff --git a/client-next/atoms/table/customDataGridExportable.tsx b/client-next/atoms/table/customDataGridExportable.tsx new file mode 100644 index 000000000..87208ce42 --- /dev/null +++ b/client-next/atoms/table/customDataGridExportable.tsx @@ -0,0 +1,32 @@ +/* eslint-disable react/display-name */ +import * as React from 'react'; +import { + DataGrid, + DataGridProps, + GridToolbarContainer, + GridToolbarExport, +} from '@material-ui/data-grid'; +import { dataGridLocale } from './dataGridLocale.kr'; + +export interface CustomDataGridExportableProps { + exportFileName: string; +} + +export default function CustomDataGridExportable({ + exportFileName, + ...props +}: DataGridProps & CustomDataGridExportableProps): JSX.Element { + return ( + ( + + + + ), + }} + {...props} + /> + ); +} diff --git a/client-next/atoms/table/dataGridLocale.kr.ts b/client-next/atoms/table/dataGridLocale.kr.ts new file mode 100644 index 000000000..f507064a3 --- /dev/null +++ b/client-next/atoms/table/dataGridLocale.kr.ts @@ -0,0 +1,68 @@ +import { GridLocaleText } from '@material-ui/data-grid'; + +export const dataGridLocale: Partial = { + // Root + noRowsLabel: '데이터가 없습니다', + errorOverlayDefaultLabel: '에러가 발생했습니다. support@onad.io로 문의바랍니다.', + + // Density selector toolbar button text + toolbarDensity: '행 간격', + toolbarDensityLabel: '행 간격', + toolbarDensityCompact: '좁게', + toolbarDensityStandard: '보통', + toolbarDensityComfortable: '넓게', + + // Columns selector toolbar button text + toolbarColumns: '컬럼 설정', + toolbarColumnsLabel: '컬럼 설정 보기', + + // Filters toolbar button text + toolbarFilters: '필터목록', + toolbarFiltersLabel: '필터 보기', + toolbarFiltersTooltipHide: '필터 숨김', + toolbarFiltersTooltipShow: '필터 보기', + toolbarFiltersTooltipActive: count => `${count} 필터 선택됨`, + + // Columns panel text + columnsPanelTextFieldLabel: '컬럼 이름', + columnsPanelTextFieldPlaceholder: '컬럼 이름', + columnsPanelDragIconLabel: '재정렬', + columnsPanelShowAllButton: '모두 보기', + columnsPanelHideAllButton: '모두 숨김', + + // Filter panel text + filterPanelAddFilter: '필터 추가', + filterPanelDeleteIconLabel: '삭제', + filterPanelOperators: '필터 방식', + filterPanelOperatorAnd: 'And', + filterPanelOperatorOr: 'Or', + filterPanelColumns: '타겟 컬럼', + + // Column menu text + columnMenuLabel: '메뉴', + columnMenuShowColumns: '컬럼 설정 보기', + columnMenuFilter: '필터', + columnMenuHideColumn: '숨김', + columnMenuUnsort: '정렬해제', + columnMenuSortAsc: '오름차순 정렬', // Sort by Asc + columnMenuSortDesc: '내림차순 정렬', // Sort by Desc + + // Column header text + columnHeaderFiltersTooltipActive: count => `${count} 필터 선택됨`, + columnHeaderFiltersLabel: '필터 보기', + columnHeaderSortIconLabel: '정렬', + + // Rows selected footer text + footerRowSelected: count => + count !== 1 ? `${count.toLocaleString()} 행 선택됨` : `${count.toLocaleString()} 행 선택됨`, + + // Total rows footer text + footerTotalRows: '총 행:', + + // Pagination footer text + // footerPaginationRowsPerPage: '페이지 당 행:', + + toolbarExport: '내보내기', + toolbarExportLabel: '내보내기', + toolbarExportCSV: 'CSV 파일로 다운로드', +}; diff --git a/client-next/atoms/table/marketerSettlementLogsTable.tsx b/client-next/atoms/table/marketerSettlementLogsTable.tsx new file mode 100644 index 000000000..488be4981 --- /dev/null +++ b/client-next/atoms/table/marketerSettlementLogsTable.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { nanoid } from 'nanoid'; +// @material-ui/core components +import { Table, TableHead, TableRow, TableBody, TableCell, Button } from '@material-ui/core'; +// custom table component +import CustomTableFooter from './tableFooter'; +import useTableStyles from './table.style'; +// import CpcCpmTooltip from '../Tooltip/CpcCpmTooltip'; + +interface MarketerSettlementLogsTableProps { + tableHead: string[]; + tableData: string[][]; + handleDialogOpen?: (v?: any) => void; + detailButton?: boolean; +} + +export default function MarketerSettlementLogsTable({ + tableHead, + tableData, + handleDialogOpen, + detailButton = false, +}: MarketerSettlementLogsTableProps): JSX.Element { + const classes = useTableStyles(); + + const [page, setPage] = React.useState(0); // 테이블 페이지 + const [rowsPerPage, setRowsPerPage] = React.useState(4); // 테이블 페이지당 행 + const emptyRows = rowsPerPage - Math.min(rowsPerPage, tableData.length - page * rowsPerPage); + // page handler + function handleChangeTablePage( + event: React.MouseEvent | null, + newPage: number, + ): void { + setPage(newPage); + } + // page per row handler + function handleChangeTableRowsPerPage( + event: React.ChangeEvent, + ): void { + setRowsPerPage(parseInt(event.target.value, 10)); + } + + return ( +
+ + + + {tableHead.map(value => ( + + {value} + + ))} + + + + + {tableData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((prop, i) => ( + + {prop.map(value => ( + + {value} + + ))} + {detailButton && ( + + + + )} + + ))} + + {emptyRows > 0 && ( + + + + )} + + + +
+
+ ); +} diff --git a/client-next/atoms/table/materialTable.tsx b/client-next/atoms/table/materialTable.tsx new file mode 100644 index 000000000..2003e47a6 --- /dev/null +++ b/client-next/atoms/table/materialTable.tsx @@ -0,0 +1,77 @@ +/* eslint-disable react/display-name */ +import * as React from 'react'; +import MuiMaterialTable, { MaterialTableProps, Column } from 'material-table'; +import ArrowUpward from '@material-ui/icons/ArrowUpwardRounded'; +import Check from '@material-ui/icons/CheckRounded'; +import Clear from '@material-ui/icons/ClearRounded'; +import ChevronLeft from '@material-ui/icons/ChevronLeftRounded'; +import ChevronRight from '@material-ui/icons/ChevronRightRounded'; +import FilterList from '@material-ui/icons/FilterListRounded'; +import FirstPage from '@material-ui/icons/FirstPageRounded'; +import LastPage from '@material-ui/icons/LastPageRounded'; +import Delete from '@material-ui/icons/Delete'; +import Search from '@material-ui/icons/Search'; + +const tableIcons = { + Check: React.forwardRef((props, ref) => ), + Clear: React.forwardRef((props, ref) => ), + ResetSearch: React.forwardRef((props, ref) => ), + Delete: React.forwardRef((props, ref) => ), + Filter: React.forwardRef((props, ref) => ), + FirstPage: React.forwardRef((props, ref) => ), + LastPage: React.forwardRef((props, ref) => ), + NextPage: React.forwardRef((props, ref) => ), + PreviousPage: React.forwardRef((props, ref) => ( + + )), + SortArrow: React.forwardRef((props, ref) => ), + Search: React.forwardRef((props, ref) => ), +}; +const localization = { + body: { + emptyDataSourceMessage: '잠시만 기다려주세요', + }, + pagination: { + firstTooltip: '첫 페이지', + previousTooltip: '이전 페이지', + nextTooltip: '다음 페이지', + lastTooltip: '마지막 페이지', + labelRowsSelect: '행', + }, + header: { + actions: '', + }, + toolbar: { + searchTooltip: '', + searchPlaceholder: '검색어를 입력해주세요!', + }, +}; + +interface CustomMaterialTableProps> extends MaterialTableProps { + cellWidth?: number; + style?: React.CSSProperties; +} + +export default function MaterialTable>( + props: CustomMaterialTableProps, +): JSX.Element { + const { columns, cellWidth, ...rest } = props; + + function styleColumn(_columns: Column[], minWidth = 100): Column[] { + _columns.map(col => { + const column = col; + column.cellStyle = { minWidth, padding: 0, ...column }; + return column; + }); + return _columns; + } + + return ( + + ); +} diff --git a/client-next/atoms/table/salesIncomeSettlementLogByOrderTable.tsx b/client-next/atoms/table/salesIncomeSettlementLogByOrderTable.tsx new file mode 100644 index 000000000..eb071b622 --- /dev/null +++ b/client-next/atoms/table/salesIncomeSettlementLogByOrderTable.tsx @@ -0,0 +1,84 @@ +/* eslint-disable react/display-name */ +import { Box, Typography } from '@material-ui/core'; +import { GridColumns } from '@material-ui/data-grid'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { useMarketerPaymentMethods } from '../../utils/hooks/query/useMarketerPaymentMethods'; +import { SettlementByOrderData } from '../../utils/hooks/query/useMarketerSettlementLogsByOrder'; +import CustomDataGridExportable from './customDataGridExportable'; + +interface SalesIncomeSettlementLogByOrderTableProps { + exportFileName: string; + isLoading: boolean; + settlementLogsData: SettlementByOrderData[]; +} + +export default function SalesIncomeSettlementLogByOrderTable({ + exportFileName, + isLoading, + settlementLogsData, +}: SalesIncomeSettlementLogByOrderTableProps): React.ReactElement { + const paymentMethods = useMarketerPaymentMethods(); + + const column: GridColumns = [ + { field: 'orderId', headerName: '주문번호', width: 100 }, + { field: 'isLiveCommerce', headerName: '캠페인유형' }, + { field: 'campaignName', headerName: '캠페인명' }, + { field: 'orderPrice', headerName: '정산대상액' }, + { field: 'purchaseChannel', headerName: '구매채널' }, + { field: 'commissionAmount', headerName: '일반수수료' }, + { field: 'VAT', headerName: '부가세' }, + { field: 'deliveryFee', headerName: '배송비' }, + { field: 'paymentCommissionAmount', headerName: '전자결제수단별수수료' }, + { field: 'actualSendedAmount', headerName: '실지급액' }, + { field: 'paymentMethod', headerName: '결제방법' }, + { field: 'orderDate', headerName: '주문일자', width: 160 }, + { field: 'purchaseConfirmDate', headerName: '구매확정일자', width: 160 }, + { field: 'bigo', headerName: '비고' }, + { field: 'cancelDate', headerName: '취소일자' }, + ].map(x => ({ + ...x, + editable: false, + filterable: false, + sortable: false, + width: x.width || 150, + })); + + const preprocessedData = useMemo(() => { + if (!settlementLogsData) return []; + return settlementLogsData.map(d => ({ + ...d, + isLiveCommerce: d.isLiveCommerce ? '라이브커머스' : '판매형광고', + })); + }, [settlementLogsData]); + return ( + + + + + + {!paymentMethods.isLoading && paymentMethods.data && ( + + 결제수단별 수수료 + {paymentMethods.data.map(paymentMethod => ( + + {`${paymentMethod.method}: `} + {paymentMethod.paymentFee + ? `${paymentMethod.paymentFee}%` + : `${paymentMethod.paymentFeeFixed}원 (고정)`}{' '} + + ))} + + )} + + ); +} diff --git a/client-next/atoms/table/salesIncomeSettlementLogMonthlyTable.tsx b/client-next/atoms/table/salesIncomeSettlementLogMonthlyTable.tsx new file mode 100644 index 000000000..f03c63312 --- /dev/null +++ b/client-next/atoms/table/salesIncomeSettlementLogMonthlyTable.tsx @@ -0,0 +1,78 @@ +/* eslint-disable react/display-name */ +import { Box } from '@material-ui/core'; +import Link from 'next/link'; +import { GridColumns } from '@material-ui/data-grid'; +import * as React from 'react'; +import { useRouter } from 'next/router'; +import { SalesIncomeSettlement } from '../../utils/hooks/query/useMarketerSettlement'; +import CustomDataGridExportable from './customDataGridExportable'; + +interface SalesIncomeSettlementLogMonthlyTableProps { + exportFileName: string; + isLoading: boolean; + settlementLogsData: SalesIncomeSettlement[]; +} + +export default function SalesIncomeSettlementLogMonthlyTable({ + exportFileName, + isLoading, + settlementLogsData, +}: SalesIncomeSettlementLogMonthlyTableProps): React.ReactElement { + const router = useRouter(); + const column: GridColumns = [ + { + field: 'doneDate', + headerName: '년월', + renderCell: (data: any): React.ReactElement => { + const [year, month] = data.row.doneDate.split('-'); + const round = data.row.roundInMonth; + return ( + + {data.row.doneDate} + + ); + }, + width: 100, + }, + { field: 'roundInMonth', headerName: '회차', width: 80 }, + { field: 'amount', headerName: '정산대상액' }, + { field: 'commissionAmount', headerName: '일반수수료' }, + { field: 'VAT', headerName: '부가세' }, + { field: 'amountDeliveryFee', headerName: '배송비' }, + { field: 'paymentCommissionAmount', headerName: '전자결제수단별 수수료' }, + { field: 'actualSendedAmount', headerName: '실지급액' }, + ].map(x => ({ + ...x, + editable: false, + filterable: false, + sortable: false, + width: x.width || 150, + })); + + return ( + <> + + + + + ); +} diff --git a/client-next/atoms/table/table.style.ts b/client-next/atoms/table/table.style.ts new file mode 100644 index 000000000..69672e108 --- /dev/null +++ b/client-next/atoms/table/table.style.ts @@ -0,0 +1,62 @@ +import { Theme, makeStyles } from '@material-ui/core/styles'; + +const useTableStyles = makeStyles((theme: Theme) => ({ + table: { + marginBottom: '0', + width: '100%', + maxWidth: '100%', + backgroundColor: 'transparent', + borderSpacing: '0', + borderCollapse: 'collapse', + }, + tableHeadCell: { + textAlign: 'center', + color: theme.palette.text.primary, + fontWeight: theme.typography.body1.fontWeight, + fontSize: theme.typography.body1.fontSize, + }, + tableCell: { + padding: theme.spacing(1), + textAlign: 'center', + color: theme.palette.text.primary, + fontWeight: theme.typography.body1.fontWeight, + fontSize: theme.typography.body1.fontSize, + }, + tableFooter: { + borderBottom: 'none', + }, + tableFooterPagination: { + borderBottom: 'none', + }, + tableResponsive: { + width: '100%', + marginTop: theme.spacing(1), + overflowX: 'auto', + }, + imgCell: { + padding: '10px 8px', + [theme.breakpoints.up('md')]: { + maxWidth: '25vh', + }, + maxHeight: '6vh', + }, + tableButton: { + [theme.breakpoints.down('sm')]: { + fontSize: '2px', + }, + }, + ButtonCell: { + [theme.breakpoints.down('sm')]: { + padding: 0, + }, + }, + imgCellNoPage: { + maxWidth: '5.7vh', + maxHeight: '2.6vh', + [theme.breakpoints.down('sm')]: { + maxHeight: '50px', + }, + }, +})); + +export default useTableStyles; diff --git a/client-next/atoms/table/table.tsx b/client-next/atoms/table/table.tsx new file mode 100644 index 000000000..b9c466a86 --- /dev/null +++ b/client-next/atoms/table/table.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { nanoid } from 'nanoid'; +// @material-ui/core components +import { Table, TableHead, TableRow, TableBody, TableCell } from '@material-ui/core'; +import Done from '@material-ui/icons/Done'; +// custom table component +import CustomTableFooter from './tableFooter'; + +// core components +import useTableStyles from './table.style'; + +interface CustomTableProps { + tableHead: string[]; + tableData: string[][]; + pagination?: boolean; + rowPerPage?: number; +} + +function CustomTable({ + tableHead, + tableData, + pagination = false, + rowPerPage = 5, +}: CustomTableProps): JSX.Element { + const classes = useTableStyles(); + + const [page, setPage] = React.useState(0); // 테이블 페이지 + const [rowsPerPage, setRowsPerPage] = React.useState(rowPerPage); // 테이블 페이지당 행 + const emptyRows = rowsPerPage - Math.min(rowsPerPage, tableData.length - page * rowsPerPage); + // page handler + function handleChangeTablePage( + event: React.MouseEvent | null, + newPage: number, + ): void { + setPage(newPage); + } + // page per row handler + function handleChangeTableRowsPerPage( + event: React.ChangeEvent, + ): void { + setRowsPerPage(parseInt(event.target.value, 10)); + } + + return ( +
+ + {tableHead !== undefined ? ( + + + {tableHead.map(value => ( + + {value} + + ))} + + + ) : null} + {pagination ? ( + + {/** 페이지네이션 있는 경우 */} + {tableData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map(prop => ( + + {prop.map(value => + value.indexOf('data:image/') === -1 ? ( // 없는 경우 + + {value === '완료됨' ? ( + + {value} + + + ) : ( + value + )} + + ) : ( + + banner + + ), + )} + + ))} + + {emptyRows > 0 && ( + + + + )} + + ) : ( + + {/** 페이지네이션 없는경우 */} + {tableData.map(prop => ( + + {prop.map((value, i) => + typeof value === 'string' && + (value.indexOf('data:image/') >= 0 || value.indexOf('http') === 0) ? ( // 사진데이터 또는 사진 url 인 경우 + + banner + + ) : ( + + {value} + + ), + )} + + ))} + + )} + + {pagination !== false && ( + + )} +
+
+ ); +} + +export default CustomTable; diff --git a/client-next/atoms/table/tableFooter.tsx b/client-next/atoms/table/tableFooter.tsx new file mode 100644 index 000000000..7d5c28c19 --- /dev/null +++ b/client-next/atoms/table/tableFooter.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import { TableRow, TableFooter, TablePagination, IconButton } from '@material-ui/core'; +import { LastPage, FirstPage, KeyboardArrowRight, KeyboardArrowLeft } from '@material-ui/icons'; +// jss file import +import useTableStyles from './table.style'; + +// Style for footer +const useStyles = makeStyles(theme => ({ + root: { + flexShrink: 0, + color: theme.palette.text.secondary, + marginLeft: theme.spacing(2.5), + }, +})); + +// Action buttons component - 테이블 페이지네이션 버튼들 +interface TablePagenationActionsProps { + count: number; + page: number; + rowsPerPage: number; + onChangePage: (e: React.MouseEvent, page: number) => void; +} +function TablePaginationActions(props: TablePagenationActionsProps): JSX.Element { + const classes = useStyles(); + const { count, page, rowsPerPage, onChangePage } = props; + + // 처음 페이지로 + function handleFirstPageButtonClick(event: React.MouseEvent): void { + onChangePage(event, 0); + } + + // 이전 페이지로 + function handleBackButtonClick(event: React.MouseEvent): void { + onChangePage(event, page - 1); + } + + // 다음 페이지로 + function handleNextButtonClick(event: React.MouseEvent): void { + onChangePage(event, page + 1); + } + + // 마지막 페이지로 + function handleLastPageButtonClick(event: React.MouseEvent): void { + onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); + } + + return ( +
+ + + + + + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="Next Page" + > + + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="Last Page" + > + + +
+ ); +} + +// 테이블 페이지네이션 footer 컴포넌트 +interface CustomTableFooterProps { + handleChangeTablePage: (event: React.MouseEvent | null, page: number) => void; + handleChangeTableRowsPerPage?: React.ChangeEventHandler; + page: number; + rowsPerPage: number; + count: number; +} + +function CustomTableFooter({ + count = 0, + rowsPerPage = 5, + page = 0, + handleChangeTablePage, + handleChangeTableRowsPerPage, +}: CustomTableFooterProps): JSX.Element { + const classes = useTableStyles(); + + return ( + + + + + + ); +} + +export default CustomTableFooter; diff --git a/client-next/atoms/tooltip/styledTooltip.tsx b/client-next/atoms/tooltip/styledTooltip.tsx new file mode 100644 index 000000000..7b3699c01 --- /dev/null +++ b/client-next/atoms/tooltip/styledTooltip.tsx @@ -0,0 +1,14 @@ +import { withStyles, Tooltip } from '@material-ui/core'; + +const StyledTooltip = withStyles(theme => ({ + tooltip: { + backgroundColor: theme.palette.success.main, + maxWidth: '425px', + }, + arrow: { + color: theme.palette.success.main, + fontSize: theme.spacing(3), + }, +}))(Tooltip); + +export default StyledTooltip; diff --git a/client-next/atoms/typography/danger.tsx b/client-next/atoms/typography/danger.tsx new file mode 100644 index 000000000..a62cb6937 --- /dev/null +++ b/client-next/atoms/typography/danger.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import useTypographyStyle from './typography.style'; + +interface DangerTypographyProps { + children: React.ReactNode; +} + +export default function Danger({ children }: DangerTypographyProps): JSX.Element { + const classes = useTypographyStyle(); + return
{children}
; +} diff --git a/client-next/atoms/typography/success.tsx b/client-next/atoms/typography/success.tsx new file mode 100644 index 000000000..dbecf5e8d --- /dev/null +++ b/client-next/atoms/typography/success.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import useTypographyStyle from './typography.style'; + +interface SuccessTypographyProps { + children: React.ReactNode; +} + +export default function Success({ children }: SuccessTypographyProps): JSX.Element { + const classes = useTypographyStyle(); + return
{children}
; +} diff --git a/client-next/atoms/typography/typography.style.ts b/client-next/atoms/typography/typography.style.ts new file mode 100644 index 000000000..1ef8bda62 --- /dev/null +++ b/client-next/atoms/typography/typography.style.ts @@ -0,0 +1,24 @@ +import { makeStyles, Theme } from '@material-ui/core/styles'; + +const useTypographyStyles = makeStyles((theme: Theme) => ({ + defaultFontStyle: { + fontSize: '14px', + }, + primaryText: { + color: theme.palette.primary.main, + }, + infoText: { + color: theme.palette.info.main, + }, + successText: { + color: theme.palette.success.main, + }, + warningText: { + color: theme.palette.warning.main, + }, + dangerText: { + color: theme.palette.error.main, + }, +})); + +export default useTypographyStyles; diff --git a/client-next/components/mainpage/introduction/howToUseCreator.tsx b/client-next/components/mainpage/introduction/howToUseCreator.tsx new file mode 100644 index 000000000..1ff07d2b9 --- /dev/null +++ b/client-next/components/mainpage/introduction/howToUseCreator.tsx @@ -0,0 +1,132 @@ +// material-UI +import { Grid, Button, Typography, Dialog } from '@material-ui/core'; +// 내부 소스 +// 프로젝트 내부 모듈 +import { useState } from 'react'; +import { nanoid } from 'nanoid'; +// 컴포넌트 +// util 계열 +import useDialog from '../../../utils/hooks/useDialog'; +// 스타일 +import useStyles from '../../../styles/mainpage/introduction/howToUseCreator.style'; + +interface Source { + source: { + firstContent: string; + secondContent: string; + thirdContent: string; + fourthContent: string; + }; +} + +function HowToUseCreator({ source }: Source): JSX.Element { + const classes = useStyles(); + const [imgStep, setImgStep] = useState('contract'); + const UseStep = useDialog(); + + return ( +
+ + +
+ + 1 + + + 이용동의하기 + +
+ {source.firstContent.split('\n').map(row => ( + {`${row}`} + ))} +
+ + + + +
+ + 2 + + + 배너 광고 설정 + +
+ {source.secondContent.split('\n').map(row => ( + {`${row}`} + ))} +
+ + + +
+ + 3 + + + 클릭 광고 설정 + +
+ {source.thirdContent.split('\n').map(row => ( + {`${row}`} + ))} +
+ + + +
+ + 4 + + + 수익정산 + +
+ {source.fourthContent.split('\n').map(row => ( + {`${row}`} + ))} +
+ + + + + + sample + +
+ ); +} + +export default HowToUseCreator; diff --git a/client-next/components/mainpage/introduction/howToUseMarketer.tsx b/client-next/components/mainpage/introduction/howToUseMarketer.tsx new file mode 100644 index 000000000..d9cd3e371 --- /dev/null +++ b/client-next/components/mainpage/introduction/howToUseMarketer.tsx @@ -0,0 +1,169 @@ +// material-UI +import { Grid, Button, Typography } from '@material-ui/core'; +// 내부 소스 +// 프로젝트 내부 모듈 +import { useState } from 'react'; +import { nanoid } from 'nanoid'; +// 컴포넌트 +import Inquire from '../main/inquiry/inquiry'; +import Dialog from '../../../atoms/dialog/dialog'; +import CustomButtons from '../../../atoms/button/customButton'; +// util 계열 +import useDialog from '../../../utils/hooks/useDialog'; +// 스타일 +import useStyles from '../../../styles/mainpage/introduction/howToUseMarketer.style'; + +interface Props { + source: { + firstContent: string; + secondContent: string; + thirdContent: string; + fourthContent: string; + }; +} + +function HowToUsemarketer({ source }: Props): JSX.Element { + const classes = useStyles(); + const InquireDialog = useDialog(); + const [imgStep, setImgStep] = useState('banner'); + const UseStep = useDialog(); + + return ( +
+ + +
+ + 1 + + + 배너등록 + +
+ {source.firstContent.split('\n').map(row => ( + {`${row}`} + ))} +
+ { + InquireDialog.handleOpen(); + }} + > + 배너가 아직 없으시다면 클릭! + + + + window.open( + 'https://onad-static-files.s3.ap-northeast-2.amazonaws.com/pdfs/bannerGuide.pdf', + '_blank', + ) + } + > + 배너가이드 + + + +
+ + 2 + + + 캠페인생성 + +
+ {source.secondContent.split('\n').map(row => ( + {`${row}`} + ))} +
+ + + +
+ + 3 + + + 광고송출확인 + +
+ {source.thirdContent.split('\n').map(row => ( + {`${row}`} + ))} +
+ + + +
+ + 4 + + + 세금계산서/현금영수증 + +
+ {source.fourthContent.split('\n').map(row => ( + {`${row}`} + ))} +
+ + + + + + +
+ } + > + + + + sample + +
+ ); +} + +export default HowToUsemarketer; diff --git a/client-next/components/mainpage/introduction/introduceMiddle.tsx b/client-next/components/mainpage/introduction/introduceMiddle.tsx new file mode 100644 index 000000000..9db4be0ce --- /dev/null +++ b/client-next/components/mainpage/introduction/introduceMiddle.tsx @@ -0,0 +1,351 @@ +// material-UI +import { Typography, Button, Divider } from '@material-ui/core'; +// 내부 소스 +// 프로젝트 내부 모듈 +import { useState } from 'react'; +import classNames from 'classnames'; +// 컴포넌트 +// util 계열 +// 스타일 +import useStyles from '../../../styles/mainpage/introduction/introduceMiddle.style'; + +export default function IntroduceTop({ userType }: { userType: string | boolean }): JSX.Element { + const classes = useStyles(); + + const [platform, setFlatform] = useState(true); + + function handlePlatform(platformType: string): void { + switch (platformType) { + case 'afreecatv': + return setFlatform(false); + default: + return setFlatform(true); + } + } + + return ( +
+ {userType === 'marketer' ? ( +
+
+ + + +
+ +
+
+
+
+
+ + 배너광고 (CPM) + + + 생방송 화면에 노출되는 배너를 송출하여 + + + 상품 및 브랜드의 인지도를 높이는 광고입니다. + +
+ exCPM +
+ +
+
+ + 광고 형식 + + GIF, JPG, PNG 형식을 지원합니다. + 해상도 1920*1080px 기준 + 배너 사이즈 320*160px입니다. +
+
+ + 과금 기준 + + 시청자수 (1명) X 방송시간 X 2원 + + 2000cpm(1000회 노출당 비용=2000원/1000원) + +
+
+ + + +
+
+ + 광고 페이지 (CPC) + + + 랜딩페이지 (회사 홈페이지, 쇼핑몰)로의 유입을 원하신다면? + +
+
+ +
+
+ exCPC +
+ {platform ? ( +
+ + 광고 형식 1. 패널 + + 방송인이 개인방송 플랫폼(채널)에 + 패널을 등록한 후 패널 클릭시 + + 지금 송출되는 광고 랜딩페이지로 이동합니다. + + + 과금 기준 + + + 100 CPC (클릭당 비용 = 100원 / 1회 클릭) + +
+ ) : ( +
+ + 광고 형식 : 방송국 배너 + + 방송인이 개인방송 플랫폼(채널)에 + + 플로팅 혹은 하단 배너를 등록한 후 배너 클릭시 + + + 지금 송출되는 광고 랜딩페이지로 이동합니다. + + + 과금 기준 + + + 100 CPC (클릭당 비용 = 100원 / 1회 클릭) + +
+ )} +
+ + {platform && ( +
+
+ exChat +
+
+ + 광고 형식 2. 채팅봇 + + 방송인이 개인방송 플랫폼(채널)에 + 채팅봇 등록 후 채팅봇 클릭 시 + + 지금 송출되는 광고 랜딩페이지로 이동합니다. + + + 과금 기준 + + + 100 CPC (클릭당 비용 = 100원 / 1회 클릭) + +
+
+ )} +
+
+
+ ) : ( +
+
+ + + +
+ +
+
+
+
+
+ + 배너광고 (CPM) + + + 생방송 화면에 노출되는 배너를 송출하여 + + + 상품 및 브랜드의 인지도를 높이는 광고입니다. + +
+ exCPM +
+ +
+
+ + 광고 형식 + + GIF, JPG, PNG 형식을 지원합니다. + 해상도 1920*1080px 기준 + 배너 사이즈 320*160px입니다. +
+
+ + 과금 기준 + + 시청자수 (1명) X 방송시간 X 2원 + + 2000cpm(1000회 노출당 비용=2000원/1000원) + +
+
+ + + +
+
+ + 광고 페이지 (CPC) + + + 랜딩페이지 (회사 홈페이지, 쇼핑몰)로의 유입을 원하신다면? + +
+
+ +
+
+ exCPC +
+ {platform ? ( +
+ + 광고 형식 1. 패널 + + 방송인이 개인방송 플랫폼(채널)에 + 패널을 등록한 후 패널 클릭시 + + 지금 송출되는 광고 랜딩페이지로 이동합니다. + + + 과금 기준 + + + 100 CPC (클릭당 비용 = 100원 / 1회 클릭) + +
+ ) : ( +
+ + 광고 형식 : 방송국 배너 + + 방송인이 개인방송 플랫폼(채널)에 + + 플로팅 혹은 하단 배너를 등록한 후 배너 클릭시 + + + 지금 송출되는 광고 랜딩페이지로 이동합니다. + + + 과금 기준 + + + 100 CPC (클릭당 비용 = 100원 / 1회 클릭) + +
+ )} +
+ + {platform && ( +
+
+ exChat +
+
+ + 광고 형식 2. 채팅봇 + + 방송인이 개인방송 플랫폼(채널)에 + 채팅봇 등록 후 채팅봇 클릭 시 + + 지금 송출되는 광고 랜딩페이지로 이동합니다. + + + 과금 기준 + + + 100 CPC (클릭당 비용 = 100원 / 1회 클릭) + +
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/client-next/components/mainpage/introduction/question.tsx b/client-next/components/mainpage/introduction/question.tsx new file mode 100644 index 000000000..56a7816bf --- /dev/null +++ b/client-next/components/mainpage/introduction/question.tsx @@ -0,0 +1,129 @@ +// material-UI +import { Typography, withStyles } from '@material-ui/core'; +import MuiAccordion from '@material-ui/core/Accordion'; +import MuiAccordionSummary from '@material-ui/core/AccordionSummary'; +import MuiAccordionDetails from '@material-ui/core/AccordionDetails'; +// 내부 소스 +// 프로젝트 내부 모듈 +import * as React from 'react'; +import { nanoid } from 'nanoid'; +import textSource from '../../../source/introductionSource'; +// 컴포넌트 +// util 계열 +// 스타일 +import Styles from '../../../styles/mainpage/introduction/question.style'; + +function Question({ MainUserType }: { MainUserType: string }): JSX.Element { + const classes = Styles(); + + let ansData; + if (MainUserType === 'marketer') { + ansData = textSource.answerMarketer; + } else { + ansData = textSource.answerCreator; + } + + let source; + if (MainUserType === 'marketer') { + source = textSource.questionMarketer; + } else { + source = textSource.questionCreator; + } + + const Accordion = withStyles(() => ({ + root: { + borderTop: MainUserType === 'marketer' ? '3px solid #007af4' : '3px solid #33c2a3', + borderBottom: MainUserType === 'marketer' ? '3px solid #007af4' : '3px solid #33c2a3', + boxShadow: 'none', + '&:not(:last-child)': { + borderBottom: 0, + }, + '&:before': { + display: 'none', + }, + '&$expanded': { + margin: 'auto', + }, + }, + expanded: {}, + }))(MuiAccordion); + + const AccordionSummary = withStyles(() => ({ + root: { + borderBottom: '1px solid #c6c6c6', + marginBottom: -1, + minHeight: 70, + '&$expanded': { + minHeight: 70, + }, + }, + content: { + '&$expanded': { + margin: '12px 0', + }, + }, + expanded: {}, + }))(MuiAccordionSummary); + + const AccordionDetails = withStyles(theme => ({ + root: { + padding: theme.spacing(2), + backgroundColor: '#f8f8f8', + }, + }))(MuiAccordionDetails); + + function QnAAccordion({ source: _source, ansData: _ansData }: any): JSX.Element { + const [expanded, setExpanded] = React.useState('one'); + + const handleChange = (row: any) => (event: React.ChangeEvent, newExpanded: boolean) => { + setExpanded(newExpanded ? row.id : false); + }; + + return ( +
+ {_source.map((row: any, index: number) => ( + + +
+
{row.text}
+ {MainUserType === 'marketer' ? ( + arrow + ) : ( + arrow + )} +
+
+ +
+ {_ansData[index].split('\n').map((ans: string) => ( + + {`${ans}`} + + ))} +
+
+
+ ))} +
+ ); + } + + return ( +
+ + FAQ + + +
+ ); +} + +export default Question; diff --git a/client-next/components/mainpage/layout/appFooter.tsx b/client-next/components/mainpage/layout/appFooter.tsx new file mode 100644 index 000000000..01e82e21b --- /dev/null +++ b/client-next/components/mainpage/layout/appFooter.tsx @@ -0,0 +1,115 @@ +// material-UI +import Grid from '@material-ui/core/Grid'; +import Link from '@material-ui/core/Link'; +import Typography from '@material-ui/core/Typography'; +// 내부 소스 +import Image from 'next/image'; +import blogSVG from '../../../public/footer/blog.svg'; +import instagramSVG from '../../../public/footer/instagram.svg'; +import youtubeSVG from '../../../public/footer/youtube.svg'; +// 프로젝틑 내부 모듈 +// 스타일 +import useStyles from '../../../styles/mainpage/layout/appFooter.style'; + +function AppFooter(): JSX.Element { + const classes = useStyles(); + return ( +
+ + +
    +
  • + + 이용약관 + +
  • +
  • + + 개인정보 처리방침 + +
  • +
+
+ + + While True + + iconlogo + +
+ + + + 부산광역시 금정구 장전온천천로 51 테라스파크 3층 313호 와일트루 + + + + + + +
+ 대표명 + 강동기 +
+
+ 사업자등록번호 + 659-03-01549 +
+
+ 통신판매업 신고번호 + 제2019-부산금정-0581호 +
+
+ 이메일 + support@onad.io +
+
+ 고객센터 + 051-515-6309 +
+
+
+ + + © while True Corp. + {' All rights Reserved'} + +
+ ); +} +export default AppFooter; diff --git a/client-next/components/mainpage/layout/head.tsx b/client-next/components/mainpage/layout/head.tsx new file mode 100644 index 000000000..52dcb3a3f --- /dev/null +++ b/client-next/components/mainpage/layout/head.tsx @@ -0,0 +1,48 @@ +import Head from 'next/head'; + +export default function HeadCompo(): JSX.Element { + const MainURL = 'https://onad.io'; + return ( + + + + + {/* naver: wt_onad */} + + {/* Favicon */}l + + + + + + + + + + + + + + + + + + + + + + 온애드 | 1인 미디어 실시간 광고 플랫폼 + + + + ); +} diff --git a/client-next/components/mainpage/layout/navTop.tsx b/client-next/components/mainpage/layout/navTop.tsx new file mode 100644 index 000000000..b5209ae0b --- /dev/null +++ b/client-next/components/mainpage/layout/navTop.tsx @@ -0,0 +1,204 @@ +// material-UI +import MoreIcon from '@material-ui/icons/MoreVert'; +import { + Menu, + MenuItem, + IconButton, + Button, + useScrollTrigger, + AppBar, + Toolbar, +} from '@material-ui/core'; +// 내부 소스 +// 프로젝트 내부 모듈 +import { useState, useCallback } from 'react'; +import * as React from 'react'; +import classNames from 'classnames'; +import Link from 'next/link'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import textLogo from '../../../public/logo/textLogo.png'; +// 컴포넌트 +import LoginPopover from '../login/loginPopover'; // 현재 여기 작업 +// util 계열 +import HOST from '../../../config'; +import axios from '../../../utils/axios'; +// 스타일 +import useStyles from '../../../styles/mainpage/layout/navTop.style'; + +interface NavTopProps { + MainUserType?: boolean; + logout: () => void; + isLogin?: boolean; +} + +function NavTop({ MainUserType, logout, isLogin }: NavTopProps): JSX.Element { + const classes = useStyles(); + const router = useRouter(); + const trigger = useScrollTrigger({ threshold: 100, disableHysteresis: true }); + + // 마이페이지 이동 핸들러 + const handleClick = useCallback(buttonType => { + axios + .get(`${HOST}/login/check`) + .then(res => { + const { userType } = res.data; + if (userType === undefined) { + if (buttonType) { + alert('로그인 이후 이용하세요'); + } + } else if (userType === 'marketer') { + router.push('/mypage/marketer/main'); + } else if (userType === 'creator') { + router.push('/mypage/creator/main'); + } + }) + .catch(err => { + console.log(err); + }); + }, []); + + // 회원가입 버튼 + const RegButton = (): JSX.Element => { + if (isLogin) { + return ( + + ); + } + return ; + }; + + // 로그인 버튼 + const LoginButton = (): JSX.Element => { + if (isLogin) { + return ( + + ); + } + return ; + }; + + const [mobileMoreAnchorEl, setMobileMoreAnchorEl] = useState(null); + const isMobileMenuOpen = Boolean(mobileMoreAnchorEl); + + // 모바일 메뉴버튼 오픈 state + function handleMobileMenuOpen(event: React.MouseEvent): void { + setMobileMoreAnchorEl(event.currentTarget); + } + + // 모바일 메뉴버튼 오픈 닫는 핸들링 함수 + function handleMobileMenuClose(): void { + setMobileMoreAnchorEl(null); + } + + // 모바일 메뉴 컴포넌트 + const renderMobileMenu = ( + +
+ + + {/* eslint-disable-next-line */} + 이용 방법 + + + + {MainUserType ? ( + + + {/* eslint-disable-next-line */} + 방송인 목록 + + + ) : null} + + + {isLogin ? ( + + ) : ( + + )} + + + + {isLogin ? ( + + ) : ( + + )} + +
+
+ ); + + return ( + <> + + +
+ + + textlogo + + +
+ {/* 이용방법 버튼 */} +
+ + {/* eslint-disable-next-line */} + 이용방법 + +
+ + {/* 방송인 목록 버튼 */} + {MainUserType ? ( +
+ + {/* eslint-disable-next-line */} + 방송인 목록 + +
+ ) : null} + + {/* 회원가입 버튼 */} + + + {/* 로그인 버튼 */} + +
+ +
+ + + +
+ + {renderMobileMenu} + + + ); +} + +export default NavTop; diff --git a/client-next/components/mainpage/login/creatorLoginForm.tsx b/client-next/components/mainpage/login/creatorLoginForm.tsx new file mode 100644 index 000000000..fec292e18 --- /dev/null +++ b/client-next/components/mainpage/login/creatorLoginForm.tsx @@ -0,0 +1,180 @@ +import { + Dialog, + DialogContent, + Button, + Typography, + CircularProgress, + TextField, + Divider, + IconButton, +} from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { Close } from '@material-ui/icons'; +// 내부 소스 + +// 프로젝트 내부 모듈 +import classnames from 'classnames'; +import { useState } from 'react'; +// 컴포넌트 +import { useRouter } from 'next/router'; +import StyledTooltip from '../../../atoms/tooltip/styledTooltip'; +import OnadLogo from '../../shared/onadLogo'; +// util 계열 +import useEventTargetValue from '../../../utils/hooks/useEventTargetValue'; +import { useLoginMutation } from '../../../utils/hooks/mutation/useLoginMutation'; +// 스타일 +import useStyles from '../../../styles/mainpage/login/loginForm.style'; + +interface CreatorLoginFormProps { + open: boolean; + handleClose: () => void; +} + +export default function CreatorLoginForm({ + open, + handleClose, +}: CreatorLoginFormProps): JSX.Element { + const classes = useStyles(); + const router = useRouter(); + const [loading, setLoading] = useState(false); + + const userid = useEventTargetValue(); + const passwd = useEventTargetValue(); + const [error, setError] = useState(); + + const loginMutation = useLoginMutation(); + + const handleLogin = (): void => { + loginMutation + .mutateAsync({ type: 'creator', userid: userid.value, passwd: passwd.value }) + .then(res => { + passwd.handleReset(); + setTimeout(() => { + // 15초 간의 timeout을 두고, 로딩 컴포넌트를 없앤다 + setError('로그인에 일시적인 문제가 발생했습니다.\n잠시후 다시 시도해주세요.'); + }, 1000 * 10); // 15초 + + if (res.data[0]) { + if (res.data[1]) setError(res.data[1] as string); + } + if (res.data === 'success') router.push('/mypage/creator/main'); + }) + .catch(err => { + setError(err.response.data.message); + }); + }; + + return ( + // 크리에이터 로그인 창 + { + handleClose(); + userid.handleReset(); + passwd.handleReset(); + setLoading(false); + if (reason !== 'backdropClick') { + Close(event); + } + }} + maxWidth="xs" + fullWidth + disableScrollLock + > + + + + + +
+ +
+ 온애드 방송인 로그인 + +
+ + { + if (e.key === 'Enter') { + handleLogin(); + } + }} + value={passwd.value} + /> + + + {error && ( + + {error} + + )} + + {/* new 로그인 */} + + + + +
+ + 온애드 계정이 없으신가요?  + {/* eslint-disable-next-line */} + => router.push('/regist/cre-signup')} + style={{ color: 'red', textDecoration: 'underline', cursor: 'pointer' }} + > + 가입하기 + + + + 트위치 계정 로그인 방식으로 온애드를 사용했었나요?  + 기존회원은여기} + > + {/* eslint-disable-next-line */} + => router.push('/regist/pre-user')} + style={{ color: 'red', textDecoration: 'underline', cursor: 'pointer' }} + > + 기존계정로그인 + + + +
+
+ + {loading && ( +
+ +
+ )} +
+ ); +} diff --git a/client-next/components/mainpage/login/findDialog.tsx b/client-next/components/mainpage/login/findDialog.tsx new file mode 100644 index 000000000..c763498f8 --- /dev/null +++ b/client-next/components/mainpage/login/findDialog.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; +import { + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Button, + TextField, +} from '@material-ui/core'; +import style from '../../../styles/mainpage/login/findDialog.style'; +import axios from '../../../utils/axios'; +import HOST from '../../../config'; +import { useMarketerUpdateTmpPassword } from '../../../utils/hooks/mutation/useMarketerUpdateTmpPassword'; + +interface Props { + dialogType: string; + findDialogOpen: boolean; + handleFindDialogClose: () => void; + handleClose: () => void; +} + +const initialState = { + marketerName: '', + marketerMail: '', + marketerId: '', +}; + +const FindResult: any = initialState; + +type InputType = React.ChangeEvent; +type FormType = React.FormEvent; + +function FindDialog({ + dialogType, + findDialogOpen, + handleFindDialogClose, + handleClose, +}: Props): JSX.Element { + const classes = style(); + const [findContent, setFindContent] = React.useState(initialState); + + function onChange(e: InputType): void { + const { name, value } = e.currentTarget; + FindResult[name] = value; + setFindContent(FindResult); + } + const CheckId = (event: FormType) => { + event.preventDefault(); + const emailReg = + /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*[.]+[a-zA-Z]{2,3}$/i; + if (emailReg.test(findContent.marketerMail)) { + axios + .get(`${HOST}/marketer/id`, { params: findContent }) + .then(res => { + const ans = res.data; + if (ans.error) { + alert(ans.message); + setFindContent(initialState); + } else { + alert(`당신의 ID는 ${ans.message} 입니다.`); + handleFindDialogClose(); + setFindContent(initialState); + } + }) + .catch(err => { + console.log(err); + handleFindDialogClose(); + setFindContent(initialState); + }); + } else { + // 이메일 형식 오류 + alert('이메일 형식이 올바르지 않습니다.'); + setFindContent(initialState); + } + }; + + const tmpPwMutation = useMarketerUpdateTmpPassword(); + + const CheckPasswd = (event: FormType) => { + event.preventDefault(); + const emailReg = + /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*[.]+[a-zA-Z]{2,3}$/i; + if (emailReg.test(findContent.marketerMail)) { + tmpPwMutation.mutateAsync(findContent).then(res => { + if (res.data.error) { + alert(res.data.message); + setFindContent(initialState); + } else { + alert('가입시 등록한 이메일로 임시비밀번호가 발송되었습니다.'); + handleFindDialogClose(); + handleClose(); + setFindContent(initialState); + } + }); + } else { + // 이메일 형식 오류 + alert('이메일 형식이 올바르지 않습니다.'); + setFindContent(initialState); + } + }; + + const handleSubmit = () => { + if (dialogType === 'PASSWORD') { + return CheckPasswd; + } + return CheckId; + }; + + const Content = () => { + if (dialogType === 'PASSWORD') { + return ( + + + ONAD에 등록시에 입력하였던 ID와 EMAIL을 입력하세요. + + + + + ); + } + return ( + + + ONAD에 등록시에 입력하였던 NAME와 EMAIL을 입력하세요. + + + + + ); + }; + + return ( + + Find ID/PW +
+ + + + + + +
+ ); +} + +export default FindDialog; diff --git a/client-next/components/mainpage/login/loginPopover.tsx b/client-next/components/mainpage/login/loginPopover.tsx new file mode 100644 index 000000000..355d88f74 --- /dev/null +++ b/client-next/components/mainpage/login/loginPopover.tsx @@ -0,0 +1,92 @@ +// material-UI +import { Button } from '@material-ui/core'; +// 프로젝트 내부 모듈 +import { useState } from 'react'; +// 컴포넌트 +import Link from 'next/link'; +import MarketerLoginForm from './marketerLoginForm'; +import CreatorLoginForm from './creatorLoginForm'; +import RegistDialog from '../regist/registDialog'; +// util 계열 +// 스타일 +import useStyles from '../../../styles/mainpage/login/loginPopover.style'; + +interface LoginPopoverProps { + type?: string; + logout: () => void; + MainUserType?: boolean; +} + +// login +// regist가 다르게 렌더링 되어야함. +// RegistDialog 열기 +function LoginPopover({ type, MainUserType, logout }: LoginPopoverProps): JSX.Element { + const [loginValue, setLoginValue] = useState(''); + const [registOpen, setRegistOpen] = useState(false); + + function handleDialogOpenClick(newValue: string): void { + setLoginValue(newValue); + } + + function handleDialogClose(): void { + setLoginValue(''); + } + + function handleRegistClose(): void { + setRegistOpen(false); + } + + function handleRegistOpen(): void { + setRegistOpen(true); + } + const classes = useStyles(); + + return ( + <> + {type === '로그인' ? ( + <> + + + + + + ) : ( + <> + {MainUserType ? ( +
+ +
+ ) : ( +
+ + {/* eslint-disable-next-line */} + 회원가입 + +
+ )} + + + + )} + + ); +} + +export default LoginPopover; diff --git a/client-next/components/mainpage/login/marketerLoginForm.tsx b/client-next/components/mainpage/login/marketerLoginForm.tsx new file mode 100644 index 000000000..eef99d168 --- /dev/null +++ b/client-next/components/mainpage/login/marketerLoginForm.tsx @@ -0,0 +1,240 @@ +import { + Dialog, + DialogContent, + Button, + TextField, + Divider, + Typography, + IconButton, + CircularProgress, +} from '@material-ui/core'; +import { Close } from '@material-ui/icons'; +// 내부 소스 +// 프로젝트 내부 모듈 +import classnames from 'classnames'; +import Image from 'next/image'; +import { useState } from 'react'; +import * as React from 'react'; +import { useRouter } from 'next/router'; +import kakaoLogo from '../../../public/logo/kakao/kakaolink_btn_small.png'; +import naverLogo from '../../../public/logo/naver/naver_icon_green.png'; +import googleLogo from '../../../public/logo/google/google.png'; +// 컴포넌트 +import FindDialog from './findDialog'; +import OnadLogo from '../../shared/onadLogo'; +// util 계열 +import { useLoginMutation } from '../../../utils/hooks/mutation/useLoginMutation'; +import { MarketerInfo } from '../../../utils/hooks/query/useMarketerProfile'; +import HOST from '../../../config'; +// 스타일 +import useStyles from '../../../styles/mainpage/login/loginForm.style'; + +interface Props { + open: boolean; + handleClose: () => void; + logout?: () => void; +} +// TODO: 비밀번호 암호화하여 전달하기. +function LoginForm({ open, handleClose }: Props): JSX.Element { + // prop를 통해 Marketer 인지 Creator인지 확인. + // 데이터가 변경되는 것일 때 state로 처리를 한다. + const classes = useStyles(); + const [findDialogOpen, setFindDialogOpen] = useState(false); + const [dialogType, setDialogType] = useState('ID'); + const [userid, setUserid] = useState(''); + const [passwd, setPasswd] = useState(''); + const router = useRouter(); + // 하나의 change로 값을 받을 수 있다. + const onChange = (event: React.ChangeEvent) => { + if (event.target.name === 'userid') { + setUserid(event.target.value); + } else { + setPasswd(event.target.value); + } + }; + + const [loading, setLoading] = useState(false); + + const loginMutation = useLoginMutation(); + const login = (event: React.SyntheticEvent) => { + if (event) { + event.preventDefault(); + } + loginMutation + .mutateAsync({ userid, passwd, type: 'marketer' }) + .then(res => { + if (res.data[0]) { + setPasswd(''); + alert(res.data[1]); + if (res.data[1] === '이메일 본인인증을 해야합니다.') { + handleClose(); + } + } else { + const userData = res.data[1] as MarketerInfo; + if (userData.temporaryLogin) { + handleClose(); + router.push('/'); + } else { + // dispatch({ type: 'session', data: userData }); + handleClose(); + router.push('/mypage/marketer/main'); + } + } + }) + .catch(reason => { + console.log(reason); + setPasswd(''); // 비밀번호 초기화 + alert('회원이 아닙니다.'); + }); + }; + + const dialog = ( + // 마케터 로그인 창 + + + + + + +
+ +
+ + 온애드 광고주 로그인 + +
+ + { + if (e.key === 'Enter') login(e); + }} + /> + + + + + + + + + + + + +
+ + 계정이 없으신가요?  + {/* eslint-disable-next-line */} + => router.push('/regist/main')} + > + 회원가입하기 + + + + + 아이디가 기억나지 않나요?  + {/* eslint-disable-next-line */} + { + setDialogType('ID'); + setFindDialogOpen(true); + }} + style={{ color: 'red', textDecoration: 'underline', cursor: 'pointer' }} + > + 아이디 찾기 + + + + 비밀번호가 기억나지 않나요?  + {/* eslint-disable-next-line */} + { + setDialogType('PASSWORD'); + setFindDialogOpen(true); + }} + style={{ color: 'red', textDecoration: 'underline', cursor: 'pointer' }} + > + 비밀번호 찾기 + + +
+
+ + {loading && ( +
+ +
+ )} +
+ ); + + return ( +
+ {dialog} + { + setFindDialogOpen(false); + }} + handleClose={handleClose} + /> +
+ ); +} + +export default LoginForm; diff --git a/client-next/components/mainpage/login/rePassword.tsx b/client-next/components/mainpage/login/rePassword.tsx new file mode 100644 index 000000000..fa959f4a7 --- /dev/null +++ b/client-next/components/mainpage/login/rePassword.tsx @@ -0,0 +1,156 @@ +import { + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Button, + TextField, +} from '@material-ui/core'; +// 내부 소스 + +// 프로젝트 내부 모듈 +import { useReducer } from 'react'; +import * as React from 'react'; +import { useRouter } from 'next/router'; +// 컴포넌트 +// util 계열 +import passwordRegex from '../../../utils/inputs/regex/password.regex'; +import { useMarketerUpdateMarketerInfoMutation } from '../../../utils/hooks/mutation/useMarketerUpdateMarketerInfoMutation'; +// 스타일 +import useStyles from '../../../styles/mainpage/login/rePassword.style'; + +const initialValue = { + value: '', + password: false, + repasswd: false, +}; + +interface RepasswordState { + password: boolean; + repasswd: boolean; + value: string; +} + +type RepasswordAction = { type: 'password'; value: string } | { type: 'repasswd'; value: string }; + +// reducer를 사용하여 Error를 handling하자 +const myReducer = (state: RepasswordState, action: RepasswordAction) => { + switch (action.type) { + case 'password': { + if (passwordRegex.test(action.value)) { + console.log('비밀번호가 형식과 일치합니다.'); + return { ...state, value: action.value, password: false }; + } + return { ...state, value: action.value, password: true }; + } + case 'repasswd': { + if (state.value === action.value) { + return { ...state, repasswd: false }; + } + return { ...state, repasswd: true }; + } + default: { + return state; + } + } +}; + +type InputType = React.ChangeEvent; +type FormType = React.FormEvent; +interface Props { + setRepassword: any; + logout: () => void; + repasswordOpen: boolean; +} + +function RePasswordDialog({ setRepassword, logout, repasswordOpen }: Props): JSX.Element { + const [state, dispatch] = useReducer(myReducer, initialValue); + const classes = useStyles(); + const router = useRouter(); + + const checkPasswd = (event: InputType) => { + event.preventDefault(); + dispatch({ type: 'password', value: event.target.value }); + }; + + const checkRePasswd = (event: InputType) => { + event.preventDefault(); + dispatch({ type: 'repasswd', value: event.target.value }); + }; + + const updateMarketerMutation = useMarketerUpdateMarketerInfoMutation(); + + const handleSubmit = (event: FormType) => { + event.preventDefault(); + if (state.password || state.repasswd) { + alert('입력이 올바르지 않습니다.'); + return; + } + + updateMarketerMutation + .mutateAsync({ + type: 'password', + value: state.value, + }) + .then(() => { + alert('비밀번호 변경이 완료되었습니다. 다시 로그인 해주세요'); + setRepassword(false); + logout(); + router.replace('/'); + }) + .catch(err => { + console.log(err); + }); + }; + + return ( + + CHANGE PW +
+ + + 변경할 비밀번호를 입력하세요. + + + + + + + +
+
+ ); +} + +export default RePasswordDialog; diff --git a/client-next/components/mainpage/main/advantage/advantage.tsx b/client-next/components/mainpage/main/advantage/advantage.tsx new file mode 100644 index 000000000..0db181826 --- /dev/null +++ b/client-next/components/mainpage/main/advantage/advantage.tsx @@ -0,0 +1,138 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +// material-UI +import { Divider, Typography } from '@material-ui/core'; +// 내부 소스 + +// 프로젝트 내부 모듈 +import { useState } from 'react'; +import classNames from 'classnames'; +import { nanoid } from 'nanoid'; +import Image from 'next/image'; +// 컴포넌트 +import Slider from './sub/index'; +// util 계열 +// 스타일 +import styles from '../../../../styles/mainpage/main/advantage/advantage.style'; + +interface Props { + MainUserType: boolean; + source: any; +} + +function Advantage({ source, MainUserType }: Props): JSX.Element { + const classes = styles(); + + const silderProps = { + zoomFactor: 20, + slideMargin: 10, + maxVisibleSlides: 4, + pageTransition: 500, + }; + + const clickFlag: any = { + contentWrap0: false, + contentWrap1: false, + contentWrap2: false, + contentWrap3: false, + contentWrap4: false, + }; + + const [itemClicked, setItemClicked] = useState(clickFlag); + + function flipHandler(e: any) { + const contentWrap = e.currentTarget as HTMLElement; + const contentWrapId = contentWrap.id; + const newItemClicked = { ...itemClicked, [contentWrapId]: !itemClicked[contentWrapId] }; + + setItemClicked(newItemClicked); + } + + return ( +
+ {MainUserType ? ( + + {source.marketer.map((content: any, i: number) => ( +
flipHandler(e)} + > +
+
+ advantage +
+ + {content.title} + + + 자세히보기 > + +
+
+ + {content.title} + + + {content.content} +
+
+ ))} +
+ ) : ( + + {source.creator.map((content: any, i: number) => ( +
flipHandler(e)} + > +
+
+ advantage +
+ + {content.title} + + + 자세히보기 > + +
+
+ + {content.title} + + + {content.content} +
+
+ ))} +
+ )} +
+ ); +} + +export default Advantage; diff --git a/client-next/components/mainpage/main/advantage/sub/index.tsx b/client-next/components/mainpage/main/advantage/sub/index.tsx new file mode 100644 index 000000000..b9f2c21aa --- /dev/null +++ b/client-next/components/mainpage/main/advantage/sub/index.tsx @@ -0,0 +1,150 @@ +import { useState, useEffect, useRef } from 'react'; +import ArrowRight from '@material-ui/icons/ArrowRight'; +import ArrowLeft from '@material-ui/icons/ArrowLeft'; +import SliderItem from './sliderItems'; +import { StyledSliderWrapper, StyledSlider } from './slider.style'; + +interface SliderProps { + children?: any; + zoomFactor: number; + slideMargin: number; + maxVisibleSlides: number; + pageTransition: number; + MainUserType: boolean; +} + +const numberOfSlides = (maxVisibleSlides: number, windowWidth: number) => { + if (windowWidth > 1650) return maxVisibleSlides; + if (windowWidth > 1280) return 3; + if (windowWidth > 960) return 2; + if (windowWidth > 600) return 2; + if (windowWidth > 400) return 2; + return 1; +}; + +function Slider({ + children, + zoomFactor, + slideMargin, + maxVisibleSlides, + pageTransition, + MainUserType, +}: SliderProps): JSX.Element { + const [currentPage, setCurrentPage] = useState(0); + const [transformValue, setTransformValue] = useState(`-${zoomFactor / 2}%`); + const [scrollSize, setScrollSize] = useState(0); + + const sliderRef = useRef(null); + + const visibleSlides = numberOfSlides(maxVisibleSlides, scrollSize); + const totalPages: number = Math.ceil(children.length / visibleSlides) - 1; + + useEffect(() => { + const resizeObserver = new ResizeObserver(entries => { + setScrollSize(entries[0].contentRect.width); + }); + if (sliderRef.current) { + resizeObserver.observe(sliderRef.current); + } + + const sliderRefCurrent = sliderRef.current; + + return (): void => { + if (sliderRefCurrent) { + resizeObserver.unobserve(sliderRefCurrent); + } + }; + }, []); + + useEffect(() => { + if (sliderRef && sliderRef.current) { + if (currentPage > totalPages) setCurrentPage(totalPages); + sliderRef.current.style.transform = `translate3D(-${currentPage * scrollSize}px, 0, 0)`; + } + }, [sliderRef, currentPage, scrollSize, totalPages]); + + const disableHoverEffect = () => { + if (sliderRef.current) sliderRef.current.style.pointerEvents = 'none'; + setTimeout(() => { + if (sliderRef.current) sliderRef.current.style.pointerEvents = 'all'; + }, pageTransition); + }; + + const handleSlideMove = (forward: boolean) => { + disableHoverEffect(); + setCurrentPage(currentPage + (forward ? 1 : -1)); + + if (sliderRef.current) { + sliderRef.current.style.transform = `translate3D(-${ + (currentPage + (forward ? 1 : -1)) * scrollSize + }px, 0, 0)`; + } + }; + + const handleMouseOver = (id: number) => { + if (id % visibleSlides === 1) setTransformValue('0%'); + if (id % visibleSlides === 0) setTransformValue(`-${zoomFactor}%`); + }; + + const handleMouseOut = () => { + setTransformValue(`-${zoomFactor / 2}%`); + }; + + const assignSlideClass = (index: number, visibleSlides1: number) => { + const classes = ['right', 'left']; + return classes[index % visibleSlides1] || ''; + }; + + return ( + + + {children.map((child: any, i: any) => ( + + {child} + + ))} + + {currentPage > 0 && ( +
+ +
+ )} + {currentPage !== totalPages && ( +
+ +
+ )} +
+ ); +} + +export default Slider; diff --git a/client-next/components/mainpage/main/advantage/sub/slider.style.ts b/client-next/components/mainpage/main/advantage/sub/slider.style.ts new file mode 100644 index 000000000..53610ff13 --- /dev/null +++ b/client-next/components/mainpage/main/advantage/sub/slider.style.ts @@ -0,0 +1,101 @@ +import styled from 'styled-components'; +import StyledSliderItem from './sliderItems.style'; + +type SliderWrapperProps = { + zoomFactor: number; + visibleSlides: number; +}; + +type SliderProps = { + visibleSlides: number; + transformValue: string; + zoomFactor: number; + slideMargin: number; + pageTransition: number; + ref: any; +}; + +export const StyledSliderWrapper = styled.div` + overflow: hidden; + position: relative; + padding: ${props => `${(props.zoomFactor / props.visibleSlides) * 1}%`} 0; + .button-wrapper { + position: absolute; + width: 30px; + height: 30px; + bottom: 20px; + box-sizing: border-box; + } + .button { + background-color: white; + margin: 0px; + padding: 0px; + display: block; + width: 30px; + height: 30px; + border: 2px solid #009efd; + color: #009efd; + font-weight: 800; + cursor: pointer; + transition: all 0.7s; + user-select: none; + border-radius: 50%; + :hover { + opacity: 0.5; + } + } + .button2 { + background-color: white; + display: block; + width: 30px; + height: 30px; + border: 2px solid #00d1c9; + color: #00d1c9; + border-radius: 50%; + font-weight: 800; + cursor: pointer; + transition: all 0.7s; + user-select: none; + :hover { + opacity: 0.5; + } + } + .backWrapper { + left: 50%; + bottom: 20px; + transform: translate(100%, 0); + } + .forwardWrapper { + left: 50%; + transform: translate(-100%, 0); + bottom: 20px; + } + .back { + display: flex; + flex-direction: row; + justify-content: center; + position: relative; + } + .forward { + display: flex; + flex-direction: row; + justify-content: center; + position: relative; + } + .buttonArrow { + position: absolute; + left: 0px; + top: 0px; + width: 26px; + height: 26px; + } +`; + +export const StyledSlider = styled.div` + display: flex; + padding: 0 60px; + transition: transform ${props => props.pageTransition}ms ease; + :hover ${StyledSliderItem} { + transform: translateX(${props => props.transformValue}); + } +`; diff --git a/client-next/components/mainpage/main/advantage/sub/sliderItems.style.ts b/client-next/components/mainpage/main/advantage/sub/sliderItems.style.ts new file mode 100644 index 000000000..cf7304e5c --- /dev/null +++ b/client-next/components/mainpage/main/advantage/sub/sliderItems.style.ts @@ -0,0 +1,42 @@ +import styled from 'styled-components'; + +export type Props = { + zoomFactor: number; + slideMargin: number; + visibleSlides: number; + className: string; +}; + +const StyledSliderItem = styled.div` + margin: 0 ${props => props.slideMargin}px; + transition: transform 500ms ease; + border-radius: 20px; + cursor: pointer; + width: 100%; + height: 100%; + box-sizing: border-box; + display: flex; + transform: scale(1); + user-select: none; + flex: 0 0 calc(100% / ${props => props.visibleSlides} - ${props => props.slideMargin * 2}px); + :hover { + transform: scale(${props => props.zoomFactor / 100 + 1}) !important; + } + :hover ~ * { + transform: translateX(${props => `${props.zoomFactor / 2}%`}) !important; + } + &.left { + transform-origin: left; + :hover ~ * { + transform: translateX(${props => `${props.zoomFactor}%`}) !important; + } + } + &.right { + transform-origin: right; + :hover ~ * { + transform: translateX(0%) !important; + } + } +`; + +export default StyledSliderItem; diff --git a/client-next/components/mainpage/main/advantage/sub/sliderItems.tsx b/client-next/components/mainpage/main/advantage/sub/sliderItems.tsx new file mode 100644 index 000000000..4a4de80af --- /dev/null +++ b/client-next/components/mainpage/main/advantage/sub/sliderItems.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import StyledSliderItem from './sliderItems.style'; + +interface SliderItemProps { + slideClass: string; + zoomFactor: number; + id: number; + callback: (id: number) => void; + callbackOut: () => void; + slideMargin: number; + visibleSlides: number; + children: React.ReactNode; +} + +function SliderItem({ + children, + slideClass, + zoomFactor, + id, + callback, + callbackOut, + slideMargin, + visibleSlides, +}: SliderItemProps): JSX.Element { + return ( + callback(id)} + onMouseOut={callbackOut} + > + {children} + + ); +} + +export default SliderItem; diff --git a/client-next/components/mainpage/main/background.tsx b/client-next/components/mainpage/main/background.tsx new file mode 100644 index 000000000..927606d18 --- /dev/null +++ b/client-next/components/mainpage/main/background.tsx @@ -0,0 +1,25 @@ +// 스타일 +import style from '../../../styles/mainpage/main/background.style'; + +function Background(): JSX.Element { + const classes = style(); + + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default Background; diff --git a/client-next/components/mainpage/main/contact/contact.tsx b/client-next/components/mainpage/main/contact/contact.tsx new file mode 100644 index 000000000..051ab072d --- /dev/null +++ b/client-next/components/mainpage/main/contact/contact.tsx @@ -0,0 +1,138 @@ +// material-UI +import { Button, Typography } from '@material-ui/core'; +// 내부 소스 + +// 프로젝트 내부 모듈 +import { useState } from 'react'; +import * as React from 'react'; +// 컴포넌트 +import Inquiry from '../inquiry/inquiry'; +import InquiryCreator from '../inquiry/inquiryCreator'; +import Dialog from '../../../../atoms/dialog/dialog'; +import CreatorLoginForm from '../../login/creatorLoginForm'; +import MarketerLoginForm from '../../login/marketerLoginForm'; +// util 계열 + +// 스타일 +import styles from '../../../../styles/mainpage/main/contact/contact.style'; + +interface ContactProps { + MainUserType: boolean; + source: { + content: { + title: string; + text: string; + location: string; + }; + }; + isLogin: boolean; + logout: () => void; +} + +function Contact({ source, MainUserType, isLogin, logout }: ContactProps): JSX.Element { + const classes = styles(); + const [loginValue, setLoginValue] = React.useState(''); + + function handleDialogOpenClick(newValue: string): void { + setLoginValue(newValue); + } + + function handleDialogClose(): void { + setLoginValue(''); + } + + function useDialog(): any { + const [open, setOpen] = useState(false); + const [isMarketer, setIsMarketer] = useState(false); + + function handleOpen(buttonType: string): void { + setIsMarketer(buttonType === 'marketer'); + setOpen(true); + } + function handleClose(): void { + setOpen(false); + } + return { + open, + isMarketer, + handleOpen, + handleClose, + }; + } + + const { open, handleOpen, handleClose } = useDialog(); + + return ( +
+
+
+
+ + {source.content.title} + + + {source.content.text} + +
+
+
+ + 지금 바로 온애드와 시작해보세요 + +
+ + + {!isLogin ? ( + + ) : ( + + )} +
+
+ + +
+ } + > + {MainUserType ? ( + + ) : ( + + )} + + + +
+ ); +} + +export default Contact; diff --git a/client-next/components/mainpage/main/contact/introContact.tsx b/client-next/components/mainpage/main/contact/introContact.tsx new file mode 100644 index 000000000..0e6ae0392 --- /dev/null +++ b/client-next/components/mainpage/main/contact/introContact.tsx @@ -0,0 +1,117 @@ +// material-UI +import { Button, Typography } from '@material-ui/core'; +// 내부 소스 +// 프로젝트 내부 모듈 +import { useState } from 'react'; +import * as React from 'react'; +// 컴포넌트 +import Inquiry from '../inquiry/inquiry'; +import InquiryCreator from '../inquiry/inquiryCreator'; +import Dialog from '../../../../atoms/dialog/dialog'; +import CreatorLoginForm from '../../login/creatorLoginForm'; +import MarketerLoginForm from '../../login/marketerLoginForm'; +// util 계열 +// 스타일 +import styles from '../../../../styles/mainpage/main/contact/introContact.style'; + +interface IntroContactProps { + MainUserType: boolean; + isLogin: boolean; + logout: () => void; +} + +function IntroContact({ MainUserType, isLogin, logout }: IntroContactProps): JSX.Element { + const classes = styles(); + const [loginValue, setLoginValue] = React.useState(''); + + function handleDialogOpenClick(newValue: string): void { + setLoginValue(newValue); + } + + function handleDialogClose(): void { + setLoginValue(''); + } + function useDialog(): any { + const [open, setOpen] = useState(false); + const [isMarketer, setIsMarketer] = useState(false); + + function handleOpen(buttonType: string): void { + setIsMarketer(buttonType === 'marketer'); + setOpen(true); + } + function handleClose(): void { + setOpen(false); + } + return { + open, + isMarketer, + handleOpen, + handleClose, + }; + } + + const { open, handleOpen, handleClose } = useDialog(); + + return ( +
+
+ + 지금 바로 온애드와 시작해보세요 + +
+ + + {!isLogin ? ( + + ) : ( + + )} +
+
+ + +
+ } + > + {MainUserType ? ( + + ) : ( + + )} + + + + + ); +} + +export default IntroContact; diff --git a/client-next/components/mainpage/main/hero/productHero.tsx b/client-next/components/mainpage/main/hero/productHero.tsx new file mode 100644 index 000000000..d990dcbb7 --- /dev/null +++ b/client-next/components/mainpage/main/hero/productHero.tsx @@ -0,0 +1,105 @@ +// material-UI +import { Typography, Button } from '@material-ui/core'; +// 내부 소스 +// 프로젝트 내부 모듈 +import { useState } from 'react'; +// 컴포넌트 +import Image from 'next/image'; +import ProductHeroLayout from './productHeroLayout'; +import CreatorLoginForm from '../../login/creatorLoginForm'; +import MarketerLoginForm from '../../login/marketerLoginForm'; +// util 계열 +// 스타일 +import styles from '../../../../styles/mainpage/main/hero/productHero.style'; + +interface ProductHeroProps { + MainUserType: boolean; + source: { + text: { + title: string; + beforeSubTitle: string; + subTitle: string; + }; + textCreator: { + title: string; + subTitle: string; + }; + }; + isLogin: boolean; + logout: () => void; +} + +function ProductHero({ MainUserType, source, isLogin, logout }: ProductHeroProps): JSX.Element { + const classes = styles(); + + const [loginValue, setLoginValue] = useState(''); + + function handleDialogOpenClick(newValue: string): void { + setLoginValue(newValue); + } + + function handleDialogClose(): void { + setLoginValue(''); + } + + return ( + + {MainUserType ? ( + // 마케터 페이지 +
+ + {source.text.title} + + + {source.text.beforeSubTitle} + {source.text.subTitle} + + + {!isLogin ? ( + + ) : ( + + )} +
+ ) : ( + // 크리에이터 페이지 +
+ + {source.textCreator.title} + + + {source.textCreator.subTitle} + + + {!isLogin ? ( + + ) : ( + + )} +
+ )} + + +
+ ); +} + +export default ProductHero; diff --git a/client-next/components/mainpage/main/hero/productHeroLayout.tsx b/client-next/components/mainpage/main/hero/productHeroLayout.tsx new file mode 100644 index 000000000..6d86e1e22 --- /dev/null +++ b/client-next/components/mainpage/main/hero/productHeroLayout.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import useStyles from '../../../../styles/mainpage/main/hero/productHeroLayout.style'; + +interface ProductHeroLayoutProps { + children: React.ReactNode; + MainUserType: boolean; +} + +function ProductHeroLayout({ children, MainUserType }: ProductHeroLayoutProps): JSX.Element { + const classes = useStyles(); + + return ( +
{children}
+ ); +} + +export default ProductHeroLayout; diff --git a/client-next/components/mainpage/main/howToUse/howToUse.tsx b/client-next/components/mainpage/main/howToUse/howToUse.tsx new file mode 100644 index 000000000..088eb6efd --- /dev/null +++ b/client-next/components/mainpage/main/howToUse/howToUse.tsx @@ -0,0 +1,91 @@ +// material-UI +import { Button, Typography, CircularProgress } from '@material-ui/core'; +// 내부 소스 +// 프로젝트 내부 모듈 +import { useState, useEffect } from 'react'; +import { nanoid } from 'nanoid'; +// 컴포넌트 +// util 계열 +// 스타일 +import styles from '../../../../styles/mainpage/main/howToUse/howToUse.style'; + +interface HowToUseProps { + source: { + content: string[]; + }; + MainUserType: boolean; +} + +function HowToUse({ source, MainUserType }: HowToUseProps): JSX.Element { + const classes = styles(); + const [loading, setLoading] = useState(false); + const [iframeLoading, setIframeLoading] = useState(false); + + function handleClick(): void { + setLoading(true); + setTimeout(() => { + setLoading(false); + }, 1000); + } + + useEffect(() => { + const iframeDocument = document.getElementById('onadYouTube') as HTMLIFrameElement; + + function handleLoad() { + if (!iframeLoading) { + setIframeLoading(true); + iframeDocument.src += '?autoplay=1'; + } + } + iframeDocument.addEventListener('load', handleLoad); + + return () => { + iframeDocument.removeEventListener('load', handleLoad); + }; + }, [iframeLoading]); + + return ( +
+
+
+