├── packages
├── server
│ ├── .gitignore
│ ├── src
│ │ ├── auth
│ │ │ ├── entities
│ │ │ │ ├── auth.entity.ts
│ │ │ │ └── token.entity.ts
│ │ │ ├── dto
│ │ │ │ ├── naver-singIn.dto.ts
│ │ │ │ └── signup.dto.ts
│ │ │ ├── interfaces
│ │ │ │ └── jwtPayload.ts
│ │ │ ├── decorators
│ │ │ │ ├── public.decorator.ts
│ │ │ │ └── roles.decorator.ts
│ │ │ ├── guard
│ │ │ │ ├── jwt.guard.ts
│ │ │ │ └── role.guard.ts
│ │ │ ├── strategy
│ │ │ │ └── jwt.strategy.ts
│ │ │ ├── auth.module.ts
│ │ │ ├── naverOAuth.service.ts
│ │ │ └── auth.controller.ts
│ │ ├── cafe
│ │ │ ├── enum
│ │ │ │ ├── menuType.enum.ts
│ │ │ │ └── menuSize.enum.ts
│ │ │ ├── mock
│ │ │ │ ├── menuOptionRelation.mock.ts
│ │ │ │ ├── option.entity.mock.ts
│ │ │ │ ├── menu.entity.mock.ts
│ │ │ │ └── mockDataGenerator.ts
│ │ │ ├── entities
│ │ │ │ ├── option.entity.ts
│ │ │ │ ├── cafeMenu.entity.ts
│ │ │ │ ├── menuOption.entity.ts
│ │ │ │ ├── menu.entity.ts
│ │ │ │ └── cafe.entity.ts
│ │ │ ├── cafe.module.ts
│ │ │ ├── dto
│ │ │ │ ├── CafeMenuRes.dto.ts
│ │ │ │ ├── CafeRes.dto.ts
│ │ │ │ ├── OptionRes.dto.ts
│ │ │ │ ├── MenuRes.dto.ts
│ │ │ │ └── MenuDetailRes.dto.ts
│ │ │ ├── cafe.controller.ts
│ │ │ └── cafe.service.ts
│ │ ├── user
│ │ │ ├── dto
│ │ │ │ └── update-user.dto.ts
│ │ │ ├── enum
│ │ │ │ └── userRole.enum.ts
│ │ │ ├── user.module.ts
│ │ │ ├── user.controller.ts
│ │ │ ├── user.service.ts
│ │ │ └── entities
│ │ │ │ └── user.entity.ts
│ │ ├── order
│ │ │ ├── enum
│ │ │ │ └── orderStatus.enum.ts
│ │ │ ├── dto
│ │ │ │ ├── requested-order.dto.ts
│ │ │ │ ├── update-order.dto.ts
│ │ │ │ ├── oldRequestedOrdersDto.ts
│ │ │ │ ├── OrderStatusRes.dto.ts
│ │ │ │ ├── updateOrderReq.dto.ts
│ │ │ │ ├── create-order.dto.ts
│ │ │ │ ├── orderMenu.dto.ts
│ │ │ │ ├── orderRes.dto.ts
│ │ │ │ └── ordersRes.dto.ts
│ │ │ ├── order.v1.module.ts
│ │ │ ├── order.v2.module.ts
│ │ │ ├── order.v3.module.ts
│ │ │ └── entities
│ │ │ │ └── orderMenu.entity.ts
│ │ ├── common
│ │ │ └── entities
│ │ │ │ └── common.entity.ts
│ │ ├── utils
│ │ │ ├── dateTime.util.ts
│ │ │ └── getMySQLTestTypeOrmModule.ts
│ │ ├── redisCache
│ │ │ ├── redisCache.controller.ts
│ │ │ └── redisCache.module.ts
│ │ ├── main.ts
│ │ ├── setEnvVar.ts
│ │ ├── middleware
│ │ │ ├── logger.http.ts
│ │ │ └── exception.filter.ts
│ │ ├── setNestApp.ts
│ │ ├── config
│ │ │ └── route.ts
│ │ └── app.module.ts
│ ├── tsconfig.build.json
│ ├── pm2.prod.yml
│ ├── nest-cli.json
│ ├── test
│ │ └── mock
│ │ │ ├── create-cafe.json
│ │ │ └── create-order.json
│ ├── Dockerfile.dev
│ ├── jest-e2e.json
│ ├── Dockerfile.prod
│ ├── tsconfig.json
│ ├── scripts
│ │ ├── prod.deploy.sh
│ │ └── dev.deploy.sh
│ └── README.md
├── nginx
│ ├── configs
│ │ ├── modules
│ │ ├── conf.d
│ │ │ └── default.conf
│ │ ├── scgi_params
│ │ ├── uwsgi_params
│ │ ├── nginx.conf
│ │ └── fastcgi_params
│ └── dockerfile
└── client
│ ├── src
│ ├── react-app-env.d.ts
│ ├── pages
│ │ ├── manager
│ │ │ └── AcceptList
│ │ │ │ ├── styled.ts
│ │ │ │ ├── index.tsx
│ │ │ │ └── index.test.tsx
│ │ ├── customer
│ │ │ ├── MenuDetail
│ │ │ │ ├── styled.ts
│ │ │ │ └── components
│ │ │ │ │ ├── Amount
│ │ │ │ │ ├── styled.ts
│ │ │ │ │ ├── index.test.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── OrderButton
│ │ │ │ │ ├── styled.ts
│ │ │ │ │ └── index.test.tsx
│ │ │ │ │ ├── MenuInformation
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── styled.ts
│ │ │ │ │ └── index.test.tsx
│ │ │ │ │ ├── TemperatureSelector
│ │ │ │ │ ├── __snapshots__
│ │ │ │ │ │ └── index.test.tsx.snap
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── styled.ts
│ │ │ │ │ └── index.test.tsx
│ │ │ │ │ ├── OptionSelector
│ │ │ │ │ ├── index.test.tsx
│ │ │ │ │ └── styled.ts
│ │ │ │ │ └── SizeSelector
│ │ │ │ │ ├── styled.ts
│ │ │ │ │ ├── index.test.tsx
│ │ │ │ │ ├── __snapshots__
│ │ │ │ │ └── index.test.tsx.snap
│ │ │ │ │ └── index.tsx
│ │ │ ├── Cart
│ │ │ │ ├── components
│ │ │ │ │ ├── EmptyCart
│ │ │ │ │ │ ├── styled.ts
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── CartFooter
│ │ │ │ │ │ ├── styled.ts
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ └── CartItem
│ │ │ │ │ │ ├── styled.ts
│ │ │ │ │ │ └── index.tsx
│ │ │ │ ├── styled.ts
│ │ │ │ ├── index.spec.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── MenuList
│ │ │ │ ├── components
│ │ │ │ │ ├── MenuItem
│ │ │ │ │ │ ├── styled.ts
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ └── SnackBar
│ │ │ │ │ │ ├── styled.ts
│ │ │ │ │ │ └── index.tsx
│ │ │ │ ├── styled.ts
│ │ │ │ ├── index.spec.tsx
│ │ │ │ └── index.tsx
│ │ │ └── OrderStatus
│ │ │ │ ├── index.spec.tsx
│ │ │ │ └── index.tsx
│ │ ├── Home
│ │ │ ├── styled.ts
│ │ │ └── index.tsx
│ │ ├── MyPage
│ │ │ ├── index.spec.tsx
│ │ │ ├── styled.ts
│ │ │ └── index.tsx
│ │ ├── Signin
│ │ │ ├── styled.ts
│ │ │ └── __snapshots__
│ │ │ │ └── index.spec.tsx.snap
│ │ └── Signup
│ │ │ └── styled.ts
│ ├── mocks
│ │ ├── server.ts
│ │ ├── handlers
│ │ │ ├── index.ts
│ │ │ ├── menu.ts
│ │ │ ├── auth.ts
│ │ │ └── order.ts
│ │ └── data
│ │ │ ├── cart.ts
│ │ │ └── menu.ts
│ ├── index.tsx
│ ├── setupTests.ts
│ ├── components
│ │ ├── Header
│ │ │ ├── index.tsx
│ │ │ ├── styled.ts
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ │ ├── Toast
│ │ │ ├── styled.ts
│ │ │ └── index.tsx
│ │ ├── Button
│ │ │ ├── index.tsx
│ │ │ └── styled.ts
│ │ ├── Footer
│ │ │ ├── NavigateItem.tsx
│ │ │ ├── index.spec.tsx
│ │ │ ├── styled.ts
│ │ │ └── index.tsx
│ │ ├── OrderDateList
│ │ │ ├── styled.ts
│ │ │ └── index.tsx
│ │ ├── LeftArrow
│ │ │ ├── styled.ts
│ │ │ └── index.tsx
│ │ ├── CountSelector
│ │ │ ├── styled.ts
│ │ │ ├── index.tsx
│ │ │ └── index.test.tsx
│ │ ├── OrderDetailList
│ │ │ └── styled.ts
│ │ └── OrderList
│ │ │ └── styled.ts
│ ├── assets
│ │ └── icons
│ │ │ ├── minus.svg
│ │ │ ├── plus.svg
│ │ │ ├── x_icon.svg
│ │ │ ├── withdrawal.svg
│ │ │ ├── change.svg
│ │ │ ├── order.svg
│ │ │ ├── signout.svg
│ │ │ ├── home.svg
│ │ │ ├── cart.svg
│ │ │ ├── down_arrow.svg
│ │ │ ├── left_arrow.svg
│ │ │ ├── size.svg
│ │ │ ├── edit_nickname.svg
│ │ │ └── receipt.svg
│ ├── stores
│ │ ├── index.ts
│ │ └── MenuDetail
│ │ │ └── index.tsx
│ ├── utils
│ │ ├── fetch.ts
│ │ ├── index.ts
│ │ ├── index.test.ts
│ │ ├── localStorage.ts
│ │ └── testSetup.tsx
│ ├── hooks
│ │ ├── useFetchOrderList.tsx
│ │ ├── useMenuListData.tsx
│ │ ├── useCustomQuery.tsx
│ │ ├── useOrderStatus.tsx
│ │ ├── useFetch.tsx
│ │ └── useOrderDates.tsx
│ ├── App.tsx
│ ├── UserRoleProvider.tsx
│ ├── constants.ts
│ ├── index.css
│ ├── Router.tsx
│ └── types
│ │ └── index.ts
│ ├── public
│ ├── robots.txt
│ ├── manifest.json
│ └── index.html
│ ├── tsconfig.json
│ ├── craco.config.ts
│ ├── package.json
│ └── README.md
├── .prettierrc
├── .github
├── pull_request_template.md
├── ISSUE_TEMPLATE
│ ├── 기능-이슈.md
│ ├── 리팩토링-이슈.md
│ └── 버그-이슈.md
└── workflows
│ ├── fe-dev-ci.yml
│ ├── be-dev-ci.yml
│ └── fe-dev-cd.yml
├── .eslintrc.json
└── package.json
/packages/server/.gitignore:
--------------------------------------------------------------------------------
1 | *.env
--------------------------------------------------------------------------------
/packages/nginx/configs/modules:
--------------------------------------------------------------------------------
1 | /usr/lib/nginx/modules
--------------------------------------------------------------------------------
/packages/server/src/auth/entities/auth.entity.ts:
--------------------------------------------------------------------------------
1 | export class Auth {}
2 |
--------------------------------------------------------------------------------
/packages/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/enum/menuType.enum.ts:
--------------------------------------------------------------------------------
1 | export enum MENU_TYPE {
2 | HOT = 'hot',
3 | ICED = 'iced',
4 | }
5 |
--------------------------------------------------------------------------------
/packages/server/src/user/dto/update-user.dto.ts:
--------------------------------------------------------------------------------
1 | export class UpdateUserDto {
2 | readonly nickname: string;
3 | }
4 |
--------------------------------------------------------------------------------
/packages/server/src/user/enum/userRole.enum.ts:
--------------------------------------------------------------------------------
1 | export enum USER_ROLE {
2 | CLIENT = 'CLIENT',
3 | MANAGER = 'MANAGER',
4 | }
5 |
--------------------------------------------------------------------------------
/packages/server/src/auth/dto/naver-singIn.dto.ts:
--------------------------------------------------------------------------------
1 | export class NaverSignInDto {
2 | readonly code: string;
3 | readonly state: string;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/server/pm2.prod.yml:
--------------------------------------------------------------------------------
1 | name: buddah-server
2 | script: dist/main.js
3 | instances: 4
4 | exec_mode: cluster
5 | env:
6 | NODE_ENV: production
7 |
--------------------------------------------------------------------------------
/packages/client/src/pages/manager/AcceptList/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.div`
4 | width: 100%;
5 | `;
6 |
--------------------------------------------------------------------------------
/packages/server/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src"
5 | }
6 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.main`
4 | width: 100%;
5 | `;
6 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/mock/menuOptionRelation.mock.ts:
--------------------------------------------------------------------------------
1 | export const mockMenuOptionRelation = {
2 | 1: [1, 2],
3 | 2: [3],
4 | 3: [1, 3],
5 | 4: [2, 3],
6 | };
7 |
--------------------------------------------------------------------------------
/packages/client/src/mocks/server.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from 'msw/node';
2 | import { handlers } from './handlers/index';
3 |
4 | export const server = setupServer(...handlers);
5 |
--------------------------------------------------------------------------------
/packages/server/test/mock/create-cafe.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "부스트 다방",
3 | "description": "은계점",
4 | "latitude": 123,
5 | "longitude": 123,
6 | "address": "경기도 시흥시 은행로"
7 | }
8 |
--------------------------------------------------------------------------------
/packages/server/src/auth/interfaces/jwtPayload.ts:
--------------------------------------------------------------------------------
1 | import { USER_ROLE } from 'src/user/enum/userRole.enum';
2 |
3 | export interface JwtPayload {
4 | id: number;
5 | userRole: USER_ROLE;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/server/src/order/enum/orderStatus.enum.ts:
--------------------------------------------------------------------------------
1 | export enum ORDER_STATUS {
2 | REQUESTED = 'REQUESTED',
3 | ACCEPTED = 'ACCEPTED',
4 | REJECTED = 'REJECTED',
5 | COMPLETED = 'COMPLETED',
6 | }
7 |
--------------------------------------------------------------------------------
/packages/server/src/auth/decorators/public.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from '@nestjs/common';
2 |
3 | export const PUBLIC_KEY = 'PUBLIC_KEY';
4 | export const Public = () => SetMetadata(PUBLIC_KEY, true);
5 |
--------------------------------------------------------------------------------
/packages/server/src/order/dto/requested-order.dto.ts:
--------------------------------------------------------------------------------
1 | import { ArrayMinSize, IsArray } from 'class-validator';
2 |
3 | export class RequestedOrderDto {
4 | @IsArray()
5 | @ArrayMinSize(1)
6 | newOrders: number[];
7 | }
8 |
--------------------------------------------------------------------------------
/packages/server/src/order/dto/update-order.dto.ts:
--------------------------------------------------------------------------------
1 | import { PartialType } from '@nestjs/mapped-types';
2 | import { CreateOrderDto } from './create-order.dto';
3 |
4 | export class UpdateOrderDto extends PartialType(CreateOrderDto) {}
5 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/Amount/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | justify-content: space-between;
6 | margin: 1rem;
7 | `;
8 |
--------------------------------------------------------------------------------
/packages/server/src/auth/dto/signup.dto.ts:
--------------------------------------------------------------------------------
1 | import { USER_ROLE } from '../../user/enum/userRole.enum';
2 |
3 | export class SignUpDto {
4 | readonly userRole: USER_ROLE;
5 | readonly nickname: string;
6 | readonly corporate: string;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/client/src/pages/Home/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.main`
4 | width: 100%;
5 | `;
6 |
7 | export const ListContainer = styled.section`
8 | margin: 0 0 3rem 0;
9 | `;
10 |
--------------------------------------------------------------------------------
/packages/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Buddha",
3 | "name": "Boost DDhaBang",
4 | "icons": [],
5 | "start_url": ".",
6 | "display": "standalone",
7 | "theme_color": "#000000",
8 | "background_color": "#ffffff"
9 | }
10 |
--------------------------------------------------------------------------------
/packages/client/src/mocks/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import { authHandlers } from './auth';
2 | import { menuHandlers } from './menu';
3 | import { orderHandlers } from './order';
4 |
5 | export const handlers = [...authHandlers, ...menuHandlers, ...orderHandlers];
6 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/enum/menuSize.enum.ts:
--------------------------------------------------------------------------------
1 | export enum MENU_SIZE {
2 | TALL = 'tall',
3 | GRANDE = 'grande',
4 | VENTI = 'venti',
5 | }
6 |
7 | export enum SIZE_PRICE {
8 | 'tall' = 0,
9 | 'grande' = 500,
10 | 'venti' = 1000,
11 | }
12 |
--------------------------------------------------------------------------------
/packages/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client';
2 | import App from './App';
3 | import './index.css';
4 |
5 | const root = ReactDOM.createRoot(
6 | document.getElementById('root') as HTMLElement
7 | );
8 |
9 | root.render();
10 |
--------------------------------------------------------------------------------
/packages/server/src/auth/decorators/roles.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from '@nestjs/common';
2 | import { USER_ROLE } from '../../user/enum/userRole.enum';
3 |
4 | export const ROLES_KEY = 'roles';
5 | export const Roles = (...roles: USER_ROLE[]) => SetMetadata(ROLES_KEY, roles);
6 |
--------------------------------------------------------------------------------
/packages/server/src/order/dto/oldRequestedOrdersDto.ts:
--------------------------------------------------------------------------------
1 | import { ArrayMinSize, IsArray, IsNumber } from 'class-validator';
2 | export class OldRequestedOrdersDto {
3 | @IsArray()
4 | @ArrayMinSize(1)
5 | @IsNumber({}, { each: true })
6 | oldRequestedOrderPks: Array;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/client/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "quoteProps": "consistent",
8 | "trailingComma": "es5",
9 | "bracketSpacing": true,
10 | "arrowParens": "always",
11 | "endOfLine": "lf"
12 | }
13 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # 제목 - 주요 기능/버그/수정 사항
2 |
3 | ## 📕 제목
4 | - #관련이슈 번호
5 |
6 | ## 📗 작업 내용
7 |
8 | > 구현 내용 및 작업 했던 내역
9 |
10 | - [x] 작업내용1
11 | - [x] 작업내용2
12 | - [x] 작업내용3
13 |
14 | ## 📘 PR 특이 사항
15 |
16 | > PR을 볼 때 주의깊게 봐야하거나 말하고 싶은 점
17 |
18 | - 특이사항1
19 | - 특이사항2
20 |
--------------------------------------------------------------------------------
/packages/server/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | # BUILD
2 | FROM node:18.12.1 AS build
3 |
4 | WORKDIR /app
5 |
6 | COPY *.json ./
7 |
8 | COPY package*.json ./
9 |
10 | RUN npm install
11 |
12 | # RUN
13 | FROM node:alpine
14 |
15 | WORKDIR /app
16 |
17 | COPY --from=build /app /app
18 |
19 | CMD ["npm", "run", "start:dev-remote"]
--------------------------------------------------------------------------------
/packages/nginx/configs/conf.d/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 3000;
3 |
4 | access_log /var/log/nginx/access.log main_log_format;
5 |
6 | location / {
7 | root /usr/share/buddah-client;
8 | index index.html index.htm;
9 | try_files $uri $uri/ /index.html;
10 | }
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/packages/nginx/dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx
2 |
3 | # COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
4 | # COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
5 |
6 | WORKDIR /usr/share
7 | RUN mkdir buddah-client
8 |
9 | # COPY ./client/build/index.html /usr/share/buddah-client
10 | # COPY ./client/build/static /usr/share/buddah-client/static
--------------------------------------------------------------------------------
/packages/client/src/components/Header/index.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { Container } from './styled';
3 |
4 | interface Props {
5 | title: string;
6 | }
7 |
8 | function Header({ title }: Props) {
9 | return (
10 |
11 | {title}
12 |
13 | );
14 | }
15 |
16 | export default memo(Header);
17 |
--------------------------------------------------------------------------------
/packages/server/jest-e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleFileExtensions": ["js", "json", "ts"],
3 | "rootDir": ".",
4 | "moduleDirectories": ["/", "node_modules", "mock"],
5 | "testEnvironment": "node",
6 | "testRegex": [".int.spec.ts$"],
7 | "transform": {
8 | "^.+\\.(t|j)s$": "ts-jest"
9 | },
10 | "setupFiles": ["/src/setEnvVar.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/packages/server/src/common/entities/common.entity.ts:
--------------------------------------------------------------------------------
1 | import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
2 |
3 | export abstract class TimestampableEntity {
4 | @CreateDateColumn()
5 | public created_at: Date;
6 |
7 | @UpdateDateColumn()
8 | public updated_at: Date;
9 |
10 | @DeleteDateColumn()
11 | public deleted_at: Date;
12 | }
13 |
--------------------------------------------------------------------------------
/packages/server/src/utils/dateTime.util.ts:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 |
3 | const DAYS = [
4 | '일요일',
5 | '월요일',
6 | '화요일',
7 | '수요일',
8 | '목요일',
9 | '금요일',
10 | '토요일',
11 | ];
12 |
13 | export class DateTimeUtil {
14 | static toString(date: Date): string {
15 | return moment(date).format('YYYY-MM-DD-HH:MM-') + DAYS[date.getDay()];
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/minus.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/기능-이슈.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 기능 이슈
3 | about: 기능 설명
4 | title: "[Feature] : 기능명"
5 | labels: "✨ FEAT"
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 💁 설명
11 | - Github 이슈 설명
12 |
13 | ## 📑 체크리스트
14 | > 구현해야하는 이슈 체크리스트
15 |
16 | - [ ] 체크 사항 1
17 | - [ ] 체크 사항 2
18 | - [ ] 체크 사항 3
19 | - [ ] 체크 사항 4
20 |
21 | ## 🚧 주의 사항
22 | > 이슈를 구현할 때 유의깊게 살펴볼 사항
23 |
24 | - 주의 사항 1
25 | - 주의 사항 2
26 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/mock/option.entity.mock.ts:
--------------------------------------------------------------------------------
1 | export const mockOptions = {
2 | 1: {
3 | id: 1,
4 | name: '옵션1',
5 | price: 500,
6 | category: '옵션 카테고리 1',
7 | },
8 | 2: {
9 | id: 2,
10 | name: '옵션2',
11 | price: 300,
12 | category: '옵션 카테고리 2',
13 | },
14 | 3: {
15 | id: 3,
16 | name: '옵션3',
17 | price: 0,
18 | category: '옵션 카테고리 2',
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/리팩토링-이슈.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 리팩토링 이슈
3 | about: 리팩토링 설명
4 | title: "[Refactor] : 기능명"
5 | labels: "\U0001F528 REFACTOR"
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 💁 설명
11 | - Github 이슈 설명
12 |
13 | ## 📑 체크리스트
14 | > 구현해야하는 이슈 체크리스트
15 |
16 | - [ ] 체크 사항 1
17 | - [ ] 체크 사항 2
18 | - [ ] 체크 사항 3
19 | - [ ] 체크 사항 4
20 |
21 | ## 🚧 주의 사항
22 | > 이슈를 구현할 때 유의깊게 살펴볼 사항
23 |
24 | - 주의 사항 1
25 | - 주의 사항 2
26 |
--------------------------------------------------------------------------------
/packages/server/src/auth/entities/token.entity.ts:
--------------------------------------------------------------------------------
1 | import { TimestampableEntity } from 'src/common/entities/common.entity';
2 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
3 |
4 | @Entity()
5 | export class Token extends TimestampableEntity {
6 | @PrimaryGeneratedColumn()
7 | id: number;
8 |
9 | @Column()
10 | accessToken: string;
11 |
12 | @Column()
13 | refreshToken: string;
14 | }
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/버그-이슈.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 버그 이슈
3 | about: 버그 설명
4 | title: "[BUG] : 버그명"
5 | labels: "\U0001F41E BUG"
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 💁 설명
11 | - 버그 제보 (가능하면 버그 시나리오를 상세히 적어주세요)
12 |
13 | ## 🎬 시나리오
14 | 1.
15 | 2.
16 | 3.
17 |
18 | ## 📢 예상 결과
19 | - 정상동작 했을 때 결과를 적어주세요
20 |
21 | ## 🃏 실제 결과
22 | - 버그가 발생한 현재 동작 결과를 적어주세요
23 |
24 | ## 📸 스크린샷
25 | - 가능하다면 오류메세지나 동작결과에 대해 스크린샷을 첨부해주세요
26 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/plus.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/packages/server/src/redisCache/redisCache.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 | import { RedisCacheService } from './redisCache.service';
3 |
4 | @Controller()
5 | export class RedisCacheController {
6 | constructor(private readonly redisCacheService: RedisCacheService) {}
7 |
8 | @Get('/reconnect')
9 | async getRequestedOrders() {
10 | return await this.redisCacheService.reconnect();
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/client/src/stores/index.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 | import { CartMenu, UserRole } from '@/types';
3 |
4 | export const cartState = atom({
5 | key: 'cartState',
6 | default: [],
7 | });
8 |
9 | export const userRoleState = atom({
10 | key: 'userRoleState',
11 | default: 'UNAUTH',
12 | });
13 |
14 | export const toastMessageState = atom({
15 | key: 'toastMessageState',
16 | default: '',
17 | });
18 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/entities/option.entity.ts:
--------------------------------------------------------------------------------
1 | import { TimestampableEntity } from 'src/common/entities/common.entity';
2 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
3 |
4 | @Entity()
5 | export class Option extends TimestampableEntity {
6 | @PrimaryGeneratedColumn()
7 | id: number;
8 |
9 | @Column()
10 | name: string;
11 |
12 | @Column()
13 | price: number;
14 |
15 | @Column()
16 | category: string;
17 | }
18 |
--------------------------------------------------------------------------------
/packages/client/src/components/Header/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.header`
4 | width: 100%;
5 | margin-bottom: 20px;
6 | padding: 15px;
7 | font-size: ${(props) => props.theme.font.size.lg};
8 | font-weight: ${(props) => props.theme.font.weight.bold700};
9 | border-bottom: ${(props) => props.theme.border.default};
10 | border-bottom-color: ${(props) => props.theme.colors.grey200};
11 | `;
12 |
--------------------------------------------------------------------------------
/packages/client/src/components/Header/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`헤더 컴포넌트 스냅샷 1`] = `
4 |
5 |
8 |
11 |
14 |
15 | 주문 내역
16 |
17 |
18 |
19 |
20 |
21 | `;
22 |
--------------------------------------------------------------------------------
/packages/server/src/main.ts:
--------------------------------------------------------------------------------
1 | import { setNestApp } from 'src/setNestApp';
2 | import { NestFactory } from '@nestjs/core';
3 | import { AppModule } from './app.module';
4 |
5 | declare module 'express-session' {
6 | interface SessionData {
7 | name: string;
8 | email: string;
9 | }
10 | }
11 |
12 | async function bootstrap() {
13 | const app = await NestFactory.create(AppModule);
14 |
15 | setNestApp(app);
16 | await app.listen(8080);
17 | }
18 | bootstrap();
19 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/OrderButton/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | justify-content: center;
6 | padding: 20px 0;
7 | position: fixed;
8 | bottom: 0;
9 | width: 100%;
10 | min-width: 320px;
11 | max-width: 480px;
12 | background-color: white;
13 | box-shadow: 0px 0px 4px rgba(204, 204, 204, 0.5),
14 | 0px 0px 4px rgba(0, 0, 0, 0.25);
15 | `;
16 |
--------------------------------------------------------------------------------
/packages/server/test/mock/create-order.json:
--------------------------------------------------------------------------------
1 | {
2 | "menus": [
3 | {
4 | "id": 6,
5 | "name": "아메리카노",
6 | "price": 5000,
7 | "options": [],
8 | "size": "tall",
9 | "type": "hot",
10 | "count": 2
11 | },
12 | {
13 | "id": 7,
14 | "name": "카페라떼",
15 | "price": 6700,
16 | "options": [1, 2],
17 | "size": "grande",
18 | "type": "iced",
19 | "count": 3
20 | }
21 | ],
22 | "cafeId": 1
23 | }
24 |
--------------------------------------------------------------------------------
/packages/client/src/components/Toast/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.section`
4 | position: absolute;
5 | padding: 0.6rem 1.5rem;
6 | left: 50%;
7 | bottom: 4rem;
8 | transform: translate(-50%, 0);
9 | color: white;
10 | background-color: rgba(102, 102, 102, 0.9);
11 | border-radius: 50px;
12 | font-size: ${({ theme }) => theme.font.size.xs};
13 | font-weight: ${({ theme }) => theme.font.weight.bold500};
14 | z-index: 999;
15 | `;
16 |
--------------------------------------------------------------------------------
/packages/client/src/utils/fetch.ts:
--------------------------------------------------------------------------------
1 | import { AnyObject, APIMethod } from '@/types';
2 | import axios from 'axios';
3 |
4 | interface Params {
5 | url: string;
6 | method: APIMethod;
7 | data?: AnyObject;
8 | }
9 |
10 | export async function customFetch({ url, method, data }: Params) {
11 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
12 |
13 | return await axios({
14 | method,
15 | url: `${api}${url}`,
16 | data: method !== 'get' && data,
17 | withCredentials: true,
18 | });
19 | }
20 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/cafe.module.ts:
--------------------------------------------------------------------------------
1 | import { Cafe } from './entities/cafe.entity';
2 | import { Menu } from './entities/menu.entity';
3 | import { Module } from '@nestjs/common';
4 | import { CafeService } from './cafe.service';
5 | import { CafeController } from './cafe.controller';
6 | import { TypeOrmModule } from '@nestjs/typeorm';
7 |
8 | @Module({
9 | imports: [TypeOrmModule.forFeature([Cafe, Menu])],
10 | controllers: [CafeController],
11 | providers: [CafeService],
12 | })
13 | export class CafeModule {}
14 |
--------------------------------------------------------------------------------
/packages/client/src/components/Button/index.tsx:
--------------------------------------------------------------------------------
1 | import { MouseEventHandler, ReactNode } from 'react';
2 | import { CustomButton } from './styled';
3 |
4 | interface ButtonProps {
5 | onClick?: MouseEventHandler;
6 | children?: ReactNode | ReactNode[];
7 | className?: string;
8 | }
9 |
10 | function Button({ onClick, children, className }: ButtonProps) {
11 | return (
12 |
13 | {children}
14 |
15 | );
16 | }
17 |
18 | export default Button;
19 |
--------------------------------------------------------------------------------
/packages/client/src/components/Footer/NavigateItem.tsx:
--------------------------------------------------------------------------------
1 | import { MouseEventHandler, ReactNode } from 'react';
2 | import { NavItem } from './styled';
3 |
4 | interface NavigateItemProps {
5 | onClick: MouseEventHandler;
6 | children: ReactNode[];
7 | className: string;
8 | }
9 |
10 | function NavigateItem({ onClick, children, className }: NavigateItemProps) {
11 | return (
12 |
13 | {children}
14 |
15 | );
16 | }
17 |
18 | export default NavigateItem;
19 |
--------------------------------------------------------------------------------
/packages/server/Dockerfile.prod:
--------------------------------------------------------------------------------
1 | # BUILD
2 | FROM node:18.12.1 AS build
3 |
4 | WORKDIR /app
5 |
6 | COPY package*.json ./
7 |
8 | RUN npm install
9 |
10 | COPY . .
11 |
12 | RUN npm run build
13 |
14 | # RUN
15 | FROM node:alpine
16 |
17 | WORKDIR /app
18 |
19 | COPY --from=build /app/node_modules /app/node_modules
20 | COPY --from=build /app/package.json /app/package.json
21 | COPY --from=build /app/pm2.prod.yml /app/pm2.prod.yml
22 | COPY --from=build /app/dist /app/dist
23 |
24 | CMD ["npx", "pm2-runtime", "start", "pm2.prod.yml"]
--------------------------------------------------------------------------------
/packages/server/src/user/user.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { UserService } from './user.service';
3 | import { UserController } from './user.controller';
4 | import { TypeOrmModule } from '@nestjs/typeorm';
5 | import { User } from './entities/user.entity';
6 |
7 | @Module({
8 | imports: [TypeOrmModule.forFeature([User])],
9 | controllers: [UserController],
10 | providers: [UserService],
11 | exports: [UserService], // User repository 만든걸 다른 곳에서도 사용하고 싶은 경우
12 | })
13 | export class UserModule {}
14 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/entities/cafeMenu.entity.ts:
--------------------------------------------------------------------------------
1 | import { TimestampableEntity } from 'src/common/entities/common.entity';
2 | import { Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
3 | import { Cafe } from './cafe.entity';
4 | import { Menu } from './menu.entity';
5 |
6 | @Entity()
7 | export class CafeMenu extends TimestampableEntity {
8 | @PrimaryGeneratedColumn()
9 | id: number;
10 |
11 | @ManyToOne(() => Cafe, (cafe) => cafe.cafeMenus)
12 | cafe: Cafe;
13 |
14 | @ManyToOne(() => Menu)
15 | menu: Menu;
16 | }
17 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/entities/menuOption.entity.ts:
--------------------------------------------------------------------------------
1 | import { TimestampableEntity } from 'src/common/entities/common.entity';
2 | import { Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
3 | import { Menu } from './menu.entity';
4 | import { Option } from './option.entity';
5 |
6 | @Entity()
7 | export class MenuOption extends TimestampableEntity {
8 | @PrimaryGeneratedColumn()
9 | id: number;
10 |
11 | @ManyToOne(() => Menu, (menu) => menu.menuOptions)
12 | menu: Menu;
13 |
14 | @ManyToOne(() => Option)
15 | option: Option;
16 | }
17 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/x_icon.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/Cart/components/EmptyCart/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const EmptyCartWrapper = styled.div`
4 | width: 100%;
5 | padding: 1rem 1rem 1rem 1rem;
6 |
7 | p {
8 | padding: 0 0 0.7rem 0;
9 | }
10 |
11 | p.empty-title {
12 | font-weight: ${(props) => props.theme.font.weight.bold700};
13 | }
14 |
15 | p.description {
16 | padding-right: 5rem;
17 | font-size: ${(props) => props.theme.font.size.sm};
18 | color: ${(props) => props.theme.colors.grey600};
19 | }
20 | `;
21 |
--------------------------------------------------------------------------------
/packages/server/src/setEnvVar.ts:
--------------------------------------------------------------------------------
1 | import { existsSync, readFileSync } from 'fs';
2 | import path from 'path';
3 |
4 | const TEST_ENV_NAME = '.test.env';
5 | const envPath = path.join(process.env.PWD, TEST_ENV_NAME);
6 | if (!existsSync(envPath)) {
7 | throw new Error('환경변수 로드가 안됐습니다.');
8 | }
9 | const env = readFileSync(path.join(process.env.PWD, TEST_ENV_NAME)).toString();
10 |
11 | const lines = env.split('\n').filter((el) => el !== '');
12 |
13 | lines.forEach((el) => {
14 | const [key, value] = el.split('=');
15 |
16 | process.env[key] = value;
17 | });
18 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true,
5 | "node": true
6 | },
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:import/recommended",
10 | "plugin:prettier/recommended",
11 | "plugin:@typescript-eslint/recommended",
12 | "prettier"
13 | ],
14 | "overrides": [],
15 | "parser": "@typescript-eslint/parser",
16 | "parserOptions": {
17 | "ecmaVersion": "latest",
18 | "sourceType": "module"
19 | },
20 | "plugins": ["@typescript-eslint"],
21 | "rules": {
22 | "import/no-unresolved": "off"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/withdrawal.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/change.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/MenuInformation/index.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | import { Img, MenuInfoContainer } from './styled';
4 | import { MenuInfo } from '@/types';
5 |
6 | interface Props {
7 | menu: MenuInfo;
8 | }
9 |
10 | function MenuInformation({ menu }: Props) {
11 | return (
12 | <>
13 |
14 |
15 | {menu.name}
16 | {menu.description}
17 |
18 | >
19 | );
20 | }
21 |
22 | export default memo(MenuInformation);
23 |
--------------------------------------------------------------------------------
/packages/server/src/order/dto/OrderStatusRes.dto.ts:
--------------------------------------------------------------------------------
1 | import { ORDER_STATUS } from './../enum/orderStatus.enum';
2 | import { Exclude, Expose } from 'class-transformer';
3 |
4 | export class OrderStatusResDto {
5 | @Exclude() private readonly _id: number;
6 | @Exclude() private readonly _orderStatus: ORDER_STATUS;
7 |
8 | constructor(orderId: number, status: ORDER_STATUS) {
9 | this._id = orderId;
10 | this._orderStatus = status;
11 | }
12 |
13 | @Expose()
14 | get id(): number {
15 | return this._id;
16 | }
17 |
18 | @Expose()
19 | get orderStatus(): ORDER_STATUS {
20 | return this._orderStatus;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/server/src/utils/getMySQLTestTypeOrmModule.ts:
--------------------------------------------------------------------------------
1 | import { TypeOrmModule } from '@nestjs/typeorm';
2 | import * as path from 'path';
3 |
4 | // test db 접속용
5 | export function getMySQLTestTypeOrmModule() {
6 | const entityPath = path.join(process.env.PWD, 'src/**/*.entity{.ts,.js}');
7 | return TypeOrmModule.forRoot({
8 | type: 'mysql',
9 | host: process.env.MYSQL_HOST,
10 | port: parseInt(process.env.MYSQL_PORT),
11 | username: process.env.MYSQL_USERNAME,
12 | password: process.env.MYSQL_PASSWORD,
13 | database: process.env.MYSQL_DATABASE,
14 | entities: [entityPath],
15 | synchronize: true,
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/order.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/TemperatureSelector/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`음료 타입(핫, 아이스) 선택 컴포넌트 스냅샷 1`] = `
4 |
5 |
8 |
11 |
14 |
17 | HOT
18 |
19 |
22 | ICED
23 |
24 |
25 |
26 |
27 |
28 | `;
29 |
--------------------------------------------------------------------------------
/packages/server/src/order/dto/updateOrderReq.dto.ts:
--------------------------------------------------------------------------------
1 | import { Exclude, Expose } from 'class-transformer';
2 | import { Order } from '../entities/order.entity';
3 | import { ORDER_STATUS } from '../enum/orderStatus.enum';
4 |
5 | export class UpdateOrderReqDto {
6 | @Exclude() private readonly _id: number;
7 | @Exclude() private readonly _newStatus: ORDER_STATUS;
8 |
9 | constructor(order: Order) {
10 | this._id = order.id;
11 | this._newStatus = order.status;
12 | }
13 |
14 | @Expose()
15 | get id(): number {
16 | return this._id;
17 | }
18 |
19 | @Expose()
20 | get newStatus(): ORDER_STATUS {
21 | return this._newStatus;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/client/src/mocks/handlers/menu.ts:
--------------------------------------------------------------------------------
1 | import { rest } from 'msw';
2 | import { menuDetailData, menuListData } from '@/mocks/data/menu';
3 |
4 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
5 |
6 | export const menuHandlers = [
7 | rest.get(`${api}/cafe/1/menus`, (req, res, next) => {
8 | return res(next.json(menuListData));
9 | }),
10 | // 메뉴 상세 정보 조회
11 | rest.get(`${api}/cafe/menu/:menuId`, (req, res, next) => {
12 | const { menuId } = req.params;
13 |
14 | switch (menuId) {
15 | case '1':
16 | return res(next.json(menuDetailData));
17 | default:
18 | return res(next.status(400));
19 | }
20 | }),
21 | ];
22 |
--------------------------------------------------------------------------------
/packages/client/src/components/Toast/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { useRecoilState } from 'recoil';
3 |
4 | import { toastMessageState } from '@/stores';
5 | import { Container } from './styled';
6 |
7 | function Toast() {
8 | const [toastMessage, setToastMessage] = useRecoilState(toastMessageState);
9 | const timer = useRef(null);
10 |
11 | useEffect(() => {
12 | timer.current = setTimeout(() => setToastMessage(''), 2000);
13 |
14 | return () => {
15 | clearTimeout(timer.current);
16 | };
17 | });
18 |
19 | return <>{toastMessage !== '' && {toastMessage}}>;
20 | }
21 |
22 | export default Toast;
23 |
--------------------------------------------------------------------------------
/packages/client/src/components/Button/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const CustomButton = styled.button`
4 | width: 100%;
5 | padding: 0.3rem 1rem 0.3rem 1rem;
6 | background-color: ${(props) => props.theme.colors.primary};
7 | color: white;
8 | font-size: ${(props) => props.theme.font.size.xs};
9 | font-weight: ${(props) => props.theme.font.weight.bold700};
10 | border: none;
11 | border-radius: 50px;
12 | cursor: pointer;
13 |
14 | &.wd-80 {
15 | width: 80%;
16 | }
17 |
18 | &.wd-fit {
19 | width: fit-content;
20 | }
21 |
22 | &.disabled {
23 | background-color: ${(props) => props.theme.colors.grey200};
24 | }
25 | `;
26 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/MenuInformation/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Img = styled.img`
4 | width: 100%;
5 | object-fit: cover;
6 | `;
7 |
8 | export const MenuInfoContainer = styled.section`
9 | margin: 1rem;
10 |
11 | & > .price {
12 | font-size: ${({theme}) => theme.font.size.lg};
13 | }
14 |
15 | h2{
16 | font-size: ${({theme}) => theme.font.size.xl};
17 | font-weight: ${({theme}) => theme.font.weight.bold500};
18 | }
19 |
20 | & > .description {
21 | margin: 1rem 0;
22 | color: ${({theme}) => theme.colors.grey600};
23 | font-size: ${({theme}) => theme.font.size.sm};
24 | }
25 | `;
26 |
--------------------------------------------------------------------------------
/packages/server/src/middleware/logger.http.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
2 | import { Request, Response, NextFunction } from 'express';
3 |
4 | @Injectable()
5 | export class LoggerMiddleware implements NestMiddleware {
6 | private logger = new Logger('HTTP');
7 |
8 | use(req: Request, res: Response, next: NextFunction) {
9 | const { ip, method, originalUrl } = req;
10 | const userAgent = req.get('user-agent') || '';
11 | res.on('finish', () => {
12 | const { statusCode } = res;
13 | this.logger.log(
14 | `${method} ${statusCode} - ${originalUrl} - ${ip} - ${userAgent}`
15 | );
16 | });
17 | next();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/signout.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/packages/client/src/components/Header/index.test.tsx:
--------------------------------------------------------------------------------
1 | import Layout from '@/Layout';
2 | import { render, screen } from '@testing-library/react';
3 | import Header from '.';
4 |
5 | const setup = ({ title }: { title: string }) => {
6 | const { asFragment } = render(
7 |
8 |
9 |
10 | );
11 |
12 | return { asFragment };
13 | };
14 |
15 | describe('헤더 컴포넌트', () => {
16 | const title = '주문 내역';
17 | it('요소 존재 여부', () => {
18 | setup({ title });
19 |
20 | screen.getByText(title);
21 | });
22 |
23 | it('스냅샷', () => {
24 | const { asFragment } = setup({ title });
25 |
26 | expect(asFragment()).toMatchSnapshot();
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/useFetchOrderList.tsx:
--------------------------------------------------------------------------------
1 | import { QUERY_KEYS } from '@/constants';
2 | import { UserRole } from '@/types';
3 | import { customFetch } from '@/utils/fetch';
4 | import { useQuery } from '@tanstack/react-query';
5 |
6 | interface Params {
7 | userRole: UserRole;
8 | url: string;
9 | }
10 |
11 | function useFetchOrderList({ userRole, url }: Params) {
12 | const queryResponse = useQuery(
13 | [QUERY_KEYS.ORDER_LIST],
14 | async () => {
15 | const res = await customFetch({ url, method: 'GET' });
16 | return res.data;
17 | },
18 | {
19 | refetchInterval: 2000,
20 | }
21 | );
22 |
23 | return queryResponse;
24 | }
25 |
26 | export default useFetchOrderList;
27 |
--------------------------------------------------------------------------------
/packages/client/src/components/OrderDateList/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.section<{ noBottomPadding?: boolean }>`
4 | padding: 0 0
5 | ${({ noBottomPadding }) => (noBottomPadding === true ? '0' : '3rem')} 0;
6 | font-size: ${({ theme }) => theme.font.size.sm};
7 |
8 | .date-title {
9 | font-size: ${({ theme }) => theme.font.size.lg};
10 | }
11 | `;
12 |
13 | export const ItemContainer = styled.div`
14 | padding: 1rem;
15 | width: 100%;
16 | `;
17 |
18 | export const NoOrderContainer = styled.div`
19 | margin: 0.5rem 1rem;
20 | padding: 1.5rem;
21 | border-radius: 10px;
22 | background-color: ${({ theme }) => theme.colors.fourth};
23 | `;
24 |
--------------------------------------------------------------------------------
/packages/nginx/configs/scgi_params:
--------------------------------------------------------------------------------
1 |
2 | scgi_param REQUEST_METHOD $request_method;
3 | scgi_param REQUEST_URI $request_uri;
4 | scgi_param QUERY_STRING $query_string;
5 | scgi_param CONTENT_TYPE $content_type;
6 |
7 | scgi_param DOCUMENT_URI $document_uri;
8 | scgi_param DOCUMENT_ROOT $document_root;
9 | scgi_param SCGI 1;
10 | scgi_param SERVER_PROTOCOL $server_protocol;
11 | scgi_param REQUEST_SCHEME $scheme;
12 | scgi_param HTTPS $https if_not_empty;
13 |
14 | scgi_param REMOTE_ADDR $remote_addr;
15 | scgi_param REMOTE_PORT $remote_port;
16 | scgi_param SERVER_PORT $server_port;
17 | scgi_param SERVER_NAME $server_name;
18 |
--------------------------------------------------------------------------------
/packages/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "declaration": true,
6 | "removeComments": true,
7 | "emitDecoratorMetadata": true,
8 | "experimentalDecorators": true,
9 | "allowSyntheticDefaultImports": true,
10 | "target": "es2017",
11 | "sourceMap": true,
12 | "outDir": "./dist",
13 | "baseUrl": "./",
14 | "incremental": true,
15 | "skipLibCheck": true,
16 | "strictNullChecks": false,
17 | "noImplicitAny": false,
18 | "strictBindCallApply": false,
19 | "forceConsistentCasingInFileNames": false,
20 | "noFallthroughCasesInSwitch": false,
21 | "esModuleInterop": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuList/components/MenuItem/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const MenuWrapper = styled.li`
4 | display: flex;
5 | flex-direction: row;
6 | gap: 1rem;
7 | width: 100%;
8 | margin: 1rem 0 1rem 0;
9 | cursor: pointer;
10 | transition: transform;
11 | transition-duration: 0.2s;
12 |
13 | &:hover{
14 | transform: scale(1.02);
15 | }
16 | `;
17 |
18 | export const MenuImg = styled.img`
19 | width: 5rem;
20 | border-radius: 50%;
21 | `;
22 |
23 | export const MenuInfoWrapper = styled.div`
24 | display: flex;
25 | flex-direction: column;
26 | justify-content: center;
27 | gap: 0.5rem;
28 | font-size: ${(props) => props.theme.font.size.sm}
29 | `
30 |
--------------------------------------------------------------------------------
/packages/server/src/auth/guard/jwt.guard.ts:
--------------------------------------------------------------------------------
1 | import { ExecutionContext, Injectable } from '@nestjs/common';
2 | import { Reflector } from '@nestjs/core';
3 | import { AuthGuard } from '@nestjs/passport';
4 | import { PUBLIC_KEY } from '../decorators/public.decorator';
5 |
6 | @Injectable()
7 | export class JwtGuard extends AuthGuard('jwt') {
8 | constructor(private reflector: Reflector) {
9 | super();
10 | }
11 |
12 | canActivate(context: ExecutionContext) {
13 | const isPublic = this.reflector.getAllAndOverride(PUBLIC_KEY, [
14 | context.getHandler(),
15 | context.getClass(),
16 | ]);
17 | if (isPublic) {
18 | return true;
19 | }
20 |
21 | return super.canActivate(context);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/Cart/components/EmptyCart/index.tsx:
--------------------------------------------------------------------------------
1 | import Button from 'components/Button';
2 | import { useNavigate } from 'react-router-dom';
3 | import { EmptyCartWrapper } from './styled';
4 |
5 | function EmptyCart() {
6 | const navigate = useNavigate();
7 |
8 | const handleClickMenu = () => {
9 | navigate('/menu');
10 | };
11 |
12 | return (
13 |
14 | 장바구니가 비어있습니다.
15 |
16 | 원하는 메뉴를 장바구니에 담고 한번에 주문해보세요.
17 |
18 |
21 |
22 | );
23 | }
24 |
25 | export default EmptyCart;
26 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/Amount/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 |
3 | import Layout from '@/Layout';
4 | import MenuDetailContextProvider from '@/stores/MenuDetail';
5 | import Amount from '.';
6 |
7 | const setup = async () => {
8 | const { asFragment } = render(
9 |
10 |
11 |
12 |
13 |
14 | );
15 |
16 | return { asFragment };
17 | };
18 |
19 | describe('메뉴 가격, 수량 선택 컴포넌트', () => {
20 | it('요소 존재 여부', async () => {
21 | await setup();
22 |
23 | screen.getByText('1');
24 | screen.getByText('6,000원');
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/packages/server/src/order/order.v1.module.ts:
--------------------------------------------------------------------------------
1 | import { OrderMenu } from './entities/orderMenu.entity';
2 | import { Module } from '@nestjs/common';
3 | import { OrderService } from './order.service';
4 | import { OrderController } from './order.v1.controller';
5 | import { Order } from './entities/order.entity';
6 | import { TypeOrmModule } from '@nestjs/typeorm';
7 | import { MenuOption } from 'src/cafe/entities/menuOption.entity';
8 | import { RedisCacheModule } from 'src/redisCache/redisCache.module';
9 |
10 | @Module({
11 | imports: [
12 | TypeOrmModule.forFeature([Order, MenuOption, OrderMenu]),
13 | RedisCacheModule,
14 | ],
15 | controllers: [OrderController],
16 | providers: [OrderService],
17 | })
18 | export class OrderModuleV1 {}
19 |
--------------------------------------------------------------------------------
/packages/server/src/order/order.v2.module.ts:
--------------------------------------------------------------------------------
1 | import { OrderMenu } from './entities/orderMenu.entity';
2 | import { Module } from '@nestjs/common';
3 | import { OrderService } from './order.service';
4 | import { OrderController } from './order.v2.controller';
5 | import { Order } from './entities/order.entity';
6 | import { TypeOrmModule } from '@nestjs/typeorm';
7 | import { MenuOption } from 'src/cafe/entities/menuOption.entity';
8 | import { RedisCacheModule } from 'src/redisCache/redisCache.module';
9 |
10 | @Module({
11 | imports: [
12 | TypeOrmModule.forFeature([Order, MenuOption, OrderMenu]),
13 | RedisCacheModule,
14 | ],
15 | controllers: [OrderController],
16 | providers: [OrderService],
17 | })
18 | export class OrderModuleV2 {}
19 |
--------------------------------------------------------------------------------
/packages/server/src/order/order.v3.module.ts:
--------------------------------------------------------------------------------
1 | import { OrderMenu } from './entities/orderMenu.entity';
2 | import { Module } from '@nestjs/common';
3 | import { OrderService } from './order.service';
4 | import { OrderController } from './order.v3.controller';
5 | import { Order } from './entities/order.entity';
6 | import { TypeOrmModule } from '@nestjs/typeorm';
7 | import { MenuOption } from 'src/cafe/entities/menuOption.entity';
8 | import { RedisCacheModule } from 'src/redisCache/redisCache.module';
9 |
10 | @Module({
11 | imports: [
12 | TypeOrmModule.forFeature([Order, MenuOption, OrderMenu]),
13 | RedisCacheModule,
14 | ],
15 | controllers: [OrderController],
16 | providers: [OrderService],
17 | })
18 | export class OrderModuleV3 {}
19 |
--------------------------------------------------------------------------------
/packages/nginx/configs/uwsgi_params:
--------------------------------------------------------------------------------
1 |
2 | uwsgi_param QUERY_STRING $query_string;
3 | uwsgi_param REQUEST_METHOD $request_method;
4 | uwsgi_param CONTENT_TYPE $content_type;
5 | uwsgi_param CONTENT_LENGTH $content_length;
6 |
7 | uwsgi_param REQUEST_URI $request_uri;
8 | uwsgi_param PATH_INFO $document_uri;
9 | uwsgi_param DOCUMENT_ROOT $document_root;
10 | uwsgi_param SERVER_PROTOCOL $server_protocol;
11 | uwsgi_param REQUEST_SCHEME $scheme;
12 | uwsgi_param HTTPS $https if_not_empty;
13 |
14 | uwsgi_param REMOTE_ADDR $remote_addr;
15 | uwsgi_param REMOTE_PORT $remote_port;
16 | uwsgi_param SERVER_PORT $server_port;
17 | uwsgi_param SERVER_NAME $server_name;
18 |
--------------------------------------------------------------------------------
/packages/server/scripts/prod.deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # 이미지 pull
4 | docker pull $1/$2:latest
5 |
6 | # 컨테이너 내리고, 띄우기
7 | if [[ $(docker ps -a --filter="name=$3" --filter "status=running" | grep -w $3) ]]; then
8 | docker stop $3
9 | fi
10 | if [[ $(docker ps -a --filter="name=$3" --filter "status=created" | grep -w $3) ]]; then
11 | docker rm $3
12 | fi
13 | if [[ $(docker ps -a --filter="name=$3" --filter "status=exited" | grep -w $3) ]]; then
14 | docker rm $3
15 | fi
16 |
17 | docker create -p 8080:8080 --name $3 $1/$2:latest
18 |
19 | docker cp ~/server/src/.prod.env $3:/app
20 |
21 | docker start $3
22 |
23 | if [[ $(docker ps -a --filter="name=$3" --filter "status=running" | grep -w $3) ]]; then
24 | echo 'prod-deploy success'
25 | fi
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/home.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/packages/server/scripts/dev.deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # 이미지 pull
4 | docker pull $1/$2:latest
5 |
6 | # 컨테이너 내리고, 띄우기
7 | if [[ $(docker ps -a --filter="name=$3" --filter "status=running" | grep -w $3) ]]; then
8 | docker stop $3
9 | fi
10 | if [[ $(docker ps -a --filter="name=$3" --filter "status=created" | grep -w $3) ]]; then
11 | docker rm $3
12 | fi
13 | if [[ $(docker ps -a --filter="name=$3" --filter "status=exited" | grep -w $3) ]]; then
14 | docker rm $3
15 | fi
16 |
17 | docker create -p 8080:8080 -v ~/server/src:/app/src --name $3 $1/$2:latest
18 |
19 | docker cp ~/server/src/.dev.env $3:/app
20 |
21 | docker start $3
22 |
23 | if [[ $(docker ps -a --filter="name=$3" --filter "status=running" | grep -w $3) ]]; then
24 | echo 'dev-deploy success'
25 | fi
26 |
--------------------------------------------------------------------------------
/packages/server/src/order/dto/create-order.dto.ts:
--------------------------------------------------------------------------------
1 | import { Type } from 'class-transformer';
2 | import {
3 | ArrayMinSize,
4 | IsArray,
5 | IsNumber,
6 | ValidateNested,
7 | } from 'class-validator';
8 | import { OrderMenuDto } from './orderMenu.dto';
9 | export class CreateOrderDto {
10 | @IsArray()
11 | @ValidateNested({ each: true })
12 | @ArrayMinSize(1)
13 | @Type(() => OrderMenuDto)
14 | menus: OrderMenuDto[];
15 |
16 | @IsNumber()
17 | cafeId: number;
18 |
19 | static of({ menus, cafeId }): CreateOrderDto {
20 | const createOrderDto = new CreateOrderDto();
21 | createOrderDto.cafeId = cafeId;
22 | createOrderDto.menus = menus.map((menu) => {
23 | return OrderMenuDto.of(menu);
24 | });
25 | return createOrderDto;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/nginx/configs/nginx.conf:
--------------------------------------------------------------------------------
1 |
2 | user nginx;
3 | worker_processes auto;
4 |
5 | pid /var/run/nginx.pid;
6 |
7 |
8 | events {
9 | worker_connections 1024;
10 | }
11 |
12 |
13 | http {
14 | include /etc/nginx/mime.types;
15 | default_type application/octet-stream;
16 |
17 | log_format main_log_format '$remote_addr - $remote_user [$time_local] '
18 | '"$request" $status $body_bytes_sent '
19 | '"$http_referer" "$http_user_agent" "$http_x_forwarded_for"';
20 |
21 | access_log off;
22 | log_not_found off;
23 |
24 | error_log /var/log/nginx/error.log crit;
25 |
26 | sendfile on;
27 |
28 | keepalive_timeout 65;
29 |
30 | gzip on;
31 |
32 | include /etc/nginx/conf.d/*.conf;
33 | }
34 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuList/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const MenuListPageWrapper = styled.div`
4 | width: 100%;
5 | `;
6 |
7 | export const MenuListWrapper = styled.ul`
8 | margin: 0 2rem 6.5rem 2rem;
9 | `;
10 |
11 | export const CategoryBarWrapper = styled.ul`
12 | display:flex;
13 | overflow-x: auto;
14 | white-space: nowrap;
15 | border-bottom: 1px solid ${(props) => props.theme.colors.grey200};
16 |
17 | &::-webkit-scrollbar{
18 | display:none;
19 | }
20 | `;
21 |
22 | export const CategoryItem = styled.li`
23 | display: inline-block;
24 | padding: 0.2rem 0.5rem 0.2rem 0.5rem;
25 | color: ${(props) => props.theme.colors.secondary};
26 | font-size: ${(props) => props.theme.font.size.sm};
27 | cursor: pointer;
28 | `;
29 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/dto/CafeMenuRes.dto.ts:
--------------------------------------------------------------------------------
1 | import { Exclude, Expose } from 'class-transformer';
2 | import { Cafe } from './../entities/cafe.entity';
3 | import { MenuDto } from './MenuRes.dto';
4 |
5 | export class CafeMenuResDto {
6 | @Exclude() private readonly _id: number;
7 | @Exclude() readonly _name: string;
8 | @Exclude() readonly _menus: MenuDto[];
9 |
10 | constructor(cafe: Cafe) {
11 | this._id = cafe.id;
12 | this._name = cafe.name;
13 | this._menus = cafe.cafeMenus.map((cafeMenu) => MenuDto.from(cafeMenu.menu));
14 | }
15 |
16 | @Expose()
17 | get id(): number {
18 | return this._id;
19 | }
20 |
21 | @Expose()
22 | get name(): string {
23 | return this._name;
24 | }
25 |
26 | @Expose()
27 | get menus(): MenuDto[] {
28 | return this._menus;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/useMenuListData.tsx:
--------------------------------------------------------------------------------
1 | import { QUERY_KEYS } from '@/constants';
2 | import { Menu } from '@/types';
3 | import useCustomQuery from './useCustomQuery';
4 |
5 | function useMenuListData() {
6 | const data = useCustomQuery({
7 | queryKey: [QUERY_KEYS.MENU_LIST_DATA],
8 | url: '/cafe/1/menus',
9 | });
10 |
11 | if (data) {
12 | return {
13 | menuList: data.data.menus,
14 | categoryList: makeCategoryList(data.data.menus),
15 | };
16 | }
17 |
18 | return {
19 | menuList: [],
20 | categoryList: [],
21 | };
22 | }
23 |
24 | function makeCategoryList(menuList: Menu[]) {
25 | if (menuList) {
26 | return Array.from(
27 | new Set(['전체'].concat(menuList.map((menu: Menu) => menu.category)))
28 | );
29 | }
30 | }
31 |
32 | export default useMenuListData;
33 |
--------------------------------------------------------------------------------
/packages/client/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 금액 세자리 단위 콤마 추가
3 | */
4 | export const getPriceComma = (price: number | string) => {
5 | return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
6 | };
7 |
8 | /**
9 | * 문자의 첫 글자만 대문자로 변경
10 | */
11 | export const getFirstUpper = (text: string) => {
12 | return `${text.slice(0, 1).toUpperCase()}${text.slice(1)}`;
13 | };
14 |
15 | /**
16 | * 문자열 날짜를 숫자로 변환하여 반환
17 | *
18 | * @params (string) YYYY-MM-DD
19 | * @returns (number) YYYYMMDD
20 | */
21 | export const getDateNumber = (date: string) =>
22 | Number(date.slice(0, 10).replace(/-/g, ''));
23 |
24 | /**
25 | * 주문 내역 날짜를 내림차순으로 정렬
26 | */
27 | export const sortDateDesc = (prev: string, curr: string) => {
28 | if (prev === '오늘') return -1;
29 | return getDateNumber(curr) - getDateNumber(prev);
30 | };
31 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuList/components/SnackBar/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const SnackBarWrapper = styled.div`
4 | display: flex;
5 | align-items: center;
6 | justify-content: space-between;
7 | flex-direction: row;
8 | position: fixed;
9 | bottom: 3rem;
10 | width: 100%;
11 | min-width: 320px;
12 | max-width: 480px;
13 | height: 2.5rem;
14 | padding: 0 1rem 0 1rem;
15 | background-color: ${(props) => props.theme.colors.secondary};
16 | color: white;
17 | font-size: ${(props) => props.theme.font.size.xs};
18 | font-weight: ${(props) => props.theme.font.weight.bold500};
19 | `;
20 |
21 | export const CartWrapper = styled.div`
22 | display: flex;
23 | flex-direction: row;
24 | align-items: center;
25 | gap: 0.3rem;
26 | cursor: pointer;
27 | `;
28 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuList/components/SnackBar/index.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { ReactComponent as Cart } from 'icons/cart.svg';
4 | import { getCartCount } from 'utils/localStorage';
5 | import { SnackBarWrapper, CartWrapper } from './styled';
6 |
7 | function SnackBar() {
8 | const navigate = useNavigate();
9 |
10 | const handleClickCart = () => {
11 | navigate('/cart');
12 | };
13 |
14 | return (
15 |
16 | 주문할 매장을 선택해주세요
17 |
18 | {`장바구니 ${getCartCount()}개`}
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default memo(SnackBar);
26 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/cart.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/packages/server/src/auth/strategy/jwt.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { PassportStrategy } from '@nestjs/passport';
4 | import { ExtractJwt, Strategy } from 'passport-jwt';
5 | @Injectable()
6 | export class JwtStrategy extends PassportStrategy(Strategy) {
7 | constructor(private readonly configService: ConfigService) {
8 | super({
9 | jwtFromRequest: ExtractJwt.fromExtractors([
10 | (request) => {
11 | return request?.cookies?.accessToken;
12 | },
13 | ]),
14 | ignoreExpiration: false,
15 | secretOrKey: configService.get('JWT_SECRET'),
16 | signOptions: { expiresIn: '1h' },
17 | });
18 | }
19 |
20 | async validate(payload) {
21 | return { id: parseInt(payload.id), userRole: payload.userRole };
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuList/components/MenuItem/index.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from 'react-router-dom';
2 | import { Menu } from '@/types';
3 | import { getPriceComma } from '@/utils';
4 | import { MenuImg, MenuWrapper, MenuInfoWrapper } from './styled';
5 |
6 | function MenuItem(props: Menu) {
7 | const navigate = useNavigate();
8 |
9 | const handleClickMenuItem = () => {
10 | navigate(`/menu/${props.id}`);
11 | };
12 |
13 | return (
14 |
19 |
20 |
21 | {props.name}
22 | {getPriceComma(props.price)}원
23 |
24 |
25 | );
26 | }
27 |
28 | export default MenuItem;
29 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/down_arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/left_arrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/packages/server/src/user/user.controller.ts:
--------------------------------------------------------------------------------
1 | import { JwtGuard } from './../auth/guard/jwt.guard';
2 | import { Controller, Body, Patch, Get, UseGuards, Req } from '@nestjs/common';
3 | import { UserService } from './user.service';
4 | import { UpdateUserDto } from './dto/update-user.dto';
5 | import { Request } from 'express';
6 | import { JwtPayload } from 'src/auth/interfaces/jwtPayload';
7 | import { User } from './entities/user.entity';
8 |
9 | @Controller()
10 | export class UserController {
11 | constructor(private readonly userService: UserService) {}
12 |
13 | // 회원정보(닉네임) 수정
14 | @Patch()
15 | update(@Body() updateUserDto: UpdateUserDto) {
16 | return;
17 | }
18 |
19 | @Get()
20 | @UseGuards(JwtGuard)
21 | async findOne(@Req() req: Request): Promise {
22 | const { id } = req.user as JwtPayload;
23 | return await this.userService.findById(id);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/client/src/components/LeftArrow/styled.ts:
--------------------------------------------------------------------------------
1 | import { Theme } from '@emotion/react';
2 | import styled from '@emotion/styled';
3 |
4 | import { ReactComponent as SVG } from 'icons/left_arrow.svg';
5 | import { Props } from '.';
6 |
7 | export const LeftArrowSVG = styled(SVG)`
8 | position: absolute;
9 | top: ${({ top }) => top}rem;
10 | left: ${({ left }) => left}rem;
11 | width: ${({ width }) => width}rem;
12 | height: ${({ height }) => height}rem;
13 | color: ${(props) => props.theme.colors.grey600};
14 | cursor: pointer;
15 |
16 | & path {
17 | fill: ${({ color, theme }: { color: string; theme: Theme }) => {
18 | if (
19 | color === 'primary' ||
20 | color === 'secondary' ||
21 | color === 'tertiary' ||
22 | color === 'fourth'
23 | )
24 | return theme.colors[color];
25 | return color;
26 | }};
27 | }
28 | `;
29 |
--------------------------------------------------------------------------------
/.github/workflows/fe-dev-ci.yml:
--------------------------------------------------------------------------------
1 | name: Frontend dev ci
2 |
3 | on:
4 | pull_request:
5 | branches: ['dev']
6 | paths:
7 | - 'packages/client/**'
8 | - '.github/workflows/fe-dev-ci.yml'
9 |
10 | jobs:
11 | ci:
12 | runs-on: ubuntu-latest
13 | defaults:
14 | run:
15 | working-directory: './packages/client'
16 |
17 | steps:
18 | - name: checkout to the branch
19 | uses: actions/checkout@v3
20 |
21 | - name: node set-up
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: 18.6.0
25 |
26 | - name: Install dependencies
27 | run: npm install
28 |
29 | - name: Inject Environment Variables
30 | env:
31 | FE_ENV: ${{ secrets.FE_ENV }}
32 | run: echo "$FE_ENV" > .env
33 |
34 | - name: Test
35 | run: npm run test
36 | env:
37 | CI: false
38 |
--------------------------------------------------------------------------------
/packages/client/src/components/LeftArrow/index.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, memo } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { LeftArrowSVG } from './styled';
4 |
5 | export interface Props {
6 | color: 'primary' | 'secondary' | 'tertiary' | 'fourth' | string;
7 | top: number;
8 | left: number;
9 | width?: number;
10 | height?: number;
11 | }
12 |
13 | function LeftArrow({ color, top, left, width = 1, height = 1 }: Props) {
14 | const navigate = useNavigate();
15 |
16 | const handleClickBack = useCallback(() => {
17 | // 뒤로가기, window.history.popState()
18 | navigate(-1);
19 | }, [navigate]);
20 |
21 | return (
22 |
30 | );
31 | }
32 |
33 | export default memo(LeftArrow);
34 |
--------------------------------------------------------------------------------
/packages/client/src/pages/manager/AcceptList/index.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 |
3 | import OrderDateList from 'components/OrderDateList';
4 | import Footer from 'components/Footer';
5 | import Header from 'components/Header';
6 |
7 | import { QUERY_KEYS } from '@/constants';
8 | import { customFetch } from '@/utils/fetch';
9 | import { Container } from './styled';
10 |
11 | function AcceptList() {
12 | const { data: list = {} } = useQuery([QUERY_KEYS.ACCEPTED_LIST], async () => {
13 | const res = await customFetch({ url: '/order/accepted', method: 'GET' });
14 | return res.data;
15 | });
16 |
17 | return (
18 |
19 |
20 | {list.orders && (
21 |
22 | )}
23 |
24 |
25 | );
26 | }
27 |
28 | export default AcceptList;
29 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/dto/CafeRes.dto.ts:
--------------------------------------------------------------------------------
1 | import { Exclude, Expose } from 'class-transformer';
2 | import { Cafe } from './../entities/cafe.entity';
3 |
4 | export class CafeResDto {
5 | @Exclude() private readonly _id: number;
6 | @Exclude() readonly _name: string;
7 | @Exclude() readonly _description: string;
8 | @Exclude() readonly _address: string;
9 |
10 | constructor(cafe: Cafe) {
11 | this._id = cafe.id;
12 | this._name = cafe.name;
13 | this._description = cafe.description;
14 | this._address = cafe.address;
15 | }
16 |
17 | @Expose()
18 | get id(): number {
19 | return this._id;
20 | }
21 |
22 | @Expose()
23 | get name(): string {
24 | return this._name;
25 | }
26 |
27 | @Expose()
28 | get description(): string {
29 | return this._description;
30 | }
31 |
32 | @Expose()
33 | get address(): string {
34 | return this._address;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/be-dev-ci.yml:
--------------------------------------------------------------------------------
1 | name: Backend dev ci
2 |
3 | on:
4 | pull_request:
5 | branches: ['dev']
6 | paths:
7 | - 'packages/server/**'
8 | - '.github/workflows/be-dev-ci.yml'
9 |
10 | jobs:
11 | ci:
12 | runs-on: ubuntu-latest
13 | defaults:
14 | run:
15 | working-directory: './packages/server'
16 |
17 | steps:
18 | - name: checkout to the branch
19 | uses: actions/checkout@v3
20 |
21 | - name: node set-up
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: 18.6.0
25 |
26 | - name: Install dependencies
27 | run: npm install
28 |
29 | - name: Inject Environment Variables
30 | env:
31 | BE_DEV_ENV: ${{ secrets.BE_DEV_ENV }}
32 | run: echo "$BE_DEV_ENV" > .dev.env
33 |
34 | - name: Test
35 | run: npm run test:cov
36 | env:
37 | CI: false
38 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/dto/OptionRes.dto.ts:
--------------------------------------------------------------------------------
1 | import { Exclude, Expose } from 'class-transformer';
2 |
3 | export class OptionResDto {
4 | @Exclude() readonly _id: number;
5 | @Exclude() readonly _name: string;
6 | @Exclude() readonly _price: number;
7 | @Exclude() readonly _category: string;
8 |
9 | constructor(option) {
10 | this._id = option.id;
11 | this._name = option.name;
12 | this._price = option.price;
13 | this._category = option.category;
14 | }
15 |
16 | static from(option: any) {
17 | return new OptionResDto(option);
18 | }
19 |
20 | @Expose()
21 | get id(): number {
22 | return this._id;
23 | }
24 |
25 | @Expose()
26 | get name(): string {
27 | return this._name;
28 | }
29 |
30 | @Expose()
31 | get price(): number {
32 | return this._price;
33 | }
34 |
35 | @Expose()
36 | get category(): string {
37 | return this._category;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/mock/menu.entity.mock.ts:
--------------------------------------------------------------------------------
1 | import { MENU_TYPE } from 'src/cafe/enum/menuType.enum';
2 |
3 | export const mockMenus = {
4 | 1: {
5 | id: 1,
6 | name: '커피1',
7 | description: '목 데이터 커피 1',
8 | price: 5000,
9 | category: '카테고리1',
10 | thumbnail: '썸네일1',
11 | type: MENU_TYPE.HOT,
12 | },
13 | 2: {
14 | id: 2,
15 | name: '커피2',
16 | description: '목 데이터 커피 2',
17 | price: 4000,
18 | category: '카테고리2',
19 | thumbnail: '썸네일2',
20 | type: MENU_TYPE.ICED,
21 | },
22 | 3: {
23 | id: 3,
24 | name: '커피3',
25 | description: '목 데이터 커피 3',
26 | price: 3000,
27 | category: '카테고리3',
28 | thumbnail: '썸네일3',
29 | type: MENU_TYPE.HOT,
30 | },
31 | 4: {
32 | id: 4,
33 | name: '커피4',
34 | description: '목 데이터 커피 4',
35 | price: 3000,
36 | category: '카테고리1',
37 | thumbnail: '썸네일4',
38 | type: MENU_TYPE.HOT,
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/packages/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter } from 'react-router-dom';
2 | import { RecoilRoot } from 'recoil';
3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4 |
5 | import Router from '@/Router';
6 | import Layout from '@/Layout';
7 | import Toast from '@/components/Toast';
8 |
9 | import UserRoleProvider from './UserRoleProvider';
10 |
11 | function App() {
12 | const queryClient = new QueryClient();
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | export default App;
33 |
--------------------------------------------------------------------------------
/packages/client/src/utils/index.test.ts:
--------------------------------------------------------------------------------
1 | import { getFirstUpper, getPriceComma } from '.';
2 |
3 | describe('유틸', () => {
4 | it('금액 세자리 단위 콤마 추가', () => {
5 | const cases = [
6 | { input: 3000, output: '3,000' },
7 | { input: 400, output: '400' },
8 | { input: 4500, output: '4,500' },
9 | { input: 1000000, output: '1,000,000' },
10 | { input: 0, output: '0' },
11 | { input: '', output: '' },
12 | ];
13 |
14 | cases.forEach((c) => {
15 | expect(getPriceComma(c.input)).toBe(c.output);
16 | });
17 | });
18 |
19 | it('문자 첫 글자만 대문자로 변경', () => {
20 | const cases = [
21 | { input: 'tall', output: 'Tall' },
22 | { input: 'grande', output: 'Grande' },
23 | { input: '한글', output: '한글' },
24 | { input: '', output: '' },
25 | ];
26 |
27 | cases.forEach((c) => {
28 | expect(getFirstUpper(c.input)).toBe(c.output);
29 | });
30 | });
31 | });
32 |
33 | export {};
34 |
--------------------------------------------------------------------------------
/packages/server/src/middleware/exception.filter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ExceptionFilter,
3 | Catch,
4 | ArgumentsHost,
5 | HttpException,
6 | Logger,
7 | } from '@nestjs/common';
8 | import { Request, Response } from 'express';
9 |
10 | // Http Exception을 상속받는 Exception들은 해당 필터로 오게 된다.
11 | @Catch(HttpException)
12 | export class HttpExceptionFilter implements ExceptionFilter {
13 | private logger = new Logger('ERROR');
14 |
15 | catch(exception: HttpException, host: ArgumentsHost) {
16 | const ctx = host.switchToHttp();
17 | const response = ctx.getResponse();
18 | const request = ctx.getRequest();
19 | const status = exception.getStatus();
20 |
21 | this.logger.warn(`${exception.message}, ${exception.stack}`);
22 |
23 | response.status(status).json({
24 | statusCode: status,
25 | message: exception.message,
26 | timestamp: new Date().toISOString(),
27 | path: request.url,
28 | });
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/packages/client/src/mocks/data/cart.ts:
--------------------------------------------------------------------------------
1 | export const cartData = [
2 | {
3 | id: 1,
4 | name: '자몽 허니 블랙 티',
5 | type: 'iced',
6 | size: 'grande',
7 | count: 2,
8 | thumbnail:
9 | 'https://www.istarbucks.co.kr/upload/store/skuimg/2021/04/[9200000000187]_20210419131229539.jpg',
10 | price: 7700,
11 | options: [
12 | { id: 2, name: '2', price: 1000, category: '에스프레소 샷' },
13 | { id: 4, name: '1', price: 500, category: '클래식 시럽' },
14 | ],
15 | },
16 | ];
17 |
18 | export const appendedCartData = [
19 | {
20 | id: 1,
21 | name: '자몽 허니 블랙 티',
22 | type: 'iced',
23 | size: 'grande',
24 | count: 4,
25 | thumbnail:
26 | 'https://www.istarbucks.co.kr/upload/store/skuimg/2021/04/[9200000000187]_20210419131229539.jpg',
27 | price: 7700,
28 | options: [
29 | { id: 2, name: '2', price: 1000, category: '에스프레소 샷' },
30 | { id: 4, name: '1', price: 500, category: '클래식 시럽' },
31 | ],
32 | },
33 | ];
34 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/OrderButton/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 |
3 | import Layout from '@/Layout';
4 | import { server } from '@/mocks/server';
5 | import MenuDetailContextProvider from '@/stores/MenuDetail';
6 | import OrderButton from '.';
7 | import { MemoryRouter } from 'react-router-dom';
8 |
9 | beforeAll(() => server.listen());
10 | beforeEach(() => server.resetHandlers());
11 | afterAll(() => server.close());
12 |
13 | const setup = async () => {
14 | const { asFragment } = render(
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 |
24 | return { asFragment };
25 | };
26 |
27 | describe('메뉴 주문 버튼 컴포넌트', () => {
28 | it('요소 존재 여부', async () => {
29 | await setup();
30 |
31 | screen.getByText(/장바구니 담기/);
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/Amount/index.tsx:
--------------------------------------------------------------------------------
1 | import CountSelector from '@/components/CountSelector';
2 | import { useMenuDetailDispatch } from '@/stores/MenuDetail';
3 | import { getPriceComma } from '@/utils';
4 | import { memo } from 'react';
5 | import { Container } from './styled';
6 |
7 | interface Props {
8 | price: number;
9 | count: number;
10 | }
11 |
12 | function Amount({ price, count }: Props) {
13 | const dispatch = useMenuDetailDispatch();
14 |
15 | /**
16 | * 수량 선택에 따라 수량 변경
17 | */
18 | const handleClickCount = (newCount: number) => {
19 | dispatch({ type: 'SET_COUNT', count: newCount });
20 | };
21 |
22 | return (
23 |
24 | {getPriceComma(price * count)}원
25 |
31 |
32 | );
33 | }
34 |
35 | export default memo(Amount);
36 |
--------------------------------------------------------------------------------
/packages/client/src/UserRoleProvider.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useEffect } from 'react';
2 | import axios, { AxiosError } from 'axios';
3 | import { useSetRecoilState } from 'recoil';
4 | import { userRoleState } from '@/stores';
5 |
6 | interface Props {
7 | children: ReactNode;
8 | }
9 |
10 | function UserRoleProvider({ children }: Props) {
11 | const setUserRole = useSetRecoilState(userRoleState);
12 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
13 |
14 | const fetchUserRole = async () => {
15 | try {
16 | const res = await axios.get(`${api}/auth`, {
17 | withCredentials: true,
18 | });
19 | setUserRole(res.data.role);
20 | } catch (err) {
21 | const error = err as AxiosError;
22 |
23 | if (error.response?.status === 401) {
24 | setUserRole('UNAUTH');
25 | }
26 | }
27 | };
28 |
29 | useEffect(() => {
30 | fetchUserRole();
31 | }, []);
32 |
33 | return <>{children}>;
34 | }
35 |
36 | export default UserRoleProvider;
37 |
--------------------------------------------------------------------------------
/packages/server/src/auth/guard/role.guard.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Injectable,
3 | CanActivate,
4 | ExecutionContext,
5 | BadRequestException,
6 | } from '@nestjs/common';
7 | import { Reflector } from '@nestjs/core';
8 | import { USER_ROLE } from 'src/user/enum/userRole.enum';
9 | import { ROLES_KEY } from '../decorators/roles.decorator';
10 |
11 | @Injectable()
12 | export class RolesGuard implements CanActivate {
13 | constructor(private reflector: Reflector) {}
14 |
15 | canActivate(context: ExecutionContext): boolean | Promise {
16 | const req = context.switchToHttp().getRequest();
17 | const { user } = req;
18 |
19 | if (!user) {
20 | throw new BadRequestException(
21 | '요청에 유저를 식별할 수 있는 정보가 없습니다.'
22 | );
23 | }
24 |
25 | const allowedRoles = this.reflector.getAllAndOverride(
26 | ROLES_KEY,
27 | [context.getHandler(), context.getClass()]
28 | );
29 |
30 | return allowedRoles.some((role) => user.userRole === role);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/packages/server/src/setNestApp.ts:
--------------------------------------------------------------------------------
1 | import { Reflector } from '@nestjs/core';
2 | import { ClassSerializerInterceptor, INestApplication } from '@nestjs/common';
3 | import cookieParser from 'cookie-parser';
4 | import session from 'express-session';
5 | import { HttpExceptionFilter } from './middleware/exception.filter';
6 |
7 | export function setNestApp(app: T): void {
8 | app.useGlobalFilters(new HttpExceptionFilter());
9 |
10 | app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
11 |
12 | app.enableCors({
13 | origin: [process.env.CLIENT_URI1, process.env.CLIENT_URI2],
14 | credentials: true,
15 | });
16 | app.use(cookieParser());
17 | app.use(
18 | session({
19 | secret: process.env.SESSION_PASSWORD,
20 | resave: false,
21 | saveUninitialized: false,
22 | cookie: {
23 | maxAge: 1000 * 60 * 3,
24 | secure: false,
25 | httpOnly: true,
26 | },
27 | name: 'sid',
28 | })
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/Cart/components/CartFooter/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const CartFooterWrapper = styled.div`
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | gap: 0.6rem;
8 | position: fixed;
9 | bottom: 0;
10 | width: 100%;
11 | min-width: 320px;
12 | max-width: 480px;
13 | padding: 0.8rem 2rem 1rem 2rem;
14 | background-color: white;
15 | box-shadow: 0px 0px 4px rgba(204, 204, 204, 0.5),
16 | 0px 0px 4px rgba(0, 0, 0, 0.25);
17 | `;
18 |
19 | export const CartFooterInfoWrapper = styled.div`
20 | display: flex;
21 | flex-direction: row;
22 | justify-content: space-between;
23 | align-items: center;
24 | width: 100%;
25 | padding: 0 0.2rem 0 0.2rem;
26 | font-weight: ${(props) => props.theme.font.weight.bold700};
27 |
28 | .cart-number {
29 | font-size: ${(props) => props.theme.font.size.xs};
30 | }
31 |
32 | .cart-price {
33 | font-size: ${(props) => props.theme.font.size.lg};
34 | }
35 | `;
36 |
--------------------------------------------------------------------------------
/packages/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "target": "es5",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "esModuleInterop": true,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 | "baseUrl": "./",
20 | "paths": {
21 | "@/*": ["src/*"],
22 | "components/*": ["src/components/*"],
23 | "containers/*": ["src/containers/*"],
24 | "hooks/*": ["src/hooks/*"],
25 | "pages/*": ["src/pages/*"],
26 | "types/*": ["src/types/*"],
27 | "utils/*": ["src/utils/*"],
28 | "icons/*": ["src/assets/icons/*"]
29 | }
30 | },
31 | "include": ["src"]
32 | }
33 |
--------------------------------------------------------------------------------
/packages/server/src/user/user.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { InjectRepository } from '@nestjs/typeorm';
3 | import { Repository } from 'typeorm';
4 | import { User } from './entities/user.entity';
5 |
6 | @Injectable()
7 | export class UserService {
8 | constructor(
9 | @InjectRepository(User) private userRepository: Repository
10 | ) {}
11 |
12 | async findOneByEmail(email: string) {
13 | const user: User | null = await this.userRepository.findOneBy({ email });
14 | return user;
15 | }
16 |
17 | async create(user: User): Promise {
18 | const userObjInserted = await this.userRepository.save(user);
19 | return userObjInserted;
20 | }
21 |
22 | async findById(id: number): Promise {
23 | const user: User = await this.userRepository.findOneBy({ id });
24 | return user;
25 | }
26 |
27 | async findOneByName(name: string) {
28 | const user: User | null = await this.userRepository.findOneBy({ name });
29 | return user;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/dto/MenuRes.dto.ts:
--------------------------------------------------------------------------------
1 | import { Exclude, Expose } from 'class-transformer';
2 |
3 | export class MenuDto {
4 | @Exclude() readonly _id: number;
5 | @Exclude() readonly _name: string;
6 | @Exclude() readonly _thumbnail: string;
7 | @Exclude() readonly _price: number;
8 | @Exclude() readonly _category: string;
9 |
10 | constructor(menu) {
11 | this._id = menu.id;
12 | this._name = menu.name;
13 | this._thumbnail = menu.thumbnail;
14 | this._price = menu.price;
15 | this._category = menu.category;
16 | }
17 |
18 | static from(menu: any) {
19 | return new MenuDto(menu);
20 | }
21 |
22 | @Expose()
23 | get id(): number {
24 | return this._id;
25 | }
26 |
27 | @Expose()
28 | get name(): string {
29 | return this._name;
30 | }
31 |
32 | @Expose()
33 | get thumbnail(): string {
34 | return this._thumbnail;
35 | }
36 |
37 | @Expose()
38 | get category(): string {
39 | return this._category;
40 | }
41 |
42 | @Expose()
43 | get price(): number {
44 | return this._price;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/server/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Description
3 |
4 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
5 |
6 | ## Installation
7 |
8 | ```bash
9 | $ npm install
10 | ```
11 |
12 | ## Running the app
13 |
14 | ```bash
15 | # development
16 | $ npm run start
17 |
18 | # watch mode
19 | $ npm run start:dev
20 |
21 | # production mode
22 | $ npm run start:prod
23 | ```
24 |
25 | ## Test
26 |
27 | ```bash
28 | # unit tests
29 | $ npm run test
30 |
31 | # e2e tests
32 | $ npm run test:e2e
33 |
34 | # test coverage
35 | $ npm run test:cov
36 | ```
37 |
38 | ## Support
39 |
40 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
41 |
42 | ## Stay in touch
43 |
44 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
45 | - Website - [https://nestjs.com](https://nestjs.com/)
46 | - Twitter - [@nestframework](https://twitter.com/nestframework)
47 |
48 | ## License
49 |
50 | Nest is [MIT licensed](LICENSE).
51 |
--------------------------------------------------------------------------------
/packages/server/src/order/dto/orderMenu.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEnum, IsNumber, IsString } from 'class-validator';
2 | import { MENU_SIZE } from 'src/cafe/enum/menuSize.enum';
3 | import { MENU_TYPE } from 'src/cafe/enum/menuType.enum';
4 |
5 | export class OrderMenuDto {
6 | @IsNumber()
7 | id: number;
8 |
9 | @IsString()
10 | name: string;
11 |
12 | @IsNumber()
13 | price: number;
14 |
15 | @IsNumber({}, { each: true })
16 | options: number[];
17 |
18 | @IsEnum(MENU_SIZE) // 이 데코레이터가 있어야 그에 맞게 validator가 validate한다.
19 | size: MENU_SIZE; // 이 타입은 typescript를 위한 type일 뿐이다.
20 |
21 | @IsEnum(MENU_TYPE)
22 | type: MENU_TYPE;
23 |
24 | @IsNumber()
25 | count: number;
26 |
27 | static of({ id, name, price, options, size, type, count }) {
28 | const orderMenuDto = new OrderMenuDto();
29 | orderMenuDto.id = id;
30 | orderMenuDto.name = name;
31 | orderMenuDto.price = price;
32 | orderMenuDto.options = options;
33 | orderMenuDto.size = size;
34 | orderMenuDto.type = type;
35 | orderMenuDto.count = count;
36 | return orderMenuDto;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/MenuInformation/index.test.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { render, screen } from '@testing-library/react';
3 |
4 | import Layout from '@/Layout';
5 | import { server } from '@/mocks/server';
6 | import MenuDetailContextProvider from '@/stores/MenuDetail';
7 | import MenuInformation from '.';
8 |
9 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
10 |
11 | beforeAll(() => server.listen());
12 | beforeEach(() => server.resetHandlers());
13 | afterAll(() => server.close());
14 |
15 | const setup = async () => {
16 | const menu = (await axios.get(`${api}/cafe/menu/1`)).data;
17 |
18 | const { asFragment } = render(
19 |
20 |
21 |
22 |
23 |
24 | );
25 |
26 | return { asFragment };
27 | };
28 |
29 | describe('메뉴 상세 정보 컴포넌트', () => {
30 | it('요소 존재 여부', async () => {
31 | await setup();
32 |
33 | await screen.findByText(/자몽 허니 블랙 티/);
34 | await screen.findByText(/새콤한/);
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/packages/server/src/config/route.ts:
--------------------------------------------------------------------------------
1 | import { AuthModule } from 'src/auth/auth.module';
2 | import { CafeModule } from 'src/cafe/cafe.module';
3 | import { OrderModuleV1 } from 'src/order/order.v1.module';
4 | import { OrderModuleV2 } from 'src/order/order.v2.module';
5 | import { OrderModuleV3 } from 'src/order/order.v3.module';
6 | import { RedisCacheModule } from 'src/redisCache/redisCache.module';
7 | import { UserModule } from 'src/user/user.module';
8 |
9 | export const routeTable = {
10 | path: 'api',
11 | children: [
12 | {
13 | path: 'v1/user',
14 | module: UserModule,
15 | },
16 | {
17 | path: 'v1/order',
18 | module: OrderModuleV1,
19 | },
20 | {
21 | path: 'v2/order',
22 | module: OrderModuleV2,
23 | },
24 | {
25 | path: 'v3/order',
26 | module: OrderModuleV3,
27 | },
28 | {
29 | path: 'v1/cafe',
30 | module: CafeModule,
31 | },
32 | {
33 | path: 'v1/auth',
34 | module: AuthModule,
35 | },
36 | {
37 | path: 'v3/redis',
38 | module: RedisCacheModule,
39 | },
40 | ],
41 | };
42 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/useCustomQuery.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import { customFetch } from 'utils/fetch';
3 | import { AxiosError } from 'axios';
4 | import { useNavigate } from 'react-router-dom';
5 | import { useSetRecoilState } from 'recoil';
6 | import { AnyObject } from '@/types';
7 | import { userRoleState } from '@/stores';
8 |
9 | interface CustomQueryParams {
10 | queryKey: string[];
11 | url: string;
12 | options?: AnyObject;
13 | }
14 |
15 | function useCustomQuery({ queryKey, url, options }: CustomQueryParams) {
16 | const navigate = useNavigate();
17 | const setUserRole = useSetRecoilState(userRoleState);
18 |
19 | const { isSuccess, data, error } = useQuery(
20 | queryKey,
21 | async () => await customFetch({ url, method: 'GET' }),
22 | { ...options }
23 | );
24 |
25 | if (isSuccess) return data;
26 | if (error instanceof AxiosError) {
27 | if (error.response?.status === 401) {
28 | setUserRole('UNAUTH');
29 | navigate('/');
30 | }
31 | console.log(error);
32 | }
33 | return;
34 | }
35 |
36 | export default useCustomQuery;
37 |
--------------------------------------------------------------------------------
/packages/client/src/pages/manager/AcceptList/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { server } from '@/mocks/server';
2 | import { setup, setupManager } from '@/utils/testSetup';
3 | import { fireEvent, screen } from '@testing-library/react';
4 |
5 | beforeAll(() => server.listen());
6 | beforeEach(() => server.resetHandlers());
7 | afterAll(() => server.close());
8 |
9 | describe('주문수락내역', () => {
10 | it('요소 존재 여부', async () => {
11 | setupManager();
12 | setup({ url: '/manager/accept' });
13 |
14 | await screen.findByText('주문 수락 내역');
15 | await screen.findByText('현재 진행중인 주문');
16 | await screen.findByText('화이트 초콜릿 모카');
17 | });
18 |
19 | it('주문 상세 정보 조회', async () => {
20 | setupManager();
21 | setup({ url: '/manager/accept' });
22 |
23 | await screen.findByText('화이트 초콜릿 모카 1잔');
24 | await screen.findByText('에스프레소 샷 추가');
25 | await screen.findByText('제조 완료');
26 | });
27 |
28 | it('제조 완료 클릭', async () => {
29 | setupManager();
30 | setup({ url: '/manager/accept' });
31 |
32 | const complete = await screen.findByText('제조 완료');
33 | fireEvent.click(complete);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/packages/nginx/configs/fastcgi_params:
--------------------------------------------------------------------------------
1 |
2 | fastcgi_param QUERY_STRING $query_string;
3 | fastcgi_param REQUEST_METHOD $request_method;
4 | fastcgi_param CONTENT_TYPE $content_type;
5 | fastcgi_param CONTENT_LENGTH $content_length;
6 |
7 | fastcgi_param SCRIPT_NAME $fastcgi_script_name;
8 | fastcgi_param REQUEST_URI $request_uri;
9 | fastcgi_param DOCUMENT_URI $document_uri;
10 | fastcgi_param DOCUMENT_ROOT $document_root;
11 | fastcgi_param SERVER_PROTOCOL $server_protocol;
12 | fastcgi_param REQUEST_SCHEME $scheme;
13 | fastcgi_param HTTPS $https if_not_empty;
14 |
15 | fastcgi_param GATEWAY_INTERFACE CGI/1.1;
16 | fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
17 |
18 | fastcgi_param REMOTE_ADDR $remote_addr;
19 | fastcgi_param REMOTE_PORT $remote_port;
20 | fastcgi_param SERVER_ADDR $server_addr;
21 | fastcgi_param SERVER_PORT $server_port;
22 | fastcgi_param SERVER_NAME $server_name;
23 |
24 | # PHP only, required if PHP was built with --enable-force-cgi-redirect
25 | fastcgi_param REDIRECT_STATUS 200;
26 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/TemperatureSelector/index.tsx:
--------------------------------------------------------------------------------
1 | import { Container } from './styled';
2 | import { Temperature } from '@/types';
3 | import { memo } from 'react';
4 | import { useMenuDetailDispatch } from '@/stores/MenuDetail';
5 |
6 | interface Props {
7 | temperature: Temperature;
8 | }
9 |
10 | function TemperatureSelector({ temperature }: Props) {
11 | const dispatch = useMenuDetailDispatch();
12 |
13 | /**
14 | * 음료 타입(핫, 아이스) 선택에 따라 음료 타입 변경
15 | */
16 | const handleClickTemperature = (event: React.MouseEvent) => {
17 | const newType = event.currentTarget.className;
18 |
19 | if (newType !== 'hot' && newType !== 'iced') return;
20 | dispatch({ type: 'SET_TEMPERATURE', temperature: newType });
21 | };
22 |
23 | return (
24 |
25 |
26 | HOT
27 |
28 |
29 | ICED
30 |
31 |
32 | );
33 | }
34 |
35 | export default memo(TemperatureSelector);
36 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/OptionSelector/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 |
3 | import OptionSelector from '.';
4 | import Layout from '@/Layout';
5 | import { server } from '@/mocks/server';
6 | import axios from 'axios';
7 | import MenuDetailContextProvider from '@/stores/MenuDetail';
8 |
9 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
10 |
11 | beforeAll(() => server.listen());
12 | beforeEach(() => server.resetHandlers());
13 | afterAll(() => server.close());
14 |
15 | const setup = async () => {
16 | const { options } = (await axios.get(`${api}/cafe/menu/1`)).data;
17 |
18 | const { asFragment } = render(
19 |
20 |
21 |
22 |
23 |
24 | );
25 |
26 | return { asFragment };
27 | };
28 |
29 | describe('옵션 선택 컴포넌트', () => {
30 | it('요소 존재 여부', async () => {
31 | await setup();
32 |
33 | await screen.findByText(/퍼스널 옵션/);
34 | await screen.findByText(/에스프레소/);
35 | await screen.findByText(/클래식 시럽/);
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/useOrderStatus.tsx:
--------------------------------------------------------------------------------
1 | import { QUERY_KEYS } from '@/constants';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { customFetch } from '@/utils/fetch';
4 | import { useEffect, useState } from 'react';
5 | import { OrderDetailMenu, OrderStatusCode } from '@/types';
6 |
7 | interface Response {
8 | date: string;
9 | id: number;
10 | menus: OrderDetailMenu[];
11 | status: OrderStatusCode;
12 | }
13 |
14 | function useOrderStatus(orderId: string) {
15 | const [response, setResponse] = useState({
16 | status: 'REQUESTED',
17 | date: '',
18 | id: -1,
19 | menus: [],
20 | });
21 |
22 | const { data } = useQuery(
23 | [QUERY_KEYS.ORDER_STATUS, orderId],
24 | async () => {
25 | const res = await customFetch({
26 | url: `/order/${orderId}`,
27 | method: 'GET',
28 | });
29 | return res.data;
30 | },
31 | {
32 | refetchInterval: 5000,
33 | }
34 | );
35 |
36 | useEffect(() => {
37 | if (data) {
38 | setResponse({ ...data });
39 | }
40 | }, [data]);
41 |
42 | return response;
43 | }
44 |
45 | export default useOrderStatus;
46 |
--------------------------------------------------------------------------------
/packages/server/src/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { HttpModule } from '@nestjs/axios';
3 | import { AuthService } from './auth.service';
4 | import { AuthController } from './auth.controller';
5 | import { UserModule } from 'src/user/user.module';
6 | import { PassportModule } from '@nestjs/passport';
7 | import { JwtModule } from '@nestjs/jwt';
8 | import { JwtStrategy } from './strategy/jwt.strategy';
9 | import { NaverOAuthService } from './naverOAuth.service';
10 | import { ConfigModule, ConfigService } from '@nestjs/config';
11 |
12 | @Module({
13 | imports: [
14 | HttpModule,
15 | UserModule,
16 | PassportModule,
17 | JwtModule.registerAsync({
18 | imports: [ConfigModule],
19 | inject: [ConfigService],
20 | useFactory: async (configService: ConfigService) => ({
21 | secret: configService.get('JWT_SECRET'),
22 | signOptions: { expiresIn: '1h', issuer: 'buddah.com' },
23 | }),
24 | }),
25 | ],
26 | controllers: [AuthController],
27 | providers: [AuthService, JwtStrategy, NaverOAuthService],
28 | exports: [AuthService],
29 | })
30 | export class AuthModule {}
31 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/OptionSelector/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.section`
4 | margin: 5%;
5 | margin-bottom: 100px;
6 | `;
7 |
8 | export const Title = styled.p`
9 | font-size: ${({theme}) => theme.font.size.lg};
10 | `;
11 |
12 | export const CategoryContainer = styled.div`
13 | padding: 0.6rem 0;
14 | `;
15 |
16 | export const CategoryTitle = styled.p`
17 | font-size: ${({theme}) => theme.font.size.md};
18 | font-weight: ${({theme}) => theme.font.weight.bold500};
19 | `;
20 |
21 | export const OptionsContainer = styled.div`
22 | display: flex;
23 | flex-direction: column;
24 | width: 100%;
25 | padding: 0.6rem 0;
26 | border-bottom: 1px solid ${({theme}) => theme.colors.grey400};
27 | `;
28 |
29 | export const OptionItemContainer = styled.div`
30 | display: flex;
31 | justify-content: space-between;
32 | align-items: center;
33 | font-size: ${({theme}) => theme.font.size.sm};
34 |
35 | & > div {
36 | display: flex;
37 | justify-content: flex-end;
38 | align-items: center;
39 | }
40 |
41 | & > div > label {
42 | margin: 5% 0;
43 | }
44 | `;
45 |
--------------------------------------------------------------------------------
/packages/client/src/components/CountSelector/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import { ReactComponent as MinusSVG } from 'icons/minus.svg';
4 | import { ReactComponent as PlusSVG } from 'icons/plus.svg';
5 |
6 | interface Props {
7 | count: number;
8 | width: number;
9 | height: number;
10 | }
11 |
12 | export const Container = styled.div`
13 | display: flex;
14 | justify-content: space-between;
15 | align-items: center;
16 | width: 30%;
17 | `;
18 |
19 | export const Minus = styled(MinusSVG)`
20 | width: ${({ width }) => width}rem;
21 | height: ${({ height }) => height}rem;
22 | cursor: ${({ count }) =>
23 | count === 1 ? 'unset' : 'pointer'};
24 |
25 | & > path {
26 | fill: ${({ count, theme }) =>
27 | count === 1 ? theme.colors.grey200 : theme.colors.grey800};
28 | }
29 | `;
30 |
31 | export const Plus = styled(PlusSVG)`
32 | width: ${({ width }) => width}rem;
33 | height: ${({ height }) => height}rem;
34 | cursor: ${({ count }) => count === 20 ? 'unset' : 'pointer'};
35 |
36 | & path {
37 | fill: ${({ count, theme }) =>
38 | count === 20 ? theme.colors.grey200 : theme.colors.grey800};
39 | }
40 | `;
41 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/entities/menu.entity.ts:
--------------------------------------------------------------------------------
1 | import { TimestampableEntity } from 'src/common/entities/common.entity';
2 | import { OrderMenu } from 'src/order/entities/orderMenu.entity';
3 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
4 | import { MENU_TYPE } from '../enum/menuType.enum';
5 | import { MenuOption } from './menuOption.entity';
6 |
7 | @Entity()
8 | export class Menu extends TimestampableEntity {
9 | @PrimaryGeneratedColumn()
10 | id: number;
11 |
12 | @Column()
13 | name: string;
14 |
15 | @Column()
16 | description: string;
17 |
18 | @Column()
19 | price: number;
20 |
21 | @Column()
22 | category: string;
23 |
24 | @Column({ type: 'varchar', length: '2000' })
25 | thumbnail: string;
26 |
27 | @Column({
28 | type: 'enum',
29 | enum: MENU_TYPE,
30 | })
31 | type: MENU_TYPE;
32 |
33 | @OneToMany(() => MenuOption, (menuOption) => menuOption.menu)
34 | menuOptions: MenuOption[];
35 |
36 | @OneToMany(() => OrderMenu, (orderMenu) => orderMenu.menu)
37 | orderMenus: OrderMenu[];
38 |
39 | static byId({ id }): Menu {
40 | const menu = new Menu();
41 | menu.id = id;
42 | return menu;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/packages/client/src/pages/Home/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRecoilValue } from 'recoil';
2 |
3 | import Footer from 'components/Footer';
4 | import Header from 'components/Header';
5 | import OrderDateList from 'components/OrderDateList';
6 |
7 | import { userRoleState } from '@/stores';
8 | import { Container } from './styled';
9 | import useFetchOrderList from '@/hooks/useFetchOrderList';
10 |
11 | function Home() {
12 | const userRole = useRecoilValue(userRoleState);
13 |
14 | const { data: list = {} } = useFetchOrderList({
15 | userRole,
16 | url: userRole === 'CLIENT' ? '/order' : '/order/requested',
17 | });
18 |
19 | return (
20 |
21 |
22 | {userRole === 'CLIENT' && (
23 |
28 | )}
29 | {list.orders && (
30 |
34 | )}
35 |
36 |
37 | );
38 | }
39 |
40 | export default Home;
41 |
--------------------------------------------------------------------------------
/packages/client/src/utils/localStorage.ts:
--------------------------------------------------------------------------------
1 | import { CART_KEY } from '@/constants';
2 | import { CartMenu } from '@/types';
3 |
4 | /**
5 | * 장바구니 배열 return
6 | */
7 | export const getCart = () => {
8 | return JSON.parse(localStorage.getItem(CART_KEY) || '[]');
9 | };
10 |
11 | /**
12 | * 장바구니 담긴 갯수 return
13 | */
14 | export const getCartCount = () => {
15 | const cart = getCart();
16 | let count = 0;
17 |
18 | cart.forEach((menu: CartMenu) => {
19 | count += menu.count;
20 | });
21 |
22 | return count;
23 | };
24 |
25 | export const getCartPrice = () => {
26 | const cart = getCart();
27 | let price = 0;
28 |
29 | cart.forEach((menu: CartMenu) => {
30 | price += menu.price * menu.count;
31 | });
32 |
33 | return price;
34 | };
35 |
36 | export const getMenuIdx = (menu: CartMenu) => {
37 | return getCart().findIndex((cart: CartMenu) => {
38 | return isEqualJSON(menu, cart);
39 | });
40 | };
41 |
42 | const isEqualJSON = (a: Object, b: Object) => {
43 | return (
44 | JSON.stringify(Object.entries(a).sort()) ===
45 | JSON.stringify(Object.entries(b).sort())
46 | );
47 | };
48 |
49 | export const setLocalStorage = (key: string, data: string) => {
50 | localStorage.setItem(key, data);
51 | };
52 |
--------------------------------------------------------------------------------
/packages/client/src/pages/MyPage/index.spec.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, screen } from '@testing-library/react';
2 | import { server } from '@/mocks/server';
3 | import { setup, setupClient, setupManager } from 'utils/testSetup';
4 |
5 | beforeAll(() => server.listen());
6 | afterEach(() => server.resetHandlers());
7 | afterAll(() => server.close());
8 |
9 | describe('MenuList', () => {
10 | it('REQUESTED 상태 컴포넌트 검사', async () => {
11 | setupClient();
12 | setup({ url: '/mypage' });
13 |
14 | await screen.findByTestId('my-page');
15 | });
16 |
17 | it('미구현 토스트 띄우기', async () => {
18 | setupClient();
19 | setup({ url: '/mypage' });
20 |
21 | fireEvent.click(await screen.findByText('닉네임 수정'));
22 | await screen.findByText('미구현 기능입니다');
23 | });
24 |
25 | it('고객 메뉴 목록 페이지 이동', async () => {
26 | setupClient();
27 | setup({ url: '/mypage' });
28 |
29 | fireEvent.click(await screen.findByText('주문하러 가기'));
30 | await screen.findByTestId('menu-list-page');
31 | });
32 |
33 | it('업주 주문 요청 목록 페이지 이동', async () => {
34 | setupManager();
35 | setup({ url: '/mypage' });
36 |
37 | fireEvent.click(await screen.findByText('주문 받으러 가기'));
38 | await screen.findByText('주문 요청 내역');
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/packages/client/src/constants.ts:
--------------------------------------------------------------------------------
1 | import { Size } from './types';
2 |
3 | export const PLACEHOLDER = {
4 | nickname: '닉네임을 입력해주세요. 알파벳, 숫자만 사용',
5 | corporate: '사업자 등록 번호를 입력해주세요.',
6 | };
7 |
8 | export const SIZES: Size[] = ['tall', 'grande', 'venti'];
9 |
10 | export const SIZE_VOLUME = {
11 | tall: 355,
12 | grande: 473,
13 | venti: 591,
14 | };
15 |
16 | export const CART_KEY = 'buddhaCart';
17 |
18 | export const PROGRESS_CLASS = {
19 | REQUESTED: 'wd-10',
20 | ACCEPTED: 'wd-50',
21 | REJECTED: 'wd-0',
22 | COMPLETED: 'wd-100',
23 | };
24 |
25 | export const PROGRESS_IMAGE = {
26 | REQUESTED: 'https://kr.object.ncloudstorage.com/buddha-dev/requested.gif',
27 | ACCEPTED: 'https://kr.object.ncloudstorage.com/buddha-dev/making.gif',
28 | REJECTED: 'https://kr.object.ncloudstorage.com/buddha-dev/rejected.gif',
29 | COMPLETED: 'https://kr.object.ncloudstorage.com/buddha-dev/completed.gif',
30 | };
31 |
32 | export const QUERY_KEYS = {
33 | MENU_LIST_DATA: 'menu-list',
34 | ORDER_STATUS: 'order-status',
35 | USER_ROLE: 'user-role',
36 | ORDER_LIST: 'order-list',
37 | ACCEPTED_LIST: 'accepted-list',
38 | };
39 |
40 | export const USER_ROLE = {
41 | CLIENT: 'CLIENT',
42 | MANAGER: 'MANAGER',
43 | UNAUTH: 'UNAUTH',
44 | };
45 |
--------------------------------------------------------------------------------
/packages/client/craco.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | module.exports = {
4 | // webpack alias 추가
5 | webpack: {
6 | alias: {
7 | '@': path.resolve(__dirname, 'src/'),
8 | 'components': path.resolve(__dirname, 'src/components/'),
9 | 'containers': path.resolve(__dirname, 'src/containers/'),
10 | 'hooks': path.resolve(__dirname, 'src/hooks/'),
11 | 'pages': path.resolve(__dirname, 'src/pages/'),
12 | 'types': path.resolve(__dirname, 'src/types/'),
13 | 'utils': path.resolve(__dirname, 'src/utils/'),
14 | 'icons': path.resolve(__dirname, 'src/assets/icons/'),
15 | },
16 | },
17 | // jest alias 추가
18 | jest: {
19 | configure: {
20 | moduleNameMapper: {
21 | '^@/(.*)$': '/src/$1',
22 | '^components/(.*)$': '/src/components/$1',
23 | '^containers/(.*)$': '/src/containers/$1',
24 | '^hooks/(.*)$': '/src/hooks/$1',
25 | '^pages/(.*)$': '/src/pages/$1',
26 | '^types/(.*)$': '/src/types/$1',
27 | '^utils/(.*)$': '/src/utils/$1',
28 | '^icons/(.*)$': '/src/assets/icons/$1',
29 | '^axios$': require.resolve('axios'),
30 | },
31 | },
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/packages/client/src/pages/Signin/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | justify-content: center;
8 | gap: 3rem;
9 | width: 100%;
10 | height: 100%;
11 | background-color: ${(props) => props.theme.colors.fourth};
12 |
13 | & p {
14 | margin: 15px 0;
15 | font-size: ${(props) => props.theme.font.size.md};
16 | }
17 | `;
18 |
19 | export const Logo = styled.img`
20 | width: 80%;
21 | `;
22 |
23 | export const NaverOAuth = styled.img`
24 | width: 40%;
25 | cursor: pointer;
26 | `;
27 |
28 | export const TempSigninContainer = styled.section`
29 | padding: 1rem;
30 | width: 60%;
31 | text-align: center;
32 | border-radius: 12px;
33 | background-color: white;
34 | box-shadow: 0px 0px 4px rgba(204, 204, 204, 0.5),
35 | 0px 0px 4px rgba(0, 0, 0, 0.25);
36 | `;
37 |
38 | export const TempInput = styled.input`
39 | width: 100%;
40 | padding: 0 0 0.2rem 0;
41 | margin: 0 0 0.5rem 0;
42 | font-size: ${(props) => props.theme.font.size.sm};
43 | border: none;
44 | border-bottom: ${(props) => `1px solid ${props.theme.colors.grey400}`};
45 |
46 | &:focus {
47 | outline: none;
48 | }
49 | `;
50 |
--------------------------------------------------------------------------------
/packages/server/src/redisCache/redisCache.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { RedisCacheService } from './redisCache.service';
3 | import { RedisModule } from '@liaoliaots/nestjs-redis';
4 | import { ConfigModule, ConfigService } from '@nestjs/config';
5 | import { RedisCacheController } from './redisCache.controller';
6 |
7 | @Module({
8 | imports: [
9 | RedisModule.forRootAsync({
10 | imports: [ConfigModule],
11 | useFactory: (configService: ConfigService) => {
12 | return {
13 | config: {
14 | host: configService.get('REDIS_HOST'),
15 | port: configService.get('REDIS_PORT'),
16 | password: configService.get('REDIS_PASSWORD'),
17 | retryStrategy: (times) => {
18 | // times => reconnection 시도 횟수
19 | // if (times >= 5) {
20 | // return null;
21 | // }
22 | return 1000;
23 | },
24 | maxRetriesPerRequest: 1,
25 | },
26 | };
27 | },
28 | inject: [ConfigService],
29 | }),
30 | ],
31 | controllers: [RedisCacheController],
32 | providers: [RedisCacheService],
33 | exports: [RedisCacheService],
34 | })
35 | export class RedisCacheModule {}
36 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/Cart/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const CartPageWrapper = styled.div`
4 | width: 100%;
5 | `;
6 |
7 | export const FixedHeader = styled.div`
8 | display: flex;
9 | flex-direction: row;
10 | align-items: center;
11 | position: fixed;
12 | top: 0;
13 | width: 100%;
14 | max-width: 480px;
15 | height: 2rem;
16 | background-color: ${(props) => props.theme.colors.secondary};
17 | z-index: 1;
18 | `;
19 |
20 | export const CartHeader = styled.div`
21 | width: 100%;
22 | padding: 2rem 1.5rem 0.6rem 1.5rem;
23 | background-color: ${(props) => props.theme.colors.secondary};
24 | color: white;
25 |
26 | .title {
27 | margin-bottom: 0.6rem;
28 | font-size: ${(props) => props.theme.font.size.lg};
29 | }
30 |
31 | .input-cafe {
32 | padding-bottom: 2px;
33 | font-size: ${(props) => props.theme.font.size.xs};
34 | border-bottom: 1px solid white;
35 | }
36 | `;
37 |
38 | export const CartContentWrapper = styled.div`
39 | width: 100%;
40 | padding: 1rem 0rem 5rem 0rem;
41 |
42 | .title {
43 | padding: 0 1rem 0 1rem;
44 | font-size: ${(props) => props.theme.font.size.sm};
45 | font-weight: ${(props) => props.theme.font.weight.bold700};
46 | }
47 | `;
48 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/OrderStatus/index.spec.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import { server } from '@/mocks/server';
3 | import { setup, setupClient } from 'utils/testSetup';
4 |
5 | beforeAll(() => server.listen());
6 | afterEach(() => server.resetHandlers());
7 | afterAll(() => server.close());
8 |
9 | describe('MenuList', () => {
10 | it('REQUESTED 상태 컴포넌트 검사', async () => {
11 | setupClient();
12 | setup({ url: '/order/1' });
13 |
14 | await screen.findByTestId('status-bar');
15 | await screen.findByTestId('REQUESTED');
16 | });
17 |
18 | it('ACCEPTED 상태 컴포넌트 검사', async () => {
19 | setupClient();
20 | setup({ url: '/order/2' });
21 |
22 | await screen.findByTestId('status-bar');
23 | await screen.findByTestId('ACCEPTED');
24 | });
25 |
26 | it('COMPLETED 상태 컴포넌트 검사', async () => {
27 | setupClient();
28 | setup({ url: '/order/3' });
29 |
30 | await screen.findByTestId('status-bar');
31 | await screen.findByTestId('COMPLETED');
32 | });
33 |
34 | it('REJECTED 상태 컴포넌트 검사', async () => {
35 | setupClient();
36 | setup({ url: '/order/4' });
37 |
38 | await screen.findByTestId('status-bar');
39 | await screen.findByTestId('REJECTED');
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/TemperatureSelector/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.section<{ selected: string }>`
4 | margin: 10% 5%;
5 | display: flex;
6 | justify-content: center;
7 |
8 | & > span {
9 | padding: 5px;
10 | width: 50%;
11 | text-align: center;
12 | color: white;
13 | }
14 |
15 | & > .hot {
16 | border-top-left-radius: 100px;
17 | border-bottom-left-radius: 100px;
18 | border: ${(props) => props.theme.border.default};
19 | border-color: ${(props) => props.theme.colors.grey200};
20 | background-color: ${(props) => (props.selected === 'hot' ? 'red' : '')};
21 | color: ${(props) =>
22 | props.selected === 'hot' ? 'white' : props.theme.colors.grey600};
23 | cursor: pointer;
24 | }
25 |
26 | & > .iced {
27 | border-top-right-radius: 100px;
28 | border-bottom-right-radius: 100px;
29 | border: ${(props) => props.theme.border.default};
30 | border-color: ${(props) => props.theme.colors.grey200};
31 | background-color: ${(props) => (props.selected === 'iced' ? 'blue' : '')};
32 | color: ${(props) =>
33 | props.selected === 'iced' ? 'white' : props.theme.colors.grey600};
34 | cursor: pointer;
35 | }
36 | `;
37 |
--------------------------------------------------------------------------------
/packages/client/src/mocks/handlers/auth.ts:
--------------------------------------------------------------------------------
1 | import { rest, RestRequest } from 'msw';
2 | import { SignupRequestBody } from '@/types';
3 |
4 | const naverOAuthURL = process.env.REACT_APP_NAVER_OAUTH_URL!.split('?')[0];
5 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
6 |
7 | export const authHandlers = [
8 | // 네이버 로그인 OAuth 요청
9 | rest.get(naverOAuthURL, (req, res, ctx) => {
10 | return res(ctx.status(200));
11 | }),
12 | // OAuth code, state 서버 전송 및 응답
13 | rest.get(`${api}/auth/naver-oauth`, (req, res, ctx) => {
14 | const { name, email } = req.cookies;
15 |
16 | if (name && email) {
17 | return res(ctx.status(200));
18 | }
19 | return res(ctx.status(303));
20 | }),
21 | rest.post(
22 | `${api}/auth/signup`,
23 | (req: RestRequest, res, next) => {
24 | const { userRole, nickname, corporate } = req.body;
25 | if (userRole === 'MANAGER' && corporate && nickname) {
26 | return res(next.status(201));
27 | } else if (userRole === 'CLIENT' && nickname) {
28 | return res(next.status(201));
29 | } else {
30 | return res(next.status(400));
31 | }
32 | }
33 | ),
34 | rest.get(`${api}/auth`, (req, res, next) => {
35 | return res(next.json({ role: 'UNAUTH' }));
36 | }),
37 | ];
38 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/entities/cafe.entity.ts:
--------------------------------------------------------------------------------
1 | import { TimestampableEntity } from 'src/common/entities/common.entity';
2 | import { Order } from 'src/order/entities/order.entity';
3 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
4 | import { CafeMenu } from './cafeMenu.entity';
5 |
6 | @Entity()
7 | export class Cafe extends TimestampableEntity {
8 | @PrimaryGeneratedColumn()
9 | id: number;
10 |
11 | @Column()
12 | name: string;
13 |
14 | @Column()
15 | description: string;
16 |
17 | @Column()
18 | latitude: number;
19 |
20 | @Column()
21 | longitude: number;
22 |
23 | @Column()
24 | address: string;
25 |
26 | @OneToMany(() => CafeMenu, (cafeMenus) => cafeMenus.cafe)
27 | cafeMenus: CafeMenu[];
28 |
29 | @OneToMany(() => Order, (orders) => orders.cafe)
30 | orders: Order[];
31 |
32 | static of({ id, name, description, latitude, longitude, address }) {
33 | const cafe = new Cafe();
34 | cafe.id = id;
35 | cafe.name = name;
36 | cafe.description = description;
37 | cafe.latitude = latitude;
38 | cafe.longitude = longitude;
39 | cafe.address = address;
40 | return cafe;
41 | }
42 |
43 | static byId(id): Cafe {
44 | const cafe = new Cafe();
45 | cafe.id = id;
46 | return cafe;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "buddha",
3 | "version": "0.0.1",
4 | "description": "부스트 DDㅏ방, 부따 Buddha ᕕ( ᐛ )ᕗ",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "concurrently --kill-others-on-fail \"npm run test:cov -w server\" \"npm run test -w client\"",
8 | "dev": "concurrently --kill-others-on-fail \"npm run start:dev -w server\" \"npm run start -w client\"",
9 | "build": "concurrently --kill-others-on-fail \"npm run build -w server\" \"npm run build -w client\"",
10 | "prod": "concurrently --kill-others-on-fail \"npm run start:prod -w server\""
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/boostcampwm-2022/web29-Buddha.git"
15 | },
16 | "author": "",
17 | "license": "ISC",
18 | "bugs": {
19 | "url": "https://github.com/boostcampwm-2022/web29-Buddha/issues"
20 | },
21 | "homepage": "https://github.com/boostcampwm-2022/web29-Buddha#readme",
22 | "workspaces": [
23 | "./packages/*"
24 | ],
25 | "devDependencies": {
26 | "@typescript-eslint/eslint-plugin": "^5.42.1",
27 | "@typescript-eslint/parser": "^5.42.1",
28 | "concurrently": "^7.5.0",
29 | "eslint": "^8.27.0",
30 | "eslint-config-prettier": "^8.5.0",
31 | "eslint-plugin-prettier": "^4.2.1",
32 | "prettier": "^2.7.1",
33 | "typescript": "^4.8.4"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/.github/workflows/fe-dev-cd.yml:
--------------------------------------------------------------------------------
1 | name: Frontend dev cd
2 |
3 | on:
4 | push:
5 | branches: ['dev']
6 | paths:
7 | - 'packages/client/**'
8 | - '.github/workflows/fe-dev-cd.yml'
9 |
10 | jobs:
11 | cd:
12 | runs-on: ubuntu-latest
13 | defaults:
14 | run:
15 | working-directory: './packages/client'
16 |
17 | steps:
18 | - name: checkout to the branch
19 | uses: actions/checkout@v3
20 |
21 | - name: node set-up
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: 18.6.0
25 |
26 | - name: Install dependencies
27 | run: npm install
28 |
29 | - name: Inject Environment Variables
30 | env:
31 | FE_ENV: ${{ secrets.FE_ENV }}
32 | run: echo "$FE_ENV" > .env
33 |
34 | - name: Build
35 | run: npm run build
36 | env:
37 | CI: false
38 |
39 | - name: Deploy to Remote Server
40 | uses: appleboy/scp-action@master
41 | with:
42 | host: ${{ secrets.REMOTE_SSH_HOST }}
43 | username: ${{ secrets.REMOTE_SSH_USERNAME }}
44 | password: ${{ secrets.REMOTE_SSH_PASSWORD }}
45 | port: ${{ secrets.REMOTE_SSH_PORT }}
46 | source: 'packages/client/build/*'
47 | target: '~/client/build'
48 | strip_components: 3
49 |
--------------------------------------------------------------------------------
/packages/client/src/pages/Signup/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const PageWrapper = styled.div`
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | gap: 3rem;
8 | width: 100%;
9 | padding-top: 3rem;
10 | `;
11 |
12 | export const ChangeForm = styled.div`
13 | display: flex;
14 | flex-direction: row;
15 | width: 50%;
16 | `;
17 |
18 | export const ChangeButton = styled.span`
19 | width: 50%;
20 | padding: 0.8rem 0 0.8rem 0;
21 | font-size: ${(props) => props.theme.font.size.sm};
22 | font-weight: ${(props) => props.theme.font.weight.bold700};
23 | border-bottom: ${(props) => `2px solid ${props.theme.colors.grey600}`};
24 | text-align: center;
25 |
26 | &.selected {
27 | border-bottom: ${(props) => `4px solid ${props.theme.colors.primary}`};
28 | }
29 | `;
30 |
31 | export const InputWrapper = styled.div`
32 | display: flex;
33 | flex-direction: column;
34 | gap: 1rem;
35 | width: 80%;
36 | `;
37 |
38 | export const InputTitle = styled.p`
39 | font-size: ${(props) => props.theme.font.size.xs};
40 | `;
41 |
42 | export const Input = styled.input`
43 | width: 100%;
44 | padding: 0 0 0.2rem 0;
45 | font-size: ${(props) => props.theme.font.size.sm};
46 | border: none;
47 | border-bottom: ${(props) => `1px solid ${props.theme.colors.grey400}`};
48 |
49 | &:focus {
50 | outline: none;
51 | }
52 | `;
53 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/dto/MenuDetailRes.dto.ts:
--------------------------------------------------------------------------------
1 | import { MenuOption } from './../entities/menuOption.entity';
2 | import { OptionResDto } from './OptionRes.dto';
3 | import { Exclude, Expose } from 'class-transformer';
4 |
5 | export class MenuDetailResDto {
6 | @Exclude() readonly _id: number;
7 | @Exclude() readonly _name: string;
8 | @Exclude() readonly _description: string;
9 | @Exclude() readonly _price: number;
10 | @Exclude() readonly _thumbnail: string;
11 | @Exclude() readonly _options;
12 |
13 | constructor(menu) {
14 | this._id = menu.id;
15 | this._name = menu.name;
16 | this._description = menu.description;
17 | this._price = menu.price;
18 | this._thumbnail = menu.thumbnail;
19 | this._options = menu.menuOptions.map((menuOption: MenuOption) =>
20 | OptionResDto.from(menuOption.option)
21 | );
22 | }
23 |
24 | @Expose()
25 | get id(): number {
26 | return this._id;
27 | }
28 |
29 | @Expose()
30 | get name(): string {
31 | return this._name;
32 | }
33 |
34 | @Expose()
35 | get description(): string {
36 | return this._description;
37 | }
38 |
39 | @Expose()
40 | get price(): number {
41 | return this._price;
42 | }
43 |
44 | @Expose()
45 | get thumbnail(): string {
46 | return this._thumbnail;
47 | }
48 |
49 | @Expose()
50 | get options(): number {
51 | return this._options;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/packages/server/src/auth/naverOAuth.service.ts:
--------------------------------------------------------------------------------
1 | import { HttpService } from '@nestjs/axios';
2 | import { Injectable } from '@nestjs/common';
3 |
4 | @Injectable()
5 | export class NaverOAuthService {
6 | constructor(private readonly httpService: HttpService) {}
7 |
8 | async getTokens(code: string, state: string) {
9 | const redirectURI = encodeURIComponent(process.env.CLIENT_URI);
10 |
11 | const clientId = process.env.CLIENT_ID;
12 | const clientSecret = process.env.CLIENT_SECRET;
13 |
14 | const api_url =
15 | 'https://nid.naver.com/oauth2.0/token?grant_type=authorization_code&client_id=' +
16 | clientId +
17 | '&client_secret=' +
18 | clientSecret +
19 | '&redirect_uri=' +
20 | redirectURI +
21 | '&code=' +
22 | code +
23 | '&state=' +
24 | state;
25 |
26 | const apiRes = await this.httpService.axiosRef.get(api_url, {
27 | headers: {
28 | 'X-Naver-Client-Id': clientId,
29 | 'X-Naver-Client-Secret': clientSecret,
30 | },
31 | });
32 |
33 | return apiRes.data;
34 | }
35 |
36 | async getUserInfo(access_token: string) {
37 | const api_url = 'https://openapi.naver.com/v1/nid/me';
38 | const apiRes = await this.httpService.axiosRef.get(api_url, {
39 | headers: {
40 | Authorization: 'Bearer ' + access_token,
41 | },
42 | });
43 |
44 | return apiRes.data.response;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/client/src/pages/Signin/__snapshots__/index.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`로그인 페이지 스냡샷 1`] = `
4 |
5 |
8 |
11 |
14 |

19 |
22 |
23 | 오직 당신만을 위한 카페 주문
24 |
25 |
26 | 부따 (부스트 다방, Buddha)
27 |
28 |
29 |

34 |
53 |
54 |
55 |
56 |
57 | `;
58 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/cafe.controller.ts:
--------------------------------------------------------------------------------
1 | import { JwtGuard } from './../auth/guard/jwt.guard';
2 | import { Cafe } from './entities/cafe.entity';
3 | import {
4 | Controller,
5 | Get,
6 | Param,
7 | ParseIntPipe,
8 | ClassSerializerInterceptor,
9 | UseInterceptors,
10 | UseGuards,
11 | } from '@nestjs/common';
12 | import { CafeService } from './cafe.service';
13 | import { CafeMenuResDto } from './dto/CafeMenuRes.dto';
14 | import { MenuDetailResDto } from './dto/MenuDetailRes.dto';
15 | import { CafeResDto } from './dto/CafeRes.dto';
16 | @Controller()
17 | export class CafeController {
18 | constructor(private readonly cafeService: CafeService) {}
19 |
20 | @UseInterceptors(ClassSerializerInterceptor)
21 | @Get(':cafeId/menus')
22 | async findAllMenuById(
23 | @Param('cafeId', ParseIntPipe) cafeId: number
24 | ): Promise {
25 | return await this.cafeService.findAllMenuById(cafeId);
26 | }
27 |
28 | @UseInterceptors(ClassSerializerInterceptor)
29 | @Get('menu/:menuId')
30 | async findOneMenuDetail(
31 | @Param('menuId', ParseIntPipe) menuId: number
32 | ): Promise {
33 | return this.cafeService.findOneMenuDetail(menuId);
34 | }
35 |
36 | @Get()
37 | @UseGuards(JwtGuard)
38 | @UseInterceptors(ClassSerializerInterceptor)
39 | async findAll(): Promise {
40 | const cafes = await this.cafeService.findAll();
41 | return cafes.map((cafe: Cafe) => new CafeResDto(cafe));
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/useFetch.tsx:
--------------------------------------------------------------------------------
1 | import { toastMessageState } from '@/stores';
2 | import { AnyObject, APIMethod } from '@/types';
3 | import axios, { AxiosError } from 'axios';
4 | import { useEffect, useState } from 'react';
5 | import { useNavigate } from 'react-router-dom';
6 | import { useSetRecoilState } from 'recoil';
7 |
8 | interface Params {
9 | url: string;
10 | method: APIMethod;
11 | data?: AnyObject;
12 | }
13 |
14 | function useFetch({ url, method, data }: Params) {
15 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
16 | const setToastMessage = useSetRecoilState(toastMessageState);
17 | const [jsonData, setJsonData] = useState({});
18 | const navigate = useNavigate();
19 |
20 | useEffect(() => {
21 | if (!api || !method || !url) return;
22 |
23 | const fetch = async () => {
24 | try {
25 | const res = await axios({
26 | method,
27 | url: `${api}${url}`,
28 | data: method !== 'get' && data,
29 | withCredentials: true,
30 | });
31 |
32 | setJsonData(res.data);
33 | } catch (err) {
34 | const { response } = err as AxiosError;
35 |
36 | if (response?.status === 401) {
37 | navigate('/');
38 | }
39 | setToastMessage('오류가 발생했습니다.\n다시 시도해주세요.');
40 | }
41 | };
42 | fetch();
43 | }, [api, url, method, data, navigate, setToastMessage]);
44 |
45 | return { jsonData };
46 | }
47 |
48 | export default useFetch;
49 |
--------------------------------------------------------------------------------
/packages/client/src/components/Footer/index.spec.tsx:
--------------------------------------------------------------------------------
1 | import { screen, fireEvent } from '@testing-library/react';
2 | import { server } from '@/mocks/server';
3 | import { setup, setupClient, setupManager } from 'utils/testSetup';
4 |
5 | beforeAll(() => server.listen());
6 | afterEach(() => server.resetHandlers());
7 | afterAll(() => server.close());
8 |
9 | describe('Footer', () => {
10 | it('(고객) Home 클릭 시 주문 내역 화면으로 전환', async () => {
11 | setupClient();
12 | setup({ url: '/' });
13 |
14 | setTimeout(async () => {
15 | fireEvent.click(await screen.findByText('Home'));
16 | await screen.findByText('주문 내역');
17 | });
18 | });
19 |
20 | it('(고객) Order 클릭 시 메뉴 내역 화면으로 전환', async () => {
21 | setupClient();
22 | setup({ url: '/' });
23 |
24 | setTimeout(async () => {
25 | fireEvent.click(await screen.findByText('Order'));
26 | await screen.findByTestId('menu-list-page');
27 | });
28 | });
29 |
30 | it('(업주) Home 클릭 시 주문 요청 내역 화면으로 전환', async () => {
31 | setupManager();
32 | setup({ url: '/' });
33 |
34 | setTimeout(async () => {
35 | fireEvent.click(await screen.findByText('새 주문'));
36 | await screen.findByText('주문 요청 내역');
37 | });
38 | });
39 |
40 | it('(공통) MY 클릭 시 마이페이지 화면으로 전환', async () => {
41 | setupClient();
42 | setup({ url: '/' });
43 |
44 | setTimeout(async () => {
45 | fireEvent.click(await screen.findByText('MY'));
46 | await screen.findByTestId('my-page');
47 | });
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/packages/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@emotion/react": "^11.10.5",
7 | "@emotion/styled": "^11.10.5",
8 | "@tanstack/react-query": "^4.19.1",
9 | "@testing-library/jest-dom": "^5.16.5",
10 | "@testing-library/react": "^13.4.0",
11 | "@testing-library/user-event": "^13.5.0",
12 | "@types/jest": "^27.5.2",
13 | "@types/node": "^16.18.3",
14 | "@types/react": "^18.0.25",
15 | "@types/react-dom": "^18.0.8",
16 | "axios": "^1.1.3",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "react-router-dom": "^6.4.3",
20 | "react-scripts": "5.0.1",
21 | "recoil": "^0.7.6",
22 | "typescript": "^4.8.4",
23 | "web-vitals": "^2.1.4"
24 | },
25 | "scripts": {
26 | "start": "craco start",
27 | "build": "craco build",
28 | "test": "craco test --watchAll=false --coverage",
29 | "eject": "react-scripts eject"
30 | },
31 | "eslintConfig": {
32 | "extends": [
33 | "react-app",
34 | "react-app/jest"
35 | ]
36 | },
37 | "browserslist": {
38 | "production": [
39 | ">0.2%",
40 | "not dead",
41 | "not op_mini all"
42 | ],
43 | "development": [
44 | "last 1 chrome version",
45 | "last 1 firefox version",
46 | "last 1 safari version"
47 | ]
48 | },
49 | "devDependencies": {
50 | "@craco/craco": "^7.0.0",
51 | "msw": "^0.48.3"
52 | },
53 | "msw": {
54 | "workerDirectory": "public"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/packages/client/src/utils/testSetup.tsx:
--------------------------------------------------------------------------------
1 | import { RecoilRoot } from 'recoil';
2 | import { MemoryRouter } from 'react-router-dom';
3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4 | import { render } from '@testing-library/react';
5 | import { rest } from 'msw';
6 |
7 | import Router from '@/Router';
8 | import Layout from '@/Layout';
9 | import UserRoleProvider from '@/UserRoleProvider';
10 | import { server } from '@/mocks/server';
11 | import Toast from '@/components/Toast';
12 |
13 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
14 |
15 | export const setup = ({ url }: { url: string }) => {
16 | const queryClient = new QueryClient();
17 |
18 | const { asFragment } = render(
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 |
33 | return { asFragment };
34 | };
35 |
36 | export const setupClient = () => {
37 | server.use(
38 | rest.get(`${api}/auth`, (req, res, next) => {
39 | return res(next.json({ role: 'CLIENT' }));
40 | })
41 | );
42 | };
43 |
44 | export const setupManager = () => {
45 | server.use(
46 | rest.get(`${api}/auth`, (req, res, next) => {
47 | return res(next.json({ role: 'MANAGER' }));
48 | })
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/packages/server/src/user/entities/user.entity.ts:
--------------------------------------------------------------------------------
1 | import { TimestampableEntity } from 'src/common/entities/common.entity';
2 | import { Order } from 'src/order/entities/order.entity';
3 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
4 | import { USER_ROLE } from '../enum/userRole.enum';
5 |
6 | @Entity()
7 | export class User extends TimestampableEntity {
8 | @PrimaryGeneratedColumn()
9 | id: number;
10 |
11 | @Column()
12 | name: string;
13 |
14 | @Column()
15 | email: string;
16 |
17 | @Column()
18 | nickname: string;
19 |
20 | @Column({
21 | type: 'enum',
22 | enum: USER_ROLE,
23 | default: USER_ROLE.CLIENT,
24 | })
25 | role: USER_ROLE;
26 |
27 | @Column({ nullable: true })
28 | corporate: string;
29 |
30 | @OneToMany(() => Order, (order) => order.user)
31 | orders: Order[];
32 |
33 | static createClient({ name, email, nickname, userRole }) {
34 | const user = new User();
35 | user.name = name;
36 | user.email = email;
37 | user.nickname = nickname;
38 | user.role = userRole;
39 | user.corporate = null;
40 | return user;
41 | }
42 |
43 | static createManager({ name, email, nickname, userRole, corporate }) {
44 | const user = new User();
45 | user.name = name;
46 | user.email = email;
47 | user.nickname = nickname;
48 | user.role = userRole;
49 | user.corporate = corporate;
50 | return user;
51 | }
52 |
53 | static byId(userId) {
54 | const user = new User();
55 | user.id = userId;
56 | return user;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuList/index.spec.tsx:
--------------------------------------------------------------------------------
1 | import { screen, fireEvent, waitFor } from '@testing-library/react';
2 | import { server } from '@/mocks/server';
3 | import { setup, setupClient } from 'utils/testSetup';
4 |
5 | beforeAll(() => server.listen());
6 | afterEach(() => server.resetHandlers());
7 | afterAll(() => server.close());
8 |
9 | describe('MenuList', () => {
10 | it('컴포넌트 검사', async () => {
11 | setupClient();
12 | setup({ url: '/menu' });
13 |
14 | const menuItems = await screen.findAllByTestId('menu-item');
15 | expect(menuItems).toHaveLength(3);
16 |
17 | screen.getByTestId('category-bar');
18 | screen.getByTestId('snack-bar');
19 | });
20 |
21 | it('카테고리 클릭시 카테고리 전환', async () => {
22 | setupClient();
23 | setup({ url: '/menu' });
24 |
25 | fireEvent.click(await screen.findByText('콜드 브루'));
26 | await waitFor(async () => {
27 | const menuItems = await screen.findAllByTestId('menu-item');
28 | expect(menuItems).toHaveLength(2);
29 | });
30 | });
31 |
32 | it('메뉴 선택시 메뉴 상세 화면으로 전환', async () => {
33 | setupClient();
34 | setup({ url: '/menu' });
35 |
36 | const menuItems = await screen.findAllByTestId('menu-item');
37 | fireEvent.click(menuItems[0]);
38 | await screen.findByTestId('menu-detail-page');
39 | });
40 |
41 | it('장바구니 클릭시 장바구니 화면으로 전환', async () => {
42 | setupClient();
43 | setup({ url: '/menu' });
44 |
45 | fireEvent.click(await screen.findByTestId('cart-button'));
46 | await screen.findByText('장바구니');
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/packages/server/src/order/dto/orderRes.dto.ts:
--------------------------------------------------------------------------------
1 | import { Exclude, Expose } from 'class-transformer';
2 | import { Cafe } from 'src/cafe/entities/cafe.entity';
3 | import { DateTimeUtil } from 'src/utils/dateTime.util';
4 | import { Order } from '../entities/order.entity';
5 | import { OrderMenu } from '../entities/orderMenu.entity';
6 | import { ORDER_STATUS } from '../enum/orderStatus.enum';
7 |
8 | export class OrderResDto {
9 | @Exclude() private readonly _id: number;
10 | @Exclude() private readonly _status: ORDER_STATUS;
11 | @Exclude() private readonly _date: Date;
12 | @Exclude() private readonly _orderMenus: OrderMenu[];
13 |
14 | constructor(order: Order) {
15 | this._id = order.id;
16 | this._status = order.status;
17 | this._date = order.created_at;
18 | this._orderMenus = order.orderMenus;
19 | }
20 |
21 | @Expose()
22 | get id(): number {
23 | return this._id;
24 | }
25 |
26 | @Expose()
27 | get status(): ORDER_STATUS {
28 | return this._status;
29 | }
30 |
31 | @Expose()
32 | get date(): string {
33 | return DateTimeUtil.toString(this._date);
34 | }
35 |
36 | @Expose()
37 | get menus(): any[] {
38 | const menus = this._orderMenus.map((orderMenu) => {
39 | return {
40 | id: orderMenu.menu.id,
41 | name: orderMenu.menu.name,
42 | price: orderMenu.price,
43 | options: JSON.parse(orderMenu.options),
44 | count: orderMenu.count,
45 | size: orderMenu.size,
46 | type: orderMenu.type,
47 | };
48 | });
49 | return menus;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/client/src/components/OrderDetailList/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | flex-direction: column;
6 | justify-content: space-between;
7 | gap: 1rem;
8 | margin: 0.5rem 0;
9 | padding: 1.5rem;
10 | border-radius: 10px;
11 | background-color: ${({ theme }) => theme.colors.fourth};
12 | `;
13 |
14 | export const ItemContainer = styled.div`
15 | display: flex;
16 | justify-content: space-between;
17 | align-items: center;
18 | `;
19 |
20 | export const OptionContainer = styled.div`
21 | padding: 0.3rem 0;
22 | `;
23 |
24 | export const DivisionLine = styled.div`
25 | height: 1px;
26 | /*
27 | Cusomize CSS Border
28 | https://kovart.github.io/dashed-border-generator/
29 | */
30 | background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' stroke='black' stroke-width='4' stroke-dasharray='6%2c 14' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e");
31 | `;
32 |
33 | export const PriceText = styled.p`
34 | margin: 0 0 0 0.7rem;
35 | white-space: nowrap;
36 | `;
37 |
38 | export const ButtonContainer = styled.div`
39 | display: flex;
40 | justify-content: flex-end;
41 | gap: 1rem;
42 | `;
43 |
44 | export const OptionText = styled.p<{ isOption?: boolean }>`
45 | padding: ${(props) => (props.isOption ? '0' : '0.2rem')} 0;
46 | font-size: ${(props) => props.theme.font.size.xs};
47 | color: ${(props) => props.theme.colors.grey400};
48 | `;
49 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/SizeSelector/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import { Size } from '@/types';
4 | import { ReactComponent as SizeSVG } from 'icons/size.svg';
5 |
6 | export const Container = styled.section`
7 | display: flex;
8 | justify-content: space-around;
9 | align-items: center;
10 | margin: 5%;
11 | `;
12 |
13 | export const ItemContainer = styled.div<{ isSelected: boolean }>`
14 | display: flex;
15 | flex-direction: column;
16 | justify-content: flex-end;
17 | align-items: center;
18 | gap: 0.2rem;
19 | padding: 0.5rem;
20 | width: 28%;
21 | height: 5rem;
22 | border-radius: 10px;
23 | text-align: center;
24 | border: ${(props) => props.theme.border.default};
25 | border-width: 2px;
26 | border-color: ${(props) =>
27 | props.isSelected ? props.theme.colors.primary : props.theme.colors.grey200};
28 | cursor: pointer;
29 | `;
30 |
31 | export const SizeText = styled.p`
32 | font-size: ${(props) => props.theme.font.size.md};
33 | `;
34 |
35 | export const VolumeText = styled.p`
36 | font-size: ${(props) => props.theme.font.size.xs};
37 | color: ${(props) => props.theme.colors.grey600};
38 | `;
39 |
40 | export const SizeIcon = styled(SizeSVG)<{ size: Size }>`
41 | width: ${(props) =>
42 | props.size === 'tall'
43 | ? '1rem'
44 | : props.size === 'grande'
45 | ? '1.5rem'
46 | : '2rem'};
47 | height: ${(props) =>
48 | props.size === 'tall'
49 | ? '1rem'
50 | : props.size === 'grande'
51 | ? '1.5rem'
52 | : '2rem'};
53 | `;
54 |
--------------------------------------------------------------------------------
/packages/client/src/mocks/handlers/order.ts:
--------------------------------------------------------------------------------
1 | import { rest } from 'msw';
2 | import {
3 | orderListData,
4 | requestedOrderData,
5 | orderStatusData,
6 | acceptedOrderData,
7 | } from '@/mocks/data/order';
8 |
9 | const api = process.env.REACT_APP_API_SERVER_BASE_URL;
10 |
11 | export const orderHandlers = [
12 | rest.get(`${api}/order`, (req, res, next) => {
13 | return res(next.json(orderListData));
14 | }),
15 | rest.get(`${api}/order/requested`, (req, res, next) => {
16 | return res(next.json(requestedOrderData));
17 | }),
18 | rest.get(`${api}/order/accepted`, (req, res, next) => {
19 | return res(next.json(acceptedOrderData));
20 | }),
21 | rest.post(`${api}/order/accepted`, (req, res, next) => {
22 | return res(next.status(200));
23 | }),
24 | rest.post(`${api}/order/rejected`, (req, res, next) => {
25 | return res(next.status(200));
26 | }),
27 | rest.post(`${api}/order/completed`, (req, res, next) => {
28 | return res(next.status(200));
29 | }),
30 | rest.post(`${api}/order`, (req, res, next) => {
31 | return res(next.status(201));
32 | }),
33 | rest.get(`${api}/order/1`, (req, res, next) => {
34 | return res(next.json(orderStatusData.REQUESTED));
35 | }),
36 | rest.get(`${api}/order/2`, (req, res, next) => {
37 | return res(next.json(orderStatusData.ACCEPTED));
38 | }),
39 | rest.get(`${api}/order/3`, (req, res, next) => {
40 | return res(next.json(orderStatusData.COMPLETED));
41 | }),
42 | rest.get(`${api}/order/4`, (req, res, next) => {
43 | return res(next.json(orderStatusData.REJECTED));
44 | }),
45 | ];
46 |
--------------------------------------------------------------------------------
/packages/client/src/mocks/data/menu.ts:
--------------------------------------------------------------------------------
1 | export const menuDetailData = {
2 | id: 1,
3 | name: '자몽 허니 블랙 티',
4 | description:
5 | '새콤한 자몽과 달콤한 꿀이 깊고 그윽한 풍미의 스타벅스 티바나 블랙 티의 조합',
6 | price: 5700,
7 | thumbnail:
8 | 'https://www.istarbucks.co.kr/upload/store/skuimg/2021/04/[9200000000187]_20210419131229539.jpg',
9 | options: [
10 | {
11 | id: 1,
12 | name: '1',
13 | price: 500,
14 | category: '에스프레소 샷',
15 | },
16 | {
17 | id: 2,
18 | name: '2',
19 | price: 1000,
20 | category: '에스프레소 샷',
21 | },
22 | {
23 | id: 3,
24 | name: '3',
25 | price: 1500,
26 | category: '에스프레소 샷',
27 | },
28 | {
29 | id: 4,
30 | name: '1',
31 | price: 500,
32 | category: '클래식 시럽',
33 | },
34 | {
35 | id: 5,
36 | name: '2',
37 | price: 1000,
38 | category: '클래식 시럽',
39 | },
40 | ],
41 | };
42 |
43 | export const menuListData = {
44 | id: 1,
45 | cafe_name: 'Cafe name 1',
46 | menus: [
47 | {
48 | id: 1,
49 | name: '카페 아메리카노',
50 | thumbnail:
51 | 'https://www.istarbucks.co.kr/upload/store/skuimg/2022/10/[9200000004312]_20221005145029134.jpg',
52 | price: 1000,
53 | category: '아메리카노',
54 | },
55 | {
56 | id: 2,
57 | name: 'Menu name 2',
58 | thumbnail: 'Menu Thumbnail',
59 | price: 1000,
60 | category: '콜드 브루',
61 | },
62 | {
63 | id: 3,
64 | name: 'Menu name 3',
65 | thumbnail: 'Menu Thumbnail',
66 | price: 1000,
67 | category: '콜드 브루',
68 | },
69 | ],
70 | };
71 |
--------------------------------------------------------------------------------
/packages/client/src/components/Footer/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { ReactComponent as HomeSVG } from 'icons/home.svg';
3 | import { ReactComponent as OrderSVG } from 'icons/order.svg';
4 | import { ReactComponent as MypageSVG } from 'icons/mypage.svg';
5 |
6 | export const FooterWrapper = styled.footer`
7 | position: fixed;
8 | bottom: 0;
9 | width: 100%;
10 | min-width: 320px;
11 | max-width: 480px;
12 | height: 3rem;
13 | background-color: white;
14 | box-shadow: 0px 0px 4px rgba(204, 204, 204, 0.5),
15 | 0px 0px 4px rgba(0, 0, 0, 0.25);
16 | `;
17 |
18 | export const NavWrapper = styled.nav`
19 | display: flex;
20 | flex-direction: row;
21 | justify-content: space-evenly;
22 | align-items: center;
23 | width: 100%;
24 | height: 100%;
25 | `;
26 |
27 | export const NavItem = styled.div`
28 | text-align: center;
29 | padding: 0 1rem;
30 | cursor: pointer;
31 |
32 | p {
33 | color: ${(props) => props.theme.colors.tertiary};
34 | font-size: ${(props) => props.theme.font.size.xs};
35 | }
36 |
37 | path {
38 | fill: ${(props) => props.theme.colors.tertiary};
39 | }
40 |
41 | &.selected {
42 | p {
43 | color: ${(props) => props.theme.colors.primary};
44 | }
45 |
46 | path {
47 | fill: ${(props) => props.theme.colors.primary};
48 | }
49 | }
50 | `;
51 |
52 | export const Home = styled(HomeSVG)`
53 | width: 1rem;
54 | height: 1rem;
55 | `;
56 |
57 | export const Order = styled(OrderSVG)`
58 | width: 1rem;
59 | height: 1rem;
60 | `;
61 |
62 | export const Mypage = styled(MypageSVG)`
63 | width: 1rem;
64 | height: 1rem;
65 | `;
66 |
--------------------------------------------------------------------------------
/packages/client/src/assets/icons/size.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/client/src/hooks/useOrderDates.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { AnyObject, Order, OrderStatusCode } from '@/types';
4 |
5 | interface Params {
6 | list: Order[];
7 | status?: OrderStatusCode[];
8 | }
9 |
10 | const d = new Date();
11 |
12 | function useOrderGroup({ list, status }: Params) {
13 | const [orderGroup, setOrderGroup] = useState({});
14 | const [today] = useState(
15 | `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
16 | );
17 |
18 | useEffect(() => {
19 | if (!list || !status) return;
20 |
21 | let result;
22 |
23 | if (!status.includes('COMPLETED')) {
24 | result = list.reduce((prev: AnyObject, curr) => {
25 | const currStatus = curr.status;
26 |
27 | if (!status.includes(currStatus)) {
28 | return { ...prev };
29 | }
30 | return {
31 | ...prev,
32 | '현재 진행중인 주문': [
33 | { ...curr },
34 | ...(prev['현재 진행중인 주문'] ?? []),
35 | ],
36 | };
37 | }, {});
38 | } else {
39 | result = list.reduce((prev: AnyObject, curr) => {
40 | const date = curr.date.slice(0, 10);
41 | const day = curr.date.slice(17, 18);
42 |
43 | const key = date === today ? '오늘' : `${date} (${day})`;
44 |
45 | if (curr.status === 'COMPLETED') {
46 | return { ...prev, [key]: [{ ...curr }, ...(prev[key] ?? [])] };
47 | }
48 | return { ...prev };
49 | }, {});
50 | }
51 |
52 | setOrderGroup({ ...result });
53 | }, [list, status, today]);
54 |
55 | return { orderGroup };
56 | }
57 |
58 | export default useOrderGroup;
59 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/TemperatureSelector/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import TemperatureSelector from '.';
3 | import Layout from '@/Layout';
4 | import { Temperature } from '@/types';
5 | import MenuDetailContextProvider from '@/stores/MenuDetail';
6 |
7 | const setup = ({ temperature }: { temperature: Temperature }) => {
8 | const { asFragment } = render(
9 |
10 |
11 |
12 |
13 |
14 | );
15 |
16 | return { asFragment };
17 | };
18 |
19 | describe('음료 타입(핫, 아이스) 선택 컴포넌트', () => {
20 | it('요소 존재 여부', () => {
21 | setup({ temperature: 'hot' });
22 |
23 | screen.getByText(/hot/i);
24 | screen.getByText(/iced/i);
25 | });
26 |
27 | it('HOT 선택됨', () => {
28 | setup({ temperature: 'hot' });
29 |
30 | const btnHot = screen.getByText(/hot/i);
31 | expect(btnHot).toHaveStyle('background-color: red;');
32 | });
33 |
34 | it('ICED 선택됨', () => {
35 | setup({ temperature: 'iced' });
36 |
37 | const btnIced = screen.getByText(/iced/i);
38 |
39 | expect(btnIced).toHaveStyle('background-color: blue;');
40 | });
41 |
42 | // it('타입 변경 함수 실행', () => {
43 | // const { handleClickType } = setup({ type: 'hot' });
44 |
45 | // const btnIced = screen.getByText(/iced/i);
46 | // fireEvent.click(btnIced);
47 |
48 | // expect(handleClickType).toBeCalled();
49 | // });
50 |
51 | it('스냅샷', () => {
52 | const { asFragment } = setup({ temperature: 'hot' });
53 |
54 | expect(asFragment()).toMatchSnapshot();
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/SizeSelector/index.test.tsx:
--------------------------------------------------------------------------------
1 | import Layout from '@/Layout';
2 | import MenuDetailContextProvider from '@/stores/MenuDetail';
3 | import { Size } from '@/types';
4 | import { render, screen } from '@testing-library/react';
5 | import SizeSelector from '.';
6 |
7 | interface Setup {
8 | size: Size;
9 | }
10 |
11 | const setup = ({ size }: Setup) => {
12 | const { asFragment } = render(
13 |
14 |
15 |
16 |
17 |
18 | );
19 |
20 | return { asFragment };
21 | };
22 |
23 | describe('음료 용량 선택 컴포넌트', () => {
24 | it('요소 존재 여부', () => {
25 | setup({ size: 'tall' });
26 |
27 | screen.getByTitle(/tall/);
28 | screen.getByTitle(/grande/);
29 | screen.getByTitle(/venti/);
30 | });
31 |
32 | it('tall 선택됨', () => {
33 | setup({ size: 'tall' });
34 |
35 | const btnTall = screen.getByTitle(/tall/);
36 | expect(btnTall).toHaveStyle('border-color: #567F72');
37 | });
38 |
39 | it('grande 선택됨', () => {
40 | setup({ size: 'grande' });
41 |
42 | const btnGrande = screen.getByTitle(/grande/);
43 | expect(btnGrande).toHaveStyle('border-color: #567F72');
44 | });
45 |
46 | // it('용량 변경 함수 실행', () => {
47 | // const { handleClickSize } = setup({ size: 'tall' });
48 |
49 | // const btnGrande = screen.getByTitle(/grande/);
50 | // fireEvent.click(btnGrande);
51 |
52 | // expect(handleClickSize).toBeCalled();
53 | // });
54 |
55 | it('스냅샷', () => {
56 | const { asFragment } = setup({ size: 'tall' });
57 |
58 | expect(asFragment()).toMatchSnapshot();
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/packages/client/src/components/CountSelector/index.tsx:
--------------------------------------------------------------------------------
1 | import { toastMessageState } from '@/stores';
2 | import React from 'react';
3 | import { useSetRecoilState } from 'recoil';
4 | import { Container, Minus, Plus } from './styled';
5 |
6 | interface Listener {
7 | onClick: (count: number) => void;
8 | }
9 |
10 | export interface Props {
11 | count: number;
12 | svgWidth?: number;
13 | svgHeight?: number;
14 | }
15 |
16 | function CountSelector({
17 | count,
18 | svgWidth = 1,
19 | svgHeight = 1,
20 | onClick,
21 | }: Props & Listener) {
22 | const setToastMessage = useSetRecoilState(toastMessageState);
23 |
24 | const handleClickCount = (event: React.MouseEvent) => {
25 | const name = event.currentTarget.getAttribute('name');
26 |
27 | if (count === 1 && name === 'minus')
28 | return setToastMessage('최소 1개 이상 주문 가능합니다.');
29 | if (count === 20 && name === 'plus')
30 | return setToastMessage('최대 20개 주문 가능합니다.');
31 |
32 | if (name === 'minus') onClick(count - 1);
33 | else onClick(count + 1);
34 | };
35 |
36 | return (
37 |
38 | <>
39 |
47 | >
48 | {count}
49 | <>
50 |
58 | >
59 |
60 | );
61 | }
62 |
63 | export default CountSelector;
64 |
--------------------------------------------------------------------------------
/packages/client/src/components/OrderList/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import { ReactComponent as ReceiptSVG } from 'icons/receipt.svg';
4 | import { ReactComponent as DownArrowSVG } from 'icons/down_arrow.svg';
5 |
6 | export const Container = styled.section`
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: center;
10 | gap: 0.7rem;
11 | margin: 0.7rem 0;
12 | `;
13 |
14 | export const ItemContainer = styled.div`
15 | display: flex;
16 | flex-direction: column;
17 | justify-content: center;
18 | padding: 0.3rem 0.8rem;
19 | cursor: pointer;
20 |
21 | border-radius: 10px;
22 | box-shadow: 0px 0px 10px 2px rgba(204, 204, 204, 0.5);
23 |
24 | transition: box-shadow, transform;
25 | transition-duration: 0.5s;
26 |
27 | &:hover {
28 | box-shadow: 0px 0px 10px 2px grey;
29 | transform: scale(1.01);
30 | }
31 | `;
32 |
33 | export const Overview = styled.div`
34 | display: flex;
35 | justify-content: space-between;
36 | align-items: center;
37 | `;
38 |
39 | export const RowContainer = styled.div`
40 | display: flex;
41 | align-items: center;
42 | padding: 0.5rem 0.5rem;
43 | cursor: pointer;
44 | `;
45 |
46 | export const Receipt = styled(ReceiptSVG)`
47 | margin-right: 10px;
48 | min-width: 1.5rem;
49 | `;
50 |
51 | export const DownArrow = styled(DownArrowSVG)`
52 | margin-left: 10px;
53 | cursor: pointer;
54 | `;
55 |
56 | export const PriceText = styled.p`
57 | margin: 0 0 0 0.7rem;
58 | white-space: nowrap;
59 | `;
60 |
61 | export const OrderIdText = styled.p`
62 | padding: 0.5rem 0 0 0.6rem;
63 | font-size: ${({ theme }) => theme.font.size.xs};
64 | color: ${({ theme }) => theme.colors.grey800};
65 | `;
66 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/Cart/components/CartItem/styled.ts:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { ReactComponent as DeleteButtonSVG } from 'icons/x_icon.svg';
3 |
4 | export const CartItemWrapper = styled.li`
5 | display: flex;
6 | flex-direction: row;
7 | gap: 0.5rem;
8 | align-items: center;
9 | position: relative;
10 | width: 100%;
11 | padding: 1rem 1rem 1rem 1rem;
12 | border-bottom: 1px solid ${(props) => props.theme.colors.grey200};
13 | `;
14 |
15 | export const MenuImg = styled.img`
16 | width: 4rem;
17 | border-radius: 50%;
18 | `;
19 |
20 | export const MenuInfoWrapper = styled.div`
21 | display: flex;
22 | flex-direction: column;
23 | gap: 0.5rem;
24 | flex: 1;
25 |
26 | p {
27 | font-size: ${(props) => props.theme.font.size.sm};
28 | }
29 |
30 | p.menu-name {
31 | margin-right: 0.8rem;
32 | }
33 | `;
34 |
35 | export const OptionPriceWrapper = styled.div`
36 | display: flex;
37 | flex-direction: row;
38 | align-items: center;
39 | justify-content: space-between;
40 |
41 | p {
42 | font-size: ${(props) => props.theme.font.size.xs};
43 | color: ${(props) => props.theme.colors.grey400};
44 | }
45 | `;
46 |
47 | export const MenuOptionWrapper = styled.div`
48 | display: flex;
49 | flex-direction: column;
50 | `;
51 |
52 | export const CountWrapper = styled.div`
53 | display: flex;
54 | flex-direction: row;
55 | justify-content: space-between;
56 |
57 | p {
58 | font-weight: ${(props) => props.theme.font.weight.bold700};
59 | }
60 | `;
61 |
62 | export const DeleteButton = styled(DeleteButtonSVG)`
63 | position: absolute;
64 | top: 0.4rem;
65 | right: 0.4rem;
66 | width: 1rem;
67 | height: 1rem;
68 | `;
69 |
--------------------------------------------------------------------------------
/packages/client/src/index.css:
--------------------------------------------------------------------------------
1 | /* reset.css */
2 | /* http://meyerweb.com/eric/tools/css/reset/
3 | v2.0 | 20110126
4 | License: none (public domain)
5 | */
6 |
7 | html, body, div, span, applet, object, iframe,
8 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
9 | a, abbr, acronym, address, big, cite, code,
10 | del, dfn, em, img, ins, kbd, q, s, samp,
11 | small, strike, strong, sub, sup, tt, var,
12 | b, u, i, center,
13 | dl, dt, dd, ol, ul, li,
14 | fieldset, form, label, legend,
15 | table, caption, tbody, tfoot, thead, tr, th, td,
16 | article, aside, canvas, details, embed,
17 | figure, figcaption, footer, header, hgroup,
18 | menu, nav, output, ruby, section, summary,
19 | time, mark, audio, video {
20 | margin: 0;
21 | padding: 0;
22 | border: 0;
23 | font-size: 100%;
24 | font: inherit;
25 | vertical-align: baseline;
26 | box-sizing: border-box;
27 | }
28 | /* HTML5 display-role reset for older browsers */
29 | article, aside, details, figcaption, figure,
30 | footer, header, hgroup, menu, nav, section {
31 | display: block;
32 | }
33 | body {
34 | line-height: 1;
35 | }
36 | ol, ul {
37 | list-style: none;
38 | }
39 | blockquote, q {
40 | quotes: none;
41 | }
42 | blockquote:before, blockquote:after,
43 | q:before, q:after {
44 | content: '';
45 | content: none;
46 | }
47 | table {
48 | border-collapse: collapse;
49 | border-spacing: 0;
50 | }
51 |
52 | /* 공통 css */
53 | html {
54 | font-size: 18px;
55 | }
56 |
57 | html, button, input {
58 | font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
59 | }
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuList/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import Header from '@/components/Header';
4 | import Footer from '@/components/Footer';
5 | import SnackBar from './components/SnackBar';
6 | import MenuItem from './components/MenuItem';
7 |
8 | import { Menu } from '@/types';
9 | import useMenuListData from '@/hooks/useMenuListData';
10 |
11 | import {
12 | CategoryBarWrapper,
13 | CategoryItem,
14 | MenuListPageWrapper,
15 | MenuListWrapper,
16 | } from './styled';
17 |
18 | function MenuList() {
19 | const [category, setCategory] = useState('전체');
20 | const { menuList, categoryList } = useMenuListData();
21 |
22 | return (
23 |
24 |
25 |
26 | {categoryList?.map((category, idx) => (
27 | setCategory(category)}>
28 | {category}
29 |
30 | ))}
31 |
32 |
33 | {menuList
34 | ?.filter((menu: Menu) => {
35 | if (category === '전체') return true;
36 | return menu.category === category;
37 | })
38 | .map((menu: Menu) => (
39 |
47 | ))}
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | export default MenuList;
56 |
--------------------------------------------------------------------------------
/packages/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
15 |
16 |
17 |
26 | 부따
27 |
28 |
29 |
30 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/MenuDetail/components/SizeSelector/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`음료 용량 선택 컴포넌트 스냅샷 1`] = `
4 |
5 |
8 |
11 |
14 |
18 |
23 |
26 | Tall
27 |
28 |
31 | 355ml
32 |
33 |
34 |
38 |
43 |
46 | Grande
47 |
48 |
51 | 473ml
52 |
53 |
54 |
58 |
63 |
66 | Venti
67 |
68 |
71 | 591ml
72 |
73 |
74 |
75 |
76 |
77 |
78 | `;
79 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/cafe.service.ts:
--------------------------------------------------------------------------------
1 | import { Menu } from './entities/menu.entity';
2 | import { Injectable, BadRequestException } from '@nestjs/common';
3 | import { InjectRepository } from '@nestjs/typeorm';
4 | import { Repository } from 'typeorm';
5 | import { Cafe } from './entities/cafe.entity';
6 | import { CafeMenuResDto } from './dto/CafeMenuRes.dto';
7 | import { MenuDetailResDto } from './dto/MenuDetailRes.dto';
8 |
9 | @Injectable()
10 | export class CafeService {
11 | constructor(
12 | @InjectRepository(Cafe) private cafeRepository: Repository,
13 | @InjectRepository(Menu) private menuRepository: Repository
}>
21 |
22 | {/* 비인가 사용자 */}
23 |
24 | {userRole === 'UNAUTH' && (
25 | <>
26 | } />
27 | } />
28 | >
29 | )}
30 |
31 | {/* 인가 사용자 */}
32 | {userRole !== 'UNAUTH' && (
33 | <>
34 | } />
35 | } />
36 | >
37 | )}
38 |
39 | {/* 고객용 */}
40 | {userRole === 'CLIENT' && (
41 | <>
42 | } />
43 | } />
44 | } />
45 | } />
46 | >
47 | )}
48 |
49 | {/* 업주용 */}
50 | {userRole === 'MANAGER' && (
51 | <>
52 | } />
53 | >
54 | )}
55 |
56 | {/* 404 Error */}
57 | 404 ERROR} />
58 |
59 |
60 | );
61 | }
62 |
63 | export default Router;
64 |
--------------------------------------------------------------------------------
/packages/server/src/cafe/mock/mockDataGenerator.ts:
--------------------------------------------------------------------------------
1 | import { MENU_SIZE, SIZE_PRICE } from '../enum/menuSize.enum';
2 | import { MENU_TYPE } from '../enum/menuType.enum';
3 | import { mockMenus } from './menu.entity.mock';
4 | import { mockMenuOptionRelation } from './menuOptionRelation.mock';
5 | import { mockOptions } from './option.entity.mock';
6 | /**
7 | * @Menu
8 | */
9 |
10 | export const getCorrectMenuId = () => {
11 | const menuIds = Object.keys(mockMenus);
12 | const randIdx = Math.floor(Math.random() * menuIds.length);
13 | return parseInt(menuIds[randIdx]);
14 | };
15 |
16 | export const getWrongMenuId = () => {
17 | return -1;
18 | };
19 |
20 | export const getCorrectMenuType = (menuId) => {
21 | return mockMenus[menuId].type;
22 | };
23 |
24 | export const getWorngMenuType = (menuId) => {
25 | const menuType = mockMenus[menuId].type;
26 | if (menuType === MENU_TYPE.HOT) return MENU_TYPE.ICED;
27 | if (menuType === MENU_TYPE.ICED) return MENU_TYPE.HOT;
28 | };
29 |
30 | export const getCorrectMenuPrice = (menuId) => {
31 | return mockMenus[menuId].price;
32 | };
33 |
34 | export const getWrongMenuPrice = (menuId) => {
35 | return mockMenus[menuId].price + 500;
36 | };
37 |
38 | export const getCorrectTotalPrice = (menuId, options, menuSize) => {
39 | const optionTotalPrice = options.reduce((sum, optionId) => {
40 | return sum + mockOptions[optionId].price;
41 | }, 0);
42 | return (
43 | mockMenus[menuId].price + optionTotalPrice + parseInt(SIZE_PRICE[menuSize])
44 | );
45 | };
46 |
47 | export const getCorrectOptions = (menuId) => {
48 | return mockMenuOptionRelation[menuId];
49 | };
50 |
51 | export const getWrongOptions = () => {
52 | return [-1, -2];
53 | };
54 |
55 | export const getMockMenuOptions = (menuIds: Array) => {
56 | const mockMenuOptions = [];
57 | Object.keys(mockMenuOptionRelation).map((menuId) => {
58 | if (!menuIds.includes(parseInt(menuId))) return;
59 | mockMenuOptionRelation[menuId].map((optionId) => {
60 | const newId = Object.keys(mockMenuOptions).length + 1;
61 | mockMenuOptions.push({
62 | id: newId,
63 | menu: mockMenus[menuId],
64 | option: mockOptions[optionId],
65 | });
66 | });
67 | });
68 | return mockMenuOptions;
69 | };
70 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/Cart/index.spec.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, screen } from '@testing-library/react';
2 | import { server } from '@/mocks/server';
3 | import { setup, setupClient } from 'utils/testSetup';
4 | import { cartData } from '@/mocks/data/cart';
5 |
6 | beforeAll(() => server.listen());
7 | afterEach(() => server.resetHandlers());
8 | afterAll(() => server.close());
9 |
10 | describe('Cart', () => {
11 | it('장바구니가 비었을 때 컴포넌트 검사 -> 메뉴 리스트 페이지로 이동', async () => {
12 | setupClient();
13 | setup({ url: '/cart' });
14 |
15 | await screen.findByTestId('cart-content');
16 | const menuButton = await screen.findByText('메뉴 담으러 가기');
17 | await screen.findByText('주문하기');
18 |
19 | setTimeout(async () => {
20 | fireEvent.click(menuButton);
21 | await screen.findByTestId('menu-list-page');
22 | });
23 | });
24 |
25 | it('장바구니가 있을 때 컴포넌트 검사', async () => {
26 | setupClient();
27 | localStorage.setItem('buddhaCart', JSON.stringify(cartData));
28 | setup({ url: '/cart' });
29 |
30 | await screen.findByTestId('cart-content');
31 | const cartItems = await screen.findAllByTestId('cart-item');
32 | expect(cartItems).toHaveLength(1);
33 | await screen.findByText('주문하기');
34 | });
35 |
36 | it('+/-/x 버튼검사', async () => {
37 | setupClient();
38 | localStorage.setItem('buddhaCart', JSON.stringify(cartData));
39 | setup({ url: '/cart' });
40 |
41 | const plusButton = await screen.findByTestId('plus');
42 | const minusButton = await screen.findByTestId('minus');
43 | const deleteButton = await screen.findByTestId('delete');
44 |
45 | fireEvent.click(plusButton);
46 | await screen.findByText('담은 상품 3개');
47 |
48 | fireEvent.click(minusButton);
49 | await screen.findByText('담은 상품 2개');
50 |
51 | fireEvent.click(deleteButton);
52 | await screen.findByText('메뉴 담으러 가기');
53 |
54 | await screen.findByText('주문하기');
55 | });
56 |
57 | it('주문하기 동작 검사', async () => {
58 | setupClient();
59 | localStorage.setItem('buddhaCart', JSON.stringify(cartData));
60 | setup({ url: '/cart' });
61 |
62 | const orderButton = await screen.findByText('주문하기');
63 | fireEvent.click(orderButton);
64 | await screen.findByText('주문 내역');
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/packages/client/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/packages/client/src/types/index.ts:
--------------------------------------------------------------------------------
1 | // Type
2 | export type APIMethod = 'GET' | 'get' | 'POST' | 'post';
3 | export type UserRole = 'CLIENT' | 'MANAGER' | 'UNAUTH';
4 | export type Temperature = 'hot' | 'iced';
5 | export type Size = 'tall' | 'grande' | 'venti';
6 | export type OrderStatusCode =
7 | | 'REQUESTED'
8 | | 'ACCEPTED'
9 | | 'REJECTED'
10 | | 'COMPLETED';
11 | export type AnyObject = { [key: string]: any };
12 |
13 | // Signin.ts
14 | export interface ChkUser {
15 | code: string;
16 | state: string;
17 | }
18 |
19 | // signup.ts
20 | // 확인 필요
21 | export interface SignupRequestBody {
22 | userRole: UserRole;
23 | nickname: string;
24 | corporate?: string;
25 | }
26 |
27 | // cart.ts
28 | export interface CartMenu {
29 | id: number;
30 | name: string;
31 | type: string;
32 | size: string;
33 | count: number;
34 | price: number;
35 | thumbnail: string;
36 | options: MenuOption[];
37 | }
38 |
39 | export interface MenuOption {
40 | id: number;
41 | name: string;
42 | price: number;
43 | category: string;
44 | }
45 |
46 | // menulist.ts
47 | export interface Menu {
48 | id: number;
49 | name: string;
50 | thumbnail: string;
51 | price: number;
52 | category: string;
53 | }
54 |
55 | export interface CafeMenu {
56 | id: number;
57 | cafe_name: string;
58 | menus: Menu[];
59 | }
60 |
61 | // menudetail.ts
62 | export interface MenuInfo {
63 | id: number;
64 | name: string;
65 | description: string;
66 | price: number;
67 | thumbnail: string;
68 | options: Option[];
69 | }
70 |
71 | export interface Category {
72 | [key: string]: Option[];
73 | }
74 |
75 | export interface Option {
76 | id: number;
77 | name: string;
78 | price: number;
79 | category: string;
80 | }
81 |
82 | export interface Options {
83 | [key: string]: string | undefined;
84 | }
85 |
86 | // OrderDetailList.tsx
87 | export interface OrderDetailMenu {
88 | id: number;
89 | name: string;
90 | options: any;
91 | price: number;
92 | count?: number;
93 | thumbnail?: string;
94 | type?: Temperature;
95 | size?: Size;
96 | }
97 |
98 | // OrderList.tsx
99 | export interface Order {
100 | id: number;
101 | cafeId: number;
102 | date: string;
103 | menus: OrderDetailMenu[];
104 | status: OrderStatusCode;
105 | }
106 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/Cart/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { getCart, getCartCount, getCartPrice } from 'utils/localStorage';
3 | import CartFooter from '@/pages/customer/Cart/components/CartFooter';
4 | import {
5 | CartPageWrapper,
6 | CartHeader,
7 | CartContentWrapper,
8 | FixedHeader,
9 | } from './styled';
10 | import { CartMenu } from '@/types';
11 | import CartItem from '@/pages/customer/Cart/components/CartItem';
12 | import { CART_KEY } from '@/constants';
13 | import EmptyCart from '@/pages/customer/Cart/components/EmptyCart';
14 | import LeftArrow from '@/components/LeftArrow';
15 |
16 | function Cart() {
17 | const [cart, setCart] = useState(getCart());
18 | const [cartCount, setCartCount] = useState(getCartCount());
19 | const [cartPrice, setCartPrice] = useState(getCartPrice());
20 |
21 | const setCount = (idx: number, count: number) => {
22 | let newCart = [...cart];
23 | newCart[idx].count = count;
24 | setNewCart(newCart);
25 | };
26 |
27 | const deleteMenu = (idx: number) => {
28 | let newCart = cart.filter((menu, index) => index !== idx);
29 | setNewCart(newCart);
30 | };
31 |
32 | const setNewCart = (newCart: CartMenu[]) => {
33 | setCart(newCart);
34 | localStorage.setItem(CART_KEY, JSON.stringify(newCart));
35 | setCartCount(getCartCount());
36 | setCartPrice(getCartPrice());
37 | };
38 |
39 | return (
40 |
41 |
42 |
43 |
44 |
45 | 장바구니
46 | 주문할 매장을 선택해주세요
47 |
48 |
49 | 담은 상품 {cartCount}개
50 | {cartCount > 0 ? (
51 |
52 | {cart.map((menu, idx) => (
53 |
59 | ))}
60 |
61 | ) : (
62 |
63 | )}
64 |
65 |
66 |
67 | );
68 | }
69 |
70 | export default Cart;
71 |
--------------------------------------------------------------------------------
/packages/server/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { getMySQLTestTypeOrmModule } from 'src/utils/getMySQLTestTypeOrmModule';
2 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
3 | import { routeTable } from './config/route';
4 | import { TypeOrmModule } from '@nestjs/typeorm';
5 | import { UserModule } from './user/user.module';
6 | import { OrderModuleV1 } from './order/order.v1.module';
7 | import { CafeModule } from './cafe/cafe.module';
8 | import { AuthModule } from './auth/auth.module';
9 |
10 | import { ConfigModule, ConfigService } from '@nestjs/config';
11 | import { RouterModule } from '@nestjs/core';
12 | import { LoggerMiddleware } from './middleware/logger.http';
13 | import { DataSource } from 'typeorm';
14 | import { OrderModuleV2 } from './order/order.v2.module';
15 | import { OrderModuleV3 } from './order/order.v3.module';
16 |
17 | @Module({
18 | imports: [
19 | ConfigModule.forRoot({
20 | isGlobal: true,
21 | envFilePath:
22 | process.env.NODE_ENV === 'development'
23 | ? '.dev.env'
24 | : process.env.NODE_ENV === 'test'
25 | ? '.test.env'
26 | : '.prod.env',
27 | }),
28 | process.env.NODE_ENV === 'test'
29 | ? getMySQLTestTypeOrmModule()
30 | : TypeOrmModule.forRootAsync({
31 | imports: [ConfigModule],
32 | useFactory: (configService: ConfigService) => {
33 | return {
34 | type: 'mysql',
35 | host: configService.get('MYSQL_HOST'),
36 | port: parseInt(configService.get('MYSQL_PORT')),
37 | username: configService.get('MYSQL_USERNAME'),
38 | password: configService.get('MYSQL_PASSWORD'),
39 | database: configService.get('MYSQL_DATABASE'),
40 | entities: ['dist/**/*.entity{.ts,.js}'],
41 | synchronize: configService.get('NODE_ENV') === 'development',
42 | timezone: 'UTC',
43 | };
44 | },
45 | inject: [ConfigService],
46 | }),
47 | RouterModule.register([routeTable]),
48 | UserModule,
49 | OrderModuleV1,
50 | OrderModuleV2,
51 | OrderModuleV3,
52 | CafeModule,
53 | AuthModule,
54 | ],
55 | controllers: [],
56 | providers: [],
57 | })
58 | export class AppModule implements NestModule {
59 | constructor(private dataSource: DataSource) {}
60 | configure(consumer: MiddlewareConsumer) {
61 | consumer.apply(LoggerMiddleware).forRoutes('*');
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/packages/client/src/stores/MenuDetail/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, ReactNode, useContext, useReducer } from 'react';
2 |
3 | import { MenuInfo, Options, Size, Temperature } from '@/types';
4 |
5 | interface State {
6 | menu: MenuInfo | null;
7 | count: number;
8 | temperature: Temperature;
9 | size: Size;
10 | options: Options;
11 | price: number;
12 | }
13 |
14 | const initialState: State = {
15 | menu: null,
16 | count: 1,
17 | temperature: 'hot',
18 | size: 'tall',
19 | options: {},
20 | price: 0,
21 | };
22 |
23 | type Dispatch = React.Dispatch;
24 | type Action =
25 | | { type: 'SET_MENU'; menu: MenuInfo }
26 | | { type: 'SET_COUNT'; count: number }
27 | | { type: 'SET_TEMPERATURE'; temperature: Temperature }
28 | | { type: 'SET_SIZE'; size: Size }
29 | | { type: 'SET_OPTIONS'; options: Options }
30 | | { type: 'SET_PRICE'; price: number };
31 |
32 | function reducer(state: State, action: Action): State {
33 | switch (action.type) {
34 | case 'SET_MENU':
35 | return { ...state, menu: action.menu };
36 | case 'SET_COUNT':
37 | return { ...state, count: action.count };
38 | case 'SET_TEMPERATURE':
39 | return { ...state, temperature: action.temperature };
40 | case 'SET_SIZE':
41 | return { ...state, size: action.size };
42 | case 'SET_OPTIONS':
43 | return { ...state, options: action.options };
44 | case 'SET_PRICE':
45 | return { ...state, price: action.price };
46 | default:
47 | return state;
48 | }
49 | }
50 |
51 | export const MenuDetailStateContext = createContext(null);
52 | export const MenuDetailActionContext = createContext(null);
53 |
54 | export const useMenuDetailState = function () {
55 | const state = useContext(MenuDetailStateContext);
56 | if (!state) throw new Error('State is NULL');
57 | return state;
58 | };
59 |
60 | export function useMenuDetailDispatch() {
61 | const dispatch = useContext(MenuDetailActionContext);
62 | if (!dispatch) throw new Error('Dispatch is NULL');
63 | return dispatch;
64 | }
65 |
66 | function MenuDetailContextProvider({ children }: { children: ReactNode }) {
67 | const [state, dispatch] = useReducer(reducer, initialState);
68 |
69 | return (
70 |
71 |
72 | {children}
73 |
74 |
75 | );
76 | }
77 |
78 | export default MenuDetailContextProvider;
79 |
--------------------------------------------------------------------------------
/packages/client/src/pages/customer/OrderStatus/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useParams } from 'react-router-dom';
3 |
4 | import LeftArrow from 'components/LeftArrow';
5 | import Footer from 'components/Footer';
6 |
7 | import { PROGRESS_CLASS, PROGRESS_IMAGE } from '@/constants';
8 | import useOrderStatus from '@/hooks/useOrderStatus';
9 | import {
10 | ContentWrapper,
11 | HeaderWrapper,
12 | ImageContainer,
13 | OrderInformationContainer,
14 | OrderInformationText,
15 | OrderStatusWrapper,
16 | Progress,
17 | ProgressBar,
18 | StatusBar,
19 | } from './styled';
20 | import { OrderDetailMenu, OrderStatusCode } from '@/types';
21 |
22 | interface OrderInformationProps {
23 | id: number;
24 | status: OrderStatusCode;
25 | menus: OrderDetailMenu[];
26 | }
27 |
28 | function OrderInfomation({ status, id, menus }: OrderInformationProps) {
29 | const menuLength = useMemo(() => menus.length, [menus]);
30 |
31 | return (
32 |
33 | {status === 'REJECTED' ? (
34 | 거절된 주문입니다.
35 | ) : (
36 | <>
37 | 주문 번호 : {id}
38 |
39 | {menus[0]?.name}
40 | {menuLength > 1 && ` 외 ${menuLength - 1}개`}
41 |
42 | >
43 | )}
44 |
45 | );
46 | }
47 |
48 | function OrderStatus() {
49 | const { orderId } = useParams();
50 | const { status, id, menus } = useOrderStatus(orderId ? orderId : '');
51 |
52 | return (
53 |
54 |
55 |
56 | 주문 현황
57 |
58 |
59 |
60 |
61 |
65 |
66 |
71 |
72 |
78 |
79 |
80 |
81 |
82 | );
83 | }
84 |
85 | export default OrderStatus;
86 |
--------------------------------------------------------------------------------