├── .commit-template.txt ├── .github ├── ISSUE_TEMPLATE │ └── 이슈-생성-템플릿.md ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── LICENSE ├── README.md ├── back ├── .eslintignore ├── .eslintrc.js ├── .prettierrc ├── Dockerfile ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── auth │ │ ├── auth.module.ts │ │ ├── const │ │ │ ├── authExpireTime.const.ts │ │ │ └── userStatus.const.ts │ │ ├── guard │ │ │ ├── auth.guard.ts │ │ │ └── session.guard.ts │ │ └── service │ │ │ └── auth.service.ts │ ├── benchmark │ │ ├── benchmark.module.ts │ │ ├── controller │ │ │ └── benchmark.controller.ts │ │ ├── gateway │ │ │ └── seats.gateway.ts │ │ └── service │ │ │ └── seats-benchmark.service.ts │ ├── config │ │ ├── loadDotEnv.ts │ │ ├── redisConfig.ts │ │ ├── setupSwagger.ts │ │ └── typeOrmConfig.ts │ ├── decorator │ │ └── not-in.ts │ ├── domains │ │ ├── booking │ │ │ ├── booking.module.ts │ │ │ ├── const │ │ │ │ ├── bookingAmount.const.ts │ │ │ │ ├── cronExpressions.const.ts │ │ │ │ ├── enterBooking.const.ts │ │ │ │ ├── inBookingDefaultMaxSize.const.ts │ │ │ │ ├── seatStatus.enum.ts │ │ │ │ ├── seatsBroadcastInterval.const.ts │ │ │ │ ├── seatsSseRetryTime.const.ts │ │ │ │ ├── sseMaximumInterval.ts │ │ │ │ ├── waitingBroadcastInterval.const.ts │ │ │ │ └── watingThroughputRate.const.ts │ │ │ ├── controller │ │ │ │ └── booking.controller.ts │ │ │ ├── dto │ │ │ │ ├── bookReq.dto.ts │ │ │ │ ├── bookRes.dto.ts │ │ │ │ ├── bookingAdmissionStatus.dto.ts │ │ │ │ ├── bookingAmountReq.dto.ts │ │ │ │ ├── bookingAmountRes.dto.ts │ │ │ │ ├── inBookingSizeReq.dto.ts │ │ │ │ ├── inBookingSizeRes.dto.ts │ │ │ │ ├── seatsSse.dto.ts │ │ │ │ ├── serverTime.dto.ts │ │ │ │ └── waitingSse.dto.ts │ │ │ ├── luaScripts │ │ │ │ ├── getSeatsLua.ts │ │ │ │ ├── initSectionSeatLua.ts │ │ │ │ ├── setSectionsLenLua.ts │ │ │ │ └── updateSeatLua.ts │ │ │ └── service │ │ │ │ ├── booking-seats.service.ts │ │ │ │ ├── booking.service.ts │ │ │ │ ├── enter-booking.service.ts │ │ │ │ ├── in-booking.service.ts │ │ │ │ ├── open-booking.service.ts │ │ │ │ └── waiting-queue.service.ts │ │ ├── event │ │ │ ├── controller │ │ │ │ ├── event.controller.spec.ts │ │ │ │ └── event.controller.ts │ │ │ ├── dto │ │ │ │ ├── event.dto.ts │ │ │ │ ├── eventCreation.dto.ts │ │ │ │ ├── eventId.dto.ts │ │ │ │ ├── eventSpecific.dto.ts │ │ │ │ └── placeSpecificEvent.dto.ts │ │ │ ├── entity │ │ │ │ └── event.entity.ts │ │ │ ├── event.module.ts │ │ │ ├── repository │ │ │ │ └── event.reposiotry.ts │ │ │ └── service │ │ │ │ ├── event.service.spec.ts │ │ │ │ └── event.service.ts │ │ ├── place │ │ │ ├── controller │ │ │ │ └── place.controller.ts │ │ │ ├── dto │ │ │ │ ├── layout.dto.ts │ │ │ │ ├── placeCreation.dto.ts │ │ │ │ ├── placeId.dto.ts │ │ │ │ ├── seatInfo.dto.ts │ │ │ │ ├── section.dto.ts │ │ │ │ └── sectionCreation.dto.ts │ │ │ ├── entity │ │ │ │ ├── place.entity.ts │ │ │ │ └── section.entity.ts │ │ │ ├── example │ │ │ │ └── response │ │ │ │ │ └── getSeatResponseExample.ts │ │ │ ├── place.module.ts │ │ │ ├── repository │ │ │ │ ├── place.repository.ts │ │ │ │ └── section.repository.ts │ │ │ └── service │ │ │ │ ├── place.service.spec.ts │ │ │ │ └── place.service.ts │ │ ├── program │ │ │ ├── controller │ │ │ │ ├── program.controller.spec.ts │ │ │ │ └── program.controller.ts │ │ │ ├── dto │ │ │ │ ├── eventSpecificProgram.dto.ts │ │ │ │ ├── placeMainPage.dto.ts │ │ │ │ ├── placeSpecificProgram.dto.ts │ │ │ │ ├── programCreation.dto.ts │ │ │ │ ├── programId.dto.ts │ │ │ │ ├── programMainPage.dto.ts │ │ │ │ └── programSpecific.dto.ts │ │ │ ├── entities │ │ │ │ └── program.entity.ts │ │ │ ├── program.module.ts │ │ │ ├── repository │ │ │ │ └── program.repository.ts │ │ │ └── service │ │ │ │ ├── program.service.spec.ts │ │ │ │ └── program.service.ts │ │ ├── reservation │ │ │ ├── controller │ │ │ │ ├── reservation.controller.spec.ts │ │ │ │ └── reservation.controller.ts │ │ │ ├── dto │ │ │ │ ├── reservationCreate.dto.ts │ │ │ │ ├── reservationId.dto.ts │ │ │ │ ├── reservationResult.dto.ts │ │ │ │ ├── reservationSeatInfo.dto.ts │ │ │ │ └── reservationSepecific.dto.ts │ │ │ ├── entity │ │ │ │ ├── reservation.entity.ts │ │ │ │ └── reservedSeat.entity.ts │ │ │ ├── repository │ │ │ │ ├── reservation.repository.ts │ │ │ │ └── reservedSeat.repository.ts │ │ │ ├── reservation.module.ts │ │ │ └── service │ │ │ │ ├── reservation.service.spec.ts │ │ │ │ └── reservation.service.ts │ │ └── user │ │ │ ├── const │ │ │ └── userRole.ts │ │ │ ├── controller │ │ │ ├── user.controller.spec.ts │ │ │ └── user.controller.ts │ │ │ ├── dto │ │ │ ├── userCreate.dto.ts │ │ │ ├── userInfo.dto.ts │ │ │ ├── userLogin.dto.ts │ │ │ └── userLoginIdCheck.dto.ts │ │ │ ├── entity │ │ │ └── user.entity.ts │ │ │ ├── repository │ │ │ └── user.repository.ts │ │ │ ├── service │ │ │ ├── user.service.spec.ts │ │ │ └── user.service.ts │ │ │ └── user.module.ts │ ├── main.ts │ ├── mock │ │ ├── booking │ │ │ ├── booking.controller.ts │ │ │ ├── booking.module.ts │ │ │ └── booking.service.ts │ │ ├── event │ │ │ ├── event.controller.ts │ │ │ ├── event.module.ts │ │ │ └── event.service.ts │ │ ├── mock.module.ts │ │ ├── place │ │ │ ├── place.controller.ts │ │ │ ├── place.module.ts │ │ │ └── place.service.ts │ │ ├── program │ │ │ ├── program.controller.ts │ │ │ ├── program.module.ts │ │ │ └── program.service.ts │ │ └── reservation │ │ │ ├── reservation.controller.spec.ts │ │ │ ├── reservation.controller.ts │ │ │ ├── reservation.module.ts │ │ │ ├── reservation.service.spec.ts │ │ │ └── reservation.service.ts │ └── util │ │ ├── logger │ │ ├── winstonlogger.config.ts │ │ └── winstonlogger.middleware.ts │ │ └── user-injection │ │ ├── user.decorator.middleware.ts │ │ ├── user.decorator.module.ts │ │ ├── user.decorator.service.ts │ │ ├── user.decorator.ts │ │ └── userParamDto.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── front ├── .prettierIgnore ├── .prettierrc ├── Dockerfile ├── eslint.config.js ├── index.html ├── nginx │ └── nginx.conf ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon │ │ └── favicon.ico │ └── images │ │ ├── poster0.png │ │ ├── poster1.png │ │ ├── poster2.png │ │ ├── poster3.png │ │ ├── poster4.png │ │ ├── poster5.png │ │ ├── poster6.png │ │ ├── poster7.png │ │ ├── poster8.png │ │ ├── poster9.png │ │ ├── poster_boost.png │ │ ├── stageSimple.svg │ │ └── stage_demo.svg ├── src │ ├── App.tsx │ ├── api │ │ ├── axios.ts │ │ ├── booking.ts │ │ ├── event.ts │ │ ├── place.ts │ │ ├── program.ts │ │ ├── reservation.ts │ │ └── user.ts │ ├── assets │ │ ├── fonts │ │ │ ├── Pretendard-ExtraBold.subset.woff2 │ │ │ ├── Pretendard-ExtraLight.subset.woff2 │ │ │ ├── Pretendard-Medium.subset.woff2 │ │ │ └── Pretendard-Regular.subset.woff2 │ │ └── icons │ │ │ ├── alert-triangle.svg │ │ │ ├── calendar.svg │ │ │ ├── check-circle.svg │ │ │ ├── check-square.svg │ │ │ ├── check.svg │ │ │ ├── clock.svg │ │ │ ├── down-arrow.svg │ │ │ ├── fie-x.svg │ │ │ ├── home.svg │ │ │ ├── index.ts │ │ │ ├── loading.svg │ │ │ ├── log-out.svg │ │ │ ├── map-pin.svg │ │ │ ├── refresh.svg │ │ │ ├── square.svg │ │ │ ├── ticket.svg │ │ │ ├── tickets.svg │ │ │ ├── trash.svg │ │ │ ├── up-arrow.svg │ │ │ ├── user.svg │ │ │ ├── users.svg │ │ │ └── x-circle.svg │ ├── components │ │ ├── Captcha │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── Confirm │ │ │ └── ConfirmContainer.tsx │ │ ├── Navbar │ │ │ ├── ReservationCard.tsx │ │ │ └── index.tsx │ │ ├── Toast │ │ │ ├── Toast.tsx │ │ │ ├── ToastContainer.tsx │ │ │ └── index.ts │ │ ├── common │ │ │ ├── Button.tsx │ │ │ ├── Card.tsx │ │ │ ├── Field.tsx │ │ │ ├── Icon.tsx │ │ │ ├── Input.tsx │ │ │ ├── Loading.tsx │ │ │ ├── Popover.tsx │ │ │ ├── Progressbar.tsx │ │ │ ├── Radio.tsx │ │ │ ├── Separator.tsx │ │ │ ├── Skeleton.tsx │ │ │ └── Slot.tsx │ │ └── loaders │ │ │ ├── WithLogin.tsx │ │ │ └── WithoutLogin.tsx │ ├── constants │ │ ├── index.ts │ │ ├── reservation.ts │ │ └── user.ts │ ├── contexts │ │ ├── AuthContext.tsx │ │ └── FieldContext.tsx │ ├── events │ │ ├── AuthEvent.ts │ │ └── ToastEvent.ts │ ├── hooks │ │ ├── useAuth.tsx │ │ ├── useAuthContext.tsx │ │ ├── useConfirm.tsx │ │ ├── useFieldContext.tsx │ │ ├── useForm.tsx │ │ ├── usePreventLeave.tsx │ │ └── useSSE.tsx │ ├── index.css │ ├── layout │ │ └── Layout.tsx │ ├── main.tsx │ ├── pages │ │ ├── AdminPage │ │ │ └── index.tsx │ │ ├── LoadingPage.tsx │ │ ├── LoginPage │ │ │ ├── index.tsx │ │ │ └── validate.ts │ │ ├── NotFoundPage.tsx │ │ ├── ProgramDetailPage │ │ │ ├── ProgramInformation.tsx │ │ │ └── index.tsx │ │ ├── ProgramsPage │ │ │ ├── ProgramCard.tsx │ │ │ └── index.tsx │ │ ├── ReservationPage │ │ │ ├── ReservationResult.tsx │ │ │ ├── SeatCountContent.tsx │ │ │ ├── SeatMap.tsx │ │ │ ├── SectionAndSeat.tsx │ │ │ ├── SectionSelectorMap.tsx │ │ │ └── index.tsx │ │ ├── ReservationWaitingPage │ │ │ └── index.tsx │ │ ├── SignupPage │ │ │ └── index.tsx │ │ └── WaitingQueuePage │ │ │ └── index.tsx │ ├── providers │ │ ├── AuthProvider.tsx │ │ ├── ConfirmProvider.tsx │ │ └── QueryProvider.tsx │ ├── routes │ │ └── index.tsx │ ├── type │ │ ├── booking.ts │ │ ├── index.ts │ │ ├── react-simple-captcha.d.ts │ │ ├── reservation.ts │ │ └── user.ts │ ├── utils │ │ ├── date.ts │ │ ├── debounce.ts │ │ ├── getPriceWon.ts │ │ ├── padArray.ts │ │ ├── svg.ts │ │ └── transform.ts │ └── vite-env.d.ts ├── style │ ├── colors.ts │ └── fontSize.ts ├── tailwind.config.js ├── test │ └── transform.test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── package-lock.json ├── package.json └── scripts ├── branch-create.sh └── makeLinkedBranch.js /.commit-template.txt: -------------------------------------------------------------------------------- 1 | # '#'을 지워서 사용: 2 | #✨ feat: 3 | #🐛 fix: 4 | #♻️ refactor: 5 | #💄 style: 6 | #📝 docs: 7 | #✅ test: 8 | #💩 chore: 9 | 10 | # Body Message 11 | # 여러 줄의 메시지를 작성할 땐 "-"로 구분 (한 줄은 72자 이내) 12 | 13 | # Issue Tracker Number or URL 14 | Issue Resolved: # 15 | 16 | # --- COMMIT END --- 17 | # Type can be 18 | # ✨ feat : new feature 19 | # 🐛 fix : bug fix 20 | # ♻️ refactor: refactoring production code 21 | # 💄 style : 코드 의미에 영향을 주지 않는 변경사항 (형식 지정, 세미콜론 누락 등) 22 | # 📝 docs : 문서의 추가, 수정, 삭제 23 | # ✅ test : 테스트 추가, 수정, 삭제 24 | # 💩 chore : 기타 변경사항 (빌드 부분 혹은 패키지 매니저 수정사항) 25 | # ------------------ 26 | # Remember me ~ 27 | # 제목 첫 글자를 대문자로 28 | # 제목 끝에 마침표 금지 29 | # 본문은 "어떻게" 보다 "무엇을", "왜" 를 설명한다. 30 | # ------------------ 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/이슈-생성-템플릿.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 이슈 생성 템플릿 3 | about: 해당 이슈 생성 템플릿을 통해 이슈를 생성해주세요. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## (이슈 제목) 11 | ### 구현 목록 12 | - [ ] 구현내용1 13 | - [ ] 구현내용2 14 | - [ ] 구현내용3 15 | 16 | ### 특이사항 17 | - 없음 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📌 이슈 번호 2 | - close #이슈번호 3 | 4 | ## 🚀 구현 내용 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Compiled output 3 | **/dist/ 4 | **/build/ 5 | 6 | # Node modules 7 | **/node_modules/ 8 | 9 | # Logs 10 | **/logs/ 11 | *.log 12 | npm-debug.log* 13 | pnpm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # OS files 19 | .DS_Store 20 | 21 | # Tests 22 | **/coverage/ 23 | **/.nyc_output/ 24 | 25 | # IDEs and editors 26 | **/.idea/ 27 | .project 28 | .classpath 29 | .c9/ 30 | *.launch 31 | .settings/ 32 | *.sublime-workspace 33 | 34 | # IDE - VSCode 35 | .vscode/* 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json 40 | 41 | # dotenv environment variable files 42 | **/.development.env 43 | **/.env 44 | **/.env.development.local 45 | **/.env.test.local 46 | **/.env.production.local 47 | **/.env.local 48 | ./back/src/config/setEnviorment.ts 49 | 50 | # Temp directories 51 | **/.temp/ 52 | **/.tmp/ 53 | 54 | # Runtime data 55 | pids 56 | *.pid 57 | *.seed 58 | *.pid.lock 59 | 60 | # Diagnostic reports 61 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 62 | 63 | 64 | #docker 65 | docker-compose.yml -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint-staged -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.18.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 부스트캠프 웹·모바일 9기 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /back/.eslintignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | -------------------------------------------------------------------------------- /back/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: './tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin', 'import'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | 'plugin:import/recommended', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | ignorePatterns: ['.eslintrc.js'], 20 | rules: { 21 | '@typescript-eslint/interface-name-prefix': 'off', 22 | '@typescript-eslint/explicit-function-return-type': 'off', 23 | '@typescript-eslint/explicit-module-boundary-types': 'off', 24 | '@typescript-eslint/no-explicit-any': 'off', 25 | 'import/order': [ 26 | 'error', 27 | { 28 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], 29 | 'newlines-between': 'always', 30 | alphabetize: { order: 'asc', caseInsensitive: true }, 31 | }, 32 | ], 33 | }, 34 | settings: { 35 | 'import/resolver': { 36 | typescript: { 37 | alwaysTryTypes: true, // @types/* 모듈도 해석하도록 설정 38 | }, 39 | }, 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /back/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 110, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "bracketSameLine": true, 11 | "arrowParens": "always", 12 | "rangeStart": 0, 13 | "parser": "typescript", 14 | "filepath": "", 15 | "requirePragma": false, 16 | "proseWrap": "preserve", 17 | "htmlWhitespaceSensitivity": "css", 18 | "endOfLine": "lf", 19 | } -------------------------------------------------------------------------------- /back/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node 2 | COPY package*.json ./ 3 | RUN npm install 4 | COPY . . 5 | RUN npm run build 6 | EXPOSE 8080 7 | CMD ["npm", "run", "start:prod"] 8 | -------------------------------------------------------------------------------- /back/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /back/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | 6 | describe('AppController', () => { 7 | let appController: AppController; 8 | 9 | beforeEach(async () => { 10 | const app: TestingModule = await Test.createTestingModule({ 11 | controllers: [AppController], 12 | providers: [AppService], 13 | }).compile(); 14 | 15 | appController = app.get(AppController); 16 | }); 17 | 18 | describe('root', () => { 19 | it('should return "Hello World!"', () => { 20 | expect(appController.getHello()).toBe('Hello World!'); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /back/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, UseGuards } from '@nestjs/common'; 2 | 3 | import { AppService } from './app.service'; 4 | import { AuthGuard } from './auth/guard/auth.guard'; 5 | 6 | @Controller() 7 | export class AppController { 8 | constructor(private readonly appService: AppService) {} 9 | 10 | @UseGuards(AuthGuard) 11 | @Get() 12 | getHello(): string { 13 | return this.appService.getHello(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /back/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 2 | import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; 3 | import { ScheduleModule } from '@nestjs/schedule'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | 6 | import { AppController } from './app.controller'; 7 | import { AppService } from './app.service'; 8 | import { BenchmarkModule } from './benchmark/benchmark.module'; 9 | import redisConfig from './config/redisConfig'; 10 | import ormConfig from './config/typeOrmConfig'; 11 | import { BookingModule } from './domains/booking/booking.module'; 12 | import { EventModule } from './domains/event/event.module'; 13 | import { PlaceModule } from './domains/place/place.module'; 14 | import { ProgramModule } from './domains/program/program.module'; 15 | import { ReservationModule } from './domains/reservation/reservation.module'; 16 | import { UserModule } from './domains/user/user.module'; 17 | import { MockModule } from './mock/mock.module'; 18 | import { UserDecoratorMiddleware } from './util/user-injection/user.decorator.middleware'; 19 | import { UserDecoratorModule } from './util/user-injection/user.decorator.module'; 20 | 21 | @Module({ 22 | imports: [ 23 | ...(process.env.MOCK_MODE === 'true' 24 | ? [MockModule] 25 | : [ 26 | TypeOrmModule.forRoot(ormConfig), 27 | RedisModule.forRoot(redisConfig), 28 | ScheduleModule.forRoot(), 29 | ProgramModule, 30 | ReservationModule, 31 | PlaceModule, 32 | UserModule, 33 | BookingModule, 34 | EventModule, 35 | UserDecoratorModule, 36 | ]), 37 | ...(process.env.BENCHMARK_MODE === 'true' ? [BenchmarkModule] : []), 38 | ], 39 | controllers: [AppController], 40 | providers: [AppService], 41 | }) 42 | export class AppModule { 43 | configure(consumer: MiddlewareConsumer) { 44 | consumer.apply(UserDecoratorMiddleware).forRoutes({ path: '*', method: RequestMethod.ALL }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /back/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /back/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { User } from '../domains/user/entity/user.entity'; 5 | 6 | import { AuthService } from './service/auth.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User])], 10 | controllers: [], 11 | providers: [AuthService], 12 | exports: [AuthService], 13 | }) 14 | export class AuthModule {} 15 | -------------------------------------------------------------------------------- /back/src/auth/const/authExpireTime.const.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_EXPIRE_TIME = 3600; 2 | -------------------------------------------------------------------------------- /back/src/auth/const/userStatus.const.ts: -------------------------------------------------------------------------------- 1 | export const USER_STATUS = { 2 | LOGIN: 'LOGIN', 3 | WAITING: 'WAITING', 4 | ENTERING: 'ENTERING', 5 | SELECTING_SEAT: 'SELECTING_SEAT', 6 | ADMIN: 'ADMIN', 7 | }; 8 | 9 | export const USER_LEVEL = { 10 | LOGIN: 0, 11 | WAITING: 1, 12 | ENTERING: 2, 13 | SELECTING_SEAT: 3, 14 | ADMIN: 4, 15 | }; 16 | -------------------------------------------------------------------------------- /back/src/auth/guard/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | 4 | @Injectable() 5 | export class AuthGuard implements CanActivate { 6 | canActivate(context: ExecutionContext): boolean | Promise | Observable { 7 | const request = context.switchToHttp().getRequest(); 8 | return this.validateRequest(request); 9 | } 10 | 11 | private validateRequest(request: any) { 12 | //TODO 13 | // Session에 request의 Cookie가 있다면 True 14 | // 없다면 False를 반환하도록 15 | 16 | const sid = this.getSid(request); 17 | 18 | if (sid) { 19 | return true; 20 | } 21 | 22 | return true; 23 | } 24 | 25 | private getSid(request: any) { 26 | const SID: string = request.headers.cookie 27 | .split(';') 28 | .map((e: string) => { 29 | return e.trim().split('='); 30 | }) 31 | .find((e: Array) => e[0] === 'SID')[1]; 32 | 33 | return SID; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /back/src/auth/guard/session.guard.ts: -------------------------------------------------------------------------------- 1 | import { RedisService } from '@liaoliaots/nestjs-redis'; 2 | import { ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; 3 | import { Request } from 'express'; 4 | import Redis from 'ioredis'; 5 | 6 | import { AUTH_EXPIRE_TIME } from '../const/authExpireTime.const'; 7 | import { USER_LEVEL, USER_STATUS } from '../const/userStatus.const'; 8 | 9 | export function SessionAuthGuard(requiredStatuses: string | string[] = USER_STATUS.LOGIN) { 10 | @Injectable() 11 | class SessionGuard { 12 | readonly redis: Redis; 13 | 14 | constructor(readonly redisService: RedisService) { 15 | this.redis = this.redisService.getOrThrow(); 16 | } 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | const request: Request = context.switchToHttp().getRequest(); 20 | const sessionId = request.cookies.SID; 21 | 22 | const sessionData = await this.redis.get(`user:${sessionId}`); 23 | if (!sessionData) { 24 | throw new ForbiddenException('접근 권한이 없습니다.'); 25 | } 26 | 27 | const session = JSON.parse(sessionData); 28 | if (!session) { 29 | throw new UnauthorizedException('세션이 만료되었습니다.'); 30 | } 31 | 32 | const statusesToCheck = Array.isArray(requiredStatuses) ? requiredStatuses : [requiredStatuses]; 33 | 34 | for (const requiredStatus of statusesToCheck) { 35 | if (USER_LEVEL[session.userStatus] >= USER_LEVEL[requiredStatus]) { 36 | await this.redis.expireat(`user:${sessionId}`, Math.round(Date.now() / 1000) + AUTH_EXPIRE_TIME); 37 | return true; 38 | } 39 | } 40 | throw new UnauthorizedException('해당 페이지에 접근할 수 없습니다.'); 41 | } 42 | } 43 | 44 | return SessionGuard; 45 | } 46 | -------------------------------------------------------------------------------- /back/src/auth/service/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { RedisService } from '@liaoliaots/nestjs-redis'; 2 | import { Injectable } from '@nestjs/common'; 3 | import Redis from 'ioredis'; 4 | 5 | import { USER_STATUS } from '../const/userStatus.const'; 6 | 7 | @Injectable() 8 | export class AuthService { 9 | private readonly redis: Redis | null; 10 | 11 | constructor(private redisService: RedisService) { 12 | this.redis = this.redisService.getOrThrow(); 13 | } 14 | 15 | async getUserIdFromSession(sid: string): Promise<[number | null, string | null]> { 16 | const session = JSON.parse(await this.redis.get(`user:${sid}`)); 17 | if (!session) return [null, null]; 18 | const userId = session.id; 19 | const userLoginId = session.loginId; 20 | if (!userId || !userLoginId) return [null, null]; 21 | return [userId, userLoginId]; 22 | } 23 | 24 | async setUserStatusLogin(sid: string) { 25 | const session = JSON.parse(await this.redis.get(`user:${sid}`)); 26 | if (session.userStatus === USER_STATUS.ADMIN) return; 27 | 28 | this.redis.set(`user:${sid}`, JSON.stringify({ ...session, userStatus: USER_STATUS.LOGIN })); 29 | } 30 | 31 | async setUserStatusWaiting(sid: string) { 32 | const session = JSON.parse(await this.redis.get(`user:${sid}`)); 33 | if (session.userStatus === USER_STATUS.ADMIN) return; 34 | 35 | this.redis.set(`user:${sid}`, JSON.stringify({ ...session, userStatus: USER_STATUS.WAITING })); 36 | } 37 | 38 | async setUserStatusEntering(sid: string) { 39 | const session = JSON.parse(await this.redis.get(`user:${sid}`)); 40 | if (session.userStatus === USER_STATUS.ADMIN) return; 41 | 42 | this.redis.set(`user:${sid}`, JSON.stringify({ ...session, userStatus: USER_STATUS.ENTERING })); 43 | } 44 | 45 | async setUserStatusSelectingSeat(sid: string) { 46 | const session = JSON.parse(await this.redis.get(`user:${sid}`)); 47 | if (session.userStatus === USER_STATUS.ADMIN) return; 48 | 49 | this.redis.set(`user:${sid}`, JSON.stringify({ ...session, userStatus: USER_STATUS.SELECTING_SEAT })); 50 | } 51 | 52 | async setUserStatusAdmin(sid: string) { 53 | const session = JSON.parse(await this.redis.get(`user:${sid}`)); 54 | 55 | this.redis.set(`user:${sid}`, JSON.stringify({ ...session, userStatus: USER_STATUS.ADMIN })); 56 | } 57 | 58 | async removeSession(sid: string, loginId: string) { 59 | this.redis.unlink(`user-id:${loginId}`); 60 | return this.redis.unlink(`user:${sid}`); 61 | } 62 | 63 | async getUserSession(sid: string) { 64 | return JSON.parse(await this.redis.get(`user:${sid}`)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /back/src/benchmark/benchmark.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EventEmitterModule } from '@nestjs/event-emitter'; 3 | 4 | import { AuthModule } from '../auth/auth.module'; 5 | import { BookingModule } from '../domains/booking/booking.module'; 6 | import { EventModule } from '../domains/event/event.module'; 7 | 8 | import { BenchmarkController } from './controller/benchmark.controller'; 9 | import { SeatsGateway } from './gateway/seats.gateway'; 10 | import { SeatsBenchmarkService } from './service/seats-benchmark.service'; 11 | 12 | @Module({ 13 | imports: [EventEmitterModule.forRoot(), BookingModule, EventModule, AuthModule], 14 | controllers: [BenchmarkController], 15 | providers: [SeatsGateway, SeatsBenchmarkService], 16 | exports: [], 17 | }) 18 | export class BenchmarkModule {} 19 | -------------------------------------------------------------------------------- /back/src/benchmark/controller/benchmark.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; 2 | import { ApiOperation, ApiUnauthorizedResponse } from '@nestjs/swagger'; 3 | import { Request } from 'express'; 4 | 5 | import { USER_STATUS } from '../../auth/const/userStatus.const'; 6 | import { SessionAuthGuard } from '../../auth/guard/session.guard'; 7 | import { BookingService } from '../../domains/booking/service/booking.service'; 8 | import { SeatsBenchmarkService } from '../service/seats-benchmark.service'; 9 | 10 | @Controller('benchmark') 11 | export class BenchmarkController { 12 | constructor( 13 | private readonly SeatsBenchmarkService: SeatsBenchmarkService, 14 | private readonly bookingService: BookingService, 15 | ) {} 16 | 17 | @Get('seat/:eventId') 18 | @UseGuards(SessionAuthGuard([USER_STATUS.ENTERING, USER_STATUS.SELECTING_SEAT])) 19 | @ApiOperation({ 20 | summary: '(벤치마크용)실시간 좌석 현황 조회 with HTTP Polling', 21 | description: '실시간으로 좌석 예약 현황을 조회한다.', 22 | }) 23 | @ApiUnauthorizedResponse({ description: '인증 실패' }) 24 | async getSeatStatusByEventId(@Param('eventId') eventId: number, @Req() req: Request) { 25 | const sid = req.cookies['SID']; 26 | await this.bookingService.setInBookingFromEntering(sid); 27 | 28 | return await this.SeatsBenchmarkService.getSeatsStatusByEventId(eventId); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /back/src/benchmark/service/seats-benchmark.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import { BookingSeatsService } from '../../domains/booking/service/booking-seats.service'; 4 | 5 | @Injectable() 6 | export class SeatsBenchmarkService { 7 | constructor(private readonly bookingSeatsService: BookingSeatsService) {} 8 | 9 | async getSeatsStatusByEventId(eventId: number) { 10 | return await this.bookingSeatsService.getSeats(eventId); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /back/src/config/loadDotEnv.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | dotenv.config({ path: `./src/config/.env.${process.env.NODE_ENV}` }); 4 | -------------------------------------------------------------------------------- /back/src/config/redisConfig.ts: -------------------------------------------------------------------------------- 1 | import { RedisModuleOptions } from '@liaoliaots/nestjs-redis'; 2 | // 지워야됨 3 | // import './setEnviorment'; 4 | 5 | const redisConfig: RedisModuleOptions = { 6 | readyLog: true, 7 | config: { 8 | host: process.env.REDIS_HOST, 9 | port: parseInt(process.env.REDIS_PORT, 10), 10 | password: process.env.REDIS_PASSWORD, 11 | }, 12 | }; 13 | 14 | export default redisConfig; 15 | -------------------------------------------------------------------------------- /back/src/config/setupSwagger.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | 4 | export function setupSwagger(app: INestApplication): void { 5 | const options = new DocumentBuilder() 6 | .setTitle('RealTicket API Docs') 7 | .setDescription('RealTicket의 API 명세시 입니다.') 8 | .setVersion('1.0.0') 9 | .addCookieAuth('SID') 10 | .build(); 11 | 12 | const document = SwaggerModule.createDocument(app, options); 13 | SwaggerModule.setup('api-docs', app, document, { 14 | swaggerOptions: { persistAuthorization: true }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /back/src/config/typeOrmConfig.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | 3 | const ormConfig: TypeOrmModuleOptions = { 4 | type: process.env.DATABASE_TYPE as any, 5 | host: process.env.DATABASE_HOST, 6 | port: parseInt(process.env.DATABASE_PORT, 10), 7 | username: process.env.DATABASE_USERNAME, 8 | password: process.env.DATABASE_PASSWORD, 9 | database: process.env.DATABASE_SCHEMA, 10 | entities: [__dirname + '/../**/*.entity{.ts,.js}'], 11 | synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', 12 | charset: 'utf8mb4', 13 | timezone: '+09:00', 14 | 15 | connectTimeout: 60000, 16 | poolSize: 4, 17 | retryAttempts: 20, 18 | retryDelay: 3000, 19 | }; 20 | 21 | export default ormConfig; 22 | -------------------------------------------------------------------------------- /back/src/decorator/not-in.ts: -------------------------------------------------------------------------------- 1 | import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; 2 | 3 | export function NotIn(property: string, validationOptions?: ValidationOptions) { 4 | return (object: object, propertyName: string) => { 5 | registerDecorator({ 6 | name: 'NotIn', 7 | target: object.constructor, 8 | propertyName, 9 | options: validationOptions, 10 | constraints: [property], 11 | validator: { 12 | validate(value: any, args: ValidationArguments) { 13 | const [relatedPropertyName] = args.constraints; 14 | const relatedValue = (args.object as any)[relatedPropertyName]; 15 | return ( 16 | typeof value === 'string' && typeof relatedValue === 'string' && !value.includes(relatedValue) 17 | ); 18 | }, 19 | }, 20 | }); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /back/src/domains/booking/booking.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EventEmitterModule } from '@nestjs/event-emitter'; 3 | 4 | import { AuthModule } from '../../auth/auth.module'; 5 | import { EventModule } from '../event/event.module'; 6 | import { PlaceModule } from '../place/place.module'; 7 | import { UserModule } from '../user/user.module'; 8 | 9 | import { BookingController } from './controller/booking.controller'; 10 | import { BookingSeatsService } from './service/booking-seats.service'; 11 | import { BookingService } from './service/booking.service'; 12 | import { EnterBookingService } from './service/enter-booking.service'; 13 | import { InBookingService } from './service/in-booking.service'; 14 | import { OpenBookingService } from './service/open-booking.service'; 15 | import { WaitingQueueService } from './service/waiting-queue.service'; 16 | 17 | @Module({ 18 | imports: [EventEmitterModule.forRoot(), EventModule, AuthModule, PlaceModule, UserModule], 19 | controllers: [BookingController], 20 | providers: [ 21 | BookingService, 22 | InBookingService, 23 | OpenBookingService, 24 | BookingSeatsService, 25 | WaitingQueueService, 26 | EnterBookingService, 27 | ], 28 | exports: [BookingService, InBookingService, BookingSeatsService], 29 | }) 30 | export class BookingModule {} 31 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/bookingAmount.const.ts: -------------------------------------------------------------------------------- 1 | export const MAX_BOOKING_AMOUNT = 4; 2 | export const MIN_BOOKING_AMOUNT = 1; 3 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/cronExpressions.const.ts: -------------------------------------------------------------------------------- 1 | export const ONE_MINUTE_BEFORE_THE_HOUR = '0 59 * * * *'; 2 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/enterBooking.const.ts: -------------------------------------------------------------------------------- 1 | /* Entering 세션 생존시간 2 | 배포 환경: 2분 3 | 개발 환경: 30초 4 | */ 5 | export const ENTERING_SESSION_EXPIRY = 6 | process.env.NODE_ENV === 'prod' || 'prod-in-dev' ? 2 * 60 * 1000 : 30 * 1000; 7 | 8 | /* Entering 세션 GC 주기 9 | 배포 환경: 1분 10 | 개발 환경: 10초 11 | */ 12 | export const ENTERING_GC_INTERVAL = process.env.NODE_ENV === 'prod' || 'prod-in-dev' ? 60 * 1000 : 10 * 1000; 13 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/inBookingDefaultMaxSize.const.ts: -------------------------------------------------------------------------------- 1 | /* 좌석 선택 페이지 최대 인원 수 2 | 배포 환경: 100명 3 | 개발 환경: 2명 4 | */ 5 | export const IN_BOOKING_DEFAULT_MAX_SIZE = process.env.NODE_ENV === 'prod' || 'prod-in-dev' ? 100 : 2; 6 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/seatStatus.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SeatStatus { 2 | RESERVE = 'reserved', 3 | DELETE = 'deleted', 4 | } 5 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/seatsBroadcastInterval.const.ts: -------------------------------------------------------------------------------- 1 | export const SEATS_BROADCAST_INTERVAL = 500; 2 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/seatsSseRetryTime.const.ts: -------------------------------------------------------------------------------- 1 | export const SEATS_SSE_RETRY_TIME = 5000; 2 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/sseMaximumInterval.ts: -------------------------------------------------------------------------------- 1 | export const SSE_MAXIMUM_INTERVAL = 30000; 2 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/waitingBroadcastInterval.const.ts: -------------------------------------------------------------------------------- 1 | export const WAITING_BROADCAST_INTERVAL = 3000; 2 | -------------------------------------------------------------------------------- /back/src/domains/booking/const/watingThroughputRate.const.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_WAITING_THROUGHPUT_RATE = 1000; 2 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/bookReq.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEnum, IsNumber, IsString } from 'class-validator'; 3 | 4 | import { SeatStatus } from '../const/seatStatus.enum'; 5 | 6 | export class BookReqDto { 7 | @IsNumber() 8 | @ApiProperty({ name: 'eventId', example: 123 }) 9 | eventId: number; 10 | 11 | @IsNumber() 12 | @ApiProperty({ name: 'sectionIndex', example: 4 }) 13 | sectionIndex: number; 14 | 15 | @IsNumber() 16 | @ApiProperty({ name: 'seatIndex', example: 56 }) 17 | seatIndex: number; 18 | 19 | @IsString() 20 | @IsEnum(SeatStatus) 21 | @ApiProperty({ 22 | name: 'expectedStatus', 23 | enum: SeatStatus, 24 | example: SeatStatus.RESERVE, 25 | description: '요청 종류(예약 또는 삭제)(reserve 또는 delete)', 26 | }) 27 | expectedStatus: string; 28 | } 29 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/bookRes.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEnum, IsNumber, IsString } from 'class-validator'; 3 | 4 | import { SeatStatus } from '../const/seatStatus.enum'; 5 | 6 | export class BookResDto { 7 | constructor(params: { eventId: number; sectionIndex: number; seatIndex: number; acceptedStatus: string }) { 8 | Object.assign(this, params); 9 | } 10 | 11 | @IsNumber() 12 | @ApiProperty({ name: 'eventId', example: 123 }) 13 | eventId: number; 14 | 15 | @IsNumber() 16 | @ApiProperty({ name: 'sectionIndex', example: 4 }) 17 | sectionIndex: number; 18 | 19 | @IsNumber() 20 | @ApiProperty({ name: 'seatIndex', example: 56 }) 21 | seatIndex: number; 22 | 23 | @IsString() 24 | @IsEnum(SeatStatus) 25 | @ApiProperty({ 26 | name: 'acceptedStatus', 27 | enum: SeatStatus, 28 | example: SeatStatus.RESERVE, 29 | description: '결과 상태(reserved 또는 deleted)', 30 | }) 31 | acceptedStatus: string; 32 | } 33 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/bookingAdmissionStatus.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean } from 'class-validator'; 2 | 3 | export class BookingAdmissionStatusDto { 4 | @IsBoolean() 5 | waitingStatus: boolean; 6 | @IsBoolean() 7 | enteringStatus: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/bookingAmountReq.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, Max, Min } from 'class-validator'; 3 | 4 | import { MAX_BOOKING_AMOUNT, MIN_BOOKING_AMOUNT } from '../const/bookingAmount.const'; 5 | 6 | export class BookingAmountReqDto { 7 | @ApiProperty({ 8 | description: '예매 수량', 9 | name: 'bookingAmount', 10 | type: Number, 11 | minimum: MIN_BOOKING_AMOUNT, 12 | maximum: MAX_BOOKING_AMOUNT, 13 | example: 1, 14 | }) 15 | @IsNumber() 16 | @Min(MIN_BOOKING_AMOUNT) 17 | @Max(MAX_BOOKING_AMOUNT) 18 | bookingAmount: number; 19 | } 20 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/bookingAmountRes.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, Max, Min } from 'class-validator'; 3 | 4 | import { MAX_BOOKING_AMOUNT, MIN_BOOKING_AMOUNT } from '../const/bookingAmount.const'; 5 | 6 | export class BookingAmountResDto { 7 | constructor(bookingAmount: number) { 8 | this.bookingAmount = bookingAmount; 9 | } 10 | 11 | @ApiProperty({ 12 | description: '예매 수량', 13 | name: 'bookingAmount', 14 | type: Number, 15 | minimum: MIN_BOOKING_AMOUNT, 16 | maximum: MAX_BOOKING_AMOUNT, 17 | example: 1, 18 | }) 19 | @IsNumber() 20 | @Min(MIN_BOOKING_AMOUNT) 21 | @Max(MAX_BOOKING_AMOUNT) 22 | bookingAmount: number; 23 | } 24 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/inBookingSizeReq.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class InBookingSizeReqDto { 4 | @ApiProperty({ 5 | name: 'maxSize', 6 | type: Number, 7 | example: 100, 8 | description: '설정할 크기', 9 | }) 10 | maxSize: number; 11 | } 12 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/inBookingSizeRes.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class InBookingSizeResDto { 4 | constructor(maxSize: number) { 5 | this.maxSize = maxSize; 6 | } 7 | 8 | @ApiProperty({ 9 | name: 'maxSize', 10 | type: Number, 11 | example: 100, 12 | }) 13 | maxSize: number; 14 | } 15 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/seatsSse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class SeatsSseDto { 4 | constructor(seats: number[][]) { 5 | this.seatStatus = seats; 6 | } 7 | @ApiProperty({ 8 | name: 'seatStatus', 9 | example: [ 10 | [1, 1, 0], 11 | [0, 1, 1, 1], 12 | ], 13 | description: 14 | '각 칸마다 true는 예약 가능, false는 예약 불가. 상위 배열은 구역의 배열이며 하위 배열은 구역 내의 좌석 배열임.', 15 | }) 16 | seatStatus: number[][]; 17 | } 18 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/serverTime.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ServerTimeDto { 4 | @ApiProperty({ 5 | description: '현재 서버 시간 ( 밀리초 단위)', 6 | name: 'now', 7 | type: 'number', 8 | example: 1700000000000, 9 | }) 10 | now: number; 11 | } 12 | -------------------------------------------------------------------------------- /back/src/domains/booking/dto/waitingSse.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class WaitingSseDto { 4 | constructor(headOrder: number, totalWaiting: number, throughputRate: number) { 5 | this.headOrder = headOrder; 6 | this.totalWaiting = totalWaiting; 7 | this.throughputRate = throughputRate; 8 | } 9 | 10 | @ApiProperty({ name: 'headOrder', example: 7, description: '대기열의 가장 앞에 있는 사람의 번호표' }) 11 | headOrder: number; 12 | 13 | @ApiProperty({ name: 'totalWaiting', example: 22, description: '대기 중인 사람의 수' }) 14 | totalWaiting: number; 15 | 16 | @ApiProperty({ name: 'throughputRate', example: 1000, description: '한 사람당 예상되는 대기 시간(ms)' }) 17 | throughputRate: number; 18 | } 19 | -------------------------------------------------------------------------------- /back/src/domains/booking/luaScripts/getSeatsLua.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | const getSeatsLua = ` 4 | local eventId = KEYS[1] 5 | local sectionsLen = redis.call('GET', 'event:'..eventId..':sections:len') 6 | 7 | local placeResult = {} 8 | for i = 0, tonumber(sectionsLen)-1 do 9 | local seatsLen = redis.call('GET', 'event:'..eventId..':section:'..i..':seats:len') 10 | local sectionResult = {} 11 | for j = 0, tonumber(seatsLen)-1 do 12 | local seat = redis.call('GETBIT', 'event:'..eventId..':section:'..i..':seats', j) 13 | if not (seat == 0 or seat == 1) then 14 | return nil 15 | end 16 | table.insert(sectionResult, seat) 17 | end 18 | table.insert(placeResult, sectionResult) 19 | end 20 | 21 | return placeResult 22 | `; 23 | 24 | export async function runGetSeatsLua(redis: Redis, eventId: number): Promise { 25 | // @ts-expect-error Lua 스크립트 실행 결과 타입의 자동 추론이 불가능하여, 직접 명시하기 위함. 26 | return redis.eval(getSeatsLua, 1, eventId); 27 | } 28 | -------------------------------------------------------------------------------- /back/src/domains/booking/luaScripts/initSectionSeatLua.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | const initSectionSeatLua = ` 4 | local key = KEYS[1] 5 | local lenKey = KEYS[1] .. ':len' 6 | local bitString = ARGV[1] 7 | local totalBits = string.len(bitString) 8 | 9 | redis.call('DEL', key) 10 | 11 | for i = 1, totalBits do 12 | local bit = string.sub(bitString, i, i) 13 | redis.call('SETBIT', key, i-1, bit) 14 | end 15 | 16 | redis.call('SET', lenKey, totalBits) 17 | 18 | return 1 19 | `; 20 | 21 | export async function runInitSectionSeatLua(redis: Redis, key: string, seatBitMap: string): Promise { 22 | // @ts-expect-error Lua 스크립트 실행 결과 타입의 자동 추론이 불가능하여, 직접 명시하기 위함. 23 | return redis.eval(initSectionSeatLua, 1, key, seatBitMap); 24 | } 25 | -------------------------------------------------------------------------------- /back/src/domains/booking/luaScripts/setSectionsLenLua.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | const setSectionsLenLua = ` 4 | local eventId = KEYS[1] 5 | local sectionsLen = KEYS[2] 6 | 7 | redis.call('SET', 'event:'..eventId..':sections:len', sectionsLen) 8 | 9 | return 'OK' 10 | `; 11 | 12 | export async function runSetSectionsLenLua( 13 | redis: Redis, 14 | eventId: number, 15 | sectionsLen: number, 16 | ): Promise { 17 | // @ts-expect-error Lua 스크립트 실행 결과 타입의 자동 추론이 불가능하여, 직접 명시하기 위함. 18 | return redis.eval(setSectionsLenLua, 2, eventId, sectionsLen.toString()); 19 | } 20 | -------------------------------------------------------------------------------- /back/src/domains/booking/luaScripts/updateSeatLua.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | const updateSeatLua = ` 4 | local key = KEYS[1] 5 | local lenKey = KEYS[1] .. ':len' 6 | local index = tonumber(ARGV[1]) 7 | local value = tonumber(ARGV[2]) 8 | 9 | local maxLength = redis.call('GET', lenKey) 10 | if index < 0 or index >= tonumber(maxLength) then 11 | return nil 12 | end 13 | 14 | local currentValue = redis.call('GETBIT', key, index) 15 | if currentValue ~= value then 16 | redis.call('SETBIT', key, index, value) 17 | return 1 18 | end 19 | return 0 20 | `; 21 | 22 | export async function runUpdateSeatLua( 23 | redis: Redis, 24 | sectionKey: string, 25 | seatIndex: number, 26 | value: 0 | 1, 27 | ): Promise { 28 | // @ts-expect-error Lua 스크립트 실행 결과 타입의 자동 추론이 불가능하여, 직접 명시하기 위함. 29 | return redis.eval(updateSeatLua, 1, sectionKey, seatIndex, value); 30 | } 31 | -------------------------------------------------------------------------------- /back/src/domains/event/controller/event.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { EventService } from '../service/event.service'; 4 | 5 | import { EventController } from './event.controller'; 6 | 7 | describe('EventController', () => { 8 | let controller: EventController; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | controllers: [EventController], 13 | providers: [EventService], 14 | }).compile(); 15 | 16 | controller = module.get(EventController); 17 | }); 18 | 19 | it('should be defined', () => { 20 | expect(controller).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /back/src/domains/event/dto/event.dto.ts: -------------------------------------------------------------------------------- 1 | export class EventDto { 2 | constructor({ id, reservationOpenDate, reservationCloseDate }) { 3 | this.id = id; 4 | this.reservationOpenDate = reservationOpenDate; 5 | this.reservationCloseDate = reservationCloseDate; 6 | } 7 | 8 | id: number; 9 | reservationOpenDate: Date; 10 | reservationCloseDate: Date; 11 | } 12 | -------------------------------------------------------------------------------- /back/src/domains/event/dto/eventCreation.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsDate, IsInt, IsNotEmpty } from 'class-validator'; 3 | 4 | export class EventCreationDto { 5 | @IsNotEmpty() 6 | @IsDate() 7 | @Type(() => Date) 8 | runningDate: Date; 9 | 10 | @IsNotEmpty() 11 | @IsDate() 12 | @Type(() => Date) 13 | reservationOpenDate: Date; 14 | 15 | @IsNotEmpty() 16 | @IsDate() 17 | @Type(() => Date) 18 | reservationCloseDate: Date; 19 | 20 | @IsNotEmpty() 21 | @IsInt() 22 | programId: number; 23 | } 24 | -------------------------------------------------------------------------------- /back/src/domains/event/dto/eventId.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsInt } from 'class-validator'; 3 | 4 | export class EventIdDto { 5 | @IsInt() 6 | @Transform(({ value }) => parseInt(value, 10), { toClassOnly: true }) 7 | eventId: number; 8 | } 9 | -------------------------------------------------------------------------------- /back/src/domains/event/dto/eventSpecific.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { placeSpecificEventDto } from './placeSpecificEvent.dto'; 4 | 5 | export class EventSpecificDto { 6 | constructor({ 7 | id, 8 | name, 9 | place, 10 | price, 11 | runningTime, 12 | runningDate, 13 | reservationOpenDate, 14 | reservationCloseDate, 15 | }) { 16 | this.id = id; 17 | this.name = name; 18 | this.place = new placeSpecificEventDto(place); 19 | this.price = price; 20 | this.runningTime = runningTime; 21 | this.runningDate = runningDate; 22 | this.reservationOpenDate = reservationOpenDate; 23 | this.reservationCloseDate = reservationCloseDate; 24 | } 25 | 26 | @ApiProperty({ 27 | description: '이벤트 ID', 28 | name: 'id', 29 | type: 'number', 30 | example: 101, 31 | }) 32 | id: number; 33 | 34 | @ApiProperty({ 35 | description: '이벤트 이름', 36 | name: 'name', 37 | type: 'string', 38 | example: '오페라의 유령', 39 | }) 40 | name: string; 41 | 42 | @ApiProperty({ 43 | description: '이벤트 장소 정보', 44 | name: 'place', 45 | type: placeSpecificEventDto, 46 | example: { 47 | id: 1, 48 | name: '서울예술의전당', 49 | }, 50 | }) 51 | place: placeSpecificEventDto; 52 | 53 | @ApiProperty({ 54 | description: '이벤트 가격', 55 | name: 'price', 56 | type: 'number', 57 | example: 15000, 58 | }) 59 | price: number; 60 | 61 | @ApiProperty({ 62 | description: '이벤트 러닝타임(초단위)', 63 | name: 'runningTime', 64 | type: 'number', 65 | example: 120000, 66 | }) 67 | runningTime: number; 68 | 69 | @ApiProperty({ 70 | description: '이벤트 실행 날짜 및 시간', 71 | name: 'runningDate', 72 | type: 'string', 73 | format: 'date-time', 74 | example: '2024-12-01T19:30:00Z', 75 | }) 76 | runningDate: Date; 77 | 78 | @ApiProperty({ 79 | description: '예약 시작 날짜 및 시간', 80 | name: 'reservationOpenDate', 81 | type: 'string', 82 | format: 'date-time', 83 | example: '2024-11-01T09:00:00Z', 84 | }) 85 | reservationOpenDate: Date; 86 | 87 | @ApiProperty({ 88 | description: '예약 종료 날짜 및 시간', 89 | name: 'reservationCloseDate', 90 | type: 'string', 91 | format: 'date-time', 92 | example: '2024-11-30T23:59:59Z', 93 | }) 94 | reservationCloseDate: Date; 95 | } 96 | -------------------------------------------------------------------------------- /back/src/domains/event/dto/placeSpecificEvent.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class placeSpecificEventDto { 4 | constructor({ id, name }) { 5 | this.id = id; 6 | this.name = name; 7 | } 8 | 9 | @ApiProperty({ 10 | description: '장소 ID', 11 | name: 'id', 12 | type: 'number', 13 | example: 1, 14 | }) 15 | id: number; 16 | 17 | @ApiProperty({ 18 | description: '장소 이름', 19 | name: 'name', 20 | type: 'string', 21 | example: '서울예술의전당', 22 | }) 23 | name: string; 24 | } 25 | -------------------------------------------------------------------------------- /back/src/domains/event/entity/event.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; 2 | 3 | import { Place } from 'src/domains/place/entity/place.entity'; 4 | import { Program } from 'src/domains/program/entities/program.entity'; 5 | import { Reservation } from 'src/domains/reservation/entity/reservation.entity'; 6 | 7 | import { ReservedSeat } from '../../reservation/entity/reservedSeat.entity'; 8 | 9 | @Entity({ name: 'Event' }) 10 | export class Event { 11 | @PrimaryGeneratedColumn('increment') 12 | id: number; 13 | 14 | @Column({ type: 'timestamp', name: 'running_date' }) 15 | runningDate: Date; 16 | 17 | @Column({ type: 'timestamp', name: 'reservation_open_date' }) 18 | reservationOpenDate: Date; 19 | 20 | @Column({ type: 'timestamp', name: 'reservation_close_date' }) 21 | reservationCloseDate: Date; 22 | 23 | @ManyToOne(() => Program, (program) => program.events, { lazy: true }) 24 | @JoinColumn({ name: 'program_id', referencedColumnName: 'id' }) 25 | program: Promise; 26 | 27 | @ManyToOne(() => Place, (place) => place.events, { lazy: true }) 28 | @JoinColumn({ name: 'place_id', referencedColumnName: 'id' }) 29 | place: Promise; 30 | 31 | @OneToMany(() => Reservation, (reservation) => reservation.event, { lazy: true }) 32 | reservations: Promise; 33 | 34 | @OneToMany(() => ReservedSeat, (reservedSeat) => reservedSeat.event, { lazy: true }) 35 | reservedSeats: Promise; 36 | } 37 | -------------------------------------------------------------------------------- /back/src/domains/event/event.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { PlaceModule } from '../place/place.module'; 5 | import { ProgramModule } from '../program/program.module'; 6 | 7 | import { EventController } from './controller/event.controller'; 8 | import { Event } from './entity/event.entity'; 9 | import { EventRepository } from './repository/event.reposiotry'; 10 | import { EventService } from './service/event.service'; 11 | 12 | @Module({ 13 | imports: [TypeOrmModule.forFeature([Event]), ProgramModule, PlaceModule], 14 | controllers: [EventController], 15 | providers: [EventService, EventRepository], 16 | exports: [EventService, EventRepository], 17 | }) 18 | export class EventModule {} 19 | -------------------------------------------------------------------------------- /back/src/domains/event/repository/event.reposiotry.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { MoreThan, Repository } from 'typeorm'; 4 | 5 | import { Event } from '../entity/event.entity'; 6 | 7 | @Injectable() 8 | export class EventRepository { 9 | constructor(@InjectRepository(Event) private EventRepository: Repository) {} 10 | 11 | async selectEvents(): Promise { 12 | return await this.EventRepository.find(); 13 | } 14 | 15 | async selectEvent(id: number): Promise { 16 | return await this.EventRepository.findOne({ where: { id } }); 17 | } 18 | 19 | async selectEventWithPlaceAndProgram(id: number): Promise { 20 | return await this.EventRepository.findOne({ 21 | where: { id }, 22 | relations: ['place', 'program'], 23 | }); 24 | } 25 | 26 | async selectEventWithPlaceAndProgramAndPlace(id: number): Promise { 27 | return await this.EventRepository.findOne({ 28 | where: { id }, 29 | relations: ['place', 'program', 'program.place'], 30 | }); 31 | } 32 | 33 | async selectUpcomingEvents(): Promise { 34 | const now = new Date(); 35 | 36 | return await this.EventRepository.find({ 37 | where: { 38 | reservationCloseDate: MoreThan(now), 39 | }, 40 | relations: ['place'], 41 | order: { 42 | reservationOpenDate: 'ASC', 43 | }, 44 | }); 45 | } 46 | 47 | async storeEvent(data: any) { 48 | const event = this.EventRepository.create(data); 49 | return await this.EventRepository.save(event); 50 | } 51 | 52 | async deleteProgram(id: number) { 53 | try { 54 | return await this.EventRepository.delete(id); 55 | } catch (error) { 56 | if (error.code === 'ER_ROW_IS_REFERENCED_2') 57 | throw new ConflictException('해당 프로그램에 대한 이벤트가 존재합니다.'); 58 | throw error; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /back/src/domains/event/service/event.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { EventService } from './event.service'; 4 | 5 | describe('EventService', () => { 6 | let service: EventService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [EventService], 11 | }).compile(); 12 | 13 | service = module.get(EventService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /back/src/domains/place/dto/layout.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class layoutDto { 4 | @ApiProperty({ 5 | description: '레이아웃 이미지 url', 6 | name: 'overview', 7 | type: 'string', 8 | example: 'overview/jpg', 9 | }) 10 | overview: string; 11 | 12 | overviewWidth: number; 13 | overviewHeight: number; 14 | overviewPoints: string; 15 | 16 | @ApiProperty({ 17 | description: '레이아웃의 섹션 배열', 18 | name: 'sections', 19 | type: 'array', 20 | items: { 21 | type: 'object', 22 | }, 23 | example: [ 24 | { 25 | id: 1, 26 | name: 'A구역', 27 | seats: [true, false, true, false, true], 28 | colLen: 5, 29 | }, 30 | ], 31 | }) 32 | sections: any[]; 33 | } 34 | -------------------------------------------------------------------------------- /back/src/domains/place/dto/placeCreation.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsJSON, IsNotEmpty, IsNumber, IsString } from 'class-validator'; 2 | 3 | export class PlaceCreationDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | name: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | address: string; 11 | 12 | @IsNotEmpty() 13 | @IsString() 14 | overviewSvg: string; 15 | 16 | @IsNotEmpty() 17 | @IsNumber() 18 | overviewHeight: number; 19 | 20 | @IsNotEmpty() 21 | @IsNumber() 22 | overviewWidth: number; 23 | 24 | @IsNotEmpty() 25 | @IsJSON() 26 | overviewPoints: JSON; 27 | } 28 | -------------------------------------------------------------------------------- /back/src/domains/place/dto/placeId.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsInt, IsNotEmpty } from 'class-validator'; 3 | 4 | export class PlaceIdDto { 5 | @IsNotEmpty() 6 | @IsInt() 7 | @Transform(({ value }) => parseInt(value, 10), { toClassOnly: true }) 8 | placeId: number; 9 | } 10 | -------------------------------------------------------------------------------- /back/src/domains/place/dto/seatInfo.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { layoutDto } from './layout.dto'; 4 | 5 | export class SeatInfoDto { 6 | @ApiProperty({ 7 | description: '좌석 ID', 8 | name: 'id', 9 | type: 'number', 10 | example: 1, 11 | }) 12 | id: number; 13 | 14 | @ApiProperty({ 15 | description: '좌석 레이아웃 정보', 16 | name: 'layout', 17 | type: layoutDto, 18 | example: { 19 | overview: 'overview/jpg', 20 | sections: [ 21 | { 22 | id: 1, 23 | name: 'A', 24 | seats: [true, false, true, false, true], 25 | colLen: 5, 26 | }, 27 | { 28 | id: 2, 29 | name: 'B', 30 | seats: [true, false, true, false, true], 31 | colLen: 5, 32 | }, 33 | ], 34 | }, 35 | }) 36 | layout: layoutDto; 37 | } 38 | -------------------------------------------------------------------------------- /back/src/domains/place/dto/section.dto.ts: -------------------------------------------------------------------------------- 1 | export class SectionDto { 2 | id: number; 3 | 4 | name: string; 5 | 6 | colLen: number; 7 | 8 | seats: string[]; 9 | } 10 | -------------------------------------------------------------------------------- /back/src/domains/place/dto/sectionCreation.dto.ts: -------------------------------------------------------------------------------- 1 | import { ArrayNotEmpty, IsArray, IsBoolean, IsInt, IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class SectionCreationDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | name: string; 7 | 8 | @IsNotEmpty() 9 | @IsInt() 10 | colLen: number; 11 | 12 | @IsNotEmpty() 13 | @IsArray() 14 | @ArrayNotEmpty() 15 | @IsBoolean({ each: true }) 16 | seats: string[]; 17 | 18 | @IsNotEmpty() 19 | @IsInt() 20 | placeId: number; 21 | 22 | @IsNotEmpty() 23 | @IsInt() 24 | order: number; 25 | } 26 | -------------------------------------------------------------------------------- /back/src/domains/place/entity/place.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; 2 | 3 | import { Event } from 'src/domains/event/entity/event.entity'; 4 | import { Program } from 'src/domains/program/entities/program.entity'; 5 | 6 | @Entity({ name: 'Place' }) 7 | export class Place { 8 | @PrimaryGeneratedColumn('increment') 9 | id: number; 10 | 11 | @Column({ type: 'varchar', length: 255, name: 'name' }) 12 | name: string; 13 | 14 | @Column({ type: 'varchar', length: 255, name: 'address' }) 15 | address: string; 16 | 17 | @Column({ type: 'varchar', length: 255, nullable: true, name: 'overview_svg' }) 18 | overviewSvg: string; 19 | 20 | @Column({ type: 'int', name: 'overview_height' }) 21 | overviewHeight: number; 22 | 23 | @Column({ type: 'int', name: 'overview_width' }) 24 | overviewWidth: number; 25 | 26 | @Column({ type: 'json', name: 'sections' }) 27 | sections: string[]; 28 | 29 | @Column({ type: 'text', name: 'overview_points' }) 30 | overviewPoints: string; 31 | 32 | @OneToMany(() => Program, (program) => program.place, { lazy: true }) 33 | programs: Promise; 34 | 35 | @OneToMany(() => Event, (event) => event.place, { lazy: true }) 36 | events: Promise; 37 | } 38 | -------------------------------------------------------------------------------- /back/src/domains/place/entity/section.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; 2 | 3 | import { Place } from './place.entity'; 4 | 5 | @Entity({ name: 'Section' }) 6 | export class Section { 7 | @PrimaryGeneratedColumn('increment') 8 | id: number; 9 | 10 | @Column({ type: 'varchar', length: 255, name: 'name' }) 11 | name: string; 12 | 13 | @Column({ type: 'int', name: 'col_len' }) 14 | colLen: number; 15 | 16 | @Column({ type: 'json', name: 'seats' }) 17 | seats: number[]; 18 | 19 | @ManyToOne(() => Place, (place) => place.sections, { lazy: true }) 20 | @JoinColumn({ name: 'place_id', referencedColumnName: 'id' }) 21 | place: Promise; 22 | } 23 | -------------------------------------------------------------------------------- /back/src/domains/place/example/response/getSeatResponseExample.ts: -------------------------------------------------------------------------------- 1 | export const getSeatResponseExample = { 2 | id: 1, 3 | layout: { 4 | overview: 'https://example.com/overview.svg', 5 | sections: [ 6 | { 7 | id: 2, 8 | name: 'Section A', 9 | colLen: 5, 10 | seats: [ 11 | 'T', 12 | 'F', 13 | 'T', 14 | 'T', 15 | 'F', 16 | 'T', 17 | 'F', 18 | 'T', 19 | 'T', 20 | 'F', 21 | 'T', 22 | 'F', 23 | 'T', 24 | 'T', 25 | 'F', 26 | 'T', 27 | 'F', 28 | 'T', 29 | 'T', 30 | 'F', 31 | 'T', 32 | 'F', 33 | 'T', 34 | 'T', 35 | 'F', 36 | ], 37 | }, 38 | { 39 | id: 3, 40 | name: 'Section B', 41 | colLen: 5, 42 | seats: [ 43 | 'T', 44 | 'F', 45 | 'T', 46 | 'T', 47 | 'F', 48 | 'T', 49 | 'F', 50 | 'T', 51 | 'T', 52 | 'F', 53 | 'T', 54 | 'F', 55 | 'T', 56 | 'T', 57 | 'F', 58 | 'T', 59 | 'F', 60 | 'T', 61 | 'T', 62 | 'F', 63 | 'T', 64 | 'F', 65 | 'T', 66 | 'T', 67 | 'F', 68 | ], 69 | }, 70 | { 71 | id: 4, 72 | name: 'Section C', 73 | colLen: 5, 74 | seats: [ 75 | 'T', 76 | 'F', 77 | 'T', 78 | 'T', 79 | 'F', 80 | 'T', 81 | 'F', 82 | 'T', 83 | 'T', 84 | 'F', 85 | 'T', 86 | 'F', 87 | 'T', 88 | 'T', 89 | 'F', 90 | 'T', 91 | 'F', 92 | 'T', 93 | 'T', 94 | 'F', 95 | 'T', 96 | 'F', 97 | 'T', 98 | 'T', 99 | 'F', 100 | ], 101 | }, 102 | ], 103 | }, 104 | }; 105 | -------------------------------------------------------------------------------- /back/src/domains/place/place.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { ProgramModule } from '../program/program.module'; 5 | 6 | import { PlaceController } from './controller/place.controller'; 7 | import { Place } from './entity/place.entity'; 8 | import { Section } from './entity/section.entity'; 9 | import { PlaceRepository } from './repository/place.repository'; 10 | import { SectionRepository } from './repository/section.repository'; 11 | import { PlaceService } from './service/place.service'; 12 | 13 | @Module({ 14 | imports: [TypeOrmModule.forFeature([Place, Section]), forwardRef(() => ProgramModule)], 15 | providers: [PlaceService, PlaceRepository, SectionRepository], 16 | controllers: [PlaceController], 17 | exports: [PlaceRepository, SectionRepository], 18 | }) 19 | export class PlaceModule {} 20 | -------------------------------------------------------------------------------- /back/src/domains/place/repository/place.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { DataSource, Repository } from 'typeorm'; 4 | 5 | import { Place } from '../entity/place.entity'; 6 | 7 | import { SectionRepository } from './section.repository'; 8 | 9 | @Injectable() 10 | export class PlaceRepository { 11 | constructor( 12 | @InjectRepository(Place) private PlaceRepository: Repository, 13 | private readonly dataSource: DataSource, 14 | private readonly SectionRepository: SectionRepository, 15 | ) {} 16 | 17 | async selectPlace(id: number): Promise { 18 | return await this.PlaceRepository.findOne({ where: { id } }); 19 | } 20 | 21 | storePlace(data: any) { 22 | const place = this.PlaceRepository.create(data); 23 | return this.PlaceRepository.save(place); 24 | } 25 | 26 | async updateSectionsById(orders: string[], id: number) { 27 | return await this.PlaceRepository.update({ id }, { sections: orders }); 28 | } 29 | 30 | async deleteById(id: number) { 31 | await this.dataSource.transaction(async () => { 32 | await this.SectionRepository.deleteByPlaceId(id); 33 | const result = await this.PlaceRepository.delete(id); 34 | if (!result.affected) throw new NotFoundException(`해당 장소[${id}]가 존재하지 않습니다.`); 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /back/src/domains/place/repository/section.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { In, Repository } from 'typeorm'; 4 | 5 | import { Section } from '../entity/section.entity'; 6 | 7 | @Injectable() 8 | export class SectionRepository { 9 | constructor(@InjectRepository(Section) private sectionRepository: Repository
) {} 10 | 11 | async selectSection(id: number): Promise
{ 12 | return await this.sectionRepository.findOne({ where: { id } }); 13 | } 14 | 15 | async selectSectionsByIds(ids: number[]): Promise { 16 | return await this.sectionRepository.find({ where: { id: In(ids) } }); 17 | } 18 | async findById(id: number): Promise
{ 19 | return await this.sectionRepository.findOne({ where: { id } }); 20 | } 21 | 22 | async storeSection(section: any): Promise { 23 | const secitonEntity = this.sectionRepository.create(section); 24 | return await this.sectionRepository.save(secitonEntity); 25 | } 26 | 27 | async deleteByPlaceId(placeId: number): Promise { 28 | await this.sectionRepository.delete({ place: { id: placeId } }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /back/src/domains/place/service/place.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { PlaceService } from './place.service'; 4 | 5 | describe('PlaceService', () => { 6 | let service: PlaceService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [PlaceService], 11 | }).compile(); 12 | 13 | service = module.get(PlaceService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /back/src/domains/program/controller/program.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { ProgramService } from '../service/program.service'; 4 | 5 | import { ProgramController } from './program.controller'; 6 | 7 | describe('ProgramController', () => { 8 | let controller: ProgramController; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | controllers: [ProgramController], 13 | providers: [ProgramService], 14 | }).compile(); 15 | 16 | controller = module.get(ProgramController); 17 | }); 18 | 19 | it('should be defined', () => { 20 | expect(controller).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /back/src/domains/program/dto/eventSpecificProgram.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class EventSpecificProgramDto { 4 | constructor({ id, runningDate }) { 5 | this.id = id; 6 | this.runningDate = runningDate; 7 | } 8 | 9 | @ApiProperty({ 10 | description: '이벤트 ID', 11 | name: 'id', 12 | type: 'number', 13 | example: 101, 14 | }) 15 | id: number; 16 | 17 | @ApiProperty({ 18 | description: '이벤트 날짜 및 시간', 19 | name: 'runningDate', 20 | type: 'string', 21 | format: 'date-time', 22 | example: '2024-12-01T15:00:00Z', 23 | }) 24 | runningDate: Date; 25 | } 26 | -------------------------------------------------------------------------------- /back/src/domains/program/dto/placeMainPage.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class PlaceMainPageDto { 4 | constructor({ id, name }) { 5 | this.id = id; 6 | this.name = name; 7 | } 8 | 9 | @ApiProperty({ 10 | description: '장소 ID', 11 | name: 'id', 12 | type: 'number', 13 | example: 1, 14 | }) 15 | id: number; 16 | 17 | @ApiProperty({ 18 | description: '장소 이름', 19 | name: 'name', 20 | type: 'string', 21 | example: '세종문화회관', 22 | }) 23 | name: string; 24 | } 25 | -------------------------------------------------------------------------------- /back/src/domains/program/dto/placeSpecificProgram.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class PlaceSpecificProgramDto { 4 | constructor({ id, name }) { 5 | this.id = id; 6 | this.name = name; 7 | } 8 | 9 | @ApiProperty({ 10 | description: '장소 ID', 11 | name: 'id', 12 | type: 'number', 13 | example: 1, 14 | }) 15 | id: number; 16 | 17 | @ApiProperty({ 18 | description: '장소 이름', 19 | name: 'name', 20 | type: 'string', 21 | example: '서울예술의전당', 22 | }) 23 | name: string; 24 | } 25 | -------------------------------------------------------------------------------- /back/src/domains/program/dto/programCreation.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class ProgramCreationDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | name: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | profileUrl: string; 11 | 12 | @IsNotEmpty() 13 | @IsInt() 14 | runningTime: number; 15 | 16 | @IsNotEmpty() 17 | @IsString() 18 | genre: string; 19 | 20 | @IsNotEmpty() 21 | @IsString() 22 | actors: string; 23 | 24 | @IsNotEmpty() 25 | @IsInt() 26 | price: number; 27 | 28 | @IsNotEmpty() 29 | @IsInt() 30 | placeId: number; 31 | } 32 | -------------------------------------------------------------------------------- /back/src/domains/program/dto/programId.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsInt } from 'class-validator'; 3 | 4 | export class ProgramIdDto { 5 | @IsInt() 6 | @Transform(({ value }) => parseInt(value, 10), { toClassOnly: true }) 7 | programId: number; 8 | } 9 | -------------------------------------------------------------------------------- /back/src/domains/program/dto/programMainPage.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { PlaceMainPageDto } from './placeMainPage.dto'; 4 | 5 | export class ProgramMainPageDto { 6 | constructor({ id, name, genre, place, profileUrl, actors }) { 7 | this.id = id; 8 | this.name = name; 9 | this.genre = genre; 10 | this.place = new PlaceMainPageDto(place); 11 | this.profileUrl = profileUrl; 12 | this.actors = actors; 13 | } 14 | 15 | @ApiProperty({ 16 | description: '프로그램 ID', 17 | name: 'id', 18 | type: 'number', 19 | example: 1, 20 | }) 21 | id: number; 22 | 23 | @ApiProperty({ 24 | description: '프로그램 이름', 25 | name: 'name', 26 | type: 'string', 27 | example: '뮤지컬 레미제라블', 28 | }) 29 | name: string; 30 | 31 | @ApiProperty({ 32 | description: '프로그램 장르', 33 | name: 'genre', 34 | type: 'string', 35 | example: '뮤지컬', 36 | }) 37 | genre: string; 38 | 39 | @ApiProperty({ 40 | description: '프로그램 장소 정보', 41 | name: 'place', 42 | type: PlaceMainPageDto, 43 | example: { 44 | id: 1, 45 | name: '예술의 전당', 46 | }, 47 | }) 48 | place: PlaceMainPageDto; 49 | 50 | @ApiProperty({ 51 | description: '프로그램 프로필 이미지 URL', 52 | name: 'profileUrl', 53 | type: 'string', 54 | example: 'https://example.com/profile.jpg', 55 | }) 56 | profileUrl: string; 57 | 58 | @ApiProperty({ 59 | description: '출연 배우 리스트', 60 | name: 'actors', 61 | type: 'string', 62 | example: '홍길동, 김철수, 이영희', 63 | }) 64 | actors: string; 65 | } 66 | -------------------------------------------------------------------------------- /back/src/domains/program/entities/program.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; 2 | 3 | import { Event } from 'src/domains/event/entity/event.entity'; 4 | import { Place } from 'src/domains/place/entity/place.entity'; 5 | import { Reservation } from 'src/domains/reservation/entity/reservation.entity'; 6 | 7 | @Entity({ name: 'Program' }) 8 | export class Program { 9 | @PrimaryGeneratedColumn('increment') 10 | id: number; 11 | 12 | @Column({ type: 'varchar', length: 255, name: 'name' }) 13 | name: string; 14 | 15 | @Column({ type: 'varchar', length: 255, name: 'profile_url' }) 16 | profileUrl: string; 17 | 18 | @Column({ type: 'int', name: 'running_time' }) 19 | runningTime: number; 20 | 21 | @Column({ type: 'varchar', length: 255, name: 'genre' }) 22 | genre: string; 23 | 24 | @Column({ type: 'varchar', length: 255, name: 'actors' }) 25 | actors: string; 26 | 27 | @Column({ type: 'int', name: 'price' }) 28 | price: number; 29 | 30 | @ManyToOne(() => Place, (place) => place.programs, { lazy: true }) 31 | @JoinColumn({ name: 'place_id', referencedColumnName: 'id' }) 32 | place: Promise; 33 | 34 | @OneToMany(() => Event, (event) => event.program, { lazy: true }) 35 | events: Promise; 36 | 37 | @OneToMany(() => Reservation, (reservation) => reservation.program, { lazy: true }) 38 | reservations: Promise; 39 | } 40 | -------------------------------------------------------------------------------- /back/src/domains/program/program.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { PlaceModule } from '../place/place.module'; 5 | 6 | import { ProgramController } from './controller/program.controller'; 7 | import { Program } from './entities/program.entity'; 8 | import { ProgramRepository } from './repository/program.repository'; 9 | import { ProgramService } from './service/program.service'; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([Program]), forwardRef(() => PlaceModule)], 13 | controllers: [ProgramController], 14 | providers: [ProgramService, ProgramRepository], 15 | exports: [ProgramRepository], 16 | }) 17 | export class ProgramModule {} 18 | -------------------------------------------------------------------------------- /back/src/domains/program/repository/program.repository.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { QueryFailedError, Repository } from 'typeorm'; 4 | 5 | import { Program } from '../entities/program.entity'; 6 | 7 | @Injectable() 8 | export class ProgramRepository { 9 | constructor(@InjectRepository(Program) private ProgramRepository: Repository) {} 10 | 11 | async selectAllProgramWithPlace(): Promise { 12 | return await this.ProgramRepository.find({ 13 | relations: ['place'], 14 | }); 15 | } 16 | 17 | async selectProgramWithPlace(id: number): Promise { 18 | return await this.ProgramRepository.findOne({ 19 | where: { id }, 20 | relations: ['place'], 21 | }); 22 | } 23 | 24 | async selectProgramByIdWithPlaceAndEvent(id: number): Promise { 25 | return await this.ProgramRepository.findOne({ 26 | where: { id }, 27 | relations: ['place', 'events'], 28 | }); 29 | } 30 | 31 | async storeProgram(data: any) { 32 | try { 33 | const program = this.ProgramRepository.create({ 34 | ...data, 35 | place: { id: data.placeId }, 36 | }); 37 | return await this.ProgramRepository.save(program); 38 | } catch (error) { 39 | if (error instanceof QueryFailedError) { 40 | throw new NotFoundException(`해당 장소[${data.placeId}]는 존재하지 않습니다.`); 41 | } 42 | throw error; 43 | } 44 | } 45 | 46 | async deleteProgram(id: number) { 47 | try { 48 | return await this.ProgramRepository.delete(id); 49 | } catch (error) { 50 | if (error.code === 'ER_ROW_IS_REFERENCED_2') 51 | throw new ConflictException('해당 프로그램에 대한 이벤트가 존재합니다.'); 52 | throw error; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /back/src/domains/program/service/program.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { ProgramService } from './program.service'; 4 | 5 | describe('ProgramService', () => { 6 | let service: ProgramService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [ProgramService], 11 | }).compile(); 12 | 13 | service = module.get(ProgramService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /back/src/domains/reservation/controller/reservation.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { ReservationService } from '../service/reservation.service'; 4 | 5 | import { ReservationController } from './reservation.controller'; 6 | 7 | describe('ReservationController', () => { 8 | let controller: ReservationController; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | controllers: [ReservationController], 13 | providers: [ReservationService], 14 | }).compile(); 15 | 16 | controller = module.get(ReservationController); 17 | }); 18 | 19 | it('should be defined', () => { 20 | expect(controller).toBeDefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /back/src/domains/reservation/dto/reservationCreate.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsInt } from 'class-validator'; 3 | 4 | import { ReservationSeatInfoDto } from './reservationSeatInfo.dto'; 5 | 6 | export class ReservationCreateDto { 7 | @ApiProperty({ 8 | description: '예약할 이벤트 ID', 9 | name: 'eventId', 10 | type: 'number', 11 | example: 123, 12 | }) 13 | @IsInt() 14 | eventId: number; 15 | 16 | @ApiProperty({ 17 | description: '예약할 좌석 정보 배열', 18 | name: 'seats', 19 | type: [ReservationSeatInfoDto], 20 | example: [ 21 | { sectionIndex: 2, seatIndex: 7 }, 22 | { sectionIndex: 2, seatIndex: 8 }, 23 | ], 24 | }) 25 | seats: ReservationSeatInfoDto[]; 26 | } 27 | -------------------------------------------------------------------------------- /back/src/domains/reservation/dto/reservationId.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsInt, IsNotEmpty } from 'class-validator'; 3 | 4 | export class ReservationIdDto { 5 | @IsNotEmpty() 6 | @IsInt() 7 | @Transform(({ value }) => parseInt(value, 10), { toClassOnly: true }) 8 | reservationId: number; 9 | } 10 | -------------------------------------------------------------------------------- /back/src/domains/reservation/dto/reservationResult.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | import { IsDate, IsNumber, IsString } from 'class-validator'; 4 | 5 | export class ReservationResultDto { 6 | @ApiProperty({ 7 | description: '예약된 프로그램 이름', 8 | name: 'programName', 9 | type: 'string', 10 | example: '오페라의 유령', 11 | }) 12 | @IsString() 13 | programName: string; 14 | 15 | @ApiProperty({ 16 | description: '프로그램 실행 날짜 및 시간', 17 | name: 'runningDate', 18 | type: 'string', 19 | format: 'date-time', 20 | example: '2024-12-01T19:30:00Z', 21 | }) 22 | @IsDate() 23 | runningDate: Date; 24 | 25 | @ApiProperty({ 26 | description: '예약된 프로그램 장소 이름', 27 | name: 'place', 28 | type: 'string', 29 | example: '서울예술의전당', 30 | }) 31 | @IsString() 32 | @Expose({ name: 'place' }) 33 | placeName: string; 34 | 35 | @ApiProperty({ 36 | description: '예약된 좌석 정보 배열', 37 | name: 'reservedSeats', 38 | type: 'array', 39 | items: { type: 'string' }, 40 | example: ['A구역 1행 1열', 'A구역 2행 2열'], 41 | }) 42 | @IsString({ each: true }) 43 | @Expose({ name: 'reservedSeats' }) 44 | seats: string[]; 45 | 46 | @ApiProperty({ 47 | description: '예약 1매당 가격', 48 | name: 'price', 49 | type: 'number', 50 | example: 15000, 51 | }) 52 | @IsNumber() 53 | price: number; 54 | } 55 | -------------------------------------------------------------------------------- /back/src/domains/reservation/dto/reservationSeatInfo.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class ReservationSeatInfoDto { 5 | @ApiProperty({ 6 | description: '섹션 인덱스', 7 | name: 'sectionIndex', 8 | type: 'number', 9 | example: 2, 10 | }) 11 | @IsNumber() 12 | sectionIndex: number; 13 | 14 | @ApiProperty({ 15 | description: '좌석의 index', 16 | name: 'row', 17 | type: 'number', 18 | example: 5, 19 | }) 20 | @IsNumber() 21 | seatIndex: number; 22 | } 23 | -------------------------------------------------------------------------------- /back/src/domains/reservation/dto/reservationSepecific.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ReservationSpecificDto { 4 | constructor({ id, programName, runningDate, placeName, seats }) { 5 | this.id = id; 6 | this.programName = programName; 7 | this.runningDate = runningDate; 8 | this.placeName = placeName; 9 | this.seats = seats; 10 | } 11 | 12 | @ApiProperty({ 13 | description: '예약 ID', 14 | name: 'id', 15 | type: 'number', 16 | example: 123, 17 | }) 18 | id: number; 19 | 20 | @ApiProperty({ 21 | description: '예약된 프로그램 이름', 22 | name: 'programName', 23 | type: 'string', 24 | example: '오페라의 유령', 25 | }) 26 | programName: string; 27 | 28 | @ApiProperty({ 29 | description: '예약된 프로그램의 실행 날짜 및 시간', 30 | name: 'runningDate', 31 | type: 'string', 32 | format: 'date-time', 33 | example: '2024-12-01T19:30:00Z', 34 | }) 35 | runningDate: Date; 36 | 37 | @ApiProperty({ 38 | description: '프로그램이 실행될 장소 이름', 39 | name: 'placeName', 40 | type: 'string', 41 | example: '서울예술의전당', 42 | }) 43 | placeName: string; 44 | 45 | @ApiProperty({ 46 | description: '예약된 좌석 정보', 47 | name: 'seats', 48 | type: 'string', 49 | example: 'A구역 10번, 11번', 50 | }) 51 | seats: string; 52 | } 53 | -------------------------------------------------------------------------------- /back/src/domains/reservation/entity/reservation.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | DeleteDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | } from 'typeorm'; 10 | 11 | import { Event } from 'src/domains/event/entity/event.entity'; 12 | import { Program } from 'src/domains/program/entities/program.entity'; 13 | import { User } from 'src/domains/user/entity/user.entity'; 14 | 15 | import { ReservedSeat } from './reservedSeat.entity'; 16 | 17 | @Entity({ name: 'Reservation' }) 18 | export class Reservation { 19 | @PrimaryGeneratedColumn('increment') 20 | id: number; 21 | 22 | @Column({ type: 'timestamp', name: 'created_at' }) 23 | createdAt: Date; 24 | 25 | @DeleteDateColumn({ type: 'timestamp', name: 'deleted_at', nullable: true }) 26 | deletedAt: Date; 27 | 28 | @Column({ type: 'int', name: 'amount' }) 29 | amount: number; 30 | 31 | @OneToMany(() => ReservedSeat, (reservedSeat) => reservedSeat.reservation, { lazy: true }) 32 | reservedSeats: Promise; 33 | 34 | @ManyToOne(() => Program, (program) => program.reservations, { lazy: true }) 35 | @JoinColumn({ name: 'program_id', referencedColumnName: 'id' }) 36 | program: Promise; 37 | 38 | @ManyToOne(() => Event, (event) => event.reservations, { lazy: true }) 39 | @JoinColumn({ name: 'event_id', referencedColumnName: 'id' }) 40 | event: Promise; 41 | 42 | @ManyToOne(() => User, (user) => user.reservations, { lazy: true }) 43 | @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) 44 | user: Promise; 45 | } 46 | -------------------------------------------------------------------------------- /back/src/domains/reservation/entity/reservedSeat.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | DeleteDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | Unique, 9 | } from 'typeorm'; 10 | 11 | import { Event } from '../../event/entity/event.entity'; 12 | 13 | import { Reservation } from './reservation.entity'; 14 | 15 | @Entity({ name: 'Reserved_Seat' }) 16 | @Unique(['row', 'col', 'sectionName', 'event', 'deletedAt']) 17 | export class ReservedSeat { 18 | @PrimaryGeneratedColumn('increment') 19 | id: number; 20 | 21 | @DeleteDateColumn({ type: 'timestamp', name: 'deleted_at', nullable: true }) 22 | deletedAt: Date; 23 | 24 | @Column({ type: 'varchar', length: 255, name: 'section_name' }) 25 | sectionName: string; 26 | 27 | @Column({ type: 'int', name: 'section_index' }) 28 | sectionIndex: number; 29 | 30 | @Column({ type: 'int', name: 'col_len' }) 31 | colLen: number; 32 | 33 | @Column({ type: 'int', name: 'row' }) 34 | row: number; 35 | 36 | @Column({ type: 'int', name: 'col' }) 37 | col: number; 38 | 39 | @ManyToOne(() => Reservation, (reservation) => reservation.reservedSeats, { lazy: true }) 40 | @JoinColumn({ name: 'reservation_id', referencedColumnName: 'id' }) 41 | reservation: Promise; 42 | 43 | @ManyToOne(() => Event, (event) => event.reservedSeats, { lazy: true }) 44 | @JoinColumn({ name: 'event_id', referencedColumnName: 'id' }) 45 | event: Promise; 46 | } 47 | -------------------------------------------------------------------------------- /back/src/domains/reservation/repository/reservation.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { DataSource, MoreThanOrEqual, Repository } from 'typeorm'; 4 | 5 | import { Reservation } from '../entity/reservation.entity'; 6 | 7 | import { ReservedSeatRepository } from './reservedSeat.repository'; 8 | 9 | @Injectable() 10 | export class ReservationRepository { 11 | constructor( 12 | @InjectRepository(Reservation) private ReservationRepository: Repository, 13 | private readonly reservedSeatRepository: ReservedSeatRepository, 14 | private readonly dataSource: DataSource, 15 | ) {} 16 | 17 | async selectAllReservationAfterNowByUserWithAll(userId: number): Promise { 18 | return await this.ReservationRepository.find({ 19 | where: { 20 | user: { id: userId }, 21 | event: { runningDate: MoreThanOrEqual(new Date()) }, 22 | }, 23 | relations: ['program', 'event', 'reservedSeats'], 24 | }); 25 | } 26 | 27 | async findReservationByIdMatchedUserId(userId: number, reservationId: number) { 28 | return await this.ReservationRepository.findOne({ 29 | where: { 30 | id: reservationId, 31 | user: { id: userId }, 32 | }, 33 | }); 34 | } 35 | 36 | async deleteReservationByIdMatchedUserId(userId: number, reservationId: number) { 37 | await this.dataSource.transaction(async () => { 38 | await this.reservedSeatRepository.deleteReservedSeatByReservation(reservationId); 39 | await this.ReservationRepository.softDelete({ 40 | id: reservationId, 41 | user: { id: userId }, 42 | }); 43 | }); 44 | } 45 | 46 | async storeReservation(reservationData: any) { 47 | const reservation = this.ReservationRepository.create(reservationData); 48 | return await this.ReservationRepository.save(reservation); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /back/src/domains/reservation/repository/reservedSeat.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { ReservedSeat } from '../entity/reservedSeat.entity'; 6 | 7 | @Injectable() 8 | export class ReservedSeatRepository { 9 | constructor(@InjectRepository(ReservedSeat) private reservedSeatRepository: Repository) {} 10 | 11 | storeReservedSeat(reservedSeatData: any): Promise { 12 | const reservedSeat = this.reservedSeatRepository.create(reservedSeatData); 13 | return this.reservedSeatRepository.save(reservedSeat); 14 | } 15 | 16 | async deleteReservedSeatByReservation(reservationId: number) { 17 | return await this.reservedSeatRepository.softDelete({ 18 | reservation: { id: reservationId }, 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /back/src/domains/reservation/reservation.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { AuthModule } from '../../auth/auth.module'; 5 | import { BookingModule } from '../booking/booking.module'; 6 | import { EventModule } from '../event/event.module'; 7 | import { PlaceModule } from '../place/place.module'; 8 | import { UserModule } from '../user/user.module'; 9 | 10 | import { ReservationController } from './controller/reservation.controller'; 11 | import { Reservation } from './entity/reservation.entity'; 12 | import { ReservedSeat } from './entity/reservedSeat.entity'; 13 | import { ReservationRepository } from './repository/reservation.repository'; 14 | import { ReservedSeatRepository } from './repository/reservedSeat.repository'; 15 | import { ReservationService } from './service/reservation.service'; 16 | 17 | @Module({ 18 | imports: [ 19 | TypeOrmModule.forFeature([Reservation, ReservedSeat]), 20 | EventModule, 21 | UserModule, 22 | PlaceModule, 23 | AuthModule, 24 | BookingModule, 25 | UserModule, 26 | ], 27 | controllers: [ReservationController], 28 | providers: [ReservationService, ReservationRepository, ReservedSeatRepository], 29 | }) 30 | export class ReservationModule {} 31 | -------------------------------------------------------------------------------- /back/src/domains/reservation/service/reservation.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { ReservationService } from './reservation.service'; 4 | 5 | describe('ReservationService', () => { 6 | let service: ReservationService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [ReservationService], 11 | }).compile(); 12 | 13 | service = module.get(ReservationService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /back/src/domains/user/const/userRole.ts: -------------------------------------------------------------------------------- 1 | export const USER_ROLE = { 2 | ADMIN: 'ADMIN', 3 | USER: 'USER', 4 | }; 5 | -------------------------------------------------------------------------------- /back/src/domains/user/controller/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { UserController } from './user.controller'; 4 | 5 | describe('UserController', () => { 6 | let controller: UserController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [UserController], 11 | }).compile(); 12 | 13 | controller = module.get(UserController); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /back/src/domains/user/dto/userCreate.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, Matches, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class UserCreateDto { 5 | @ApiProperty({ 6 | description: '사용자의 로그인 ID', 7 | name: 'loginId', 8 | type: 'string', 9 | example: 'user1234', 10 | minLength: 4, 11 | maxLength: 12, 12 | pattern: '^[a-z0-9]+$', 13 | }) 14 | @IsString() 15 | @MinLength(4) 16 | @MaxLength(12) 17 | @Matches(/^[a-z0-9]+$/) 18 | readonly loginId: string; 19 | 20 | @ApiProperty({ 21 | description: '사용자의 로그인 비밀번호', 22 | name: 'loginPassword', 23 | type: 'string', 24 | example: 'pass1234', 25 | minLength: 4, 26 | maxLength: 12, 27 | pattern: '^[a-z0-9]+$', 28 | }) 29 | @IsString() 30 | @MinLength(4) 31 | @MaxLength(12) 32 | @Matches(/^[a-z0-9]+$/) 33 | readonly loginPassword: string; 34 | } 35 | -------------------------------------------------------------------------------- /back/src/domains/user/dto/userInfo.dto.ts: -------------------------------------------------------------------------------- 1 | export class UserInfoDto { 2 | loginId: string; 3 | } 4 | -------------------------------------------------------------------------------- /back/src/domains/user/dto/userLogin.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, Matches, MaxLength, MinLength } from 'class-validator'; 3 | 4 | export class UserLoginDto { 5 | @ApiProperty({ 6 | description: '사용자의 로그인 ID', 7 | name: 'loginId', 8 | type: 'string', 9 | example: 'user1234', 10 | minLength: 4, 11 | maxLength: 12, 12 | pattern: '^[a-z0-9]+$', 13 | }) 14 | @IsString() 15 | @MinLength(4) 16 | @MaxLength(12) 17 | @Matches(/^[a-z0-9]+$/) 18 | readonly loginId: string; 19 | 20 | @ApiProperty({ 21 | description: '사용자의 로그인 비밀번호', 22 | name: 'loginPassword', 23 | type: 'string', 24 | example: 'pass1234', 25 | minLength: 4, 26 | maxLength: 12, 27 | pattern: '^[a-z0-9]+$', 28 | }) 29 | @IsString() 30 | @MinLength(4) 31 | @MaxLength(12) 32 | @Matches(/^[a-z0-9]+$/) 33 | readonly loginPassword: string; 34 | } 35 | -------------------------------------------------------------------------------- /back/src/domains/user/dto/userLoginIdCheck.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class UserLoginIdCheckDto { 5 | @ApiProperty({ 6 | description: '사용자의 로그인 ID', 7 | name: 'loginId', 8 | type: 'string', 9 | example: 'user1234', 10 | }) 11 | @IsString() 12 | loginId: string; 13 | } 14 | -------------------------------------------------------------------------------- /back/src/domains/user/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | import { Reservation } from 'src/domains/reservation/entity/reservation.entity'; 4 | 5 | @Entity({ name: 'User' }) 6 | export class User { 7 | @PrimaryGeneratedColumn('increment') 8 | id: number; 9 | 10 | @Column({ type: 'varchar', length: 255, name: 'login_id' }) 11 | loginId: string; 12 | 13 | @Column({ type: 'varchar', length: 255, name: 'login_password', nullable: true }) 14 | loginPassword: string | null; 15 | 16 | @Column({ type: 'varchar', length: 10, name: 'role' }) 17 | role: string; 18 | 19 | @Column({ type: 'boolean', name: 'is_guest', default: 0 }) 20 | checkGuest: boolean; 21 | 22 | @OneToMany(() => Reservation, (reservation) => reservation.user, { lazy: true }) 23 | reservations: Promise; 24 | } 25 | -------------------------------------------------------------------------------- /back/src/domains/user/repository/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | 4 | import { User } from '../entity/user.entity'; 5 | 6 | @Injectable() 7 | export class UserRepository { 8 | private userRepository: Repository; 9 | 10 | constructor(private readonly dataSource: DataSource) { 11 | this.userRepository = this.dataSource.getRepository(User); 12 | } 13 | 14 | async createUser(user: Partial) { 15 | return this.userRepository.save(user); 16 | } 17 | 18 | async findAll(): Promise { 19 | return this.userRepository.find(); 20 | } 21 | 22 | async findOne(id: string): Promise { 23 | return this.userRepository.findOne({ where: { loginId: id } }); 24 | } 25 | 26 | async findById(id: number): Promise { 27 | return this.userRepository.findOne({ where: { id: id } }); 28 | } 29 | 30 | async findByLoginId(loginId: string): Promise { 31 | return this.userRepository.findOne({ where: { loginId } }); 32 | } 33 | 34 | async deleteAllGuest() { 35 | return this.userRepository.delete({ 36 | checkGuest: true, 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /back/src/domains/user/service/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { UserService } from './user.service'; 4 | 5 | describe('UserService', () => { 6 | let service: UserService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [UserService], 11 | }).compile(); 12 | 13 | service = module.get(UserService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /back/src/domains/user/user.module.ts: -------------------------------------------------------------------------------- 1 | // src/user/user.module.ts 2 | import { Module } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | import { AuthModule } from '../../auth/auth.module'; 6 | 7 | import { UserController } from './controller/user.controller'; 8 | import { User } from './entity/user.entity'; 9 | import { UserRepository } from './repository/user.repository'; 10 | import { UserService } from './service/user.service'; 11 | 12 | @Module({ 13 | imports: [TypeOrmModule.forFeature([User]), AuthModule], 14 | providers: [UserService, UserRepository], 15 | controllers: [UserController], 16 | exports: [UserService, UserRepository], 17 | }) 18 | export class UserModule {} 19 | -------------------------------------------------------------------------------- /back/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { WsAdapter } from '@nestjs/platform-ws'; 4 | import cookieParser from 'cookie-parser'; 5 | 6 | import './config/loadDotEnv'; 7 | import { AppModule } from './app.module'; 8 | import { setupSwagger } from './config/setupSwagger'; 9 | import { winstonLoggerConfig } from './util/logger/winstonlogger.config'; 10 | import { loggerMiddleware } from './util/logger/winstonlogger.middleware'; 11 | 12 | async function bootstrap() { 13 | const app = await NestFactory.create(AppModule, { 14 | logger: winstonLoggerConfig, 15 | }); 16 | process.env.TZ = 'Asia/Seoul'; 17 | setupSwagger(app); 18 | app.enableCors({ 19 | origin: [process.env.FRONT_URL ?? 'http://localhost:3000'], 20 | credentials: true, 21 | }); 22 | app.useGlobalPipes( 23 | new ValidationPipe({ 24 | transform: true, 25 | }), 26 | ); 27 | app.useWebSocketAdapter(new WsAdapter(app)); 28 | app.use(cookieParser()); 29 | app.use(loggerMiddleware(winstonLoggerConfig)); 30 | await app.listen(process.env.PORT ?? 8080); 31 | } 32 | 33 | bootstrap(); 34 | -------------------------------------------------------------------------------- /back/src/mock/booking/booking.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Param, Sse } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { BookingService } from './booking.service'; 5 | 6 | @Controller('mock/booking') 7 | export class BookingController { 8 | constructor(private readonly bookingService: BookingService) {} 9 | 10 | @Sse('re-permission/:eventId') 11 | rePermission(@Param('eventId') eventId: number): Observable { 12 | return this.bookingService.rePermission(eventId); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /back/src/mock/booking/booking.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { BookingController } from './booking.controller'; 4 | import { BookingService } from './booking.service'; 5 | 6 | @Module({ 7 | controllers: [BookingController], 8 | providers: [BookingService], 9 | }) 10 | export class BookingModule {} 11 | -------------------------------------------------------------------------------- /back/src/mock/booking/booking.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { interval, Observable, takeWhile } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | 5 | @Injectable() 6 | export class BookingService { 7 | rePermission(eventId: number): Observable { 8 | return interval(1000).pipe( 9 | map((num) => { 10 | const userOrder = 100 - num * 10; 11 | const totalWaiting = 100 + num * 13; 12 | const restMillisecond = 10000 - num * 1000; 13 | const enteringStatus = userOrder <= 0; 14 | 15 | return { 16 | data: { 17 | id: eventId, 18 | data: { 19 | 'user-order': userOrder, 20 | 'total-waiting': totalWaiting, 21 | 'rest-millisecond': restMillisecond, 22 | 'entering-status': enteringStatus, 23 | }, 24 | }, 25 | }; 26 | }), 27 | 28 | takeWhile((response) => { 29 | return !response.data.data['entering-status']; 30 | }, true), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /back/src/mock/event/event.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, Sse } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { EventService } from './event.service'; 5 | 6 | @Controller('mock/event') 7 | export class EventController { 8 | constructor(private readonly eventService: EventService) {} 9 | 10 | @Get(':eventId') 11 | getEventById(@Param('eventId') eventId: number) { 12 | return this.eventService.getEventById(eventId); 13 | } 14 | 15 | @Sse('seat/:eventId') 16 | getReservationStatusByEventId(@Param('eventId') eventId: number): Observable { 17 | return this.eventService.getSeatStatusByEventId(eventId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /back/src/mock/event/event.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { EventController } from './event.controller'; 4 | import { EventService } from './event.service'; 5 | 6 | @Module({ 7 | controllers: [EventController], 8 | providers: [EventService], 9 | }) 10 | export class EventModule {} 11 | -------------------------------------------------------------------------------- /back/src/mock/mock.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { BookingModule } from './booking/booking.module'; 4 | import { EventModule } from './event/event.module'; 5 | import { PlaceModule } from './place/place.module'; 6 | import { ProgramModule } from './program/program.module'; 7 | import { ReservationModule } from './reservation/reservation.module'; 8 | 9 | @Module({ 10 | imports: [ReservationModule, BookingModule, PlaceModule, EventModule, ProgramModule], 11 | }) 12 | export class MockModule {} 13 | -------------------------------------------------------------------------------- /back/src/mock/place/place.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | import { PlaceService } from './place.service'; 4 | 5 | @Controller('mock/place') 6 | export class PlaceController { 7 | constructor(private readonly placeService: PlaceService) {} 8 | 9 | @Get('seat/:placeId') 10 | async getSeatsByPlaceId() { 11 | return await this.placeService.getSeatsByPlaceId(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /back/src/mock/place/place.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { PlaceController } from './place.controller'; 4 | import { PlaceService } from './place.service'; 5 | 6 | @Module({ 7 | controllers: [PlaceController], 8 | providers: [PlaceService], 9 | }) 10 | export class PlaceModule {} 11 | -------------------------------------------------------------------------------- /back/src/mock/place/place.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class PlaceService { 5 | async getSeatsByPlaceId() { 6 | return { 7 | id: 1, 8 | layout: { 9 | overview: null, 10 | sections: [ 11 | { 12 | id: 1, 13 | name: 'A구역', 14 | view: null, 15 | seats: [ 16 | true, 17 | true, 18 | true, 19 | true, 20 | true, 21 | true, 22 | true, 23 | true, 24 | true, 25 | true, 26 | true, 27 | true, 28 | true, 29 | true, 30 | true, 31 | true, 32 | true, 33 | true, 34 | true, 35 | true, 36 | ], 37 | 'col-len': 5, 38 | }, 39 | { 40 | id: 2, 41 | name: 'B구역', 42 | view: null, 43 | seats: [ 44 | true, 45 | true, 46 | false, 47 | true, 48 | true, 49 | true, 50 | true, 51 | false, 52 | true, 53 | true, 54 | true, 55 | true, 56 | false, 57 | true, 58 | true, 59 | true, 60 | true, 61 | false, 62 | true, 63 | true, 64 | ], 65 | 'col-len': 5, 66 | }, 67 | ], 68 | }, 69 | }; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /back/src/mock/program/program.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common'; 2 | 3 | import { ProgramService } from './program.service'; 4 | 5 | @Controller('mock/program') 6 | export class ProgramController { 7 | constructor(private readonly programService: ProgramService) {} 8 | 9 | @Get() 10 | getPrograms() { 11 | return this.programService.getPrograms(); 12 | } 13 | 14 | @Get(':programId') 15 | getProgramById(@Param('programId') programId: number) { 16 | return this.programService.getProgramById(programId); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /back/src/mock/program/program.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { ProgramController } from './program.controller'; 4 | import { ProgramService } from './program.service'; 5 | 6 | @Module({ 7 | controllers: [ProgramController], 8 | providers: [ProgramService], 9 | }) 10 | export class ProgramModule {} 11 | -------------------------------------------------------------------------------- /back/src/mock/program/program.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class ProgramService { 5 | getPrograms() { 6 | return { 7 | programs: [ 8 | { 9 | id: 1, 10 | name: '맘마미아', 11 | genre: '연극', 12 | place: { 13 | id: 1, 14 | name: '대극장', 15 | }, 16 | 'profile-url': null, 17 | }, 18 | ], 19 | }; 20 | } 21 | 22 | getProgramById(programId: number) { 23 | return { 24 | id: programId, 25 | name: '맘마미아', 26 | 'running-time': 1234, 27 | genre: '연극', 28 | actors: '김동현, 김동현', 29 | place: { 30 | id: 1, 31 | name: '대극장', 32 | }, 33 | 'profile-url': null, 34 | price: 15000, 35 | events: [ 36 | { 37 | id: 1, 38 | 'running-date': '2024-11-01 09:00', 39 | }, 40 | { 41 | id: 2, 42 | 'running-date': '2024-11-01 12:00', 43 | }, 44 | ], 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /back/src/mock/reservation/reservation.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { ReservationController } from './reservation.controller'; 4 | import { ReservationService } from './reservation.service'; 5 | 6 | describe('ReservationController', () => { 7 | let controller: ReservationController; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [ReservationController], 12 | providers: [ReservationService], 13 | }).compile(); 14 | 15 | controller = module.get(ReservationController); 16 | }); 17 | 18 | it('should be defined', () => { 19 | expect(controller).toBeDefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /back/src/mock/reservation/reservation.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | import { ReservationService } from './reservation.service'; 4 | 5 | @Controller('mock/reservation') 6 | export class ReservationController { 7 | constructor(private readonly reservationService: ReservationService) {} 8 | 9 | @Get() 10 | getReservations() { 11 | return this.reservationService.getReservations(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /back/src/mock/reservation/reservation.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { ReservationController } from './reservation.controller'; 4 | import { ReservationService } from './reservation.service'; 5 | 6 | @Module({ 7 | controllers: [ReservationController], 8 | providers: [ReservationService], 9 | }) 10 | export class ReservationModule {} 11 | -------------------------------------------------------------------------------- /back/src/mock/reservation/reservation.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { ReservationService } from './reservation.service'; 4 | 5 | describe('ReservationService', () => { 6 | let service: ReservationService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [ReservationService], 11 | }).compile(); 12 | 13 | service = module.get(ReservationService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /back/src/mock/reservation/reservation.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class ReservationService { 5 | getReservations() { 6 | return [ 7 | { 8 | id: 1, 9 | createdAt: new Date(), 10 | deletedAt: null, 11 | amount: 2, 12 | seats: [ 13 | [1, 1], 14 | [1, 2], 15 | ], 16 | programId: 1, 17 | eventId: 1, 18 | userId: 1, 19 | }, 20 | { 21 | id: 2, 22 | createdAt: new Date(), 23 | deletedAt: null, 24 | amount: 1, 25 | seats: [[2, 1]], 26 | programId: 1, 27 | eventId: 1, 28 | userId: 1, 29 | }, 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /back/src/util/logger/winstonlogger.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston'; 4 | import * as winston from 'winston'; 5 | import winstonDaily from 'winston-daily-rotate-file'; 6 | 7 | const logDir = path.join(__dirname, '../../../logs'); 8 | 9 | const dailyOptions = (level: string) => { 10 | return { 11 | level, 12 | datePattern: 'YYYY-MM-DD', 13 | dirname: logDir + `/${level}`, 14 | filename: `%DATE%.${level}.log`, 15 | zippedArchive: true, 16 | maxSize: '20m', 17 | maxFiles: '14d', 18 | }; 19 | }; 20 | 21 | export const winstonLoggerConfig = WinstonModule.createLogger({ 22 | transports: [ 23 | new winston.transports.Console({ 24 | level: 'silly', 25 | format: winston.format.combine( 26 | winston.format.timestamp(), 27 | winston.format.ms(), 28 | nestWinstonModuleUtilities.format.nestLike('RealTicket', { 29 | colors: true, 30 | prettyPrint: true, 31 | processId: true, 32 | appName: true, 33 | }), 34 | ), 35 | }), 36 | new winstonDaily(dailyOptions('info')), 37 | new winstonDaily(dailyOptions('warn')), 38 | new winstonDaily(dailyOptions('error')), 39 | ], 40 | }); 41 | -------------------------------------------------------------------------------- /back/src/util/logger/winstonlogger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { LoggerService } from '@nestjs/common'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | export const loggerMiddleware = (logger: LoggerService) => { 5 | return (req: Request, res: Response, next: NextFunction) => { 6 | const { method, url } = req; 7 | const startTime = Date.now(); 8 | 9 | res.on('finish', () => { 10 | const { statusCode } = res; 11 | const duration = Date.now() - startTime; 12 | logger.log(`${method} ${url} ${statusCode} - ${duration}ms`, 'HttpRequest'); 13 | }); 14 | 15 | next(); 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /back/src/util/user-injection/user.decorator.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { NextFunction } from 'express'; 3 | 4 | import { UserDecoratorService } from './user.decorator.service'; 5 | 6 | @Injectable() 7 | export class UserDecoratorMiddleware implements NestMiddleware { 8 | constructor(private readonly userDecoratorService: UserDecoratorService) {} 9 | 10 | async use(req: Request, res: Response, next: NextFunction) { 11 | const ctx = { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }) }; 12 | req['userParam'] = await this.userDecoratorService.getUserParam(ctx); 13 | next(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /back/src/util/user-injection/user.decorator.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { AuthModule } from 'src/auth/auth.module'; 5 | import { AuthService } from 'src/auth/service/auth.service'; 6 | import { User } from 'src/domains/user/entity/user.entity'; 7 | import { UserService } from 'src/domains/user/service/user.service'; 8 | import { UserModule } from 'src/domains/user/user.module'; 9 | 10 | import { UserDecoratorService } from './user.decorator.service'; 11 | 12 | @Module({ 13 | imports: [TypeOrmModule.forFeature([User]), UserModule, AuthModule], 14 | providers: [UserDecoratorService, UserService, AuthService], 15 | exports: [UserDecoratorService], 16 | }) 17 | export class UserDecoratorModule {} 18 | -------------------------------------------------------------------------------- /back/src/util/user-injection/user.decorator.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import { AuthService } from 'src/auth/service/auth.service'; 4 | 5 | import { UserParamDto } from './userParamDto'; 6 | 7 | @Injectable() 8 | export class UserDecoratorService { 9 | constructor(private readonly authService: AuthService) {} 10 | 11 | async getUserParam(ctx: any) { 12 | const req = ctx.switchToHttp().getRequest(); 13 | const sid = req.cookies['SID']; 14 | if (!sid) return; 15 | const [userId, userLoginId] = await this.authService.getUserIdFromSession(sid); 16 | if (!userId || !userLoginId) return null; 17 | return new UserParamDto({ id: userId, loginId: userLoginId }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /back/src/util/user-injection/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const User = createParamDecorator((data: unknown, ctx: ExecutionContext) => { 4 | const req = ctx.switchToHttp().getRequest(); 5 | return req.userParam; 6 | }); 7 | -------------------------------------------------------------------------------- /back/src/util/user-injection/userParamDto.ts: -------------------------------------------------------------------------------- 1 | export class UserParamDto { 2 | constructor({ id, loginId }) { 3 | this.id = id; 4 | this.loginId = loginId; 5 | } 6 | 7 | id: number; 8 | loginId: string; 9 | } 10 | -------------------------------------------------------------------------------- /back/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import * as request from 'supertest'; 4 | 5 | import { AppModule } from './../src/app.module'; 6 | 7 | describe('AppController (e2e)', () => { 8 | let app: INestApplication; 9 | 10 | beforeEach(async () => { 11 | const moduleFixture: TestingModule = await Test.createTestingModule({ 12 | imports: [AppModule], 13 | }).compile(); 14 | 15 | app = moduleFixture.createNestApplication(); 16 | await app.init(); 17 | }); 18 | 19 | it('/ (GET)', () => { 20 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /back/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /back/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /back/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "esModuleInterop": true 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "dist" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /front/.prettierIgnore: -------------------------------------------------------------------------------- 1 | **/*.html 2 | **/*.css 3 | .gitignore 4 | .prettierIgnore 5 | .husky/ 6 | .prettierrc 7 | **/*.json -------------------------------------------------------------------------------- /front/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 110, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "all", 10 | "bracketSpacing": true, 11 | "bracketSameLine": true, 12 | "arrowParens": "always", 13 | "rangeStart": 0, 14 | "parser": "typescript", 15 | "filepath": "", 16 | "requirePragma": false, 17 | "proseWrap": "preserve", 18 | "htmlWhitespaceSensitivity": "css", 19 | "endOfLine": "lf", 20 | "singleAttributePerLine": false 21 | ,"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], 22 | "importOrder": [ 23 | "^react", 24 | "^next", 25 | 26 | 27 | "^@/api/(.*)$", 28 | "^@/hooks/(.*)$", 29 | "^@/components/(.*)$", 30 | "^@/pages/(.*)$", 31 | "^@/utils/(.*)$", 32 | 33 | "", 34 | 35 | "^[./]" 36 | ], 37 | "importOrderSeparation": true, 38 | "importOrderSortSpecifiers": true 39 | } 40 | -------------------------------------------------------------------------------- /front/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 AS build 2 | WORKDIR /app 3 | COPY package*.json ./ 4 | # Install dependencies 5 | RUN npm install --legacy-peer-deps 6 | 7 | COPY . . 8 | 9 | # API 프록싱 활성화 10 | RUN echo "VITE_API_PROXYING=true" >> .env 11 | 12 | RUN npm run build 13 | 14 | # Use nginx image for production stage 15 | FROM nginx:stable-alpine 16 | 17 | WORKDIR /usr/share/nginx/html 18 | RUN rm -rf ./* 19 | COPY --from=build /app/dist/ . 20 | 21 | RUN rm /etc/nginx/conf.d/default.conf 22 | COPY nginx/nginx.conf /etc/nginx/conf.d/ 23 | ARG API_SERVER_URL 24 | # proxy_pass 주입 25 | RUN sed -i 's|proxy_pass .*|proxy_pass '"${API_SERVER_URL}"';|g' /etc/nginx/conf.d/nginx.conf 26 | 27 | EXPOSE 80 28 | 29 | # Run nginx in the foreground 30 | CMD ["nginx", "-g", "daemon off;"] 31 | -------------------------------------------------------------------------------- /front/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import pluginQuery from '@tanstack/eslint-plugin-query'; 3 | import eslintPluginPrettier from 'eslint-plugin-prettier'; 4 | import reactHooks from 'eslint-plugin-react-hooks'; 5 | import reactRefresh from 'eslint-plugin-react-refresh'; 6 | import globals from 'globals'; 7 | import tseslint from 'typescript-eslint'; 8 | 9 | export default tseslint.config( 10 | { ignores: ['dist'] }, 11 | { 12 | extends: [ 13 | js.configs.recommended, 14 | ...tseslint.configs.recommended, 15 | ...pluginQuery.configs['flat/recommended'], 16 | ], 17 | files: ['**/*.{ts,tsx}'], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | plugins: { 23 | 'react-hooks': reactHooks, 24 | 'react-refresh': reactRefresh, 25 | 'eslint-plugin-prettier': eslintPluginPrettier, 26 | }, 27 | rules: { 28 | // '@typescript-eslint/no-explicit-any': 'warn', 29 | ...reactHooks.configs.recommended.rules, 30 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 31 | 'prefer-const': ['off'], 32 | 'react-refresh/only-export-components': ['off'], 33 | }, 34 | }, 35 | ); 36 | -------------------------------------------------------------------------------- /front/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | RealTicket 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /front/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name _; 4 | 5 | # React 정적 파일 서빙 6 | location / { 7 | root /usr/share/nginx/html; # React 빌드 파일 경로 8 | index index.html index.htm; # 기본 파일 9 | try_files $uri $uri/ /index.html; # SPA 라우팅 처리 10 | } 11 | 12 | # API 요청을 백엔드로 프록시 13 | location /api { 14 | 15 | # 백엔드 URL, front 도커파일에서 설정 16 | proxy_pass ; 17 | 18 | rewrite ^/api(/.*)$ $1 break; # /api/ 제거 19 | proxy_http_version 1.1; 20 | proxy_set_header Upgrade $http_upgrade; 21 | proxy_set_header Connection 'upgrade'; 22 | proxy_redirect off; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header X-Forwarded-Host $server_name; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --port 30000", 8 | "build": "tsc -b && vite build", 9 | "start": "vite preview --port 30000 --host", 10 | "lint": "eslint --fix", 11 | "prettier": "prettier -w", 12 | "tsc": "tsc -b", 13 | "test": "vitest" 14 | }, 15 | "dependencies": { 16 | "@tanstack/react-query": "^5.60.6", 17 | "@tanstack/react-query-devtools": "^5.62.0", 18 | "axios": "^1.7.7", 19 | "class-variance-authority": "^0.7.0", 20 | "es-toolkit": "^1.27.0", 21 | "react": "^18.3.1", 22 | "react-dom": "^18.3.1", 23 | "react-router-dom": "^6.28.0", 24 | "react-simple-captcha": "^9.3.1", 25 | "tailwind-merge": "^2.5.4", 26 | "vitest": "^2.1.5" 27 | }, 28 | "devDependencies": { 29 | "@eslint/js": "^9.13.0", 30 | "@tanstack/eslint-plugin-query": "^5.60.1", 31 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 32 | "@types/node": "^22.9.0", 33 | "@types/react": "^18.3.12", 34 | "@types/react-dom": "^18.3.1", 35 | "@vitejs/plugin-react-swc": "^3.5.0", 36 | "autoprefixer": "^10.4.20", 37 | "eslint": "^9.13.0", 38 | "eslint-config-prettier": "^9.1.0", 39 | "eslint-plugin-prettier": "^5.2.1", 40 | "eslint-plugin-react-hooks": "^5.0.0", 41 | "eslint-plugin-react-refresh": "^0.4.14", 42 | "globals": "^15.11.0", 43 | "postcss": "^8.4.47", 44 | "prettier": "^3.3.3", 45 | "prettier-plugin-tailwindcss": "^0.6.8", 46 | "react-select": "^5.8.3", 47 | "tailwindcss": "^3.4.14", 48 | "typescript": "~5.6.2", 49 | "typescript-eslint": "^8.11.0", 50 | "vite": "^5.4.10", 51 | "vite-plugin-svgr": "^4.3.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /front/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /front/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/favicon/favicon.ico -------------------------------------------------------------------------------- /front/public/images/poster0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster0.png -------------------------------------------------------------------------------- /front/public/images/poster1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster1.png -------------------------------------------------------------------------------- /front/public/images/poster2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster2.png -------------------------------------------------------------------------------- /front/public/images/poster3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster3.png -------------------------------------------------------------------------------- /front/public/images/poster4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster4.png -------------------------------------------------------------------------------- /front/public/images/poster5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster5.png -------------------------------------------------------------------------------- /front/public/images/poster6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster6.png -------------------------------------------------------------------------------- /front/public/images/poster7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster7.png -------------------------------------------------------------------------------- /front/public/images/poster8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster8.png -------------------------------------------------------------------------------- /front/public/images/poster9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster9.png -------------------------------------------------------------------------------- /front/public/images/poster_boost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/public/images/poster_boost.png -------------------------------------------------------------------------------- /front/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider } from 'react-router-dom'; 2 | 3 | import ConfirmContainer from '@/components/Confirm/ConfirmContainer.tsx'; 4 | import ToastContainer from '@/components/Toast/ToastContainer.tsx'; 5 | 6 | import AuthProvider from '@/providers/AuthProvider'; 7 | import ConfirmProvider from '@/providers/ConfirmProvider.tsx'; 8 | import QueryProvider from '@/providers/QueryProvider'; 9 | import router from '@/routes/index'; 10 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 11 | 12 | function App() { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /front/src/api/axios.ts: -------------------------------------------------------------------------------- 1 | import { toast } from '@/components/Toast/index.ts'; 2 | 3 | import { ROUTE_URL } from '@/constants/index.ts'; 4 | import { auth } from '@/events/AuthEvent.ts'; 5 | import router from '@/routes/index.tsx'; 6 | import axios, { AxiosError, isAxiosError } from 'axios'; 7 | 8 | //TODO 타입 정의 9 | const isApiProxying = !!import.meta.env.VITE_API_PROXYING; 10 | const proxyingUrl = window.location.origin + '/api'; 11 | const apiServerUrl = import.meta.env.VITE_BE_URL ? import.meta.env.VITE_BE_URL : 'http://localhost:8080'; 12 | 13 | export const BASE_URL = isApiProxying ? proxyingUrl : apiServerUrl; 14 | 15 | type ErrorData = { 16 | error: string; 17 | message: string; 18 | statusCode: number; 19 | }; 20 | 21 | export type CustomError = AxiosError; 22 | 23 | export const apiClient = axios.create({ 24 | baseURL: BASE_URL, 25 | withCredentials: true, 26 | }); 27 | 28 | const UN_AUTHENTICATION_ERROR_STATUS = 403; 29 | const EXCLUDE_AUTHENTICATION_ERROR_API_URL_LIST = ['/user']; 30 | const UN_AUTHORIZATION_ERROR_STATUS = 401; 31 | const EXCLUDE_AUTHORIZATION_ERROR_API_URL_LIST = ['/user/login']; 32 | 33 | const isAuthenticateError = (error: AxiosError) => { 34 | if ( 35 | error.status === UN_AUTHENTICATION_ERROR_STATUS && 36 | error.config?.url && 37 | !EXCLUDE_AUTHENTICATION_ERROR_API_URL_LIST.includes(error.config.url) 38 | ) 39 | return true; 40 | return false; 41 | }; 42 | 43 | const isAuthorizationError = (error: AxiosError) => { 44 | if ( 45 | error.status === UN_AUTHORIZATION_ERROR_STATUS && 46 | error.config?.url && 47 | !EXCLUDE_AUTHORIZATION_ERROR_API_URL_LIST.includes(error.config.url) 48 | ) { 49 | return true; 50 | } 51 | return false; 52 | }; 53 | 54 | const isServerError = (error: AxiosError) => { 55 | if (error.status && error.status >= 500 && error.status < 600) { 56 | return true; 57 | } 58 | return false; 59 | }; 60 | 61 | const isError = (error: unknown) => error && isAxiosError(error); 62 | //TODO 500 에러 처리 필요 63 | 64 | apiClient.interceptors.response.use( 65 | (response) => response, 66 | (error) => { 67 | if (isError(error)) { 68 | if (isAuthenticateError(error)) { 69 | toast.error('로그인이 필요합니다.\n로그인 후 이용해주세요.'); 70 | auth.logout(); 71 | router.navigate(ROUTE_URL.USER.LOGIN, { replace: true }); 72 | } else if (isAuthorizationError(error)) { 73 | toast.error('잘못된 접근입니다.\n다시 시도해주세요.'); 74 | router.navigate('/', { replace: true }); 75 | } else if (isServerError(error)) { 76 | toast.error('서버에 문제가 있습니다.\n잠시 후 다시 시도해주세요.'); 77 | } 78 | throw error; 79 | } 80 | }, 81 | ); 82 | -------------------------------------------------------------------------------- /front/src/api/booking.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '@/api/axios.ts'; 2 | 3 | import { API } from '@/constants/index.ts'; 4 | 5 | export const getPermission = (id: number) => () => 6 | apiClient.get(API.BOOKING.GET_PERMISSION(id)).then((res) => res.data); 7 | export const postSeatCount = (count: number) => 8 | apiClient.post(API.BOOKING.POST_COUNT, { bookingAmount: count }); 9 | export const postSeat = (data: PostSeatData) => apiClient.post(API.BOOKING.POST_SEAT, data); 10 | 11 | export interface PostSeatData { 12 | eventId: number; 13 | sectionIndex: number; 14 | seatIndex: number; 15 | expectedStatus: 'deleted' | 'reserved'; 16 | } 17 | -------------------------------------------------------------------------------- /front/src/api/event.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '@/api/axios.ts'; 2 | 3 | import { API } from '@/constants'; 4 | 5 | export const getMockEventDetail = (id: number) => () => apiClient.get(API.EVENT.GET_EVENT_DETAIL_MOCK(id)); 6 | export const getEventDetail = (id: number) => () => 7 | apiClient.get(API.EVENT.GET_EVENT_DETAIL(id)).then((res) => res.data); 8 | -------------------------------------------------------------------------------- /front/src/api/place.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '@/api/axios.ts'; 2 | 3 | import { API } from '@/constants/index.ts'; 4 | 5 | export const getPlaceInformation = (id: number) => () => 6 | apiClient.get(API.PLACE.GET_PLACE_INFORMATION(id)).then((res) => res.data); 7 | -------------------------------------------------------------------------------- /front/src/api/program.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '@/api/axios.ts'; 2 | 3 | import { API } from '@/constants/index.ts'; 4 | 5 | export const getPrograms = () => apiClient.get(API.PROGRAMS.GET_PROGRAMS).then((res) => res.data); 6 | export const getProgramsDetail = (id: number) => () => 7 | apiClient.get(API.PROGRAMS.GET_DETAIL(id)).then((res) => res.data); 8 | export const getMockProgramDetail = (id: number) => () => 9 | apiClient.get(API.PROGRAMS.GET_DETAIL_MOCK(id)).then((res) => res.data); 10 | -------------------------------------------------------------------------------- /front/src/api/reservation.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '@/api/axios.ts'; 2 | 3 | import { API } from '@/constants/index.ts'; 4 | 5 | export const getReservation = () => apiClient.get(API.RESERVATION.GET_RESERVATION).then((res) => res.data); 6 | export const deleteReservation = (id: number) => apiClient.delete(API.RESERVATION.DELETE_RESERVATION(id)); 7 | export const postReservation = (data: PostReservationData) => 8 | apiClient.post(API.RESERVATION.POST_RESERVATION, data); 9 | 10 | export interface PostReservationData { 11 | eventId: number; 12 | seats: SeatInfo[]; 13 | } 14 | 15 | interface SeatInfo { 16 | sectionIndex: number; 17 | seatIndex: number; 18 | } 19 | -------------------------------------------------------------------------------- /front/src/api/user.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '@/api/axios.ts'; 2 | 3 | import { API } from '@/constants/index.ts'; 4 | 5 | export const postSignup = (data: UserData) => apiClient.post(API.USER.SIGNUP, data); 6 | export const postLogin = (data: UserData) => apiClient.post(API.USER.LOGIN, data); 7 | export const postLogout = () => apiClient.post(API.USER.LOGOUT); 8 | export const getUser = () => apiClient.get(API.USER.INFORMATION).then((res) => res.data); 9 | export const getGuestLogin = () => apiClient.get(API.USER.LOGIN_AS_GUEST).then((res) => res.data); 10 | 11 | export type UserData = { 12 | loginId: string; 13 | loginPassword: string; 14 | }; 15 | -------------------------------------------------------------------------------- /front/src/assets/fonts/Pretendard-ExtraBold.subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/src/assets/fonts/Pretendard-ExtraBold.subset.woff2 -------------------------------------------------------------------------------- /front/src/assets/fonts/Pretendard-ExtraLight.subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/src/assets/fonts/Pretendard-ExtraLight.subset.woff2 -------------------------------------------------------------------------------- /front/src/assets/fonts/Pretendard-Medium.subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/src/assets/fonts/Pretendard-Medium.subset.woff2 -------------------------------------------------------------------------------- /front/src/assets/fonts/Pretendard-Regular.subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web04-RealTicket/c3aaacc8ee900e27280f7e5775bfc4a2b90a3f3b/front/src/assets/fonts/Pretendard-Regular.subset.woff2 -------------------------------------------------------------------------------- /front/src/assets/icons/alert-triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /front/src/assets/icons/calendar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /front/src/assets/icons/check-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /front/src/assets/icons/check-square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /front/src/assets/icons/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/src/assets/icons/clock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /front/src/assets/icons/down-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/src/assets/icons/fie-x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /front/src/assets/icons/home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /front/src/assets/icons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Check } from '@/assets/icons/check.svg?react'; 2 | export { default as DownArrow } from '@/assets/icons/down-arrow.svg?react'; 3 | export { default as FileX } from '@/assets/icons/fie-x.svg?react'; 4 | export { default as Home } from '@/assets/icons/home.svg?react'; 5 | export { default as Loading } from '@/assets/icons/loading.svg?react'; 6 | export { default as LogOut } from '@/assets/icons/log-out.svg?react'; 7 | export { default as Tickets } from '@/assets/icons/tickets.svg?react'; 8 | export { default as Trash } from '@/assets/icons/trash.svg?react'; 9 | export { default as UpArrow } from '@/assets/icons/up-arrow.svg?react'; 10 | export { default as User } from '@/assets/icons/user.svg?react'; 11 | export { default as Clock } from '@/assets/icons/clock.svg?react'; 12 | export { default as CheckCircle } from '@/assets/icons/check-circle.svg?react'; 13 | export { default as MapPin } from '@/assets/icons/map-pin.svg?react'; 14 | export { default as Calendar } from '@/assets/icons/calendar.svg?react'; 15 | export { default as Ticket } from '@/assets/icons/ticket.svg?react'; 16 | export { default as Users } from '@/assets/icons/users.svg?react'; 17 | export { default as Square } from '@/assets/icons/square.svg?react'; 18 | export { default as CheckSquare } from '@/assets/icons/check-square.svg?react'; 19 | export { default as Alert } from '@/assets/icons/alert-triangle.svg?react'; 20 | export { default as XCircle } from '@/assets/icons/x-circle.svg?react'; 21 | -------------------------------------------------------------------------------- /front/src/assets/icons/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/src/assets/icons/log-out.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /front/src/assets/icons/map-pin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /front/src/assets/icons/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /front/src/assets/icons/square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/src/assets/icons/ticket.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /front/src/assets/icons/tickets.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /front/src/assets/icons/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /front/src/assets/icons/up-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /front/src/assets/icons/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /front/src/assets/icons/users.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /front/src/assets/icons/x-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /front/src/components/Captcha/index.css: -------------------------------------------------------------------------------- 1 | .captcha__container{ 2 | display: flex; 3 | flex-direction: column; 4 | gap:1rem; 5 | } 6 | 7 | .captcha{ 8 | padding: 0 1rem; 9 | border: 1px solid gray; 10 | border-radius: 4px; 11 | & > div{ 12 | display: flex; 13 | gap:.5rem; 14 | align-items: center; 15 | justify-content: space-between; 16 | } 17 | #canv{ 18 | width: 100%; 19 | padding: 1rem .5rem; 20 | height: 60px; 21 | object-fit: none; 22 | } 23 | #reload_href{ 24 | --primary:#2563ebff; 25 | display: block; 26 | width:2rem; 27 | height:2rem; 28 | padding: 1rem; 29 | background-image: url('@/assets/icons/refresh.svg'); 30 | background-repeat: no-repeat; 31 | background-position:center; 32 | border: 2px solid var(--primary); 33 | border-radius: 4px; 34 | font-size: 2rem; 35 | &:hover{ 36 | opacity: .7; 37 | } 38 | } 39 | 40 | } 41 | 42 | -------------------------------------------------------------------------------- /front/src/components/Confirm/ConfirmContainer.tsx: -------------------------------------------------------------------------------- 1 | import useConfirm from '@/hooks/useConfirm.tsx'; 2 | 3 | import Button from '@/components/common/Button.tsx'; 4 | 5 | export default function ConfirmContainer() { 6 | const { confirmValue } = useConfirm(); 7 | if (confirmValue == null) { 8 | return null; 9 | } 10 | 11 | return ( 12 |
13 |
14 |
15 |

{confirmValue.title}

16 | {`${confirmValue.description}`} 17 |
18 |
19 | {confirmValue.cancel && ( 20 | 23 | )} 24 | 27 |
28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /front/src/components/Navbar/ReservationCard.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@/components/common/Button.tsx'; 2 | import Icon from '@/components/common/Icon.tsx'; 3 | 4 | import { getDate, getTime } from '@/utils/date.ts'; 5 | 6 | import type { Reservation } from '@/type/reservation.ts'; 7 | 8 | interface ReservationCardProps extends Reservation { 9 | handleDeleteReservation: () => void; 10 | isDeleting: boolean; 11 | } 12 | 13 | export default function ReservationCard({ 14 | programName, 15 | runningDate, 16 | placeName, 17 | seats, 18 | isDeleting, 19 | handleDeleteReservation, 20 | }: ReservationCardProps) { 21 | return ( 22 |
23 | {isDeleting && ( 24 |
25 |
26 | 27 | deleting... 28 |
29 |
30 | )} 31 |
32 |

{programName}

33 |
34 |
{getDate(runningDate) + getTime(runningDate)}
35 |
{`공연장 : ${placeName}`}
36 |
37 |
38 | 좌석 39 |
    40 | {seats.split(',').map((seat) => ( 41 |
  • {seat}
  • 42 | ))} 43 |
44 |
45 |
46 | 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /front/src/components/Toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { ToastType } from '@/components/Toast/ToastContainer.tsx'; 4 | import Button from '@/components/common/Button.tsx'; 5 | import Icon, { IconName } from '@/components/common/Icon.tsx'; 6 | 7 | import { cva, cx } from 'class-variance-authority'; 8 | import { twMerge } from 'tailwind-merge'; 9 | 10 | interface ToastProps { 11 | className?: string; 12 | type: ToastType; 13 | text: string; 14 | close: () => void; 15 | } 16 | 17 | const typeIconNameMap: Record = { 18 | success: 'CheckCircle', 19 | error: 'XCircle', 20 | warning: 'Alert', 21 | }; 22 | 23 | export default function Toast({ type, text, close }: ToastProps) { 24 | const [isClose, setIsClose] = useState(false); 25 | 26 | useEffect(() => { 27 | const timer = setTimeout(handleClose, 3000); 28 | return () => { 29 | clearTimeout(timer); 30 | }; 31 | }, []); 32 | const handleClose = () => { 33 | setIsClose(true); 34 | }; 35 | 36 | return ( 37 |
{ 40 | if (isClose) { 41 | close(); 42 | } 43 | }}> 44 | 45 | {`${text}`} 46 | 49 |
50 | ); 51 | } 52 | 53 | const toastVariant = cva( 54 | `flex gap-4 px-4 py-3 relative h-fit w-[300px] items-center rounded border whitespace-pre-line z-20`, 55 | { 56 | variants: { 57 | type: { 58 | error: `bg-error`, 59 | success: 'bg-success', 60 | warning: `bg-warning`, 61 | }, 62 | }, 63 | }, 64 | ); 65 | -------------------------------------------------------------------------------- /front/src/components/Toast/ToastContainer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useTransition } from 'react'; 2 | 3 | import Toast from '@/components/Toast/Toast.tsx'; 4 | 5 | import ToastEvent from '@/events/ToastEvent.ts'; 6 | 7 | export type ToastType = 'success' | 'warning' | 'error'; 8 | 9 | interface ToastData { 10 | type: ToastType; 11 | text: string; 12 | id: number; 13 | } 14 | 15 | export default function ToastContainer() { 16 | const [toastList, setToastList] = useState([]); 17 | const [, startTransition] = useTransition(); 18 | const getId = () => Date.now(); 19 | const setSuccessToast = (text: string) => 20 | startTransition(() => setToastList((prev) => [{ type: 'success', text, id: getId() }, ...prev])); 21 | const setWarningToast = (text: string) => 22 | startTransition(() => setToastList((prev) => [{ type: 'warning', text, id: getId() }, ...prev])); 23 | const setErrorToast = (text: string) => 24 | startTransition(() => setToastList((prev) => [{ type: 'error', text, id: getId() }, ...prev])); 25 | 26 | useEffect(() => { 27 | const toastEvent = ToastEvent.getInstance(); 28 | toastEvent.on('success', setSuccessToast); 29 | toastEvent.on('warning', setWarningToast); 30 | toastEvent.on('error', setErrorToast); 31 | 32 | return () => { 33 | toastEvent.off('success', setSuccessToast); 34 | toastEvent.off('warning', setWarningToast); 35 | toastEvent.off('error', setErrorToast); 36 | }; 37 | }, []); 38 | return ( 39 |
40 | {toastList.map((toast) => ( 41 | { 45 | setToastList((prevList) => prevList.filter((prev) => toast.id !== prev.id)); 46 | }} 47 | /> 48 | ))} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /front/src/components/Toast/index.ts: -------------------------------------------------------------------------------- 1 | import ToastEvent from '@/events/ToastEvent.ts'; 2 | 3 | export { default as ToastContainer } from '@/components/Toast/ToastContainer.tsx'; 4 | 5 | const toastEvent = ToastEvent.getInstance(); 6 | export const toast = { 7 | success: (text: string) => { 8 | toastEvent.emit('success', text); 9 | }, 10 | error: (text: string) => { 11 | toastEvent.emit('error', text); 12 | }, 13 | warning: (text: string) => { 14 | toastEvent.emit('warning', text); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /front/src/components/common/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes, ForwardedRef, forwardRef } from 'react'; 2 | 3 | import Slot from '@/components/common/Slot'; 4 | 5 | import { VariantProps, cva } from 'class-variance-authority'; 6 | import { twMerge } from 'tailwind-merge'; 7 | 8 | interface IButtonProps 9 | extends Omit, 'color'>, 10 | VariantProps { 11 | asChild?: boolean; 12 | } 13 | 14 | const Button = forwardRef(function Button( 15 | { className, children, intent, size, color, asChild, ...rest }: IButtonProps, 16 | ref: ForwardedRef, 17 | ) { 18 | const Element = asChild ? Slot : 'button'; 19 | return ( 20 | 21 | {children} 22 | 23 | ); 24 | }); 25 | 26 | export default Button; 27 | 28 | const baseButtonClass = 29 | 'button p-2 flex items-center justify-center gap-x-2 rounded relative overflow-visible'; 30 | const disabledClass = 31 | 'disabled:bg-surface-disabled disabled:border-surface-disabled disabled:opacity-50 disabled:cursor-not-allowed'; 32 | 33 | const buttonVariants = cva([baseButtonClass, disabledClass], { 34 | variants: { 35 | color: { 36 | default: ['border-surface', 'bg-surface', 'hover:bg-surface/90'], 37 | success: ['border-success', 'bg-success', 'hover:bg-success/90'], 38 | primary: ['border-primary', 'bg-primary', 'hover:bg-primary/90'], 39 | error: ['border-error', 'bg-error', 'hover:bg-error/90'], 40 | cancel: ['bg-surface-cancel', 'hover:bg-surface-cancel/90'], 41 | }, 42 | intent: { 43 | default: [], 44 | outline: ['bg-transparent', 'border', 'hover:bg-transparent', 'hover:opacity-60'], 45 | ghost: ['bg-transparent', 'border-transparent', 'hover:bg-transparent hover:opacity-60'], 46 | }, 47 | size: { 48 | full: ['w-full'], 49 | middle: ['w-fit', 'px-4'], 50 | fit: ['w-fit'], 51 | }, 52 | }, 53 | defaultVariants: { 54 | color: 'default', 55 | intent: 'default', 56 | size: 'full', 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /front/src/components/common/Card.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | export default function Card({ children }: PropsWithChildren) { 4 | return ( 5 |
6 | {children} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /front/src/components/common/Field.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { FieldContext } from '@/contexts/FieldContext.tsx'; 4 | 5 | interface IFieldProps extends PropsWithChildren { 6 | label: string; 7 | isValid?: boolean; 8 | isRequired?: boolean; 9 | errorMessage?: string; 10 | helpMessage?: string; 11 | } 12 | export default function Field({ 13 | label, 14 | children, 15 | isValid = true, 16 | isRequired = false, 17 | errorMessage = '', 18 | helpMessage = '', 19 | }: IFieldProps) { 20 | const isHelpMessage = helpMessage.length > 0; 21 | return ( 22 |
23 |
24 | 28 | 29 | {!isValid && errorMessage} 30 | 31 |
32 | {children} 33 | {isHelpMessage && {helpMessage}} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /front/src/components/common/Icon.tsx: -------------------------------------------------------------------------------- 1 | import * as Icons from '@/assets/icons'; 2 | //TODO 최적화 필요, 필요한 icon만 호출되도록, 캐시 3 | import { VariantProps, cva, cx } from 'class-variance-authority'; 4 | import { twMerge } from 'tailwind-merge'; 5 | 6 | export type IconName = keyof typeof Icons; 7 | interface IIconProps extends VariantProps { 8 | className?: string; 9 | iconName: IconName; 10 | } 11 | 12 | export default function Icon({ className, iconName, color, size }: IIconProps) { 13 | const Icon = Icons[iconName]; 14 | 15 | return ; 16 | } 17 | 18 | const iconVariants = cva([], { 19 | variants: { 20 | color: { 21 | default: ['fill-typo stroke-typo'], 22 | disabled: ['fill-typo-disable stroke-typo-disable'], 23 | display: ['fill-typo-display stroke-typo-display'], 24 | success: ['fill-success stroke-success'], 25 | primary: ['fill-primary stroke-primary'], 26 | error: ['fill-error stroke-error'], 27 | warning: ['fill-warning stroke-warning'], 28 | }, 29 | size: { 30 | small: ['w-6 h-6'], 31 | big: ['w-8 h-8'], 32 | }, 33 | }, 34 | defaultVariants: { color: 'default', size: 'small' }, 35 | }); 36 | -------------------------------------------------------------------------------- /front/src/components/common/Input.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes, forwardRef } from 'react'; 2 | 3 | import { useFieldContext } from '@/hooks/useFieldContext'; 4 | 5 | import { cx } from 'class-variance-authority'; 6 | import { twMerge } from 'tailwind-merge'; 7 | 8 | const Input = forwardRef>(function Input( 9 | { className, checked, ...rest }, 10 | ref, 11 | ) { 12 | const { isValid, htmlFor } = useFieldContext(); 13 | const isNullHtmlFor = htmlFor === null; 14 | 15 | const isValidClass = isValid 16 | ? 'border-surface-sub focus-within:outline-surface focus:outline-surface' 17 | : 'border-error focus:outline-error focus-visible:outline-error'; 18 | return ( 19 | 35 | ); 36 | }); 37 | export default Input; 38 | -------------------------------------------------------------------------------- /front/src/components/common/Loading.tsx: -------------------------------------------------------------------------------- 1 | import Icon from '@/components/common/Icon.tsx'; 2 | 3 | import { cx } from 'class-variance-authority'; 4 | 5 | interface LoadingProps { 6 | className?: string; 7 | } 8 | export default function Loading({ className }: LoadingProps) { 9 | return ( 10 |
11 |
12 | 13 | loading.. 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /front/src/components/common/Progressbar.tsx: -------------------------------------------------------------------------------- 1 | //에니메이션 처리 고려 2 | import { cx } from 'class-variance-authority'; 3 | 4 | interface ProgressbarProps { 5 | value: number; 6 | } 7 | 8 | export default function Progressbar({ value }: ProgressbarProps) { 9 | const rounded = Math.round(value); 10 | const percent = rounded > 100 ? 100 : rounded; 11 | return ( 12 |
13 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /front/src/components/common/Radio.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from 'class-variance-authority'; 2 | 3 | interface IRadio { 4 | group: string; 5 | value: string; 6 | checked: boolean; 7 | subText?: string; 8 | onClick: () => void; 9 | className?: string; 10 | } 11 | //TODO Input, Field로 대체 가능? 12 | 13 | export default function Radio({ group, value, checked, subText, onClick, className }: IRadio) { 14 | return ( 15 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /front/src/components/common/Separator.tsx: -------------------------------------------------------------------------------- 1 | import { cx } from 'class-variance-authority'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | interface ISeparatorProps { 5 | direction: 'col' | 'row'; 6 | className?: string; 7 | } 8 | //TODO class 부닐 9 | export default function Separator({ className, direction }: ISeparatorProps) { 10 | const directionClass = direction === 'row' ? 'h-[1px] w-full' : 'w-[1px]'; 11 | return
; 12 | } 13 | -------------------------------------------------------------------------------- /front/src/components/common/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from 'react'; 2 | 3 | import { cx } from 'class-variance-authority'; 4 | 5 | export default function Skeleton({ className }: HTMLAttributes) { 6 | return
; 7 | } 8 | -------------------------------------------------------------------------------- /front/src/components/common/Slot.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement, isValidElement } from 'react'; 2 | import type { ReactNode } from 'react'; 3 | 4 | interface ISlotProps { 5 | children: ReactNode; 6 | className?: string; 7 | } 8 | export default function Slot({ children, className, ...rest }: ISlotProps) { 9 | if (!isValidElement(children)) { 10 | console.warn('slot에서는 react element만 올 수 있습니다.'); 11 | return null; 12 | } 13 | 14 | const childClassName = children.props.className || ''; 15 | return cloneElement(children, { 16 | ...rest, 17 | ...children.props, 18 | className: `${className || ''} ${childClassName}`.trim(), 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /front/src/components/loaders/WithLogin.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import { Navigate } from 'react-router-dom'; 3 | 4 | import { useAuthContext } from '@/hooks/useAuthContext.tsx'; 5 | 6 | import { ROUTE_URL } from '@/constants/index.ts'; 7 | 8 | export default function WithLogin({ children }: PropsWithChildren) { 9 | const { isLogin } = useAuthContext(); 10 | 11 | if (!isLogin) { 12 | return ; 13 | } 14 | return <>{children}; 15 | } 16 | -------------------------------------------------------------------------------- /front/src/components/loaders/WithoutLogin.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import { Navigate } from 'react-router-dom'; 3 | 4 | import { useAuthContext } from '@/hooks/useAuthContext.tsx'; 5 | 6 | import { ROUTE_URL } from '@/constants/index.ts'; 7 | 8 | export default function WithoutLogin({ children }: PropsWithChildren) { 9 | const { isLogin } = useAuthContext(); 10 | if (isLogin) { 11 | return ; 12 | } 13 | return <>{children}; 14 | } 15 | -------------------------------------------------------------------------------- /front/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const SEAT_SIZE = 10; 2 | export const SEATS_GAP = 2; 3 | export const SEAT_BOX_SIZE = SEAT_SIZE + SEATS_GAP; 4 | 5 | //api 6 | export const API = { 7 | PROGRAMS: { 8 | //TODO 단수로 9 | GET_PROGRAMS: '/program', 10 | GET_DETAIL: (id: number) => `/program/${id}`, 11 | GET_DETAIL_MOCK: (id: number) => `/mock/programs/${id}`, 12 | }, 13 | USER: { 14 | SIGNUP: '/user/signup', 15 | LOGIN: '/user/login', //signin->login으로변경 16 | CHECK_ID: '/user/checkid', 17 | LOGOUT: '/user/logout', 18 | INFORMATION: '/user', 19 | LOGIN_AS_GUEST: '/user/guest', 20 | }, 21 | EVENT: { 22 | GET_EVENT_DETAIL_MOCK: (id: number) => `/mock/events/${id}`, 23 | GET_EVENT_DETAIL: (id: number) => `/event/${id}`, 24 | }, 25 | PLACE: { 26 | GET_PLACE_INFORMATION: (id: number) => `/place/seat/${id}`, 27 | }, 28 | RESERVATION: { 29 | GET_RESERVATION: '/reservation', 30 | DELETE_RESERVATION: (id: number) => `/reservation/${id}`, 31 | POST_RESERVATION: `/reservation`, 32 | }, 33 | BOOKING: { 34 | GET_SEATS_SSE: (id: number) => `/booking/seat/${id}`, 35 | GET_SEATS_SSE_MOCK: `/mock/events/seat/1`, 36 | GET_PERMISSION: (id: number) => `/booking/permission/${id}`, 37 | POST_COUNT: `/booking/count`, 38 | POST_SEAT: `/booking`, 39 | GET_RE_PERMISSION: (id: number) => `/booking/re-permission/${id}`, 40 | }, 41 | }; 42 | 43 | export const ROUTE_URL = { 44 | HOME: '/', 45 | PROGRAM: { 46 | DEFAULT: '/program', 47 | PROGRAM_DETAIL: (programId: number) => `/program/${programId}`, 48 | }, 49 | USER: { 50 | LOGIN: `/login`, 51 | SIGN_UP: `/signup`, 52 | }, 53 | EVENT: { 54 | DEFAULT: `/event`, 55 | DETAIL: (eventId: number) => `/event/${eventId}`, 56 | BOOKING_READY: (eventId: number) => `/event/${eventId}/ready`, 57 | WAITING_ROOM: (eventId: number) => `/event/${eventId}/waiting`, 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /front/src/constants/reservation.ts: -------------------------------------------------------------------------------- 1 | export const SEAT_COUNT_LIST = [1, 2, 3, 4] as const; 2 | -------------------------------------------------------------------------------- /front/src/constants/user.ts: -------------------------------------------------------------------------------- 1 | export const LOGIN_FAILED_MESSAGE = '아이디 또는 비밀번호가 일치하지 않습니다.'; 2 | -------------------------------------------------------------------------------- /front/src/contexts/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export interface IAuthContextValue { 4 | isLogin: boolean; 5 | userId: null | string; 6 | login: ((useId: string) => void) | null; 7 | logout: (() => void) | null; 8 | } 9 | 10 | const AUTH_CONTEXT_DEFAULT_VALUE: IAuthContextValue = { 11 | isLogin: false, 12 | userId: null, 13 | login: null, 14 | logout: null, 15 | }; 16 | export const AuthContext = createContext(AUTH_CONTEXT_DEFAULT_VALUE); 17 | -------------------------------------------------------------------------------- /front/src/contexts/FieldContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | interface IFieldContextValue { 4 | isValid: boolean; 5 | htmlFor: null | string; 6 | } 7 | const FIELD_CONTEXT_DEFAULT_VALUE: IFieldContextValue = { 8 | isValid: true, 9 | htmlFor: null, 10 | }; 11 | export const FieldContext = createContext(FIELD_CONTEXT_DEFAULT_VALUE); 12 | -------------------------------------------------------------------------------- /front/src/events/AuthEvent.ts: -------------------------------------------------------------------------------- 1 | type Listener = (data?: unknown) => void; 2 | 3 | export default class AuthEvent { 4 | private static instance: AuthEvent; 5 | private list: Map> = new Map(); 6 | 7 | private constructor() {} 8 | 9 | public static getInstance() { 10 | if (!AuthEvent.instance) { 11 | AuthEvent.instance = new AuthEvent(); 12 | } 13 | return AuthEvent.instance; 14 | } 15 | 16 | on(event: string, callback: Listener) { 17 | if (!this.list.has(event)) { 18 | this.list.set(event, new Set()); 19 | } 20 | this.list.get(event)?.add(callback); 21 | } 22 | 23 | emit(event: string, data?: unknown) { 24 | if (this.list.has(event)) { 25 | const callbacks = this.list.get(event); 26 | callbacks?.forEach((listener) => listener(data)); 27 | } 28 | } 29 | 30 | off(event: string, targetListener: Listener) { 31 | if (this.list.has(event)) { 32 | this.list.get(event)?.delete(targetListener); 33 | } 34 | } 35 | } 36 | 37 | export const auth = { 38 | logout: () => AuthEvent.getInstance().emit('logout'), 39 | }; 40 | -------------------------------------------------------------------------------- /front/src/events/ToastEvent.ts: -------------------------------------------------------------------------------- 1 | type Listener = (text: string) => void; 2 | 3 | export default class ToastEvent { 4 | private static instance: ToastEvent; 5 | private list: Map> = new Map(); 6 | 7 | private constructor() {} 8 | 9 | public static getInstance() { 10 | if (!ToastEvent.instance) { 11 | ToastEvent.instance = new ToastEvent(); 12 | } 13 | return ToastEvent.instance; 14 | } 15 | 16 | on(event: string, callback: Listener) { 17 | if (!this.list.has(event)) { 18 | this.list.set(event, new Set()); 19 | } 20 | this.list.get(event)?.add(callback); 21 | } 22 | 23 | emit(event: string, text: string) { 24 | if (this.list.has(event)) { 25 | const callbacks = this.list.get(event); 26 | callbacks?.forEach((listener) => listener(text)); 27 | } 28 | } 29 | 30 | off(event: string, targetListener: Listener) { 31 | if (this.list.has(event)) { 32 | this.list.get(event)?.delete(targetListener); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /front/src/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | //TODO 로그인시 userId를 저장해야된다, 아니면 4 | interface IAuthState { 5 | isSignIn: boolean; 6 | userId: string | null; 7 | } 8 | 9 | const AUTH_DEFAULT_STATE: IAuthState = { 10 | isSignIn: false, 11 | userId: null, 12 | }; 13 | //TODO useEffect 로그인 여부 확인 14 | export const useAuth = () => { 15 | const [auth, setAuth] = useState(AUTH_DEFAULT_STATE); 16 | const signIn = (userId: string) => { 17 | setAuth({ isSignIn: true, userId }); 18 | }; 19 | const logout = () => { 20 | setAuth({ isSignIn: false, userId: null }); 21 | }; 22 | 23 | return { isSignIn: auth.isSignIn, userId: auth.userId, signIn, logout }; 24 | }; 25 | -------------------------------------------------------------------------------- /front/src/hooks/useAuthContext.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { AuthContext } from '@/contexts/AuthContext'; 4 | 5 | export const useAuthContext = () => useContext(AuthContext); 6 | -------------------------------------------------------------------------------- /front/src/hooks/useConfirm.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { ConfirmContext } from '@/providers/ConfirmProvider.tsx'; 4 | 5 | interface ConfirmProps { 6 | title: string; 7 | description: string; 8 | buttons: { 9 | ok: { 10 | title: string; 11 | color: 'error' | 'success' | 'primary'; 12 | }; 13 | cancel: { 14 | title: string; 15 | }; 16 | }; 17 | } 18 | export default function useConfirm() { 19 | const confirmContext = useContext(ConfirmContext); 20 | 21 | if (confirmContext === null) throw Error('ConfirmContext는 ConfirmProvider내에서 사용가능합니다. '); 22 | 23 | const { confirmValue, setConfirm, clearConfirm } = confirmContext; 24 | 25 | const confirm = ({ title, description, buttons }: ConfirmProps) => { 26 | const promise = new Promise((resolve) => { 27 | setConfirm({ 28 | title, 29 | description, 30 | ok: { 31 | text: buttons.ok.title, 32 | color: buttons.ok.color, 33 | handleClick: () => resolve(true), 34 | }, 35 | cancel: { 36 | text: buttons.cancel.title, 37 | handleClick: () => resolve(false), 38 | }, 39 | }); 40 | }).finally(() => { 41 | clearConfirm(); 42 | }); 43 | 44 | return promise; 45 | }; 46 | 47 | return { confirmValue, confirm }; 48 | } 49 | -------------------------------------------------------------------------------- /front/src/hooks/useFieldContext.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { FieldContext } from '@/contexts/FieldContext.tsx'; 4 | 5 | export const useFieldContext = () => useContext(FieldContext); 6 | -------------------------------------------------------------------------------- /front/src/hooks/usePreventLeave.tsx: -------------------------------------------------------------------------------- 1 | import { useBeforeUnload, useBlocker } from 'react-router-dom'; 2 | 3 | export default function usePreventLeave() { 4 | useBlocker(() => { 5 | const isConfirm = window.confirm('페이지를 이동하시겠습니까? 이동시 선택한 좌석은 취소됩니다.'); 6 | return !isConfirm; 7 | }); 8 | useBeforeUnload((event) => { 9 | event.preventDefault(); 10 | event.returnValue = ''; 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /front/src/hooks/useSSE.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | interface useSSEProps { 4 | sseURL: string; 5 | } 6 | //에러 핸들링 필요, axios 레벨에서 가능? 7 | export default function useSSE({ sseURL }: useSSEProps) { 8 | const eventSourceRef = useRef(null); 9 | 10 | const [data, setData] = useState(null); 11 | 12 | useEffect(() => { 13 | if (eventSourceRef.current === null) { 14 | eventSourceRef.current = new EventSource(`${sseURL}`, { 15 | withCredentials: true, 16 | }); 17 | eventSourceRef.current.onmessage = (event) => { 18 | const parsed = JSON.parse(event.data); 19 | 20 | if (parsed) { 21 | setData(() => parsed); 22 | } 23 | }; 24 | } 25 | return () => { 26 | if (eventSourceRef.current) { 27 | eventSourceRef.current.close(); 28 | eventSourceRef.current = null; 29 | } 30 | }; 31 | }, [sseURL]); 32 | 33 | const isLoading = data === null ? true : false; 34 | return { data, isLoading } as { data: T | null; isLoading: boolean }; 35 | } 36 | -------------------------------------------------------------------------------- /front/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | @layer base{ 7 | @font-face { 8 | font-family: 'pretendard'; 9 | src: url('./assets/fonts/Pretendard-ExtraLight.subset.woff2'); 10 | font-weight:400 ; 11 | } 12 | @font-face { 13 | font-family: 'pretendard'; 14 | src: url('./assets/fonts/Pretendard-Regular.subset.woff2'); 15 | font-weight: 500; 16 | } 17 | @font-face { 18 | font-family: 'pretendard'; 19 | src: url('./assets/fonts/Pretendard-Medium.subset.woff2'); 20 | font-weight: 600; 21 | } 22 | @font-face { 23 | font-family: 'pretendard'; 24 | src: url('./assets/fonts/Pretendard-ExtraBold.subset.woff2'); 25 | font-weight: 700; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /front/src/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { Outlet } from 'react-router-dom'; 3 | 4 | import Navbar from '@/components/Navbar/index.tsx'; 5 | 6 | import LoadingPage from '@/pages/LoadingPage'; 7 | 8 | export default function Layout() { 9 | return ( 10 | <> 11 | 12 |
13 | }> 14 | 15 | 16 |
17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /front/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './App.tsx'; 5 | import './index.css'; 6 | 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /front/src/pages/AdminPage/index.tsx: -------------------------------------------------------------------------------- 1 | export default function AdminPage() { 2 | return
admin
; 3 | } 4 | -------------------------------------------------------------------------------- /front/src/pages/LoadingPage.tsx: -------------------------------------------------------------------------------- 1 | import Icon from '@/components/common/Icon.tsx'; 2 | 3 | export default function LoadingPage() { 4 | return ( 5 |
6 |
7 |
8 | 9 |

Loading.....

10 |
11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /front/src/pages/LoginPage/validate.ts: -------------------------------------------------------------------------------- 1 | import type { Validate } from '@/hooks/useForm.tsx'; 2 | 3 | import type { LoginForm } from '@/type/user.ts'; 4 | 5 | export const lengthValidate: Validate = ({ value }) => { 6 | const isRightLength = value.length >= 4 && value.length <= 12; 7 | if (!isRightLength) return '최소 4자리, 최대 12자리 입니다.'; 8 | return null; 9 | }; 10 | -------------------------------------------------------------------------------- /front/src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | import Button from '@/components/common/Button.tsx'; 4 | import Icon from '@/components/common/Icon.tsx'; 5 | 6 | export default function NotFoundPage() { 7 | return ( 8 |
9 |
10 |
11 | 12 |

Not Found

13 | 페이지를 찾을 수 없습니다. 14 |
15 | 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /front/src/pages/ProgramDetailPage/ProgramInformation.tsx: -------------------------------------------------------------------------------- 1 | interface IProgramInformationProps 2 | extends Pick { 3 | isOneDay: boolean; 4 | startDate: string; 5 | lastDate: string; 6 | } 7 | export default function ProgramInformation({ 8 | name, 9 | runningTime, 10 | genre, 11 | actors, 12 | place, 13 | profileUrl, 14 | isOneDay, 15 | startDate, 16 | lastDate, 17 | }: IProgramInformationProps) { 18 | return ( 19 |
20 | {`${name}`} 21 |
22 |

{name}

23 |
24 |
25 |
공연 기간 : {isOneDay ? startDate : `${startDate} ~ ${lastDate}`}
26 |
공연 장소 : {place.name}
27 |
관람 시간 : {runningTime}
28 |
29 |
30 |
장르 : {genre}
31 |
출연진 : {actors}
32 |
33 |
34 |
35 |
36 | ); 37 | } 38 | 39 | interface Program { 40 | id: number; 41 | name: string; 42 | runningTime: number; 43 | genre: string; 44 | actors: string; 45 | place: { id: number; name: string }; 46 | profileUrl: string; 47 | price: number; 48 | events: Pick[]; 49 | } 50 | interface Event { 51 | id: number; 52 | name: string; 53 | place: string; 54 | runningTime: number; 55 | runningDate: string; 56 | reservationOpenDate: Date; 57 | reservationCloseDate: Date; 58 | actors: string; 59 | } 60 | -------------------------------------------------------------------------------- /front/src/pages/ProgramsPage/ProgramCard.tsx: -------------------------------------------------------------------------------- 1 | export interface IProgram { 2 | id: number; 3 | name: string; 4 | genre: string; 5 | place: { 6 | id: number; 7 | name: string; 8 | }; 9 | profileUrl: string; 10 | actors: string; 11 | } 12 | export default function ProgramCard({ 13 | name, 14 | profileUrl, 15 | actors, 16 | }: Pick) { 17 | return ( 18 |
19 | 20 |
21 |
{name}
22 |
{actors}
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /front/src/pages/ProgramsPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | import { CustomError } from '@/api/axios.ts'; 4 | import { getPrograms } from '@/api/program'; 5 | 6 | import ProgramCard from '@/pages/ProgramsPage/ProgramCard.tsx'; 7 | import type { IProgram } from '@/pages/ProgramsPage/ProgramCard.tsx'; 8 | 9 | import { ROUTE_URL } from '@/constants/index.ts'; 10 | import { useSuspenseQuery } from '@tanstack/react-query'; 11 | 12 | //TODO 지연로딩, 로딩 skeleton 적용, scrollbar 관리(나타나면서 화면을 밀어버린다 ), queryKey 관리 필요 13 | export default function ProgramsPage() { 14 | const { data } = useSuspenseQuery({ 15 | queryKey: ['programs'], 16 | queryFn: getPrograms, 17 | }); 18 | const programs = data; 19 | //현재 데이터가 없어서 mock 대체 20 | return ( 21 |
    22 | {programs.map((program) => ( 23 |
  • 24 | 25 | 26 | 27 |
  • 28 | ))} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /front/src/pages/ReservationPage/SectionSelectorMap.tsx: -------------------------------------------------------------------------------- 1 | import { calculatePolygonCentroid, getPathD } from '@/utils/svg.ts'; 2 | 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | interface ISectionSelectorMapProps { 6 | className?: string; 7 | viewBoxData: string; 8 | svgURL: string; 9 | sections: Section[]; 10 | setSelectedSection: (id: number) => void; 11 | selectedSection: number | null; 12 | } 13 | export default function SectionSelectorMap({ 14 | className, 15 | viewBoxData, 16 | svgURL, 17 | sections, 18 | setSelectedSection, 19 | selectedSection, 20 | }: ISectionSelectorMapProps) { 21 | //TODO 글자 크기 section 크기에 맞춰서 변동되도록, 22 | return ( 23 | 24 | 25 | {sections.map((section, index) => { 26 | const { id, points } = section; 27 | const [textX, textY] = calculatePolygonCentroid(points); 28 | const d = getPathD(...points); 29 | const isActive = selectedSection === index || selectedSection === null; 30 | return ( 31 | setSelectedSection(index)}> 32 | 33 | {`${id}`} 40 | 41 | ); 42 | })} 43 | 44 | ); 45 | } 46 | 47 | type Section = { 48 | id: string; 49 | points: number[][]; 50 | }; 51 | -------------------------------------------------------------------------------- /front/src/providers/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useLayoutEffect, useState } from 'react'; 2 | 3 | import { CustomError } from '@/api/axios.ts'; 4 | import { getUser } from '@/api/user.ts'; 5 | 6 | import LoadingPage from '@/pages/LoadingPage.tsx'; 7 | 8 | import { AuthContext } from '@/contexts/AuthContext'; 9 | import AuthEvent from '@/events/AuthEvent.ts'; 10 | import { UserInformation } from '@/type/user.ts'; 11 | import { useQuery } from '@tanstack/react-query'; 12 | 13 | interface IAuthProviderProps { 14 | children: ReactNode; 15 | } 16 | interface IAuthState { 17 | isLogin: boolean; 18 | userId: string | null; 19 | } 20 | 21 | const AUTH_DEFAULT_STATE: IAuthState = { 22 | isLogin: false, 23 | userId: null, 24 | }; 25 | 26 | export default function AuthProvider({ children }: IAuthProviderProps) { 27 | const [auth, setAuth] = useState(AUTH_DEFAULT_STATE); 28 | const { data: userInformation, isPending } = useQuery({ 29 | queryKey: [], 30 | queryFn: getUser, 31 | retry: false, 32 | }); 33 | 34 | useEffect(() => { 35 | const authEvent = AuthEvent.getInstance(); 36 | authEvent.on('logout', logout); 37 | 38 | return () => { 39 | authEvent.off('logout', logout); 40 | }; 41 | }, []); 42 | 43 | useLayoutEffect(() => { 44 | if (userInformation) { 45 | const { loginId } = userInformation; 46 | if (loginId) { 47 | login(loginId); 48 | } 49 | } 50 | }, [userInformation]); 51 | const login = (userId: string) => { 52 | setAuth({ isLogin: true, userId }); 53 | }; 54 | const logout = () => { 55 | setAuth({ isLogin: false, userId: null }); 56 | }; 57 | 58 | //TODO suspense 59 | if (isPending) return ; 60 | 61 | return ( 62 | 63 | {children} 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /front/src/providers/ConfirmProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, createContext, useState } from 'react'; 2 | 3 | export const ConfirmContext = createContext(null); 4 | 5 | interface ButtonContent { 6 | text: string; 7 | color: 'success' | 'error' | 'primary'; 8 | handleClick: () => void; 9 | } 10 | 11 | interface ConfirmValue { 12 | title: string; 13 | description: string; 14 | ok: ButtonContent; 15 | cancel: Omit; 16 | } 17 | interface ConfirmContext { 18 | confirmValue: ConfirmValue | null; 19 | setConfirm: (value: ConfirmValue) => void; 20 | clearConfirm: () => void; 21 | } 22 | 23 | export default function ConfirmProvider({ children }: PropsWithChildren) { 24 | const [confirmValue, setConfirmValue] = useState(null); 25 | const setConfirm = (value: ConfirmValue) => { 26 | setConfirmValue(value); 27 | }; 28 | const clearConfirm = () => { 29 | setConfirmValue(null); 30 | }; 31 | 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /front/src/providers/QueryProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | 5 | const queryClient = new QueryClient({ 6 | defaultOptions: { 7 | queries: { 8 | retry: false, 9 | }, 10 | }, 11 | }); 12 | export default function QueryProvider({ children }: PropsWithChildren) { 13 | return {children}; 14 | } 15 | -------------------------------------------------------------------------------- /front/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | import { Navigate, createBrowserRouter } from 'react-router-dom'; 3 | 4 | import WithLogin from '@/components/loaders/WithLogin'; 5 | import WithoutLogin from '@/components/loaders/WithoutLogin'; 6 | 7 | import AdminPage from '@/pages/AdminPage'; 8 | import NotFoundPage from '@/pages/NotFoundPage.tsx'; 9 | import ReservationPage from '@/pages/ReservationPage'; 10 | import ReservationWaitingPage from '@/pages/ReservationWaitingPage'; 11 | import WaitingQueuePage from '@/pages/WaitingQueuePage/index.tsx'; 12 | 13 | import { ROUTE_URL } from '@/constants/index.ts'; 14 | import Layout from '@/layout/Layout'; 15 | 16 | const LoginPage = lazy(() => import('@/pages/LoginPage')); 17 | const SignUpPage = lazy(() => import('@/pages/SignupPage')); 18 | const ProgramsPage = lazy(() => import('@/pages/ProgramsPage')); 19 | const ProgramDetailPage = lazy(() => import('@/pages/ProgramDetailPage')); 20 | 21 | //TODO lazyloading,suspene, fallback 적용, withLogin hoc접근 권한 설정, flat보다는 next 처럼 밑으로 최적화도 더 좋다 22 | const router = createBrowserRouter([ 23 | { 24 | path: '/', 25 | element: , 26 | errorElement: , 27 | children: [ 28 | { path: '*', element: }, 29 | { path: '', element: }, 30 | { path: ROUTE_URL.PROGRAM.DEFAULT, element: }, 31 | { path: `${ROUTE_URL.PROGRAM.DEFAULT}/:programId`, element: }, 32 | { 33 | path: ROUTE_URL.USER.LOGIN, 34 | element: ( 35 | 36 | 37 | 38 | ), 39 | }, 40 | 41 | { 42 | path: ROUTE_URL.USER.SIGN_UP, 43 | element: ( 44 | 45 | 46 | 47 | ), 48 | }, 49 | { 50 | path: '/admin', 51 | element: ( 52 | 53 | 54 | 55 | ), 56 | }, 57 | { 58 | path: `${ROUTE_URL.EVENT.DEFAULT}/:eventId/ready`, 59 | element: ( 60 | 61 | 62 | 63 | ), 64 | }, 65 | { 66 | path: `${ROUTE_URL.EVENT.DEFAULT}/:eventId`, 67 | element: ( 68 | 69 | 70 | 71 | ), 72 | }, 73 | { 74 | path: `${ROUTE_URL.EVENT.DEFAULT}/:eventId/waiting`, 75 | element: ( 76 | 77 | 78 | 79 | ), 80 | }, 81 | ], 82 | }, 83 | ]); 84 | 85 | export default router; 86 | -------------------------------------------------------------------------------- /front/src/type/booking.ts: -------------------------------------------------------------------------------- 1 | export interface PermissionResult { 2 | waitingStatus: boolean; 3 | enteringStatus: boolean; 4 | userOrder?: number; 5 | } 6 | 7 | export interface RePermissionResult { 8 | headOrder: number; 9 | totalWaiting: number; 10 | throughputRate: number; 11 | } 12 | -------------------------------------------------------------------------------- /front/src/type/index.ts: -------------------------------------------------------------------------------- 1 | //TODO domain 별 타입 분리 2 | export interface Program { 3 | id: number; 4 | name: string; 5 | genre: string; 6 | place: { 7 | id: number; 8 | name: string; 9 | }; 10 | profileUrl: string; 11 | actors: string; 12 | } 13 | 14 | export interface ProgramDetail { 15 | id: number; 16 | name: string; 17 | runningTime: number; 18 | genre: string; 19 | actors: string; 20 | place: { id: number; name: string }; 21 | profileUrl: string; 22 | price: number; 23 | events: Pick[]; 24 | } 25 | export interface EventDetail { 26 | id: number; 27 | name: string; 28 | price: number; 29 | place: { id: number; name: string }; 30 | runningTime: number; 31 | runningDate: string; 32 | reservationOpenDate: string; 33 | reservationCloseDate: string; 34 | } 35 | 36 | export interface PlaceInformation { 37 | id: number; 38 | layout: Layout; 39 | } 40 | 41 | interface Layout { 42 | overview: string; 43 | overviewWidth: number; 44 | overviewHeight: number; 45 | overviewPoints: string; 46 | sections: Section[]; 47 | } 48 | export interface SectionCoordinate { 49 | id: string; 50 | points: number[][]; 51 | } 52 | 53 | export interface Section { 54 | id: number; 55 | name: string; 56 | seats: number[]; 57 | colLen: number; 58 | } 59 | -------------------------------------------------------------------------------- /front/src/type/react-simple-captcha.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-simple-captcha' { 2 | import { Component } from 'react'; 3 | 4 | // 캡챠 엔진 로드 함수의 시그니처 5 | export function loadCaptchaEnginge( 6 | numberOfCharacters: number, 7 | backgroundColor?: string, 8 | fontColor?: string, 9 | charMap?: 'upper' | 'lower' | 'numbers' | 'special_char', 10 | ): void; 11 | 12 | // 캡챠 검증 함수의 시그니처 13 | export function validateCaptcha(userValue: string, reload?: boolean): boolean; 14 | 15 | // 캡챠 템플릿 컴포넌트 (Reload 기능 포함) 16 | export class LoadCanvasTemplate extends Component<{ 17 | reloadText?: string; 18 | reloadColor?: string; 19 | }> {} 20 | 21 | // 캡챠 템플릿 컴포넌트 (Reload 기능 없음) 22 | export class LoadCanvasTemplateNoReload extends Component {} 23 | } 24 | -------------------------------------------------------------------------------- /front/src/type/reservation.ts: -------------------------------------------------------------------------------- 1 | import { SEAT_COUNT_LIST } from '@/constants/reservation.ts'; 2 | 3 | export interface Reservation { 4 | id: number; 5 | programName: string; 6 | runningDate: string; 7 | placeName: string; 8 | seats: string; 9 | } 10 | 11 | export type SeatCount = (typeof SEAT_COUNT_LIST)[number]; 12 | -------------------------------------------------------------------------------- /front/src/type/user.ts: -------------------------------------------------------------------------------- 1 | export interface UserInformation { 2 | loginId: string; 3 | } 4 | 5 | export type LoginForm = { 6 | id: string; 7 | password: string; 8 | }; 9 | 10 | export type Guest = { 11 | id: number; 12 | loginId: string; 13 | userStatus: string; 14 | targetEvent: null; 15 | }; 16 | -------------------------------------------------------------------------------- /front/src/utils/date.ts: -------------------------------------------------------------------------------- 1 | export const getDate = (ms: number | Date | string) => { 2 | //TODO 에러 처리 필요 3 | const date = new Date(ms); 4 | const year = date.getFullYear(); 5 | const month = String(date.getMonth() + 1).padStart(2, '0'); 6 | const day = String(date.getDate()).padStart(2, '0'); 7 | 8 | return `${year}년 ${month}월 ${day}일 `; 9 | }; 10 | 11 | export const getTime = (ms: number | Date | string) => { 12 | const date = new Date(ms); 13 | const hours = String(date.getHours()).padStart(2, '0'); 14 | const minutes = String(date.getMinutes()).padStart(2, '0'); 15 | return `${hours}:${minutes}`; 16 | }; 17 | 18 | const DAYS = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; 19 | export const getDay = (ms: number | Date | string) => { 20 | const date = new Date(ms); 21 | const dayIndex = date.getDay(); 22 | return `${DAYS[dayIndex]}`; 23 | }; 24 | -------------------------------------------------------------------------------- /front/src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | const setDebounce = (delayTime: number) => { 2 | let timer: null | number = null; 3 | 4 | return (callback: () => void) => { 5 | if (timer) { 6 | clearTimeout(timer); 7 | } 8 | 9 | timer = setTimeout(callback, delayTime * 1000); 10 | }; 11 | }; 12 | 13 | export const changeSeatCountDebounce = setDebounce(0.5); 14 | -------------------------------------------------------------------------------- /front/src/utils/getPriceWon.ts: -------------------------------------------------------------------------------- 1 | export const getPriceWon = (price: number) => { 2 | if (price != 0 && !price) return NaN; 3 | 4 | return price.toLocaleString() + '원'; 5 | }; 6 | -------------------------------------------------------------------------------- /front/src/utils/padArray.ts: -------------------------------------------------------------------------------- 1 | export const padEndArray = (array: T[], targetLength: number, padData: P) => { 2 | const padArray = Array(targetLength).fill(padData); 3 | 4 | return array.concat(padArray).slice(0, targetLength) as (T | P)[]; 5 | }; 6 | -------------------------------------------------------------------------------- /front/src/utils/svg.ts: -------------------------------------------------------------------------------- 1 | type coordinate = number[]; 2 | 3 | export const getCenterCoordinate = (...points: coordinate[]) => { 4 | const sum = points.reduce( 5 | (acc, coordinate) => { 6 | const [tx, ty] = acc; 7 | const [x, y] = coordinate; 8 | const [nx, ny] = [tx + x, ty + y]; 9 | return [nx, ny]; 10 | }, 11 | [0, 0], 12 | ); 13 | const [tx, ty] = sum; 14 | return [tx / points.length, ty / points.length]; 15 | }; 16 | 17 | export const getPathD = (...points: number[][]) => { 18 | const lastIndex = points.length - 1; 19 | return points.reduce((acc, point, index) => { 20 | const [x, y] = point; 21 | if (index === lastIndex) return acc + `L ${x},${y} z`; 22 | if (index === 0) return acc + `M ${x},${y}`; 23 | return acc + `L ${x},${y},`; 24 | }, ''); 25 | }; 26 | 27 | export function calculatePolygonCentroid(vertices: number[][]) { 28 | let area = 0; // 다각형 면적 29 | let Cx = 0; // 무게 중심 x 좌표 30 | let Cy = 0; // 무게 중심 y 좌표 31 | 32 | // 꼭지점 수 33 | const n = vertices.length; 34 | 35 | // 면적 및 무게 중심 계산 36 | for (let i = 0; i < n; i++) { 37 | // 현재 점과 다음 점 38 | const [x0, y0] = vertices[i]; 39 | const [x1, y1] = vertices[(i + 1) % n]; // 마지막 점은 첫 점과 연결 40 | 41 | // 면적 기여도 계산 42 | const cross = x0 * y1 - x1 * y0; 43 | 44 | // 누적 계산 45 | area += cross; 46 | Cx += (x0 + x1) * cross; 47 | Cy += (y0 + y1) * cross; 48 | } 49 | 50 | // 면적 계산 완료 51 | area /= 2; 52 | 53 | // 무게 중심 좌표 계산 54 | Cx /= 6 * area; 55 | Cy /= 6 * area; 56 | 57 | return [Cx, Cy]; 58 | } 59 | -------------------------------------------------------------------------------- /front/src/utils/transform.ts: -------------------------------------------------------------------------------- 1 | type Transformer = (key: string) => string; 2 | 3 | export const transformKey = (obj: unknown, transformer: Transformer): unknown => { 4 | // 배열 처리 5 | if (Array.isArray(obj)) { 6 | return obj.map((item) => { 7 | if (typeof item === 'object' && item !== null) { 8 | return transformKey(item, transformer); // 객체일 경우 재귀적으로 처리 9 | } 10 | return item; // 객체가 아니면 그대로 반환 11 | }); 12 | } 13 | 14 | // 객체 처리 15 | if (obj !== null && typeof obj === 'object') { 16 | return Object.keys(obj).reduce( 17 | (acc: Record, key: string) => { 18 | const transformedKey = transformer(key); // 키 변환 19 | acc[transformedKey] = transformKey((obj as Record)[key], transformer); // 재귀적으로 키 변환 20 | return acc; 21 | }, 22 | {} as Record, 23 | ); 24 | } 25 | 26 | // 객체나 배열이 아닌 경우 그대로 반환 27 | return obj; 28 | }; 29 | -------------------------------------------------------------------------------- /front/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /front/style/colors.ts: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | white: '#ffffffff', 3 | black: '#000000ff', 4 | primary: '#2563ebff', 5 | error: '#dc2626ff', 6 | success: '#16a34aff', 7 | warning: '#ca8a04ff', 8 | surface: { 9 | disabled: '#d1d5dbff', 10 | card: '#f3f4f6ff', 11 | DEFAULT: '#1f2937ff', 12 | cancel: '#6b7280ff', 13 | sub: '#9ca3afff', 14 | hover: '#f3f4f6ff', 15 | cardBorder: '#d1d5dbff', 16 | }, 17 | grayscale: { 18 | 50: '#f9fafbff', 19 | 100: '#f3f4f6ff', 20 | 200: '#e5e7ebff', 21 | 300: '#d1d5dbff', 22 | 400: '#9ca3afff', 23 | 500: '#6b7280ff', 24 | 600: '#4b5563ff', 25 | 700: '#374151ff', 26 | 800: '#1f2937ff', 27 | 900: '#111827ff', 28 | }, 29 | typo: { 30 | display: '#f3f4f6ff', 31 | disable: '#6b7280ff', 32 | DEFAULT: '#111827ff', 33 | sub: '#9ca3afff', 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /front/style/fontSize.ts: -------------------------------------------------------------------------------- 1 | export const fontSize = { 2 | display1: [ 3 | '1rem', 4 | { 5 | fontWeight: 600, 6 | lineHeight: '1.25rem', 7 | }, 8 | ], 9 | display2: [ 10 | '0.875rem', 11 | { 12 | fontWeight: 600, 13 | lineHeight: '1.25rem', 14 | }, 15 | ], 16 | heading1: [ 17 | '2rem', 18 | { 19 | fontWeight: 700, 20 | lineHeight: '2rem', 21 | }, 22 | ], 23 | heading2: [ 24 | '1.25rem', 25 | { 26 | fontWeight: 700, 27 | lineHeight: '2rem', 28 | }, 29 | ], 30 | heading3: [ 31 | '1.125rem', 32 | { 33 | fontWeight: 700, 34 | lineHeight: '1.5rem', 35 | }, 36 | ], 37 | label1: [ 38 | '1rem', 39 | { 40 | fontWeight: 500, 41 | lineHeight: '1.375rem', 42 | }, 43 | ], 44 | label2: [ 45 | '0.875rem', 46 | { 47 | fontWeight: 500, 48 | lineHeight: '1.375rem', 49 | }, 50 | ], 51 | label3: [ 52 | '0.75rem', 53 | { 54 | fontWeight: 500, 55 | lineHeight: '1.375rem', 56 | }, 57 | ], 58 | caption1: [ 59 | '0.875rem', 60 | { 61 | fontWeight: 400, 62 | lineHeight: '1.5rem', 63 | }, 64 | ], 65 | caption2: [ 66 | '0.75rem', 67 | { 68 | fontWeight: 400, 69 | lineHeight: '1.5rem', 70 | }, 71 | ], 72 | }; 73 | -------------------------------------------------------------------------------- /front/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { fontFamily } from 'tailwindcss/defaultTheme'; 2 | 3 | import { colors } from './style/colors.ts'; 4 | import { fontSize } from './style/fontSize.ts'; 5 | 6 | /** @type {import('tailwindcss').Config} */ 7 | 8 | const WIDTH_LIst = Array.from({ length: 1000 }, (_, index) => index); 9 | const COL_LENGTH_RANGE = Array.from({ length: 20 }, (_, index) => index + 1); 10 | const TRANSLATE_VALUE_RANGE = Array.from({ length: 101 }, (_, index) => index); 11 | export default { 12 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 13 | safelist: [ 14 | ...WIDTH_LIst.map((width) => `w-[${width}px]`), 15 | ...COL_LENGTH_RANGE.map((length) => `grid-cols-${length}`), 16 | ...TRANSLATE_VALUE_RANGE.map((percent) => `translate-x-[${percent}%]`), 17 | ], 18 | theme: { 19 | extend: { 20 | colors, 21 | fontSize, 22 | fontFamily: { 23 | sans: ['pretendard', ...fontFamily.sans], 24 | }, 25 | animation: { 26 | 'fade-in': 'fadeIn 0.5s ease-in-out forwards', 27 | 'fade-out': 'fadeOut 0.5s ease-in-out forwards', 28 | }, 29 | keyframes: { 30 | fadeIn: { 31 | '0%': { opacity: 0, transform: 'translateY(-50px)' }, 32 | '100%': { opacity: 1, transform: 'translateY(0)' }, 33 | }, 34 | fadeOut: { 35 | '0%': { opacity: 1, transform: 'translateY(0)' }, 36 | '100%': { opacity: 0, transform: 'translateY(50px)' }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | plugins: [], 42 | }; 43 | -------------------------------------------------------------------------------- /front/test/transform.test.ts: -------------------------------------------------------------------------------- 1 | import { camelCase, kebabCase } from 'es-toolkit/compat'; 2 | import { expect, test } from 'vitest'; 3 | 4 | import { transformKey } from './../src/utils/transform'; 5 | 6 | // 테스트용 데이터 7 | const kebabTestData = { 8 | 'user-name': 'JohnDoe', 9 | 'user-profile': { 10 | 'last-login-date': '2024-11-21', 11 | 'favorite-categories': ['sports', 'music'], 12 | }, 13 | }; 14 | 15 | // 카멜 케이스 변환 함수 16 | 17 | // 스네이크 케이스로 변환하는 함수 18 | 19 | test('케밥 케이스에서 카멜 케이스로 변환', () => { 20 | const result = transformKey(kebabTestData, camelCase); 21 | 22 | expect(result).toEqual({ 23 | userName: 'JohnDoe', 24 | userProfile: { 25 | lastLoginDate: '2024-11-21', 26 | favoriteCategories: ['sports', 'music'], 27 | }, 28 | }); 29 | }); 30 | const camelTestData = { 31 | userName: 'JohnDoe', 32 | userProfile: { 33 | lastLoginDate: '2024-11-21', 34 | favoriteCategories: ['sports', 'music'], 35 | }, 36 | }; 37 | test('카멜 케이스에서 케밥 케이스로 변환', () => { 38 | const result = transformKey(camelTestData, kebabCase); 39 | 40 | expect(result).toEqual({ 41 | 'user-name': 'JohnDoe', 42 | 'user-profile': { 43 | 'last-login-date': '2024-11-21', 44 | 'favorite-categories': ['sports', 'music'], 45 | }, 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /front/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types":["vite/client"], 4 | "typeRoots": ["./src/type"], 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 6 | "target": "ES2020", 7 | "useDefineForClassFields": true, 8 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 9 | "module": "ESNext", 10 | "skipLibCheck": true, 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "Bundler", 14 | "allowImportingTsExtensions": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true, 26 | "baseUrl": "./src", 27 | "paths": { 28 | "@/*":["./*"], 29 | } 30 | }, 31 | "include": ["src"], 32 | 33 | } 34 | -------------------------------------------------------------------------------- /front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /front/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /front/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc'; 2 | import path from 'path'; 3 | import { defineConfig } from 'vite'; 4 | import svgr from 'vite-plugin-svgr'; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | server: { 9 | // port: 80, 10 | // host: '0.0.0.0', 11 | // open: true, 12 | // proxy: { 13 | // // /api로 시작하는 모든 요청을 백엔드로 프록시 14 | // '/': { 15 | // target: 'http://localhost:8080', // 백엔드 서버 주소 (NestJS 서버) 16 | // changeOrigin: true, // CORS 헤더 설정을 자동으로 처리 17 | // }, 18 | // }, 19 | }, 20 | plugins: [react(), svgr()], 21 | resolve: { 22 | alias: [{ find: '@', replacement: path.resolve(__dirname, 'src') }], 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web04-realticket", 3 | "version": "1.0.0", 4 | "description": "예매 서비스", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "lint-staged": "lint-staged", 9 | "prepare": "husky", 10 | "branch": "node ./scripts/makeLinkedBranch.js" 11 | }, 12 | "lint-staged": { 13 | "front/**/*.{ts,tsx}": [ 14 | "npm --prefix front run lint", 15 | "npm --prefix front run prettier" 16 | ], 17 | "back/**/*.ts": [ 18 | "npm --prefix back run lint", 19 | "npm --prefix back run prettier" 20 | ] 21 | }, 22 | "keywords": [], 23 | "author": "", 24 | "license": "ISC", 25 | "devDependencies": { 26 | "husky": "^9.1.6", 27 | "lint-staged": "^15.2.10" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /scripts/branch-create.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | create_branch() { 4 | if [ -z "$1" ] || [ -z "$2" ]; then 5 | echo "Usage: ./branch-create.sh ISSUE_NUMBER BRANCH_TITLE" 6 | exit 1 7 | fi 8 | 9 | ISSUE_NUMBER=$1 10 | BRANCH_TITLE=$(echo "$2" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/-\+/-/g' | sed 's/^-\|-$//') 11 | 12 | gh issue develop ${ISSUE_NUMBER} -b dev -n feat/#${ISSUE_NUMBER}-${BRANCH_TITLE} --checkout 13 | 14 | if [ $? -eq 0 ]; then 15 | echo "Branch created successfully: feat/#${ISSUE_NUMBER}-${BRANCH_TITLE}" 16 | else 17 | echo "Failed to create branch" 18 | exit 1 19 | fi 20 | } 21 | 22 | create_branch "$1" "$2" -------------------------------------------------------------------------------- /scripts/makeLinkedBranch.js: -------------------------------------------------------------------------------- 1 | const [issueNumber, branchTitle] = process.argv.slice(2); 2 | const command = `bash ./scripts/branch-create.sh ${issueNumber} "${branchTitle}"`; 3 | require("child_process").execSync(command, {stdio: "inherit"}); 4 | --------------------------------------------------------------------------------