From 6b0f7c405cbb0de4e6324b673262c62e70635e09 Mon Sep 17 00:00:00 2001 From: Sanghoon Jeong <67852689+wjdtkdgns@users.noreply.github.com> Date: Sat, 25 Feb 2023 18:00:42 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refac(admin)=20:=20new=20event=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=98=EC=98=81=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refac(admin) : admin 공연 생성 페이지 디자인 변경 반영 (#199) * fix(admin) : 버튼 text 변경 (#199) --------- Co-authored-by: wjdtkdgns --- .../new/events/firstStep/FirstStep.tsx | 2 +- .../new/events/secondStep/SecondStep.tsx | 159 +++++++----------- 2 files changed, 58 insertions(+), 103 deletions(-) diff --git a/apps/admin/src/components/new/events/firstStep/FirstStep.tsx b/apps/admin/src/components/new/events/firstStep/FirstStep.tsx index 90b692e4..8cafdba9 100644 --- a/apps/admin/src/components/new/events/firstStep/FirstStep.tsx +++ b/apps/admin/src/components/new/events/firstStep/FirstStep.tsx @@ -27,7 +27,7 @@ const FirstStep = () => { }} disabled={selectedHostId === null} > - 호스트 만들기 + 다음 ); diff --git a/apps/admin/src/components/new/events/secondStep/SecondStep.tsx b/apps/admin/src/components/new/events/secondStep/SecondStep.tsx index 5c12a2d9..b807de4d 100644 --- a/apps/admin/src/components/new/events/secondStep/SecondStep.tsx +++ b/apps/admin/src/components/new/events/secondStep/SecondStep.tsx @@ -9,37 +9,40 @@ import { Button, BorderBox, } from '@dudoong/ui'; -import { useInputs } from '@dudoong/utils'; import styled from '@emotion/styled'; -import TimeButton from './TimeButton'; import type { CreateEventRequest } from '@lib/apis/event/eventType'; -import { useRef } from 'react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import useEvents from '@lib/hooks/useEvents'; import timeFormatter from '@lib/utils/timeFormatter'; +import { useForm, FormState, FieldValues } from 'react-hook-form'; interface SecondStepProps { hostId: number; } +interface InputType { + name: string; + runTime: number; +} + const SecondStep = ({ hostId }: SecondStepProps) => { - const runTimeRef = useRef(null); - const [runTime, setRunTime] = useState(null); const [startAt, setStartAt] = useState(null); const [startAtTime, setStartAtTime] = useState(null); - const [form, onChange] = useInputs({ - hostId: hostId, - name: '', - startAt: '', - runTime: 0, + const [buttonDisable, setButtonDisable] = useState(true); + const { register, handleSubmit, formState } = useForm({ + mode: 'onChange', }); const { postEventMutation } = useEvents(); - const makeEventHandler = () => { - if (startAt && startAtTime && runTime) { + useEffect(() => { + setButtonDisable(checkDisable(startAt, startAtTime, formState)); + }, [formState.isValid, startAt, startAtTime]); + + const makeEventHandler = (data: InputType) => { + if (startAt && startAtTime) { const payload = { - ...form, - runTime: runTime, + ...data, + hostId: hostId, startAt: timeFormatter(startAt, startAtTime), } as CreateEventRequest; @@ -47,27 +50,6 @@ const SecondStep = ({ hostId }: SecondStepProps) => { } }; - const changeRunTimeHandler = (change: number) => { - if (runTimeRef.current) { - if (change === 0) { - // 초기화 - runTimeRef.current.value = '0'; - setRunTime(0); - } - if (runTimeRef.current.value) { - // 시간 입력이 되어있을 때 - const curValue = Number(runTimeRef.current.value); - runTimeRef.current.value = - curValue + change < 0 ? '0' : `${curValue + change}`; - setRunTime(Number(runTimeRef.current.value)); - } else { - // 시간 입력이 안되어있을 때 - runTimeRef.current.value = change < 0 ? '0' : `${change}`; - setRunTime(Number(runTimeRef.current.value)); - } - } - }; - return ( <> @@ -84,74 +66,49 @@ const SecondStep = ({ hostId }: SecondStepProps) => { padding={[32, 0, 12, 0]} /> - - - + + + + - - - - - - - - - { - setRunTime(Number(e.target.value)); - }} - width={154} - ref={runTimeRef} - /> - { - changeRunTimeHandler(-10); - }} - /> - { - event.preventDefault(); - changeRunTimeHandler(30); - }} - /> - { - changeRunTimeHandler(0); - }} - /> - - + ); @@ -177,20 +134,18 @@ const SubTitle = () => { const checkDisable = ( date: Date | null, time: Date | null, - runTime: number | null, - name: string, + formState: FormState, ) => { - if (!date || !time || !runTime || name === '') return true; + console.log(date, time); + // 미입력 + if (!date || !time || !formState.isValid) return true; + // 날짜가 현재 이전인 경우 + if (date < new Date() && time < new Date()) return true; return false; }; // ---------------------------------------------------------------- -interface PickerWrapperProps { - width: number; -} - -const PickerWrapper = styled(FlexBox)` - width: ${({ width }) => `${width}px`}; +const PickerWrapper = styled(FlexBox)` height: 48px; `; From d2938a75feb1007bddc93d72c352b8d5372d1d85 Mon Sep 17 00:00:00 2001 From: Sanghoon Jeong <67852689+wjdtkdgns@users.noreply.github.com> Date: Sat, 25 Feb 2023 18:00:53 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(admin)=20:=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20qr=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20(#198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(admin) : 어드민 qr 레이아웃 구현 (#163) * feat(admin) : admin qr api 연결 및 qr-reader 패키지 버전 다운그래이드 (#163) * feat(admin) : 카메라 전체화면 추가 (#163) * fix(admin) : toast 유지 시간, qr 스캔 간격 변경 (#163) * fix(admin) : editor header 오류 수정 (#163) * fix(admin) : api 변경 반영 (#163) --------- Co-authored-by: wjdtkdgns --- apps/admin/package.json | 2 + apps/admin/src/assets/scanner.svg | 9 ++ .../events/detail/EventDetailInfo.tsx | 10 +- .../src/components/events/qr/FullQrScreen.tsx | 45 +++++++++ .../components/events/qr/NormalQrScreen.tsx | 56 +++++++++++ .../src/components/events/qr/QrScanner.tsx | 97 +++++++++++++++++++ .../components/events/qr/TempQrButtonSet.tsx | 13 --- .../components/shared/layout/Breadcrumb.tsx | 3 + apps/admin/src/lib/apis/event/EventApi.ts | 15 +++ apps/admin/src/lib/apis/event/eventType.ts | 19 ++++ apps/admin/src/pages/events/Qr.tsx | 13 ++- shared/ui/src/components/Toast/index.tsx | 2 +- yarn.lock | 58 +++++++++++ 13 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 apps/admin/src/assets/scanner.svg create mode 100644 apps/admin/src/components/events/qr/FullQrScreen.tsx create mode 100644 apps/admin/src/components/events/qr/NormalQrScreen.tsx create mode 100644 apps/admin/src/components/events/qr/QrScanner.tsx delete mode 100644 apps/admin/src/components/events/qr/TempQrButtonSet.tsx diff --git a/apps/admin/package.json b/apps/admin/package.json index cd3ae3e0..04a2a052 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -25,6 +25,7 @@ "react-cookie": "^4.1.1", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", + "react-qr-reader": "^2.2.1", "react-router-dom": "^6.6.1", "recoil": "^0.7.6", "vite-plugin-svgr": "^2.4.0" @@ -32,6 +33,7 @@ "devDependencies": { "@types/react": "^18.0.26", "@types/react-dom": "^18.0.9", + "@types/react-qr-reader": "^2.1.4", "@vitejs/plugin-react": "^3.0.0", "typescript": "^4.9.3", "vite": "^4.0.0" diff --git a/apps/admin/src/assets/scanner.svg b/apps/admin/src/assets/scanner.svg new file mode 100644 index 00000000..d3b2221c --- /dev/null +++ b/apps/admin/src/assets/scanner.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/admin/src/components/events/detail/EventDetailInfo.tsx b/apps/admin/src/components/events/detail/EventDetailInfo.tsx index 8d9368c9..3fe75d9d 100644 --- a/apps/admin/src/components/events/detail/EventDetailInfo.tsx +++ b/apps/admin/src/components/events/detail/EventDetailInfo.tsx @@ -27,12 +27,13 @@ interface EventDetailInfoProps { eventId: string; } +let isInitialized = false; + const EventDetailInfo = ({ content, setForm, eventId, }: EventDetailInfoProps) => { - const [curContent, setCurContent] = useState(''); const toolbarItems = [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], @@ -44,8 +45,10 @@ const EventDetailInfo = ({ const editorRef = useRef(null); useEffect(() => { - setCurContent(content); - if (editorRef.current) editorRef.current!.getInstance().setHTML(content); + if (editorRef.current && !isInitialized && content !== '') { + isInitialized = true; + editorRef.current.getInstance().setHTML(content); + } }, [content]); // presigned 발급 api @@ -103,7 +106,6 @@ const EventDetailInfo = ({ onChange={onChange} placeholder="공연 상세 내용을 입력해주세요." previewStyle="vertical" // 미리보기 스타일 지정 - initialValue={curContent} hideModeSwitch={true} // 모드 바꾸기 스위치 숨기기 여부 previewHighlight={true} height="500px" // 에디터 창 높이 diff --git a/apps/admin/src/components/events/qr/FullQrScreen.tsx b/apps/admin/src/components/events/qr/FullQrScreen.tsx new file mode 100644 index 00000000..faed3b83 --- /dev/null +++ b/apps/admin/src/components/events/qr/FullQrScreen.tsx @@ -0,0 +1,45 @@ +import { FlexBox, ListHeader, Spacing, Button } from '@dudoong/ui'; +import { ReactComponent as Scanner } from '@assets/scanner.svg'; +import styled from '@emotion/styled'; +import { css } from '@emotion/react'; +import QrScanner from '@components/events/qr/QrScanner'; + +const FullQrScreen = ({ + setNewView, +}: { + setNewView: React.Dispatch>; +}) => { + return ( + <> + + + + + { + setNewView((prev: boolean) => { + return !prev; + }); + }} + > + 돌아가기 + + + ); +}; + +export default FullQrScreen; + +const ScannerWrapper = styled(FlexBox)` + position: fixed; + top: calc(50vh - 225px); + left: calc(50vw - 225px); + z-index: 11; +`; + +const CustomButton = styled(Button)` + z-index: 11; + position: fixed; + right: 40px; + bottom: 40px; +`; diff --git a/apps/admin/src/components/events/qr/NormalQrScreen.tsx b/apps/admin/src/components/events/qr/NormalQrScreen.tsx new file mode 100644 index 00000000..80e9f42b --- /dev/null +++ b/apps/admin/src/components/events/qr/NormalQrScreen.tsx @@ -0,0 +1,56 @@ +import { FlexBox, ListHeader, Spacing, Button } from '@dudoong/ui'; +import { ReactComponent as Scanner } from '@assets/scanner.svg'; +import styled from '@emotion/styled'; +import { css } from '@emotion/react'; +import QrScanner from '@components/events/qr/QrScanner'; +import { useState } from 'react'; + +const NormalQrScreen = ({ + setNewView, +}: { + setNewView: React.Dispatch>; +}) => { + return ( + <> + + +
+ + + + +
+ + + + + + + ); +}; + +export default NormalQrScreen; + +const ScannerWrapper = styled(FlexBox)` + position: absolute; + margin: 75px 0px; + width: 100%; + z-index: 50; +`; diff --git a/apps/admin/src/components/events/qr/QrScanner.tsx b/apps/admin/src/components/events/qr/QrScanner.tsx new file mode 100644 index 00000000..ee6f3b97 --- /dev/null +++ b/apps/admin/src/components/events/qr/QrScanner.tsx @@ -0,0 +1,97 @@ +import styled from '@emotion/styled'; +import { useMutation } from '@tanstack/react-query'; +import EventApi from '@lib/apis/event/EventApi'; +import { IssuedTicket } from '@lib/apis/event/eventType'; +import { useLocation } from 'react-router-dom'; +import useToastify from '@dudoong/ui/src/lib/useToastify'; +import QrReader from 'react-qr-reader'; +import { css } from '@emotion/react'; + +interface QrScannerProps { + newView: boolean; +} + +const QrScanner = ({ newView }: QrScannerProps) => { + const eventId = useLocation().pathname.split('/')[2]; + const { setToast } = useToastify(); + + // 입장 처리 api + const patchEventIssuedTicket = useMutation( + EventApi.PATCH_EVENT_ISSUEDTICKET, + { + onSuccess: (data: IssuedTicket) => { + console.log('PATCH_EVENT_ISSUEDTICKET : ', data); + setToast({ type: 'success', comment: '입장이 완료되었습니다.' }); + }, + onError: (error: any) => { + const comment = error.response.data.reason; + setToast({ comment: comment }); + }, + }, + ); + + const handleScan = (result: any) => { + if (result) { + console.log('scan ticket : ', result); + patchEventIssuedTicket.mutate({ + eventId: eventId, + uuid: result, + }); + } + }; + + const qrReaderStyle = newView + ? { + position: 'fixed', + top: '0px', + left: '0px', + width: '100vw', + height: '100vh', + zIndex: '10', + } + : { + borderRadius: '12px', + boxShadow: '3px 4px 7px rgba(0, 0, 0, 0.15)', + border: ` 1px solid #000`, + width: '100%', + height: '600px', + padding: '0', + margin: '0', + overflow: 'hidden', + }; + + return ( + {}} + facingMode="user" + style={qrReaderStyle} + newView={newView} + /> + ); +}; + +export default QrScanner; + +interface QrCodeReaderProps { + newView: boolean; +} + +const QrCodeReader = styled(QrReader)` + & > section > div { + border: 0px none !important; + box-shadow: none !important; + } + ${({ newView }) => + newView + ? css` + & > section > video { + height: 100vh !important; + } + & > section { + height: 100vh !important; + } + ` + : null} +`; diff --git a/apps/admin/src/components/events/qr/TempQrButtonSet.tsx b/apps/admin/src/components/events/qr/TempQrButtonSet.tsx deleted file mode 100644 index 30a4b539..00000000 --- a/apps/admin/src/components/events/qr/TempQrButtonSet.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Button } from '@dudoong/ui'; -import { useNavigate } from 'react-router-dom'; - -const TempQrButtonSet = () => { - const navigate = useNavigate(); - return ( - <> - - - - ); -}; -export default TempQrButtonSet; diff --git a/apps/admin/src/components/shared/layout/Breadcrumb.tsx b/apps/admin/src/components/shared/layout/Breadcrumb.tsx index 8d7f4da1..9bfa88ac 100644 --- a/apps/admin/src/components/shared/layout/Breadcrumb.tsx +++ b/apps/admin/src/components/shared/layout/Breadcrumb.tsx @@ -53,6 +53,9 @@ const Breadcrumb = ({ name }: { name: string }) => { if (urlDetails.length > 3) { newUrl = EVENT_URL_SET[`${urlDetails[2]}new` as EventUrlSetTypeKey]; } + if (urlDetails[2] == 'qr' && urlDetails.length > 3) { + newUrl = 'QR 확인'; + } } else { detailUrl = HOST_URL_SET[urlDetails[2] as HostUrlSetTypeKey]; if (urlDetails.length > 3) { diff --git a/apps/admin/src/lib/apis/event/EventApi.ts b/apps/admin/src/lib/apis/event/EventApi.ts index 0adc3585..43b35d1b 100644 --- a/apps/admin/src/lib/apis/event/EventApi.ts +++ b/apps/admin/src/lib/apis/event/EventApi.ts @@ -13,6 +13,7 @@ import type { ImageUrlResponse, UpdateEventDetailRequest, BasicEventRequest, + IssuedTicket, } from './eventType'; const EventApi = { @@ -102,6 +103,7 @@ const EventApi = { ); return response.data.data; }, + PATCH_EVENT_BASIC: async ( payload: BasicEventRequest, eventId: string, @@ -112,6 +114,19 @@ const EventApi = { ); return response.data.data; }, + + PATCH_EVENT_ISSUEDTICKET: async ({ + uuid, + eventId, + }: { + uuid: string; + eventId: string; + }): Promise => { + const response = await axiosPrivate.patch( + `events/${eventId}/issuedTickets/${uuid}`, + ); + return response.data.data; + }, }; export default EventApi; diff --git a/apps/admin/src/lib/apis/event/eventType.ts b/apps/admin/src/lib/apis/event/eventType.ts index 38cd2826..f965b4b1 100644 --- a/apps/admin/src/lib/apis/event/eventType.ts +++ b/apps/admin/src/lib/apis/event/eventType.ts @@ -132,3 +132,22 @@ type EventStatusType = 'CLOSED' | 'CALCULATING' | 'OPEN' | 'PREPARING'; export interface UpdateEventStatusRequest { status: EventStatus; } + +export interface IssuedTicket { + issuedTicketId: number; + issuedTicketNo: string; + uuid: string; + ticketName: string; + ticketPrice: string; + createdAt: string; + enteredAt: string; + issuedTicketStatus: IssuedTicketStatusType; + optionPrice: string; + userInfo: { + userId: number; + userName: string; + phoneNumber: string; + }; +} + +type IssuedTicketStatusType = '입장 완료' | '입장 전' | '취소 티켓'; diff --git a/apps/admin/src/pages/events/Qr.tsx b/apps/admin/src/pages/events/Qr.tsx index ab555db6..11e7a7fc 100644 --- a/apps/admin/src/pages/events/Qr.tsx +++ b/apps/admin/src/pages/events/Qr.tsx @@ -1,10 +1,13 @@ -import TempQrButtonSet from '@components/events/qr/TempQrButtonSet'; +import { useState } from 'react'; +import NormalQrScreen from '@components/events/qr/NormalQrScreen'; +import FullQrScreen from '@components/events/qr/FullQrScreen'; const Qr = () => { - return ( - <> - - + const [newView, setNewView] = useState(false); + return newView ? ( + + ) : ( + ); }; export default Qr; diff --git a/shared/ui/src/components/Toast/index.tsx b/shared/ui/src/components/Toast/index.tsx index 4c456a2e..14ddef55 100644 --- a/shared/ui/src/components/Toast/index.tsx +++ b/shared/ui/src/components/Toast/index.tsx @@ -9,7 +9,7 @@ const Toast = () => { autoClose={2000} // default : 5000 closeButton={true} newestOnTop - limit={1} + limit={5} hideProgressBar // 유지 시간 바 비활성화 /> ); diff --git a/yarn.lock b/yarn.lock index ca2f04b9..bb4ed9ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5650,6 +5650,15 @@ __metadata: languageName: node linkType: hard +"@types/react-qr-reader@npm:^2.1.4": + version: 2.1.4 + resolution: "@types/react-qr-reader@npm:2.1.4" + dependencies: + "@types/react": "*" + checksum: aa2bb2f703bb826c5d2e8f15d45e42db48091b41b026d00709e6002214e348811b4ec80f121192fca292e8bfdcb047bfa0b3bd59e355e5ab1bc7e011232f2c3e + languageName: node + linkType: hard + "@types/react@npm:*, @types/react@npm:18.0.26, @types/react@npm:^18.0.26": version: 18.0.26 resolution: "@types/react@npm:18.0.26" @@ -6297,12 +6306,14 @@ __metadata: "@toast-ui/react-editor": ^3.2.2 "@types/react": ^18.0.26 "@types/react-dom": ^18.0.9 + "@types/react-qr-reader": ^2.1.4 "@vitejs/plugin-react": ^3.0.0 antd: ^5.2.2 react: ^18.2.0 react-cookie: ^4.1.1 react-dom: ^18.2.0 react-error-boundary: ^3.1.4 + react-qr-reader: ^2.2.1 react-router-dom: ^6.6.1 recoil: ^0.7.6 typescript: ^4.9.3 @@ -13466,6 +13477,13 @@ __metadata: languageName: node linkType: hard +"jsqr@npm:^1.2.0": + version: 1.4.0 + resolution: "jsqr@npm:1.4.0" + checksum: 7c572971f90c42772e30d152bde63b84edf1164bde80c53942e6b2068ea31caf00ad704aa46cacc9e71645f52dbeddebc6e84ba15e883c678ee93cde690de339 + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.2": version: 3.3.3 resolution: "jsx-ast-utils@npm:3.3.3" @@ -17829,6 +17847,20 @@ __metadata: languageName: node linkType: hard +"react-qr-reader@npm:^2.2.1": + version: 2.2.1 + resolution: "react-qr-reader@npm:2.2.1" + dependencies: + jsqr: ^1.2.0 + prop-types: ^15.7.2 + webrtc-adapter: ^7.2.1 + peerDependencies: + react: ~16 + react-dom: ~16 + checksum: a6e997fdd9106dc06efd5df979c49174cf5da97ced9dd1212ebe084fc6c346d2b8a636c6e710668bde32ced28a54e0a4a99e843101062fc3ca99cb90287473cb + languageName: node + linkType: hard + "react-refresh@npm:^0.11.0": version: 0.11.0 resolution: "react-refresh@npm:0.11.0" @@ -18657,6 +18689,15 @@ __metadata: languageName: node linkType: hard +"rtcpeerconnection-shim@npm:^1.2.15": + version: 1.2.15 + resolution: "rtcpeerconnection-shim@npm:1.2.15" + dependencies: + sdp: ^2.6.0 + checksum: 42a733d8e3846b7e7e3c33b6d5cddec95659ceb39d3f990f0a4437e6d719ed717237fea02352d4a18d86c825a1493eaeb481cc835267d2288b0ce43582b97f46 + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -18853,6 +18894,13 @@ __metadata: languageName: node linkType: hard +"sdp@npm:^2.12.0, sdp@npm:^2.6.0": + version: 2.12.0 + resolution: "sdp@npm:2.12.0" + checksum: 5deb20ac50a1aeb4dd5ff2e506c22d1674222c21f08ae97105c9552ae979e90ddc42b8526f2c3dcacfeebc45c7b2e6b6b4254ae2cf335263bf3ddf2ad9b8ec76 + languageName: node + linkType: hard + "select-hose@npm:^2.0.0": version: 2.0.0 resolution: "select-hose@npm:2.0.0" @@ -21465,6 +21513,16 @@ __metadata: languageName: node linkType: hard +"webrtc-adapter@npm:^7.2.1": + version: 7.7.1 + resolution: "webrtc-adapter@npm:7.7.1" + dependencies: + rtcpeerconnection-shim: ^1.2.15 + sdp: ^2.12.0 + checksum: 16534a721794bf375753b031e538f5c1ce84df3a272085069bbce7fa1b085e5a2d33b92096256a6fdacdf6f9e30f8543e2478a293ccf5a3849bc7143b10b38d3 + languageName: node + linkType: hard + "websocket-driver@npm:>=0.5.1, websocket-driver@npm:^0.7.4": version: 0.7.4 resolution: "websocket-driver@npm:0.7.4" From 304f8a7724a5ef3fd98e73a581395e2e5a1cb708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=95=9C=EB=B9=84?= <99820610+AlmondBreez3@users.noreply.github.com> Date: Sat, 25 Feb 2023 20:19:10 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor(admin):eventsInfo=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20payload=EA=B0=92=20=EC=88=98=EC=A0=95=20#1?= =?UTF-8?q?93=20(#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/.gitignore | 3 + apps/admin/index.html | 2 +- apps/admin/src/components/events/info/Map.tsx | 63 +++++++++++++------ .../src/components/events/info/Pagination.tsx | 22 +++---- .../hosts/{ => events}/HostsEvents.tsx | 0 .../hosts/{ => events}/NoEventPage.tsx | 0 .../src/components/new/hosts/CreateHost.tsx | 2 +- .../{ => new}/hosts/HostDescription.tsx | 0 apps/admin/src/lib/apis/event/eventType.ts | 4 +- apps/admin/src/pages/hosts/Events.tsx | 2 +- apps/admin/vite.config.ts | 3 + package.json | 3 +- yarn.lock | 10 +++ 13 files changed, 78 insertions(+), 36 deletions(-) rename apps/admin/src/components/hosts/{ => events}/HostsEvents.tsx (100%) rename apps/admin/src/components/hosts/{ => events}/NoEventPage.tsx (100%) rename apps/admin/src/components/{ => new}/hosts/HostDescription.tsx (100%) diff --git a/apps/admin/.gitignore b/apps/admin/.gitignore index a547bf36..3c55f19e 100644 --- a/apps/admin/.gitignore +++ b/apps/admin/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +#dotenv environment +.env \ No newline at end of file diff --git a/apps/admin/index.html b/apps/admin/index.html index d7753d8e..255fcdb6 100644 --- a/apps/admin/index.html +++ b/apps/admin/index.html @@ -18,7 +18,7 @@ diff --git a/apps/admin/src/components/events/info/Map.tsx b/apps/admin/src/components/events/info/Map.tsx index f76baff8..22f7e79f 100644 --- a/apps/admin/src/components/events/info/Map.tsx +++ b/apps/admin/src/components/events/info/Map.tsx @@ -1,9 +1,9 @@ -import { Input, ListHeader, Modal, Padding, Spacing } from '@dudoong/ui'; +import { Input, ListHeader, Search, Spacing, TagButton } from '@dudoong/ui'; import { BasicEventRequest } from '@lib/apis/event/eventType'; import useBottomButton from '@lib/hooks/useBottomButton'; import useEvents from '@lib/hooks/useEvents'; import timeFormatter from '@lib/utils/timeFormatter'; -import { SyntheticEvent, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Map, MapMarker } from 'react-kakao-maps-sdk'; import ModalSearch from './ModalSearch'; @@ -20,8 +20,9 @@ interface place { const MapPage = (props: any) => { const [lat, setLat] = useState(); const [lng, setLng] = useState(); - const [placeName, setPlaceName] = useState(); - const [placeAddress, setPlaceAddress] = useState(); + const [placeName, setPlaceName] = useState(); + const [placeAddress, setPlaceAddress] = useState(); + const [detailAddress, setDetailAddress] = useState(); const [markers, setMarkers] = useState(); const [map, setMap] = useState(); const [address, setAddress] = useState(''); @@ -49,6 +50,8 @@ const MapPage = (props: any) => { lng: Number(props.place.longitude), }, }); + setPlaceName(props.place.placeName); + setDetailAddress(props.place.placeAddress); } }, [props?.place]); @@ -65,8 +68,8 @@ const MapPage = (props: any) => { name: props.name, startAt: timeFormatter(props.startDate, props.startTime), runTime: props.runtime, - placeName: curMarker.content, - placeAddress: curMarker.placeAddress, + placeName: placeName, + placeAddress: detailAddress, longitude: Number(curMarker.position.lng), latitude: Number(curMarker.position.lat), }; @@ -102,10 +105,14 @@ const MapPage = (props: any) => { setAddress(e.target.value); }; + const handleName = (e: any) => { + e.preventDefault(); + }; + const handleMap = () => { if (!map) return; const ps = new kakao.maps.services.Places(); - if (ps) { + if (ps && address !== '') { ps.keywordSearch(address, (data, status, _pagination) => { if (status === kakao.maps.services.Status.OK) { // 검색된 장소 위치를 기준으로 지도 범위를 재설정하기위해 @@ -129,8 +136,6 @@ const MapPage = (props: any) => { ); } setMarkers(markers); - console.log(markers); - console.log(_pagination); setPagination(_pagination); //여기서 lat,lngset해주면될듯? @@ -147,7 +152,6 @@ const MapPage = (props: any) => { setMarker(markers); setLat(Number(markers.position.lat)); setLng(Number(markers.position.lng)); - setPlaceName(markers.content); bounds.extend( new kakao.maps.LatLng( @@ -167,24 +171,47 @@ const MapPage = (props: any) => { <>
setIsOpen(true)} + /> + } > -
+ + setIsOpen(true)} - readOnly + placeholder="공연장 이름을 적어주세요" + value={placeName ? placeName : ''} + onChange={(e: { target: { value: string } }) => + setPlaceName(e.target.value) + } + > + + + setDetailAddress(e.target.value) + } >
{ {arr.map((num) => ( - <> - - - - + + + ))} + + + + + + + typeof orderId === 'string' ? ( + ticketRefundMutation.mutate(orderId) + ) : ( + <> + ) + } + /> + + + ); +}; + +export default RefundInfo; + +const Wrapper = styled.div` + background-color: ${theme.palette.gray_100}; + ul { + margin-top: 8px; + margin-left: 24px; + list-style: disc; + ${({ theme }) => theme.typo.P_Text_14_R} + color :${({ theme }) => theme.palette.gray_400}; + } +`; + +const RefundConfirmation = ({ + onDismiss, + onCancel, +}: { + onDismiss: () => void; + onCancel: () => void; +}) => { + return ( + + + + + + + + ); +}; diff --git a/apps/ticket/src/components/mypage/Detail/TicketsInfo.tsx b/apps/ticket/src/components/mypage/Detail/TicketsInfo.tsx new file mode 100644 index 00000000..63968a9a --- /dev/null +++ b/apps/ticket/src/components/mypage/Detail/TicketsInfo.tsx @@ -0,0 +1,44 @@ +import { ListHeader, Spacing } from '@dudoong/ui'; +import { OrderLineTicketResponse } from '@lib/apis/order/orderType'; +import { Pagination } from 'swiper'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import 'swiper/css'; +import 'swiper/css/pagination'; + +import OrderedTicket from './OrderedTicket'; + +const TicketsInfo = ({ tickets }: { tickets: OrderLineTicketResponse[] }) => { + const pagination = { + clickable: true, + renderBullet: (_index: number, className: string) => { + return ''; + }, + }; + + const swiperParams = { + centeredSlides: true, + + modules: [Pagination], + pagination: pagination, + + initialSlide: 0, + }; + + return ( + <> + + + {tickets.map((ticket: OrderLineTicketResponse, idx: number) => { + return ( + + + + + ); + })} + + + ); +}; + +export default TicketsInfo; diff --git a/apps/ticket/src/components/mypage/History.tsx b/apps/ticket/src/components/mypage/History.tsx index cc95470b..ce436059 100644 --- a/apps/ticket/src/components/mypage/History.tsx +++ b/apps/ticket/src/components/mypage/History.tsx @@ -1,11 +1,20 @@ +import Main from '@components/shared/Layout/Main'; import DDHead from '@components/shared/Layout/NextHead'; +import { NavBar } from '@dudoong/ui'; +import { useRouter } from 'next/router'; +import { useRef } from 'react'; +import OrderList from './OrderList'; const History = () => { + const ref = useRef(null); + const router = useRouter(); + return ( - <> +
-
예매내역
- + router.back()} /> + +
); }; export default History; diff --git a/apps/ticket/src/components/mypage/MyCoupon.tsx b/apps/ticket/src/components/mypage/MyCoupon.tsx new file mode 100644 index 00000000..30044d5c --- /dev/null +++ b/apps/ticket/src/components/mypage/MyCoupon.tsx @@ -0,0 +1,23 @@ +import Main from '@components/shared/Layout/Main'; +import DDHead from '@components/shared/Layout/NextHead'; +import { MenuBar, NavBar } from '@dudoong/ui'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; + +const MyCoupon = () => { + const [menu, setMenu] = useState(0); + const router = useRouter(); + return ( +
+ + router.back()} /> + +
+ ); +}; + +export default MyCoupon; diff --git a/apps/ticket/src/components/mypage/NoData.tsx b/apps/ticket/src/components/mypage/NoData.tsx new file mode 100644 index 00000000..8b8bb569 --- /dev/null +++ b/apps/ticket/src/components/mypage/NoData.tsx @@ -0,0 +1,19 @@ +import DoongDoong from '@assets/doongdoong.svg'; +import { FlexBox, Spacing, Text } from '@dudoong/ui'; +import { useScreenHeight } from '@dudoong/utils'; + +const NoData = ({ text }: { text: string }) => { + useScreenHeight(); + + return ( + + + + + {text} + + + ); +}; + +export default NoData; diff --git a/apps/ticket/src/components/mypage/OrderDetail.tsx b/apps/ticket/src/components/mypage/OrderDetail.tsx index 8e95e0c8..b25ca5f7 100644 --- a/apps/ticket/src/components/mypage/OrderDetail.tsx +++ b/apps/ticket/src/components/mypage/OrderDetail.tsx @@ -1,11 +1,43 @@ +import Main from '@components/shared/Layout/Main'; import DDHead from '@components/shared/Layout/NextHead'; +import { Divider, NavBar } from '@dudoong/ui'; +import { OrderApi } from '@lib/apis/order/OrderApi'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import DetailEventInfo from './Detail/DetailEventInfo'; +import Tickets from './Detail/TicketsInfo'; +import PaymentInfo from './Detail/PaymentInfo'; +import CancelTicket from './Detail/RefundInfo'; +import TicketsInfo from './Detail/TicketsInfo'; +import RefundInfo from './Detail/RefundInfo'; const OrderDetail = () => { + const { + query: { orderId }, + back, + } = useRouter(); + + const { data } = useQuery(['orderDetail', orderId], () => + OrderApi.GET_ORDERS_DETAIL(orderId as string), + ); + return ( - <> +
-
예매 상세내역
- + back()} /> + {data ? ( + <> + + + + + + + + ) : ( + <> + )} +
); }; diff --git a/apps/ticket/src/components/mypage/OrderItem.tsx b/apps/ticket/src/components/mypage/OrderItem.tsx new file mode 100644 index 00000000..4f2476f7 --- /dev/null +++ b/apps/ticket/src/components/mypage/OrderItem.tsx @@ -0,0 +1,100 @@ +import { + FlexBox, + ListHeader, + ListRow, + Spacing, + Tag, + TagColorKey, + Text, + theme, +} from '@dudoong/ui'; +import styled from '@emotion/styled'; +import { OrderListResponse, StageType } from '@lib/apis/order/orderType'; + +import Image from 'next/image'; +import { useRouter } from 'next/router'; + +type TagColorType = { + [key in StageType]: TagColorKey; +}; + +const TAG_COLOR: TagColorType = { + 승인대기: 'red', + 입장완료: 'main', + 관람예정: 'main', + 입장중: 'main', + 취소됨: 'mono', +}; + +const OrderItem = (prop: OrderListResponse) => { + const router = useRouter(); + return ( + router.push(`/ticket/${prop.orderUuid}`)}> + + + + + + {prop.eventProfile.name} + + + + + + ); +}; + +export default OrderItem; + +const Wrapper = styled.div` + background-color: ${theme.palette.white}; + border-radius: 12px; + border: 1px solid ${theme.palette.black}; + + width: 100%; + max-width: 440px; + height: 166px; + + overflow: hidden; + + display: flex; + justify-content: space-between; + + cursor: pointer; + + .poster { + width: 120px; + height: 100%; + + object-fit: cover; + } +`; + +const ContentWrapper = styled.div` + padding: 15px 15px 10px 15px; + width: 100%; + + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 5px; + + .title { + word-break: break-all; + word-wrap: break-word; + white-space: -moz-pre-wrap; + white-space: pre-wrap; + } +`; diff --git a/apps/ticket/src/components/mypage/OrderList.tsx b/apps/ticket/src/components/mypage/OrderList.tsx new file mode 100644 index 00000000..1ceedf34 --- /dev/null +++ b/apps/ticket/src/components/mypage/OrderList.tsx @@ -0,0 +1,62 @@ +import { MenuBar, Spacing } from '@dudoong/ui'; +import { useInfiniteQueries } from '@dudoong/utils'; +import styled from '@emotion/styled'; +import { OrderApi } from '@lib/apis/order/OrderApi'; +import { OrderListResponse } from '@lib/apis/order/orderType'; +import { ForwardedRef, forwardRef, HTMLAttributes, useState } from 'react'; +import NoData from './NoData'; +import OrderItem from './OrderItem'; + +const OrderList = ( + props: HTMLAttributes, + ref: ForwardedRef, +) => { + const [menu, setMenu] = useState(0); + const { infiniteListElement, isEmpty } = + useInfiniteQueries( + ['orderDetail', menu], + ({ pageParam = 0 }) => + OrderApi.GET_ORDERS( + { + pageParam, + }, + menu === 0, + ), + OrderItem, + ); + + return ( + <> + + + {isEmpty && ( + <> + + + + )} + {infiniteListElement} + + ); +}; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + + padding: 20px 24px 20px 24px; + + box-sizing: border-box; +`; + +export default forwardRef(OrderList); diff --git a/apps/ticket/src/components/mypage/Ticket/QrSheetContainer.tsx b/apps/ticket/src/components/mypage/Ticket/QrSheetContainer.tsx index ce93d8d7..57e78946 100644 --- a/apps/ticket/src/components/mypage/Ticket/QrSheetContainer.tsx +++ b/apps/ticket/src/components/mypage/Ticket/QrSheetContainer.tsx @@ -1,4 +1,5 @@ import { Text } from '@dudoong/ui'; +import useToastify from '@dudoong/ui/src/lib/useToastify'; import styled from '@emotion/styled'; import { IssuedTicketInfo, @@ -7,6 +8,7 @@ import { import { TicketApi } from '@lib/apis/ticket/TicketApi'; import { useQuery } from '@tanstack/react-query'; import dynamic from 'next/dynamic'; +import { useEffect } from 'react'; const GRADIENT_COLOR: Record = { '입장 전': ['#6B36DC', '#92F5CE'], @@ -22,6 +24,14 @@ const QrSheetContainer = ({ ticket }: { ticket: IssuedTicketInfo }) => { { refetchInterval: 1000 }, ); + const { setToast } = useToastify(); + + useEffect(() => { + if (ticket.issuedTicketStatus === '입장 완료') { + setToast({ comment: '입장이 완료되었어요!' }); + } + }, [data?.issuedTicketInfo.issuedTicketStatus, ticket.issuedTicketStatus]); + return ( diff --git a/apps/ticket/src/components/mypage/Ticket/TicketItem.tsx b/apps/ticket/src/components/mypage/Ticket/TicketItem.tsx index 5542274d..e585f848 100644 --- a/apps/ticket/src/components/mypage/Ticket/TicketItem.tsx +++ b/apps/ticket/src/components/mypage/Ticket/TicketItem.tsx @@ -7,9 +7,15 @@ import Link from 'next/link'; import { BottomSheet } from 'react-spring-bottom-sheet'; import QrSheetContainer from './QrSheetContainer'; -const TicketItem = ({ ticket }: { ticket: IssuedTicketInfo }) => { +const TicketItem = ({ + ticket, + orderUuid, +}: { + ticket: IssuedTicketInfo; + orderUuid: string; +}) => { const { isOpen, openOverlay, closeOverlay } = useOverlay(); - + console.log(ticket); return ( @@ -18,7 +24,7 @@ const TicketItem = ({ ticket }: { ticket: IssuedTicketInfo }) => { - + diff --git a/apps/ticket/src/components/mypage/Ticket/TicketList.tsx b/apps/ticket/src/components/mypage/Ticket/TicketList.tsx index a401cb1f..3296ffb8 100644 --- a/apps/ticket/src/components/mypage/Ticket/TicketList.tsx +++ b/apps/ticket/src/components/mypage/Ticket/TicketList.tsx @@ -5,7 +5,13 @@ import 'swiper/css'; import 'swiper/css/pagination'; import TicketItem from './TicketItem'; -const TicketList = ({ tickets }: { tickets: IssuedTicketInfo[] }) => { +const TicketList = ({ + tickets, + orderUuid, +}: { + tickets: IssuedTicketInfo[]; + orderUuid: string; +}) => { console.log(tickets); const pagination = { clickable: true, @@ -32,7 +38,7 @@ const TicketList = ({ tickets }: { tickets: IssuedTicketInfo[] }) => { {tickets.map((ticket: IssuedTicketInfo, idx: number) => { return ( - + ); })} diff --git a/apps/ticket/src/components/mypage/Ticket/index.tsx b/apps/ticket/src/components/mypage/Ticket/index.tsx index e36aa89e..0ac7dfbd 100644 --- a/apps/ticket/src/components/mypage/Ticket/index.tsx +++ b/apps/ticket/src/components/mypage/Ticket/index.tsx @@ -1,5 +1,6 @@ import Main from '@components/shared/Layout/Main'; import DDHead from '@components/shared/Layout/NextHead'; +import Shortcuts from '@components/shared/Shortcuts'; import { Divider, ListHeader, NavBar, Spacing, SyncLoader } from '@dudoong/ui'; import { parseDate } from '@dudoong/utils'; import styled from '@emotion/styled'; @@ -7,6 +8,7 @@ import { OrderApi } from '@lib/apis/order/OrderApi'; import { useQuery } from '@tanstack/react-query'; import Image from 'next/image'; import { useRouter } from 'next/router'; + import TicketList from './TicketList'; const Ticket = () => { @@ -41,9 +43,21 @@ const Ticket = () => { parseDate(data.eventProfile.startAt)[1] }\n${data.eventProfile.placeName}`} /> - + + + + + + ) : ( diff --git a/apps/ticket/src/components/mypage/index.tsx b/apps/ticket/src/components/mypage/index.tsx index bef6436f..63f0d70e 100644 --- a/apps/ticket/src/components/mypage/index.tsx +++ b/apps/ticket/src/components/mypage/index.tsx @@ -1,17 +1,72 @@ -import { Divider, ListHeader, Padding, Profile } from '@dudoong/ui'; +import { + Divider, + FlexBox, + ListHeader, + NavBar, + Padding, + Profile, + Spacing, +} from '@dudoong/ui'; import DDHead from '@components/shared/Layout/NextHead'; +import { authState } from '@store/auth'; +import { useRecoilValue, useResetRecoilState } from 'recoil'; +import OrderItem from './OrderItem'; +import { OrderApi } from '@lib/apis/order/OrderApi'; +import Shortcuts from '@components/shared/Shortcuts'; +import Main from '@components/shared/Layout/Main'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { resetCrendentials } from '@lib/utils/setCredentials'; +import { removeCookies } from 'cookies-next'; const Mypage = () => { + const { userProfile } = useRecoilValue(authState); + const resetAuthState = useResetRecoilState(authState); + const router = useRouter(); + + const { data } = useQuery(['recentOrderDetail'], () => + OrderApi.GET_RECENT_ORDER(), + ); + return ( - <> +
+ router.back()} /> - + + + + {data ? : <>} + + - + + + + + + + { + resetAuthState(); + resetCrendentials(); + removeCookies('refreshToken'); + router.push('/home'); + }} + /> + + + +
); }; diff --git a/apps/ticket/src/components/shared/Layout/HeaderLayout.tsx b/apps/ticket/src/components/shared/Layout/HeaderLayout.tsx index f5c4f4c3..f778b8eb 100644 --- a/apps/ticket/src/components/shared/Layout/HeaderLayout.tsx +++ b/apps/ticket/src/components/shared/Layout/HeaderLayout.tsx @@ -10,6 +10,7 @@ import { useResetRecoilState } from 'recoil'; import { authState } from '@store/auth'; import { removeCookies } from 'cookies-next'; import { resetCrendentials } from '@lib/utils/setCredentials'; +import useToastify from '@dudoong/ui/src/lib/useToastify'; export interface HeaderLayoutProps { children: ReactNode; name?: string; @@ -24,6 +25,7 @@ export const HeaderLayout = ({ handleLogin, }: HeaderLayoutProps) => { const { push } = useRouter(); + const { Toast } = useToastify(); const resetAuthState = useResetRecoilState(authState); const profileOption: PopupOptions[] = [ { @@ -73,6 +75,7 @@ export const HeaderLayout = ({
{children}
+
); }; diff --git a/apps/ticket/src/components/shared/Shortcuts.tsx b/apps/ticket/src/components/shared/Shortcuts.tsx new file mode 100644 index 00000000..eeff04e8 --- /dev/null +++ b/apps/ticket/src/components/shared/Shortcuts.tsx @@ -0,0 +1,41 @@ +import { KeyOfPalette, ListRow } from '@dudoong/ui'; +import Chevron from '@assets/chevron.svg'; +import { useRouter } from 'next/router'; +import styled from '@emotion/styled'; + +interface ShortcutsProps { + text: string; + textColor?: KeyOfPalette; + url?: string; + onClick?: () => void; +} + +const Shortcuts = ({ + text, + textColor = 'gray_500', + url, + onClick, +}: ShortcutsProps) => { + const router = useRouter(); + return ( + } + onClick={url ? () => router.push(`${url}`) : onClick} + /> + ); +}; + +export default Shortcuts; + +const StyledListRow = styled(ListRow)` + cursor: pointer; + + transform: scale(1); + transition: all 0.1s ease-out; + + :active { + transform: scale(0.99); + } +`; diff --git a/apps/ticket/src/lib/apis/order/OrderApi.ts b/apps/ticket/src/lib/apis/order/OrderApi.ts index 1313109f..7dd8ab20 100644 --- a/apps/ticket/src/lib/apis/order/OrderApi.ts +++ b/apps/ticket/src/lib/apis/order/OrderApi.ts @@ -1,8 +1,10 @@ +import { InfiniteRequest, InfiniteResponse } from '@dudoong/utils'; import { axiosPrivate } from '../axios'; import type { ConfirmOrderRequest, CreateOrderRequest, CreateOrderResponse, + OrderListResponse, OrderResponse, OrderTicketResponse, } from './orderType'; @@ -24,11 +26,31 @@ export const OrderApi = { ); return response.data.data; }, - + GET_ORDERS: async ( + { pageParam, size = 10, sort = 'asc' }: InfiniteRequest, + isShowing: boolean, + ): Promise> => { + const response = await axiosPrivate.get( + `/orders/?showing=${isShowing}&page=${pageParam}&size=${size}&sort=${sort}`, + ); + return response.data.data; + }, + GET_RECENT_ORDER: async (): Promise => { + const response = await axiosPrivate.get(`/orders/recent`); + return response.data.data; + }, + GET_ORDERS_DETAIL: async (order_uuid: string): Promise => { + const response = await axiosPrivate.get(`orders/${order_uuid}/`); + return response.data.data; + }, GET_ORDERS_TICKETS: async ( order_uuid: string, ): Promise => { const response = await axiosPrivate.get(`orders/${order_uuid}/tickets`); return response.data.data; }, + POST_REFUND: async (order_uuid: string): Promise => { + const response = await axiosPrivate.post(`orders/${order_uuid}/refund`); + return response.data.data; + }, }; diff --git a/apps/ticket/src/lib/apis/order/orderType.ts b/apps/ticket/src/lib/apis/order/orderType.ts index b526c348..9542fda2 100644 --- a/apps/ticket/src/lib/apis/order/orderType.ts +++ b/apps/ticket/src/lib/apis/order/orderType.ts @@ -31,13 +31,12 @@ export interface ConfirmOrderRequest { */ export interface OrderResponse { paymentInfo: OrderPaymentResponse; - tickets: OrderLineTicketResponse; + tickets: OrderLineTicketResponse[]; refundInfo: RefundInfo; eventProfile: EventProfile; orderUuid: string; orderId: number; orderMethod: OrderMethod; - approveType: ApproveType; } /** @@ -65,16 +64,16 @@ export interface OrderLineTicketResponse { userName: string; orderLinePrice: string; purchaseQuantity: number; - answers: OptionAnswer; + answers: OptionAnswer[]; eachOptionPrice: string; } /** * 예매취소 정보 */ -interface RefundInfo { +export interface RefundInfo { endAt: string; - available: boolean; + availAble: boolean; } /** @@ -104,6 +103,27 @@ export type OrderStatus = | ' 취소된 결제' | ' 결제 실패'; +export type StageType = + | '승인대기' + | '입장완료' + | '관람예정' + | '입장중' + | '취소됨'; + +/** + * 예매 목록 + * OrderBiefElement + */ +export interface OrderListResponse { + refundInfo: RefundInfo; + eventProfile: EventProfile; + orderUuid: string; + orderNo: string; + stage: StageType; + orderStatus: OrderStatus; + itemName: string; + totalQuantity: number; +} /** * 결제 아이디로 티켓 조회 */ @@ -111,6 +131,8 @@ export type OrderStatus = export interface OrderTicketResponse { tickets: IssuedTicketInfo[]; eventProfile: EventProfile; + orderUuid: string; + orderNo: string; } export interface IssuedTicketInfo { @@ -144,3 +166,5 @@ export interface eventProfile { } export type IssuedTicketStatus = '입장 완료' | '입장 전' | '취소 티켓'; + +export type OrderMethodType = '승인 방식' | '결제 방식'; diff --git a/shared/ui/src/components/Tag/index.tsx b/shared/ui/src/components/Tag/index.tsx index a97e662e..116c928a 100644 --- a/shared/ui/src/components/Tag/index.tsx +++ b/shared/ui/src/components/Tag/index.tsx @@ -2,7 +2,8 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { KeyOfPalette, KeyOfTypo } from '../../theme'; -type TagColorKey = 'main' | 'mono' | 'red'; +export type TagColorKey = 'main' | 'mono' | 'red'; + type TagColorType = { [key in TagColorKey]: { background: KeyOfPalette; From 3a125b0f4d11d15f93f37c0ab80719ede8c45f88 Mon Sep 17 00:00:00 2001 From: Eugene Kim <67894159+eugene028@users.noreply.github.com> Date: Sun, 26 Feb 2023 03:54:15 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(admin):=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=98=B5=EC=85=98=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EB=B0=8F=20=EC=98=B5=EC=85=98=20=EC=A0=81=EC=9A=A9=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(admin): 티켓 옵션 생성하기 * feat(admin): 옵션 생성시 주관식, 객관식 구별 및 미리보기 수정 * feat(admin): 티켓 옵션 삭제하기 * feat(admin) : 티켓에 옵션 적용하기(#203) * yarn install * chore : 티켓 옵션 선택 스타일링 * feat(admin) : 공연 옵션 부착, 제거 dnd * feat(admin) : 공연 옵션 부착, 제거 dnd 스타일 * feat(admin) : 공연 옵션 부착, 제거 dnd 스타일 --------- Co-authored-by: 한규진 --- .../events/options/TempOButtonSet.tsx | 13 -- .../events/options/apply/OptionDropArea.tsx | 132 +++++++++++ .../events/options/apply/OptionItem.tsx | 54 +++++ .../events/options/apply/OptionList.tsx | 98 ++++++++ .../events/options/apply/TicketListOption.tsx | 55 +++++ .../components/events/options/apply/index.tsx | 92 ++++++++ .../events/options/create/NewOption.tsx | 19 ++ .../events/options/create/OptionPreview.tsx | 85 +++++++ .../tickets/newtickets/input/TicketInput.tsx | 2 +- .../shared/overlay/GlobalOverlay.tsx | 14 +- .../shared/overlay/content/CancelOption.tsx | 65 ++++++ .../shared/overlay/content/SaveOption.tsx | 54 +++++ apps/admin/src/lib/apis/option/OptionApi.ts | 103 +++++++++ apps/admin/src/lib/apis/option/optionType.ts | 51 ++++ apps/admin/src/lib/apis/order/orderType.ts | 4 +- .../utils/DragAndDrop/dragStartHandler.tsx | 3 + .../lib/utils/DragAndDrop/onDragEndApply.tsx | 9 + .../lib/utils/DragAndDrop/onDragEndDelete.tsx | 0 .../src/pages/events/options/NewOptions.tsx | 218 +++++++++++++++++- .../src/pages/events/options/Options.tsx | 40 +++- package.json | 2 + yarn.lock | 125 +++++++++- 22 files changed, 1212 insertions(+), 26 deletions(-) delete mode 100644 apps/admin/src/components/events/options/TempOButtonSet.tsx create mode 100644 apps/admin/src/components/events/options/apply/OptionDropArea.tsx create mode 100644 apps/admin/src/components/events/options/apply/OptionItem.tsx create mode 100644 apps/admin/src/components/events/options/apply/OptionList.tsx create mode 100644 apps/admin/src/components/events/options/apply/TicketListOption.tsx create mode 100644 apps/admin/src/components/events/options/apply/index.tsx create mode 100644 apps/admin/src/components/events/options/create/NewOption.tsx create mode 100644 apps/admin/src/components/events/options/create/OptionPreview.tsx create mode 100644 apps/admin/src/components/shared/overlay/content/CancelOption.tsx create mode 100644 apps/admin/src/components/shared/overlay/content/SaveOption.tsx create mode 100644 apps/admin/src/lib/apis/option/OptionApi.ts create mode 100644 apps/admin/src/lib/apis/option/optionType.ts create mode 100644 apps/admin/src/lib/utils/DragAndDrop/dragStartHandler.tsx create mode 100644 apps/admin/src/lib/utils/DragAndDrop/onDragEndApply.tsx create mode 100644 apps/admin/src/lib/utils/DragAndDrop/onDragEndDelete.tsx diff --git a/apps/admin/src/components/events/options/TempOButtonSet.tsx b/apps/admin/src/components/events/options/TempOButtonSet.tsx deleted file mode 100644 index d48990e3..00000000 --- a/apps/admin/src/components/events/options/TempOButtonSet.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Button } from '@dudoong/ui'; -import { useNavigate } from 'react-router-dom'; - -const TempOButtonSet = () => { - const navigate = useNavigate(); - return ( - <> - - - - ); -}; -export default TempOButtonSet; diff --git a/apps/admin/src/components/events/options/apply/OptionDropArea.tsx b/apps/admin/src/components/events/options/apply/OptionDropArea.tsx new file mode 100644 index 00000000..ae0eb6f6 --- /dev/null +++ b/apps/admin/src/components/events/options/apply/OptionDropArea.tsx @@ -0,0 +1,132 @@ +import { Droppable, Draggable } from 'react-beautiful-dnd'; +import styled from '@emotion/styled'; +import { theme, Text, Padding, FlexBox } from '@dudoong/ui'; +import { OptionGroupResponse } from '@lib/apis/option/optionType'; +import { ReactNode } from 'react'; +import { css } from '@emotion/react'; + +interface OptionDropArea { + ticketItemId: number; + optionGroups: OptionGroupResponse[]; + isEditable?: boolean; +} + +const OptionDropArea = ({ + ticketItemId, + optionGroups, + isEditable = true, +}: OptionDropArea) => { + return ( + <> + + {(provided) => ( +
+ {optionGroups.length === 0 ? ( + <> + + + ) : ( + <> + {optionGroups.map( + (item: OptionGroupResponse, index: number) => ( + + {(provided) => ( +
+ +
+ )} +
+ ), + )} + {provided.placeholder} + + )} +
+ )} +
+ + ); +}; + +const AppliedOption = ({ + item, + isEditable, +}: { + item: OptionGroupResponse; + isEditable: boolean; +}) => { + const additionalPrice = + item.options.find((option) => option.answer === '예')?.additionalPrice || + '0원'; + return ( + + + + + {item.name} + + + {item.type} {additionalPrice !== '0원' && `· ${additionalPrice}`} + + + + + ); +}; + +const BlankOption = ({ + dropPlaceholder, + isEditable, +}: { + dropPlaceholder: ReactNode; + isEditable: boolean; +}) => { + return ( + + + + {isEditable + ? '추가할 옵션을 드래그 앤 드롭 해주세요.' + : '이미 판매된 티켓의 옵션은 수정할 수 없어요.'} + + + {dropPlaceholder} + + ); +}; + +export default OptionDropArea; + +const RoundWrapper = styled.div<{ isEditable: boolean }>` + border-radius: 10px; + background-color: ${theme.palette.gray_100}; + border: 1px solid ${({ theme }) => theme.palette.gray_200}; + ${({ theme, isEditable }) => + !isEditable && + css` + background-color: ${theme.palette.gray_200}; + `} + height: 100px; + margin-top: 10px; +`; + +const OptionWrapper = styled.div` + border-radius: 10px; + background-color: ${theme.palette.main_100}; + height: auto; + margin-top: 12px; +`; diff --git a/apps/admin/src/components/events/options/apply/OptionItem.tsx b/apps/admin/src/components/events/options/apply/OptionItem.tsx new file mode 100644 index 00000000..38ea1509 --- /dev/null +++ b/apps/admin/src/components/events/options/apply/OptionItem.tsx @@ -0,0 +1,54 @@ +import { useLocation } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import OptionApi from '@lib/apis/option/OptionApi'; +import { FlexBox, ListRow, TagButton, Padding } from '@dudoong/ui'; + +export interface OptionItemProps { + name: string; + subText: string; + OptionGroupId: number; +} + +const OptionItem = ({ name, subText, OptionGroupId }: OptionItemProps) => { + const { pathname } = useLocation(); + const eventId = pathname.split('/')[2]; + const queryClient = useQueryClient(); + const { mutate: optionDeleteMutate } = useMutation( + OptionApi.PATCH_OPTION_DELETE, + { + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['AllOption', eventId] }); + }, + }, + ); + + const handleRemoveClick = () => { + optionDeleteMutate({ + eventId: eventId, + optionGroupId: OptionGroupId, + }); + }; + + return ( + <> + + + + + + + + ); +}; + +export default OptionItem; diff --git a/apps/admin/src/components/events/options/apply/OptionList.tsx b/apps/admin/src/components/events/options/apply/OptionList.tsx new file mode 100644 index 00000000..af4d367a --- /dev/null +++ b/apps/admin/src/components/events/options/apply/OptionList.tsx @@ -0,0 +1,98 @@ +import { + ListHeader, + theme, + Spacing, + FlexBox, + Text, + Padding, +} from '@dudoong/ui'; +import styled from '@emotion/styled'; +import OptionItem from './OptionItem'; +import { Draggable, Droppable } from 'react-beautiful-dnd'; +import type { OptionGroupResponse } from '@lib/apis/option/optionType'; + +interface OptionListProps { + optionItems: OptionGroupResponse[]; +} + +const OptionList = ({ optionItems }: OptionListProps) => { + console.log(optionItems); + if (!optionItems?.length) { + return ( + +
+ + + + + 옵션을 먼저 생성해주세요! + + +
+
+ ); + } else { + return ( + + +
+ + +
+ + + {(provided) => ( +
+ {optionItems?.map((item, index) => ( + + {(provided) => ( +
+ + + + +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+
+ ); + } +}; + +export default OptionList; + +const Wrapper = styled.div` + & > div { + position: sticky; + position: -webkit-sticky; + margin-top: 44px; + top: 36px; + } +`; + +const OptionItemContainer = styled.div` + width: 400px; + height: auto; + box-sizing: border-box; + background-color: ${theme.palette.white}; + border-radius: 12px; + border: 1px solid ${theme.palette.black}; +`; diff --git a/apps/admin/src/components/events/options/apply/TicketListOption.tsx b/apps/admin/src/components/events/options/apply/TicketListOption.tsx new file mode 100644 index 00000000..352ba867 --- /dev/null +++ b/apps/admin/src/components/events/options/apply/TicketListOption.tsx @@ -0,0 +1,55 @@ +import { ListHeader, Spacing, theme, Text, Divider } from '@dudoong/ui'; +import styled from '@emotion/styled'; +import { useQuery } from '@tanstack/react-query'; +import { useLocation } from 'react-router-dom'; +import OptionDropArea from './OptionDropArea'; +import OptionApi from '@lib/apis/option/OptionApi'; + +const TicketListOption = () => { + const { pathname } = useLocation(); + const eventId = pathname.split('/')[2]; + + const { data, isSuccess } = useQuery(['AppliedTicket', eventId], () => + OptionApi.GET_EVENTS_APPLIEDOPTIONGROUPS(eventId), + ); + + return ( + + {isSuccess && ( + <> + + + + + {data.appliedOptionGroups?.map((item) => ( +
+ + {item.ticketName} + + + + +
+ ))} + + )} +
+ ); +}; +export default TicketListOption; + +const Wrapper = styled.div``; + +const TicketItem = styled.div` + width: 400px; + height: auto; + box-sizing: border-box; + background-color: ${theme.palette.white}; + border-radius: 12px; + border: 1px solid ${theme.palette.black}; + + padding: 24px 22px; +`; diff --git a/apps/admin/src/components/events/options/apply/index.tsx b/apps/admin/src/components/events/options/apply/index.tsx new file mode 100644 index 00000000..7c95b3e5 --- /dev/null +++ b/apps/admin/src/components/events/options/apply/index.tsx @@ -0,0 +1,92 @@ +import ContentGrid from '@components/shared/layout/ContentGrid'; +import OptionApi from '@lib/apis/option/OptionApi'; +import useGlobalOverlay from '@lib/hooks/useGlobalOverlay'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { + DragDropContext, + DropResult, + resetServerContext, +} from 'react-beautiful-dnd'; +import { useLocation } from 'react-router-dom'; +import OptionList from './OptionList'; +import TicketListOption from './TicketListOption'; + +const ApplyOption = () => { + const { pathname } = useLocation(); + const eventId = pathname.split('/')[2]; + const queryClient = useQueryClient(); + const { openOverlay, closeOverlay } = useGlobalOverlay(); + const { data, isSuccess } = useQuery(['optionGroups', eventId], () => + OptionApi.GET_ALL_OPTION(eventId), + ); + + const onDragEnd = ({ draggableId, destination }: DropResult) => { + const dragElementId = draggableId; + const isApply = dragElementId.split('-')[0] === 'eventOption'; + + if (isApply && destination) { + const ticketItemId = destination.droppableId; + //옵선 적용 + applyOptionMutate({ + eventId, + ticketItemId, + payload: { optionGroupId: parseInt(dragElementId.split('-')[1]) }, + }); + } else { + //옵션 적용 취소 + const [, ticketItemId, optionGroupId] = dragElementId.split('-'); + openOverlay({ + content: 'cancelOption', + props: { + closeOverlay, + cancelOptionHandler: () => + cancelOptionMutate({ + eventId, + ticketItemId, + payload: { optionGroupId: parseInt(optionGroupId) }, + }), + }, + }); + } + }; + + useEffect(() => { + resetServerContext(); + }, []); + + // 옵션 적용 왼->오 + const { mutate: applyOptionMutate } = useMutation( + OptionApi.PATCH_APPLY_OPTION, + { + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['AppliedTicket', eventId] }); + }, + }, + ); + //옵션 취소 왼<-오 + const { mutate: cancelOptionMutate } = useMutation( + OptionApi.PATCH_CANCEL_OPTION, + { + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['AppliedTicket', eventId] }); + closeOverlay(); + }, + }, + ); + + return ( + <> + {isSuccess && ( + onDragEnd(result)}> + + + + + + )} + + ); +}; + +export default ApplyOption; diff --git a/apps/admin/src/components/events/options/create/NewOption.tsx b/apps/admin/src/components/events/options/create/NewOption.tsx new file mode 100644 index 00000000..dc164ffd --- /dev/null +++ b/apps/admin/src/components/events/options/create/NewOption.tsx @@ -0,0 +1,19 @@ +import { Button } from '@dudoong/ui'; +import { useNavigate } from 'react-router-dom'; + +const NewOption = ({ eventId }: { eventId: string }) => { + const navigate = useNavigate(); + + return ( + <> + + + ); +}; +export default NewOption; diff --git a/apps/admin/src/components/events/options/create/OptionPreview.tsx b/apps/admin/src/components/events/options/create/OptionPreview.tsx new file mode 100644 index 00000000..55b763ca --- /dev/null +++ b/apps/admin/src/components/events/options/create/OptionPreview.tsx @@ -0,0 +1,85 @@ +import styled from '@emotion/styled'; +import { + theme, + ListHeader, + Input, + Divider, + Spacing, + FlexBox, + SelectButton, +} from '@dudoong/ui'; +import { OptionItemProps } from '@lib/apis/option/optionType'; +import { useState } from 'react'; + +const OptionPreview = ({ name, description, type }: OptionItemProps) => { + const [answer, setAnswer] = useState('Y'); + const handleClickSelect = (key: 'Y' | 'N') => { + setAnswer(key); + }; + return ( +
+ + + + + + {type === '주관식' ? ( + <> + + + ) : ( + <> + + { + handleClickSelect('Y'); + }} + /> + { + handleClickSelect('N'); + }} + /> + + + )} + +
+ ); +}; + +export default OptionPreview; + +const Wrapper = styled.div` + width: 400px; + height: auto; + box-sizing: border-box; + background-color: ${theme.palette.white}; + border-radius: 12px; + border: 1px solid ${theme.palette.black}; + + padding: 24px 22px; +`; diff --git a/apps/admin/src/components/events/tickets/newtickets/input/TicketInput.tsx b/apps/admin/src/components/events/tickets/newtickets/input/TicketInput.tsx index f21f595f..be6c7544 100644 --- a/apps/admin/src/components/events/tickets/newtickets/input/TicketInput.tsx +++ b/apps/admin/src/components/events/tickets/newtickets/input/TicketInput.tsx @@ -13,7 +13,7 @@ import { forwardRef } from 'react'; export interface TicketInputProps extends InputProps { title: string; - description: string; + description?: string; placeholder: string; titleTypo?: ListHeaderVariantKey; descriptionTypo?: KeyOfTypo; diff --git a/apps/admin/src/components/shared/overlay/GlobalOverlay.tsx b/apps/admin/src/components/shared/overlay/GlobalOverlay.tsx index 45907106..37a72829 100644 --- a/apps/admin/src/components/shared/overlay/GlobalOverlay.tsx +++ b/apps/admin/src/components/shared/overlay/GlobalOverlay.tsx @@ -13,6 +13,8 @@ import Register from './content/Register'; import Saved from './content/Saved'; import SaveTicket from './content/SaveTicket'; import TableViewDetail from './content/TableDetailView'; +import SaveOption from './content/SaveOption'; +import CancelOption from './content/CancelOption'; export type GlobalSheetContentKey = | 'register' @@ -22,15 +24,13 @@ export type GlobalSheetContentKey = | 'paidTicket' | 'approve' | 'saveTicket' + | 'saveOption' | 'tableViewDetail' | 'saved' - | 'invitation'; + | 'invitation' + | 'cancelOption'; -export type GlobalSheetContentType = { - [key in GlobalSheetContentKey]: ReactNode; -}; - -const globalSheetContent = { +const globalSheetContent: Record = { register: Register, deleteEvent: DeleteEvent, postEvent: PostEvent, @@ -38,9 +38,11 @@ const globalSheetContent = { paidTicket: PaidTicket, approve: Approve, saveTicket: SaveTicket, + saveOption: SaveOption, tableViewDetail: TableViewDetail, saved: Saved, invitation: Invitation, + cancelOption: CancelOption, }; const GlobalOverlay = () => { diff --git a/apps/admin/src/components/shared/overlay/content/CancelOption.tsx b/apps/admin/src/components/shared/overlay/content/CancelOption.tsx new file mode 100644 index 00000000..76a7a9f3 --- /dev/null +++ b/apps/admin/src/components/shared/overlay/content/CancelOption.tsx @@ -0,0 +1,65 @@ +import { + Button, + Divider, + FlexBox, + ListHeader, + Spacing, + Text, +} from '@dudoong/ui'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +interface CancelOptionProps { + closeOverlay: () => void; + cancelOptionHandler: () => void; +} + +const CancelOption = ({ + closeOverlay, + cancelOptionHandler, +}: CancelOptionProps) => { + return ( + + + + + 티켓이 판매되기 이전에는 다시 적용할 수 있어요.
+
+ + + + + +
+ ); +}; + +export default CancelOption; + +const Wrapper = styled.div` + padding: 15px 35px; + width: 492px; + box-sizing: border-box; +`; diff --git a/apps/admin/src/components/shared/overlay/content/SaveOption.tsx b/apps/admin/src/components/shared/overlay/content/SaveOption.tsx new file mode 100644 index 00000000..cea913d2 --- /dev/null +++ b/apps/admin/src/components/shared/overlay/content/SaveOption.tsx @@ -0,0 +1,54 @@ +import { + Button, + Divider, + FlexBox, + ListHeader, + Spacing, + Text, +} from '@dudoong/ui'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +interface SaveOptionProps { + closeOverlay: () => void; + saveOptionHandler: () => void; +} + +const SaveOption = ({ saveOptionHandler }: SaveOptionProps) => { + return ( + + + + + 새 옵션이 저장되었어요요. +
+ 저장된 옵션은 옵션 적용 페이지에서 확인할 수 있어요! +
+ + + + +
+ ); +}; + +export default SaveOption; + +const Wrapper = styled.div` + padding: 15px 35px; + width: 492px; + box-sizing: border-box; +`; diff --git a/apps/admin/src/lib/apis/option/OptionApi.ts b/apps/admin/src/lib/apis/option/OptionApi.ts new file mode 100644 index 00000000..2953e274 --- /dev/null +++ b/apps/admin/src/lib/apis/option/OptionApi.ts @@ -0,0 +1,103 @@ +import { axiosPrivate } from '../axios'; +import { + ApplyTicketOptionRequest, + CreateTicketOptionRequest, + GetAppliedOptionGroupsResponse, + GetEventOptionsResponse, + GetTicketItemOptionsResponse, + OptionGroupResponse, +} from './optionType'; + +const OptionApi = { + GET_ALL_OPTION: async (eventId: string): Promise => { + const response = await axiosPrivate.get(`events/${eventId}/ticketOptions`); + return response.data.data; + }, + + POST_OPTION: async ({ + eventId, + payload, + }: { + eventId: string; + payload: CreateTicketOptionRequest; + }): Promise => { + console.log(payload); + const response = await axiosPrivate.post( + `events/${eventId}/ticketOptions`, + payload, + ); + return response.data.data; + }, + + PATCH_OPTION_DELETE: async ({ + eventId, + optionGroupId, + }: { + eventId: string; + optionGroupId: number; + }): Promise => { + const response = await axiosPrivate.patch( + `events/${eventId}/ticketOptions/${optionGroupId}`, + ); + return response.data.data; + }, + + GET_TICKET_OPTION: async ({ + eventId, + ticketItemId, + }: { + eventId: string; + ticketItemId: number; + }): Promise => { + const response = await axiosPrivate.get( + `events/${eventId}/ticketItems/${ticketItemId}/options`, + ); + return response.data.data; + }, + + /** + * 옵션 적용 + * @param param0 + */ + PATCH_APPLY_OPTION: async ({ + eventId, + ticketItemId, + payload, + }: { + eventId: string; + ticketItemId: string; + payload: ApplyTicketOptionRequest; + }): Promise => { + const response = await axiosPrivate.patch( + `events/${eventId}/ticketItems/${ticketItemId}/option`, + payload, + ); + return response.data.data; + }, + PATCH_CANCEL_OPTION: async ({ + eventId, + ticketItemId, + payload, + }: { + eventId: string; + ticketItemId: string; + payload: ApplyTicketOptionRequest; + }): Promise => { + const response = await axiosPrivate.patch( + `events/${eventId}/ticketItems/${ticketItemId}/option/cancel`, + payload, + ); + return response.data.data; + }, + + GET_EVENTS_APPLIEDOPTIONGROUPS: async ( + eventId: string, + ): Promise => { + const response = await axiosPrivate.get( + `events/${eventId}/ticketItems/appliedOptionGroups`, + ); + return response.data.data; + }, +}; + +export default OptionApi; diff --git a/apps/admin/src/lib/apis/option/optionType.ts b/apps/admin/src/lib/apis/option/optionType.ts new file mode 100644 index 00000000..da08752a --- /dev/null +++ b/apps/admin/src/lib/apis/option/optionType.ts @@ -0,0 +1,51 @@ +export type OptionGroupType = '객관식' | 'Y/N' | '주관식'; + +export interface CreateTicketOptionRequest { + type: OptionGroupType; + name: string; + description: string; + additionalPrice?: number; +} +export interface ApplyTicketOptionRequest { + optionGroupId: number; +} +/** + * 티켓 상품에 달린 옵션들 + */ +export interface GetTicketItemOptionsResponse { + optionGroups: OptionGroupResponse[]; +} + +export interface OptionGroupResponse { + optionGroupId: number; + type: OptionGroupType; + name: string; + description: string; + options: OptionResponse[]; +} + +export interface OptionResponse { + optionId: number; + answer: string; + additionalPrice: string; +} + +/** + * 이벤트에 속하는 옵션들 + */ +export interface GetEventOptionsResponse { + optionGroups: OptionGroupResponse[]; +} + +/** + * 해당 이벤트에 속하는 티켓상품들에 속하는 옵션들 + */ +export interface GetAppliedOptionGroupsResponse { + appliedOptionGroups: AppliedOptionGroupsResponse[]; +} + +export interface AppliedOptionGroupsResponse { + ticketItemId: number; + ticketName: string; + optionGroups: OptionGroupResponse[]; +} diff --git a/apps/admin/src/lib/apis/order/orderType.ts b/apps/admin/src/lib/apis/order/orderType.ts index dcff6901..b46df793 100644 --- a/apps/admin/src/lib/apis/order/orderType.ts +++ b/apps/admin/src/lib/apis/order/orderType.ts @@ -1,3 +1,5 @@ +import { OptionGroupType } from "../option/optionType"; + export interface GetOrdersRequest { orderStage: 'APPROVE_WAITING' | 'CONFIRMED'; searchType?: 'PHONE' | 'NAME'; @@ -136,4 +138,4 @@ export interface OptionAnswer { answer: string; additionalPrice: string; } -export type OptionGroupType = 'Y/N' | '객관식' | '주관식'; + diff --git a/apps/admin/src/lib/utils/DragAndDrop/dragStartHandler.tsx b/apps/admin/src/lib/utils/DragAndDrop/dragStartHandler.tsx new file mode 100644 index 00000000..752e5f81 --- /dev/null +++ b/apps/admin/src/lib/utils/DragAndDrop/dragStartHandler.tsx @@ -0,0 +1,3 @@ +export const handleDragStart = async (initial: { draggableId: string }) => { + const restoreItemArray: string[] = []; +}; diff --git a/apps/admin/src/lib/utils/DragAndDrop/onDragEndApply.tsx b/apps/admin/src/lib/utils/DragAndDrop/onDragEndApply.tsx new file mode 100644 index 00000000..c379f111 --- /dev/null +++ b/apps/admin/src/lib/utils/DragAndDrop/onDragEndApply.tsx @@ -0,0 +1,9 @@ +import { useRecoilTransaction_UNSTABLE } from 'recoil'; + +export const onDragEndApply = (result: any) => { + console.log(result); + if (!result.destination) return; + const { source, destination } = result; + + if (source.droppableId !== destination.droppableId) return; +}; diff --git a/apps/admin/src/lib/utils/DragAndDrop/onDragEndDelete.tsx b/apps/admin/src/lib/utils/DragAndDrop/onDragEndDelete.tsx new file mode 100644 index 00000000..e69de29b diff --git a/apps/admin/src/pages/events/options/NewOptions.tsx b/apps/admin/src/pages/events/options/NewOptions.tsx index 2ed770b3..3c5a89cf 100644 --- a/apps/admin/src/pages/events/options/NewOptions.tsx +++ b/apps/admin/src/pages/events/options/NewOptions.tsx @@ -1,4 +1,220 @@ +import { + Spacing, + ListHeader, + FlexBox, + Text, + Input, + SelectButton, +} from '@dudoong/ui'; +import ContentGrid from '@components/shared/layout/ContentGrid'; +import OptionPreview from '@components/events/options/create/OptionPreview'; +import useGlobalOverlay from '@lib/hooks/useGlobalOverlay'; +import { useForm } from 'react-hook-form'; +import useBottomButton from '@lib/hooks/useBottomButton'; +import { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import OptionApi from '@lib/apis/option/OptionApi'; +import { AllOptionResponse } from '@lib/apis/option/optionType'; +import { OptionGroupType } from '@lib/apis/option/optionType'; +import { Controller } from 'react-hook-form'; + const NewOptions = () => { - return
뉴옵션
; + const navigate = useNavigate(); + const [optionType, setOptionType] = useState(); + const { pathname } = useLocation(); + const eventId = pathname.split('/')[2]; + const [optionName, setOptionName] = useState(''); + const [optionDescrip, setOptionDescrip] = useState(''); + const { register, handleSubmit, control, formState } = useForm({ + mode: 'onChange', + }); + const { openOverlay, closeOverlay } = useGlobalOverlay(); + const { setButtonInfo, hideButtons } = useBottomButton({ + type: 'save', + isActive: true, + }); + + //티켓 생성 api + const postOptionCreationMutation = useMutation(OptionApi.POST_OPTION, { + onSuccess: (data: AllOptionResponse) => { + console.log('POST_OPTION: ', data); + openOverlay({ + content: 'saveOption', + props: { + saveOptionHandler: () => { + navigate(`/events/${eventId}/options`); + closeOverlay(); + hideButtons(); + }, + }, + }); + }, + }); + + const onSubmit = (data: any) => { + console.log(data); + postOptionCreationMutation.mutate({ + eventId: eventId, + payload: { + ...data, + }, + }); + }; + + const onError = (error: any) => { + console.log('error', error); + }; + + //제출 버튼 상태 조절 + useEffect(() => { + formState.isValid + ? setButtonInfo({ + firstDisable: false, + firstHandler: handleSubmit(onSubmit, onError), + }) + : setButtonInfo({ + firstDisable: true, + firstHandler: () => {}, + }); + }, [formState.isValid]); + + return ( +
+ + + + 티켓에 옵션을 부여해 설문을 받거나 + + + 부가 상품을 추가할 수 있어요. + + + } + /> + + + + + setOptionName(e.target.value), + })} + /> + + + setOptionDescrip(e.target.value), + })} + /> + + + ( + + { + setOptionType('주관식'); + onChange('주관식'); + }} + /> + { + setOptionType('Y/N'); + onChange('Y/N'); + }} + /> + + )} + /> + +
+ {optionType === 'Y/N' ? ( + <> + + + + ) : ( +
+ )} +
+
+ +
+
+ ); }; export default NewOptions; diff --git a/apps/admin/src/pages/events/options/Options.tsx b/apps/admin/src/pages/events/options/Options.tsx index 8dcaa220..bc939cd1 100644 --- a/apps/admin/src/pages/events/options/Options.tsx +++ b/apps/admin/src/pages/events/options/Options.tsx @@ -1,4 +1,42 @@ +import { Spacing, ListHeader, FlexBox, Text } from '@dudoong/ui'; +import { useLocation } from 'react-router-dom'; +import ContentGrid from '@components/shared/layout/ContentGrid'; +import { useQuery } from '@tanstack/react-query'; +import OptionApi from '@lib/apis/option/OptionApi'; +import NewOption from '@components/events/options/create/NewOption'; +import ApplyOption from '@components/events/options/apply'; + const Options = () => { - return
옵션
; + const { pathname } = useLocation(); + const eventId = pathname.split('/')[2]; + + return ( + <> + + + + 티켓을 구매하기 전 설문 응답을 위해 옵션을 각 티켓으로 + + + 드래그 앤 드롭 해보세요. 옵션의 답변은 필수입니다. + + + } + /> + + + + + + + + + + ); }; export default Options; diff --git a/package.json b/package.json index 3be1ae7c..e854f352 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@types/eslint": "^8", "@types/node": "^18.11.16", "@types/react": "^18.0.26", + "@types/react-beautiful-dnd": "^13", "@typescript-eslint/eslint-plugin": "latest", "@typescript-eslint/parser": "latest", "eslint": "^8.30.0", @@ -50,6 +51,7 @@ "@emotion/styled": "^11.10.5", "@tanstack/react-query": "^4.24.9", "@tanstack/react-query-devtools": "^4.24.9", + "react-beautiful-dnd": "^13.1.1", "react-drag-drop-files": "^2.3.8", "react-hook-form": "^7.43.1", "react-kakao-maps-sdk": "^1.1.6", diff --git a/yarn.lock b/yarn.lock index 61b92f53..876c12ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1714,6 +1714,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.9.2": + version: 7.21.0 + resolution: "@babel/runtime@npm:7.21.0" + dependencies: + regenerator-runtime: ^0.13.11 + checksum: 7b33e25bfa9e0e1b9e8828bb61b2d32bdd46b41b07ba7cb43319ad08efc6fda8eb89445193e67d6541814627df0ca59122c0ea795e412b99c5183a0540d338ab + languageName: node + linkType: hard + "@babel/runtime@npm:~7.5.4": version: 7.5.5 resolution: "@babel/runtime@npm:7.5.5" @@ -5410,7 +5419,7 @@ __metadata: languageName: node linkType: hard -"@types/hoist-non-react-statics@npm:^3.0.1": +"@types/hoist-non-react-statics@npm:^3.0.1, @types/hoist-non-react-statics@npm:^3.3.0": version: 3.3.1 resolution: "@types/hoist-non-react-statics@npm:3.3.1" dependencies: @@ -5620,6 +5629,15 @@ __metadata: languageName: node linkType: hard +"@types/react-beautiful-dnd@npm:^13": + version: 13.1.3 + resolution: "@types/react-beautiful-dnd@npm:13.1.3" + dependencies: + "@types/react": "*" + checksum: e09860672c15666ee3d3acfad3dfc2ebd8fceb29b5468e12b7543af74d0e19f2eb9c4be187adb33c5d6816f47b0db958a8c0160b15826b92f6df1e8e3e34657a + languageName: node + linkType: hard + "@types/react-datepicker@npm:^4.8.0": version: 4.8.0 resolution: "@types/react-datepicker@npm:4.8.0" @@ -5659,6 +5677,18 @@ __metadata: languageName: node linkType: hard +"@types/react-redux@npm:^7.1.20": + version: 7.1.25 + resolution: "@types/react-redux@npm:7.1.25" + dependencies: + "@types/hoist-non-react-statics": ^3.3.0 + "@types/react": "*" + hoist-non-react-statics: ^3.3.0 + redux: ^4.0.0 + checksum: a61ec25cbf8bb3720850402d3c49493fcff4afb73ad447d161460b5d4c600c984ad48708e8564d2fd32052eaa3c3b3f655c5b300ce813429637cce9e5958329f + languageName: node + linkType: hard + "@types/react@npm:*, @types/react@npm:18.0.26, @types/react@npm:^18.0.26": version: 18.0.26 resolution: "@types/react@npm:18.0.26" @@ -8457,6 +8487,15 @@ __metadata: languageName: node linkType: hard +"css-box-model@npm:^1.2.0": + version: 1.2.1 + resolution: "css-box-model@npm:1.2.1" + dependencies: + tiny-invariant: ^1.0.6 + checksum: 4d113f26fed6b9150e2c314502d00dabe06f12ae43a01a7e9b6e57f3de49b4281dbb0dc46a1158a7349618f8f34d9250af57cb43d7337e9485e73e6b821e470e + languageName: node + linkType: hard + "css-color-keywords@npm:^1.0.0": version: 1.0.0 resolution: "css-color-keywords@npm:1.0.0" @@ -9325,6 +9364,7 @@ __metadata: "@types/eslint": ^8 "@types/node": ^18.11.16 "@types/react": ^18.0.26 + "@types/react-beautiful-dnd": ^13 "@typescript-eslint/eslint-plugin": latest "@typescript-eslint/parser": latest eslint: ^8.30.0 @@ -9335,6 +9375,7 @@ __metadata: lint-staged: ^13.1.0 next-intercept-stdout: ^1.0.1 prettier: ^2.8.1 + react-beautiful-dnd: ^13.1.1 react-drag-drop-files: ^2.3.8 react-hook-form: ^7.43.1 react-kakao-maps-sdk: ^1.1.6 @@ -11419,7 +11460,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.0.0, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.1": +"hoist-non-react-statics@npm:^3.0.0, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.1, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -14134,6 +14175,13 @@ __metadata: languageName: node linkType: hard +"memoize-one@npm:^5.1.1": + version: 5.2.1 + resolution: "memoize-one@npm:5.2.1" + checksum: a3cba7b824ebcf24cdfcd234aa7f86f3ad6394b8d9be4c96ff756dafb8b51c7f71320785fbc2304f1af48a0467cbbd2a409efc9333025700ed523f254cb52e3d + languageName: node + linkType: hard + "memoizerific@npm:^1.11.0, memoizerific@npm:^1.11.3": version: 1.11.3 resolution: "memoizerific@npm:1.11.3" @@ -16960,6 +17008,13 @@ __metadata: languageName: node linkType: hard +"raf-schd@npm:^4.0.2": + version: 4.0.3 + resolution: "raf-schd@npm:4.0.3" + checksum: 45514041c5ad31fa96aef3bb3c572a843b92da2f2cd1cb4a47c9ad58e48761d3a4126e18daa32b2bfa0bc2551a42d8f324a0e40e536cb656969929602b4e8b58 + languageName: node + linkType: hard + "raf@npm:^3.4.1": version: 3.4.1 resolution: "raf@npm:3.4.1" @@ -17587,6 +17642,24 @@ __metadata: languageName: node linkType: hard +"react-beautiful-dnd@npm:^13.1.1": + version: 13.1.1 + resolution: "react-beautiful-dnd@npm:13.1.1" + dependencies: + "@babel/runtime": ^7.9.2 + css-box-model: ^1.2.0 + memoize-one: ^5.1.1 + raf-schd: ^4.0.2 + react-redux: ^7.2.0 + redux: ^4.0.4 + use-memo-one: ^1.1.1 + peerDependencies: + react: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 + checksum: 5f90f7c0ab77a14dfcd496cbd94bbde457612f380c6fc815f3bba7b52effd75132948fcaa661a902a184bb1e6ae5896dcf5b0c77c4ddf809a2c65288f3eed5a7 + languageName: node + linkType: hard + "react-bootstrap-icons@npm:^1.10.2": version: 1.10.2 resolution: "react-bootstrap-icons@npm:1.10.2" @@ -17784,7 +17857,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:17.0.2, react-is@npm:^17.0.1": +"react-is@npm:17.0.2, react-is@npm:^17.0.1, react-is@npm:^17.0.2": version: 17.0.2 resolution: "react-is@npm:17.0.2" checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8 @@ -17862,6 +17935,27 @@ __metadata: languageName: node linkType: hard +"react-redux@npm:^7.2.0": + version: 7.2.9 + resolution: "react-redux@npm:7.2.9" + dependencies: + "@babel/runtime": ^7.15.4 + "@types/react-redux": ^7.1.20 + hoist-non-react-statics: ^3.3.2 + loose-envify: ^1.4.0 + prop-types: ^15.7.2 + react-is: ^17.0.2 + peerDependencies: + react: ^16.8.3 || ^17 || ^18 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 369a2bdcf87915659af9e5c55abfd9f52a84e43e0d12dcc108ed17dbe6933558b7b7fc12caa9c10c1a10a8be7df89454b6c96989d8573fedec1a772c94a1f145 + languageName: node + linkType: hard + "react-refresh@npm:^0.11.0": version: 0.11.0 resolution: "react-refresh@npm:0.11.0" @@ -18184,6 +18278,15 @@ __metadata: languageName: node linkType: hard +"redux@npm:^4.0.0, redux@npm:^4.0.4": + version: 4.2.1 + resolution: "redux@npm:4.2.1" + dependencies: + "@babel/runtime": ^7.9.2 + checksum: f63b9060c3a1d930ae775252bb6e579b42415aee7a23c4114e21a0b4ba7ec12f0ec76936c00f546893f06e139819f0e2855e0d55ebfce34ca9c026241a6950dd + languageName: node + linkType: hard + "regenerate-unicode-properties@npm:^10.1.0": version: 10.1.0 resolution: "regenerate-unicode-properties@npm:10.1.0" @@ -20334,6 +20437,13 @@ __metadata: languageName: unknown linkType: soft +"tiny-invariant@npm:^1.0.6": + version: 1.3.1 + resolution: "tiny-invariant@npm:1.3.1" + checksum: 872dbd1ff20a21303a2fd20ce3a15602cfa7fcf9b228bd694a52e2938224313b5385a1078cb667ed7375d1612194feaca81c4ecbe93121ca1baebe344de4f84c + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" @@ -20992,6 +21102,15 @@ __metadata: languageName: node linkType: hard +"use-memo-one@npm:^1.1.1": + version: 1.1.3 + resolution: "use-memo-one@npm:1.1.3" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 8f08eba26d69406b61bb4b8dacdd5a92bd6aef5b53d346dfe87954f7330ee10ecabc937cc7854635155d46053828e85c10b5a5aff7a04720e6a97b9f42999bac + languageName: node + linkType: hard + "use-subscription@npm:^1.3.0": version: 1.8.0 resolution: "use-subscription@npm:1.8.0"