diff --git a/client/.eslintrc.json b/client/.eslintrc.json index b43c292b..b6ce022b 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -26,6 +26,7 @@ "SharedArrayBuffer": "readonly" }, "rules": { + "@typescript-eslint/no-unused-vars": "error", "jest/no-disabled-tests": "warn", "jest/no-focused-tests": "error", "jest/no-identical-title": "error", diff --git a/client/cypress/integration/eventjoin.spec.ts b/client/cypress/integration/eventjoin.spec.ts index d3aa9232..7ef391da 100644 --- a/client/cypress/integration/eventjoin.spec.ts +++ b/client/cypress/integration/eventjoin.spec.ts @@ -186,6 +186,6 @@ context('이벤트 예약 페이지', () => { expect(alertStub.getCall(0)).to.be.calledWith(RESERVE_COMPLETE); }); - cy.location('pathname').should('eq', '/'); + cy.location('pathname').should('eq', '/my/tickets'); }); }); diff --git a/client/src/App.tsx b/client/src/App.tsx index d3d8d62a..68b5f1b1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -25,6 +25,7 @@ import { import GlobalStoreProvider from 'stores'; import { UserAccountState, UserAccountAction } from 'stores/accountStore'; import { defaultAccountState } from 'stores/accountStore/reducer'; +import { AfterLoginAction } from 'stores/afterLoginStore'; import { useIsMount } from 'hooks'; const { REACT_APP_TEST_UID_TOKEN } = process.env; @@ -36,11 +37,11 @@ const App: React.FC = () => { - - - + + + - { component={MyPage} /> - + @@ -64,6 +65,16 @@ const App: React.FC = () => { export default App; +function PublicRoute({ ...rest }: any): React.ReactElement { + const { setLoginCallback } = useContext(AfterLoginAction); + + useEffect(() => { + setLoginCallback('/'); + }, [setLoginCallback]); + + return ; +} + function PrivateRoute({ component: TargetPage, ...rest @@ -72,11 +83,17 @@ function PrivateRoute({ const accountState = useContext(UserAccountState); const { setLoginState } = useContext(UserAccountAction); const [isLoginCheck, setIsLoginCheck] = useState(false); + const { setLoginCallback } = useContext(AfterLoginAction); + const path = window.location.pathname; useEffect(() => { setLoginState(true); }, [setLoginState]); + useEffect(() => { + if (isLoginCheck && !accountState.isLogin) setLoginCallback(path); + }, [rest, accountState.isLogin, setLoginCallback, isLoginCheck, path]); + useIsMount(() => { if (defaultAccountState !== accountState) setIsLoginCheck(true); }, accountState); diff --git a/client/src/commons/constants/string.ts b/client/src/commons/constants/string.ts index 9c05625a..60cbf125 100644 --- a/client/src/commons/constants/string.ts +++ b/client/src/commons/constants/string.ts @@ -5,6 +5,8 @@ export const LOGIN_SOCIAL = '소셜 계정을 사용해서 로그인'; export const BEFORE_LOGIN = '가입 혹은 로그인'; export const FOOTER_INFO = '대표 이메일 boostcamp@festa.io\n북어스(BookUs) | 대표 부캠이 | 서울특별시 서울구 서울동 서울로 123-1234 | 통신판매업 12345678 | 대표전화 123-1234-1234 (문의는 이메일 바랍니다)'; +export const INTERNAL_SERVER_ERROR = + '서버 요청에 실패했습니다. 잠시 후에 다시 시도해주세요'; export const SIGNUP_EMAIL = '이메일'; export const SIGNUP_LAST_NAME = '성'; @@ -131,12 +133,15 @@ export const BOUGHT_TICKET_EVENT_TITLE_CAPTION = '현재 구매한 티켓 목록 export const HISTORY_METHOD_PUSH = 'PUSH'; export const HISTORY_METHOD_REPLACE = 'REPLACE'; - export const REFUND_TICKET_SUCCESS = '환불이 완료되었습니다.'; export const REFUND_TICKET_FAILURE = '환불이 실패했습니다.'; export const NOT_FOUND_BOUGHT_TICKET = '아직 구매한 티켓이 없네요..😅'; export const NOT_FOUND_CREATED_EVENT = '주최한 이벤트가 없네요..🤣'; +export const TICKET_REMAIN_DAYS = '일 후에 판매마감'; +export const TICKET_INVALID_DATE = '판매기간이 지났습니다'; +export const TICKET_COMMING_SOON = '일 후 판매시작'; + export const FORM_NAME: any = { event: { isPublic: '공개 여부', @@ -164,4 +169,3 @@ export const FORM_NAME: any = { refundEndAt: '티켓 환불 마감 날짜', }, }; - diff --git a/client/src/components/molecules/IconBtn/index.tsx b/client/src/components/molecules/IconBtn/index.tsx index b877a066..9ee076d6 100644 --- a/client/src/components/molecules/IconBtn/index.tsx +++ b/client/src/components/molecules/IconBtn/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import * as S from './style'; import { Props as BtnProps } from 'components/atoms/Btn'; diff --git a/client/src/components/organisms/EventHeader/index.tsx b/client/src/components/organisms/EventHeader/index.tsx index 0b924c0d..6ac956d9 100644 --- a/client/src/components/organisms/EventHeader/index.tsx +++ b/client/src/components/organisms/EventHeader/index.tsx @@ -14,7 +14,7 @@ interface Props { endAt: string; user: User; ticketType: TicketType; - doneEvent?: boolean; + doneEventType?: number; } function EventHeader({ @@ -26,12 +26,13 @@ function EventHeader({ endAt, user, ticketType, - doneEvent, + doneEventType, }: Props): React.ReactElement { const ticketInfo = ticketType; const { firstName, lastName } = user; const profileImgUrl = 'https://kr.object.ncloudstorage.com/bookus/defaultProfileImg.png'; + const doneTypes = ['등록', '종료되었습니다.', '매진되었습니다.']; return ( @@ -71,9 +72,9 @@ function EventHeader({ {ticketInfo.leftCnt}명 diff --git a/client/src/components/organisms/Ticket/index.tsx b/client/src/components/organisms/Ticket/index.tsx index c55597d8..b3a838f5 100644 --- a/client/src/components/organisms/Ticket/index.tsx +++ b/client/src/components/organisms/Ticket/index.tsx @@ -6,6 +6,11 @@ import { IconLabel, Price } from 'components'; import { calculateDiffDaysOfDateRange } from 'utils/dateCalculator'; import { FaTicketAlt, FaCheck, FaRegCalendarAlt } from 'react-icons/fa'; +import { + TICKET_INVALID_DATE, + TICKET_REMAIN_DAYS, + TICKET_COMMING_SOON, +} from 'commons/constants/string'; interface Prop extends TicketType { count?: number; @@ -29,6 +34,26 @@ function Ticket({ Date().toString(), salesEndAt, ); + + function makeLabelContent(remainDays: number, salesStartAt: string) { + if (!doneEvent) { + return `${remainDays}${TICKET_REMAIN_DAYS}`; + } + + if (remainDays <= 0) { + return TICKET_INVALID_DATE; + } + + const convertToUTCDate = new Date(); + convertToUTCDate.setHours(-9); + + const commingDays = calculateDiffDaysOfDateRange( + convertToUTCDate.toString(), + salesStartAt, + ); + return `${commingDays}${TICKET_COMMING_SOON}`; + } + return ( <> 티켓 @@ -39,21 +64,20 @@ function Ticket({ {`${name} ${count ? `* ${count}` : ''}`} {desc} - } - labelContent={`${remainCnt}개 남음`} - /> + {leftCnt !== -1 && ( + } + labelContent={`${remainCnt}개 남음`} + /> + )} + } labelContent={`1인당 ${maxCntPerPerson}개 구입 가능`} /> } - labelContent={ - remainDays <= 0 - ? '판매기간이 종료되었습니다.' - : `${remainDays}일 후에 판매마감` - } + labelContent={makeLabelContent(remainDays, salesStartAt)} /> diff --git a/client/src/components/organisms/Ticket/style.ts b/client/src/components/organisms/Ticket/style.ts index be9a0a76..7e84318e 100644 --- a/client/src/components/organisms/Ticket/style.ts +++ b/client/src/components/organisms/Ticket/style.ts @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { theme, palette } from 'styled-tools'; +import { theme, palette, ifProp } from 'styled-tools'; export const TicketLabel = styled.div` ${theme('fontStyle.h6')}; @@ -22,7 +22,11 @@ interface TicketContentWrapContainerProps { export const TicketContentWrapContainer = styled.div< TicketContentWrapContainerProps >` - color: ${palette('grayscale', 4)} + color: ${ifProp( + 'disabled', + palette('grayscale', 4), + palette('grayscale', 1), + )}; padding-left: 2rem; padding-top: 1rem; padding-bottom: 1rem; diff --git a/client/src/hooks/useFetch.spec.tsx b/client/src/hooks/useFetch.spec.tsx index 033be13f..f2c063e3 100644 --- a/client/src/hooks/useFetch.spec.tsx +++ b/client/src/hooks/useFetch.spec.tsx @@ -40,7 +40,7 @@ describe('Hooks', () => { return <>; } // when - const wrapper = mount(); + mount(); // then await new Promise(resolve => { @@ -80,7 +80,7 @@ describe('Hooks', () => { return <>; } // when - const wrapper = mount(); + mount(); // then await new Promise(resolve => { diff --git a/client/src/pages/EventCreate/store.tsx b/client/src/pages/EventCreate/store.tsx index 483852c3..30098b5f 100644 --- a/client/src/pages/EventCreate/store.tsx +++ b/client/src/pages/EventCreate/store.tsx @@ -106,7 +106,7 @@ const TicketFormDefaultState: TicketFormState = { value: '', }, isPublicLeftCnt: { - valid: false, + valid: true, value: false, }, maxCntPerPerson: { diff --git a/client/src/pages/EventDetail/index.tsx b/client/src/pages/EventDetail/index.tsx index da859ecc..c834ae96 100644 --- a/client/src/pages/EventDetail/index.tsx +++ b/client/src/pages/EventDetail/index.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useState, useRef } from 'react'; import { useHistory, useParams } from 'react-router-dom'; import { NOT_FOUND, INTERNAL_SERVER_ERROR } from 'http-status'; @@ -52,6 +52,7 @@ function EventDetailView(): React.ReactElement { const [internalServerError, setInternalError] = useState(false); const history = useHistory(); const isEventInState = checkIfEventIsInState(eventsState.events!, +eventId!); + const remainDays = useRef(0); const events = isEventInState ? eventsState.events!.get(+eventId!)! @@ -73,19 +74,33 @@ function EventDetailView(): React.ReactElement { longitude, } = events; - const remainDays = calculateDiffDaysOfDateRange( - Date().toString(), - ticketType.salesEndAt, - ); + function doneEventType() { + remainDays.current = calculateDiffDaysOfDateRange( + Date().toString(), + ticketType.salesEndAt, + ); + + if (remainDays.current <= 0) return 1; + + const remainTickets = ticketType.quantity - ticketType.leftCnt; + if (remainTickets <= 0) return 2; + + return 0; + } useEffect(() => { - if (!isEventInState) + if (!isEventInState) { eventFetchDispatcher({ type: 'EVENT', params: { eventId: +eventId!, }, }); + remainDays.current = calculateDiffDaysOfDateRange( + Date().toString(), + ticketType.salesEndAt, + ); + } if (eventsState.status === NOT_FOUND) { history.replace('/NOT_FOUND'); @@ -101,6 +116,7 @@ function EventDetailView(): React.ReactElement { eventsState.status, history, isEventInState, + ticketType.salesEndAt, ]); return ( @@ -117,11 +133,11 @@ function EventDetailView(): React.ReactElement { place, ticketType, }} - doneEvent={remainDays <= 0} + doneEventType={doneEventType()} /> } eventContent={} - ticket={} + ticket={} place={ , + eventId: number, + ) { + return events.get(eventId); + } + const [isReserved, setisReserved] = useState(false); const [isTicketChecked, setIsTicketChecked] = useState(false); const [ticketCount, setTicketCount] = useState(1); @@ -90,8 +97,6 @@ function EventJoin(): React.ReactElement { if (typeof originEventId === 'undefined') { history.push('/404'); } - const eventId = +originEventId!; - const getInEventState = useCallback( (eventData: EventDetail) => { setEventState(eventData); @@ -99,22 +104,41 @@ function EventJoin(): React.ReactElement { [setEventState], ); + const eventId = +originEventId!; + const { title, mainImg, startAt, endAt, user, ticketType } = eventState; + const { maxCntPerPerson } = ticketType; + const eventDataFromStore = getEventIfEventIsInState( + eventsState.events!, + eventId, + ); + const isEventInState = !!eventDataFromStore; + const [isLoading, setLoading] = useState(true); + useEffect(() => { - if (eventsState && eventsState.events) { - const gettedEventData = eventsState.events.get(eventId); - if (gettedEventData) { - getInEventState(gettedEventData); - } else { - eventFetchDispatcher({ - type: 'EVENT', - params: { eventId }, - }); - } + if (isEventInState) { + getInEventState(eventDataFromStore!); + setLoading(false); + return; } - }, [eventFetchDispatcher, eventId, eventsState, getInEventState]); - const { title, mainImg, startAt, endAt, user, ticketType } = eventState; - const { maxCntPerPerson } = ticketType; + eventFetchDispatcher({ + type: 'EVENT', + params: { eventId }, + }); + setLoading(false); + }, [ + eventDataFromStore, + eventFetchDispatcher, + eventId, + eventsState, + getInEventState, + isEventInState, + ]); + + useEffect(() => { + window.scrollTo(0, 0); + }, []); + const isVisibleCounter = () => { return maxCntPerPerson > minTicketCount && isTicketChecked; }; @@ -137,9 +161,14 @@ function EventJoin(): React.ReactElement { await joinEvent(eventId, ticketCount); setisReserved(true); alert(RESERVE_COMPLETE); - history.push(ROUTES.HOME); + history.replace(ROUTES.MYPAGE_TICKETS); } catch (err) { const { response } = err; + if (!response) { + alert(INTERNAL_SERVER_ERROR); + history.push(ROUTES.HOME); + return; + } const { status: statusCode, data } = response; if (statusCode === UNAUTHORIZED) { @@ -171,6 +200,7 @@ function EventJoin(): React.ReactElement { return ( } eventSection={ + {stepList} @@ -37,7 +39,7 @@ function EventJoinTemplate({ )} - {eventSection} + {eventSection} {place} diff --git a/client/src/pages/EventJoin/template/style.ts b/client/src/pages/EventJoin/template/style.ts index b4d1e4b6..d46dbc03 100644 --- a/client/src/pages/EventJoin/template/style.ts +++ b/client/src/pages/EventJoin/template/style.ts @@ -25,6 +25,12 @@ export const EventContainer = styled.div` margin-left: 10rem; `; +export const EventSectionWrapper = styled.div` + & > div { + padding: 0rem; + } +`; + export const PlaceWrapper = styled.div` margin-top: 3rem; `; diff --git a/client/src/pages/Login/index.tsx b/client/src/pages/Login/index.tsx index 3f53ff97..84b1b7a9 100644 --- a/client/src/pages/Login/index.tsx +++ b/client/src/pages/Login/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { IconBtn } from 'components'; @@ -6,13 +6,20 @@ import { OAUTH_GOOGLE, LOGIN_SOCIAL } from 'commons/constants/string'; import googleSvg from 'assets/img/google.svg'; import LogoSvg from 'assets/img/logo.svg'; import LoginTemplate from './templates'; +import { AfterLoginState } from 'stores/afterLoginStore'; const { REACT_APP_SERVER_URL } = process.env; -const AuthURL = `${REACT_APP_SERVER_URL}/api/auth?returnTo=/`; +const AuthURL = (returnTo = '/'): string => + `${REACT_APP_SERVER_URL}/api/auth?returnTo=${returnTo}`; function Login(): React.ReactElement { const history = useHistory(); + const loginCallbackState = useContext(AfterLoginState); + + useEffect(() => { + console.log(loginCallbackState); + }, [loginCallbackState]); return ( { - window.location.href = AuthURL; + window.location.href = AuthURL(loginCallbackState); }, }} fullid diff --git a/client/src/stores/afterLoginStore/index.tsx b/client/src/stores/afterLoginStore/index.tsx index 4a1321c0..de1a3644 100644 --- a/client/src/stores/afterLoginStore/index.tsx +++ b/client/src/stores/afterLoginStore/index.tsx @@ -1,14 +1,16 @@ import React, { createContext, useState, Dispatch } from 'react'; export const AfterLoginState = createContext(''); -export const AfterLoginAction = createContext>(() => {}); +export const AfterLoginAction = createContext<{ + setLoginCallback: Dispatch; +}>({ setLoginCallback: () => {} }); function AccountStoreProvider({ children }: { children: React.ReactElement }) { const [loginCallbackState, setLoginCallback] = useState(''); return ( - + {children} diff --git a/reserve-server/src/common/constants/index.ts b/reserve-server/src/common/constants/index.ts index e563b203..52a29bbc 100644 --- a/reserve-server/src/common/constants/index.ts +++ b/reserve-server/src/common/constants/index.ts @@ -1,21 +1,27 @@ export const SOLD_OUT = { + status: 403, state: 1, message: 'ticket sold out', }; export const NOT_OPEN = { + status: 403, state: 0, message: 'wrong date', }; export const EXCEED_LIMIT = { + status: 403, state: 2, message: 'limit exceed ticket per person', }; export const SUCCESS = { + status: 200, message: 'success', }; export const UNAUTH = { + status: 401, message: 'unauthorized', }; export const NOT_EXIST = { + status: 404, message: 'wrong number of ticket', }; diff --git a/reserve-server/src/routes/api/controllers/orderTicket.ts b/reserve-server/src/routes/api/controllers/orderTicket.ts index 928acb7b..6d07d116 100644 --- a/reserve-server/src/routes/api/controllers/orderTicket.ts +++ b/reserve-server/src/routes/api/controllers/orderTicket.ts @@ -2,7 +2,7 @@ import { Response } from 'express'; import { sequelize } from 'utils/sequelize'; import { Transaction } from 'sequelize/types'; import { orderTransaction } from 'services'; -import { FORBIDDEN, NOT_FOUND } from 'http-status'; +import { FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR } from 'http-status'; import { SUCCESS } from 'common/constants'; export default async (req: any, res: Response) => { @@ -15,7 +15,8 @@ export default async (req: any, res: Response) => { ); res.send(SUCCESS); } catch (err) { - if (err.state === 404) return res.status(NOT_FOUND).send(err); - return res.status(FORBIDDEN).send(err); + if (err.status === 404) return res.status(NOT_FOUND).send(err); + if (err.status === 403) return res.status(FORBIDDEN).send(err); + return res.sendStatus(INTERNAL_SERVER_ERROR); } }; diff --git a/server/src/routes/api/events/validators/createEvent.ts b/server/src/routes/api/events/validators/createEvent.ts index 9d92b71a..ee499b94 100644 --- a/server/src/routes/api/events/validators/createEvent.ts +++ b/server/src/routes/api/events/validators/createEvent.ts @@ -1,12 +1,12 @@ import { checkSchema, CustomValidator } from 'express-validator'; import { resolveObject } from 'utils/objectResolver'; -const isLessThan = (key: string): { options: CustomValidator } => ({ - options: (value, { req }) => resolveObject(req.body, key) >= value, +const isLessThanOrEqualTo = (key: string): { options: CustomValidator } => ({ + options: (value, { req }): boolean => resolveObject(req.body, key) >= value, }); -const isGreaterThan = (key: string): { options: CustomValidator } => ({ - options: (value, { req }) => resolveObject(req.body, key) <= value, +const isGreaterThanOrEqualTo = (key: string): { options: CustomValidator } => ({ + options: (value, { req }): boolean => resolveObject(req.body, key) <= value, }); const isBetweenTodayAnd = (endKey: string): { options: CustomValidator } => ({ @@ -55,7 +55,7 @@ export default checkSchema({ isISO8601: true, exists: true, toDate: true, - custom: isGreaterThan('startAt'), + custom: isGreaterThanOrEqualTo('startAt'), }, place: { in: 'body', @@ -103,7 +103,7 @@ export default checkSchema({ }, 'ticket.price': { in: 'body', - isInt: { options: { gt: 0 } }, + isInt: { options: { min: 0 } }, toInt: true, exists: true, }, @@ -124,7 +124,7 @@ export default checkSchema({ isInt: true, toInt: true, exists: true, - custom: isLessThan('ticket.quantity'), + custom: isLessThanOrEqualTo('ticket.quantity'), }, 'ticket.salesStartAt': { in: 'body', diff --git a/server/src/services/events/getEventById.ts b/server/src/services/events/getEventById.ts index f8b55daf..20fe79c2 100644 --- a/server/src/services/events/getEventById.ts +++ b/server/src/services/events/getEventById.ts @@ -18,6 +18,6 @@ export default async (id: number): Promise => { const event = await Event.findOne({ where, attributes, include }); if (!event) throw Error('Not Found'); - + if (!event.ticketType.isPublicLeftCnt) event.ticketType.leftCnt = -1; return event; }; diff --git a/server/src/services/events/getEvents.ts b/server/src/services/events/getEvents.ts index 11eb0a65..cc100014 100644 --- a/server/src/services/events/getEvents.ts +++ b/server/src/services/events/getEvents.ts @@ -25,5 +25,15 @@ export default async (limit = 20, startAt: Date): Promise => { { model: User, attributes: ['id', 'lastName', 'firstName'] }, ]; - return await Event.findAll({ where, attributes, limit, order, include }); + const events = await Event.findAll({ + where, + attributes, + limit, + order, + include, + }); + return events.map(event => { + if (!event.ticketType.isPublicLeftCnt) event.ticketType.leftCnt = -1; + return event; + }); };