diff --git a/.github/workflows/be-cd-prod-a.yml b/.github/workflows/be-cd-prod-a.yml deleted file mode 100644 index 2c4139389..000000000 --- a/.github/workflows/be-cd-prod-a.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: BE CD for Prod-A - -on: - workflow_dispatch: - - push: - branches: [ "main" ] - paths: - - backend/** - -jobs: - deploy: - timeout-minutes: 3 - runs-on: [ self-hosted, linux, ARM64, prod-a ] - - defaults: - run: - working-directory: ./backend - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setting prod-secret.yml - run: | - echo "${{ secrets.PROD_SECRET_YML }}" > ./src/main/resources/prod-secret.yml - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build with Gradle - run: ./gradlew bootJar - - - name: Stop existing Java Application - run: ps -ef | grep 'java -jar' | awk '{print $2}' | xargs sudo kill -15 || true - - - name: Start Java Application - run: sudo nohup java -jar -Dspring.profiles.active=prod ./build/libs/ddangkong-0.0.1-SNAPSHOT.jar & diff --git a/.github/workflows/be-cd-prod-b.yml b/.github/workflows/be-cd-prod-b.yml deleted file mode 100644 index aa9099de7..000000000 --- a/.github/workflows/be-cd-prod-b.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: BE CD for Prod-B - -on: - workflow_dispatch: - - push: - branches: [ "main" ] - paths: - - backend/** - -jobs: - deploy: - timeout-minutes: 3 - runs-on: [ self-hosted, linux, ARM64, prod-b ] - - defaults: - run: - working-directory: ./backend - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setting prod-secret.yml - run: | - echo "${{ secrets.PROD_SECRET_YML }}" > ./src/main/resources/prod-secret.yml - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build with Gradle - run: ./gradlew bootJar - - - name: Stop existing Java Application - run: ps -ef | grep 'java -jar' | awk '{print $2}' | xargs sudo kill -15 || true - - - name: Start Java Application - run: sudo nohup java -jar -Dspring.profiles.active=prod ./build/libs/ddangkong-0.0.1-SNAPSHOT.jar & diff --git a/.github/workflows/be-cd-prod.yml b/.github/workflows/be-cd-prod.yml new file mode 100644 index 000000000..033d0e9f7 --- /dev/null +++ b/.github/workflows/be-cd-prod.yml @@ -0,0 +1,129 @@ +name: BE CD for Prod + +on: + workflow_dispatch: + + push: + branches: [ "main" ] + paths: + - backend/** + +jobs: + build: + uses: ./.github/workflows/build.yml + with: + profile: prod + secrets: + secret_yml: ${{ secrets.PROD_SECRET_YML }} + + deploy-a: + needs: [ build ] + uses: ./.github/workflows/blue-green.yml + with: + self_hosted_runner: prod-a + artifact_name: ${{ needs.build.outputs.artifact_name }} + jar_name: ${{ needs.build.outputs.jar_name }} + profile: prod + app_path: ~/app + + deploy-b: + needs: [ build ] + uses: ./.github/workflows/blue-green.yml + with: + self_hosted_runner: prod-b + artifact_name: ${{ needs.build.outputs.artifact_name }} + jar_name: ${{ needs.build.outputs.jar_name }} + profile: prod + app_path: ~/app + + rollback-a: + name: "[Failure] Rollback Deploy A" + needs: [ deploy-a, deploy-b ] + if: failure() && (needs.deploy-a.result == 'failure' || needs.deploy-b.result == 'failure') + uses: ./.github/workflows/shutdown.yml + with: + self_hosted_runner: prod-a + port: ${{ needs.deploy-a.outputs.green_port }} + + rollback-b: + name: "[Failure] Rollback Deploy B" + needs: [ deploy-a, deploy-b ] + if: failure() && (needs.deploy-a.result == 'failure' || needs.deploy-b.result == 'failure') + uses: ./.github/workflows/shutdown.yml + with: + self_hosted_runner: prod-b + port: ${{ needs.deploy-b.outputs.green_port }} + + deploy-failure-notification: + name: "[Failure] Deploy Failure Notification" + needs: [ deploy-a, deploy-b ] + if: failure() && (needs.deploy-a.result == 'failure' || needs.deploy-b.result == 'failure') + runs-on: ubuntu-latest + steps: + - name: Send notification to Discord # todo + run: echo "테스트입니다" + + configure-nginx-a: + needs: [ deploy-a, deploy-b ] + uses: ./.github/workflows/nginx-port-forwarding.yml + with: + self_hosted_runner: prod-a + app_path: ~/app + old_port: ${{ needs.deploy-a.outputs.blue_port }} + new_port: ${{ needs.deploy-a.outputs.green_port }} + + configure-nginx-b: + needs: [ deploy-a, deploy-b ] + uses: ./.github/workflows/nginx-port-forwarding.yml + with: + self_hosted_runner: prod-b + app_path: ~/app + old_port: ${{ needs.deploy-b.outputs.blue_port }} + new_port: ${{ needs.deploy-b.outputs.green_port }} + + rollback-nginx-a: + name: "[Failure] Rollback Nginx A" + needs: [ configure-nginx-a, configure-nginx-b ] + if: failure() && (needs.configure-nginx-a.result == 'failure' || needs.configure-nginx-b.result == 'failure') + uses: ./.github/workflows/nginx-port-forwarding.yml + with: + self_hosted_runner: prod-a + app_path: ~/app + old_port: ${{ needs.configure-nginx-a.outputs.new_port }} + new_port: ${{ needs.configure-nginx-a.outputs.old_port }} + old_shutdown: true + + rollback-nginx-b: + name: "[Failure] Rollback Nginx B" + needs: [ configure-nginx-a, configure-nginx-b ] + if: failure() && (needs.configure-nginx-a.result == 'failure' || needs.configure-nginx-b.result == 'failure') + uses: ./.github/workflows/nginx-port-forwarding.yml + with: + self_hosted_runner: prod-b + app_path: ~/app + old_port: ${{ needs.configure-nginx-b.outputs.new_port }} + new_port: ${{ needs.configure-nginx-b.outputs.old_port }} + old_shutdown: true + + configure-nginx-faliure-notification: + name: "[Failure] Nginx Failure Notification" + needs: [ configure-nginx-a, configure-nginx-b ] + if: failure() && (needs.configure-nginx-a.result == 'failure' || needs.configure-nginx-b.result == 'failure') + runs-on: ubuntu-latest + steps: + - name: Send notification to Discord # todo + run: echo "테스트입니다" + + blue-shutdown-a: + needs: [ configure-nginx-a, configure-nginx-b ] + uses: ./.github/workflows/shutdown.yml + with: + self_hosted_runner: prod-a + port: ${{ needs.configure-nginx-a.outputs.old_port }} + + blue-shutdown-b: + needs: [ configure-nginx-a, configure-nginx-b ] + uses: ./.github/workflows/shutdown.yml + with: + self_hosted_runner: prod-b + port: ${{ needs.configure-nginx-b.outputs.old_port }} diff --git a/.github/workflows/blue-green.yml b/.github/workflows/blue-green.yml new file mode 100644 index 000000000..f2d4648cb --- /dev/null +++ b/.github/workflows/blue-green.yml @@ -0,0 +1,60 @@ +name: Blue Green Deployment + +on: + workflow_call: + inputs: + self_hosted_runner: + description: 'self hosted runner label' + required: true + type: string + artifact_name: + description: 'uploaded artifact name' + required: true + type: string + jar_name: + description: 'uploaded jar name' + required: true + type: string + profile: + description: 'profile' + required: true + type: string + app_path: + description: 'app path' + required: true + type: string + outputs: + green_port: + value: ${{ jobs.deploy-green.outputs.green_port }} + blue_port: + value: ${{ jobs.deploy-green.outputs.blue_port }} + +jobs: + deploy-green: + runs-on: ${{ inputs.self_hosted_runner }} + outputs: + green_port: ${{ steps.blue_green_port.outputs.green_port }} + blue_port: ${{ steps.blue_green_port.outputs.blue_port }} + steps: + - name: Download artifact file + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact_name }} + path: ${{ inputs.app_path }} + + - name: Change permission of shell script + run: chmod +x ${{ inputs.app_path }}/*.sh + + - name: Get blue green port + id: blue_green_port + run: ${{ inputs.app_path }}/get_blue_green_port.sh | awk '{print $0}' >> $GITHUB_OUTPUT + + - name: Run green java application in ${{ inputs.self_hosted_runner }} + run: sudo nohup java -Dspring.profiles.active=${{ inputs.profile }} -Dserver.port=${{ steps.blue_green_port.outputs.green_port }} -Duser.timezone=Asia/Seoul -jar ${{ inputs.app_path }}/${{ inputs.jar_name }} & + + health_check: + needs: [ deploy-green ] + runs-on: ${{ inputs.self_hosted_runner }} + steps: + - name: Health check green + run: ${{ inputs.app_path }}/green_health_check.sh ${{ needs.deploy-green.outputs.green_port }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..2d5ebb699 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,68 @@ +name: backend build jar file and upload artifact file + +on: + workflow_call: + inputs: + profile: + description: 'profile' + required: true + type: string + artifact_name: + description: 'artifact name' + default: 'app-artifact' + required: false + type: string + jar_name: + description: 'jar name' + default: 'app.jar' + required: false + type: string + gradlew_options: + description: 'gradle options' + required: false + type: string + secrets: + secret_yml: + description: 'secret yml' + required: true + outputs: + artifact_name: + value: ${{ inputs.artifact_name }} + jar_name: + value: ${{ inputs.jar_name }} + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setting ${{ inputs.profile }}-secret.yml + run: | + echo "${{ secrets.secret_yml }}" > ./src/main/resources/${{ inputs.profile }}-secret.yml + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: BootJar with Gradle + run: ./gradlew bootJar ${{ inputs.gradlew_options }} + + - name: Move artifact file # todo script 환경 분리 + run: | + mkdir -p ${{ inputs.artifact_name }} && \ + mv build/libs/*.jar ${{ inputs.artifact_name }}/${{ inputs.jar_name }} && \ + mv scripts/* ${{ inputs.artifact_name }}/ + + - name: Upload artifact file + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_name }} + path: ./backend/${{ inputs.artifact_name }} diff --git a/.github/workflows/nginx-port-forwarding.yml b/.github/workflows/nginx-port-forwarding.yml new file mode 100644 index 000000000..a057836b0 --- /dev/null +++ b/.github/workflows/nginx-port-forwarding.yml @@ -0,0 +1,48 @@ +name: Update Nginx Port Forwarding + +on: + workflow_call: + inputs: + self_hosted_runner: + description: 'self hosted runner label' + required: true + type: string + app_path: + description: 'app path' + required: true + type: string + old_port: + description: 'old port' + required: true + type: string + new_port: + description: 'new port for Nginx port forwarding' + required: true + type: string + old_shutdown: + description: 'shutdown before update' + required: false + type: boolean + outputs: + old_port: + value: ${{ inputs.old_port }} + new_port: + value: ${{ inputs.new_port }} + +jobs: + old_shutdown: + if: ${{ inputs.old_shutdown }} + uses: ./.github/workflows/shutdown.yml + with: + self_hosted_runner: ${{ inputs.self_hosted_runner }} + port: ${{ inputs.old_port }} + + update-nginx-port-forwarding: + name: update + runs-on: ${{ inputs.self_hosted_runner }} + steps: + - name: Update Nginx port forwarding from ${{ inputs.old_port }} to ${{ inputs.new_port }} + run: ${{ inputs.app_path }}/change_nginx_port_forwarding.sh ${{ inputs.new_port }} + + - name: Reload Nginx + run: sudo nginx -s reload # todo 에러 처리 diff --git a/.github/workflows/shutdown.yml b/.github/workflows/shutdown.yml new file mode 100644 index 000000000..cbf40d41d --- /dev/null +++ b/.github/workflows/shutdown.yml @@ -0,0 +1,20 @@ +name: Shutdown Server + +on: + workflow_call: + inputs: + self_hosted_runner: + description: 'self hosted runner label' + required: true + type: string + port: + description: 'shutdown port' + required: true + type: string + +jobs: + shutdown: + runs-on: ${{ inputs.self_hosted_runner }} + steps: + - name: Shutdown server - port ${{ inputs.port }} + run: sudo lsof -t -i:${{ inputs.port }} | xargs --no-run-if-empty sudo kill -9 diff --git a/backend/scripts/change_nginx_port_forwarding.sh b/backend/scripts/change_nginx_port_forwarding.sh new file mode 100755 index 000000000..3729ffbba --- /dev/null +++ b/backend/scripts/change_nginx_port_forwarding.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +NGINX_CONFIG="/etc/nginx/sites-available/default" +NEW_PORT=$1 + +if [ -z "$NEW_PORT" ]; then + echo "Invalid new port : $NEW_PORT" + exit 1 +fi + +OLD_PORT=$(sudo grep -oP '127.0.0.1:\K\d+' "$NGINX_CONFIG") + +if [ -n "$OLD_PORT" ]; then + sudo sed -i "s/127.0.0.1:$OLD_PORT/127.0.0.1:$NEW_PORT/g" "$NGINX_CONFIG" + echo "Switching from $OLD_PORT to $NEW_PORT" +else + echo "Can't find port forwarding format in nginx configuration" + exit 1 +fi diff --git a/backend/scripts/get_blue_green_port.sh b/backend/scripts/get_blue_green_port.sh new file mode 100755 index 000000000..9f18cfca0 --- /dev/null +++ b/backend/scripts/get_blue_green_port.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# 현재 BLUE 호스트 확인 (8080 or 8081) +NGINX_CONFIG="/etc/nginx/sites-available/default" +BLUE_PORT=$(sudo grep -oP '127\.0\.0\.1:\K(8080|8081)' "$NGINX_CONFIG") + +# 오류 처리: BLUE 호스트를 찾을 수 없을 경우 +if [ -z "$BLUE_PORT" ]; then + echo "Unable to determine the current BLUE host from NGINX configuration." + echo "Invalid blue port : $BLUE_PORT" + exit 1 +fi + +# 환경 변수 설정 +if [ "$BLUE_PORT" = "8080" ]; then + + echo "blue_port=8080" + echo "green_port=8081" +else + echo "blue_port=8081" + echo "green_port=8080" +fi diff --git a/backend/scripts/green_health_check.sh b/backend/scripts/green_health_check.sh new file mode 100755 index 000000000..d040ebc07 --- /dev/null +++ b/backend/scripts/green_health_check.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +GREEN_PORT=$1 + +health_check_process() { + # 헬스 체크를 최대 5번 시도 (3초 간격) + for i in $(seq 1 5); do + check_health + if [ $? -eq 0 ]; then + echo "Health check passed on attempt $i." + return 0 + fi + echo "Waiting 3 seconds before next check..." + sleep 3 + done + + # 5번 시도 후에도 실패하면 종료 + echo "Health check failed after 5 attempts." + return 1 +} + +check_health() { + # HEALTH_URL 생성 + local health_url="http://localhost:$GREEN_PORT/act-ddangkong/health" + + # 헬스 체크 실시 + local response=$(curl -s --connect-timeout 5 -o /dev/null -w "%{http_code}" "$health_url") + + # curl 요청 실패 처리 + if [ $? -ne 0 ]; then + echo "Curl request failed for $health_url." + return 1 + fi + + # HTTP 200 상태 확인 + if [ "$response" -eq 200 ]; then + echo "Health check passed for $health_url." + return 0 + else + echo "Health check failed for $health_url. Status code: $response" + return 1 + fi +} + +# 헬스 체크 실행 +if health_check_process; then + echo "Success Health Check" +else + echo "Exiting due to health check failure." + exit 1 +fi diff --git a/backend/src/docs/asciidoc/room.adoc b/backend/src/docs/asciidoc/room.adoc index b965d82cd..b2c8bc054 100644 --- a/backend/src/docs/asciidoc/room.adoc +++ b/backend/src/docs/asciidoc/room.adoc @@ -22,6 +22,10 @@ response fields include::{snippets}/room/create/response-fields.adoc[] +response cookies + +include::{snippets}/room/create/response-cookies.adoc[] + ''' === 방 참여 @@ -48,6 +52,34 @@ response fields include::{snippets}/room/join/response-fields.adoc[] +response cookies + +include::{snippets}/room/join/response-cookies.adoc[] + +''' + +=== 사용자 정보 조회 + +==== curl + +include::{snippets}/room/member/curl-request.adoc[] + +==== request + +include::{snippets}/room/member/http-request.adoc[] + +request cookies + +include::{snippets}/room/member/request-cookies.adoc[] + +==== response + +include::{snippets}/room/member/http-response.adoc[] + +response fields + +include::{snippets}/room/member/response-fields.adoc[] + ''' === 방 나가기 @@ -66,6 +98,10 @@ include::{snippets}/room/leave/path-parameters.adoc[] include::{snippets}/room/leave/http-response.adoc[] +response cookies + +include::{snippets}/room/leave/response-cookies.adoc[] + ''' === 방 설정 변경 diff --git a/backend/src/main/java/ddangkong/aop/logging/ControllerLoggingAspect.java b/backend/src/main/java/ddangkong/aop/logging/ControllerLoggingAspect.java index 893e5e51b..76cccba06 100644 --- a/backend/src/main/java/ddangkong/aop/logging/ControllerLoggingAspect.java +++ b/backend/src/main/java/ddangkong/aop/logging/ControllerLoggingAspect.java @@ -4,16 +4,19 @@ import jakarta.servlet.http.HttpServletRequest; import java.lang.reflect.Parameter; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.MDC; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @Slf4j abstract class ControllerLoggingAspect { + private static final String TRACE_ID_KEY = "traceId"; @Pointcut("execution(* ddangkong.controller..*Controller.*(..))") public void allController() { @@ -28,6 +31,7 @@ public void allControllerWithoutPolling() { } protected void logControllerRequest(JoinPoint joinPoint) { + setTraceId(); HttpServletRequest request = getHttpServletRequest(); String uri = request.getRequestURI(); String httpMethod = request.getMethod(); @@ -42,7 +46,8 @@ protected void logControllerResponse(JoinPoint joinPoint, Object responseBody) { String uri = request.getRequestURI(); String httpMethod = request.getMethod(); - log.info("Response Logging: SUCCESS {} {} Body: {}", httpMethod, uri, responseBody); + log.info("Response Logging: {} {} Body: {}", httpMethod, uri, responseBody); + removeTraceId(); } private HttpServletRequest getHttpServletRequest() { @@ -76,4 +81,13 @@ private String getBody(JoinPoint joinPoint) { } return null; } + + private void setTraceId() { + String traceId = UUID.randomUUID().toString(); + MDC.put(TRACE_ID_KEY, traceId); + } + + private void removeTraceId() { + MDC.remove(TRACE_ID_KEY); + } } diff --git a/backend/src/main/java/ddangkong/aop/logging/ProdControllerLoggingAspect.java b/backend/src/main/java/ddangkong/aop/logging/ProdControllerLoggingAspect.java index 51429c6e2..0c4c0b033 100644 --- a/backend/src/main/java/ddangkong/aop/logging/ProdControllerLoggingAspect.java +++ b/backend/src/main/java/ddangkong/aop/logging/ProdControllerLoggingAspect.java @@ -2,6 +2,7 @@ import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.springframework.context.annotation.Profile; @@ -16,4 +17,9 @@ public class ProdControllerLoggingAspect extends ControllerLoggingAspect { public void logControllerRequest(JoinPoint joinPoint) { super.logControllerRequest(joinPoint); } + + @AfterReturning(pointcut = "allControllerWithoutPolling()", returning = "responseBody") + protected void logControllerResponse(JoinPoint joinPoint, Object responseBody) { + super.logControllerResponse(joinPoint, responseBody); + } } diff --git a/backend/src/main/java/ddangkong/config/CorsConfig.java b/backend/src/main/java/ddangkong/config/CorsConfig.java index 2cbba1faf..0065883ab 100644 --- a/backend/src/main/java/ddangkong/config/CorsConfig.java +++ b/backend/src/main/java/ddangkong/config/CorsConfig.java @@ -9,19 +9,20 @@ @Configuration public class CorsConfig implements WebMvcConfigurer { - private final String corsOrigin; + private final String[] corsOrigin; - public CorsConfig(@Value("${cors.origin}") String corsOrigin) { + public CorsConfig(@Value("${cors.origin}") String[] corsOrigin) { this.corsOrigin = corsOrigin; } @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins(corsOrigin) + .allowedOriginPatterns(corsOrigin) .allowedMethods( HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.PATCH.name(), HttpMethod.DELETE.name() ) + .allowCredentials(true) .allowedHeaders("*"); } } diff --git a/backend/src/main/java/ddangkong/controller/exception/GlobalExceptionHandler.java b/backend/src/main/java/ddangkong/controller/exception/GlobalExceptionHandler.java index b1ed78b4e..04fe40b57 100644 --- a/backend/src/main/java/ddangkong/controller/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/ddangkong/controller/exception/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.springframework.validation.BindException; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MissingRequestCookieException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -60,6 +61,14 @@ public ErrorResponse handleClientAbortException(ClientAbortException e) { return new ErrorResponse(ClientErrorCode.ALREADY_DISCONNECTED); } + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleMissingRequestCookieException(MissingRequestCookieException e) { + log.warn(e.getMessage()); + + return new ErrorResponse(ClientErrorCode.NOT_FOUND_COOKIE); + } + @ExceptionHandler @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) { diff --git a/backend/src/main/java/ddangkong/controller/room/EncryptionUtils.java b/backend/src/main/java/ddangkong/controller/room/EncryptionUtils.java new file mode 100644 index 000000000..cd78ac033 --- /dev/null +++ b/backend/src/main/java/ddangkong/controller/room/EncryptionUtils.java @@ -0,0 +1,52 @@ +package ddangkong.controller.room; + +import ddangkong.exception.room.CipherException; +import ddangkong.exception.room.InvalidCookieException; +import java.util.Base64; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class EncryptionUtils { + + private final String algorithm; + + private final SecretKey secretKey; + + public EncryptionUtils(@Value("${encrypt.algorithm}") String algorithm, + @Value("${encrypt.secret-key}") String secretKeyString) { + this.algorithm = algorithm; + byte[] secretKeyBytes = secretKeyString.getBytes(); + secretKey = new SecretKeySpec(secretKeyBytes, 0, secretKeyBytes.length, algorithm); + } + + public String encrypt(String plainText) { + try { + Cipher cipher = Cipher.getInstance(algorithm); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + byte[] encrypted = cipher.doFinal(plainText.getBytes()); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new CipherException(); + } + } + + public String decrypt(String encrypted) { + try { + Cipher cipher = Cipher.getInstance(algorithm); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + byte[] decodeBytes = Base64.getDecoder().decode(encrypted); + byte[] decryptedBytes = cipher.doFinal(decodeBytes); + return new String(decryptedBytes); + } catch (IllegalBlockSizeException | BadPaddingException | IllegalArgumentException e) { + throw new InvalidCookieException(); + } catch (Exception e) { + throw new CipherException(); + } + } +} diff --git a/backend/src/main/java/ddangkong/controller/room/RoomController.java b/backend/src/main/java/ddangkong/controller/room/RoomController.java index 2f4939d08..37ac8ffc6 100644 --- a/backend/src/main/java/ddangkong/controller/room/RoomController.java +++ b/backend/src/main/java/ddangkong/controller/room/RoomController.java @@ -6,15 +6,21 @@ import ddangkong.facade.room.dto.RoomInfoResponse; import ddangkong.facade.room.dto.RoomJoinRequest; import ddangkong.facade.room.dto.RoomJoinResponse; -import ddangkong.facade.room.dto.RoomStatusResponse; +import ddangkong.facade.room.dto.RoomMemberResponse; import ddangkong.facade.room.dto.RoomSettingRequest; +import ddangkong.facade.room.dto.RoomStatusResponse; import ddangkong.facade.room.dto.RoundFinishedResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -33,11 +39,21 @@ public class RoomController { private final RoomFacade roomFacade; + private final RoomMemberCookieEncryptor roomMemberCookieEncryptor; @ResponseStatus(HttpStatus.CREATED) @PostMapping("/balances/rooms") - public RoomJoinResponse createRoom(@Valid @RequestBody RoomJoinRequest request) { - return roomFacade.createRoom(request.nickname()); + public RoomJoinResponse createRoom(@Valid @RequestBody RoomJoinRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + RoomJoinResponse roomJoinResponse = roomFacade.createRoom(request.nickname()); + setEncryptCookie(httpRequest, httpResponse, roomJoinResponse.member().memberId()); + return roomJoinResponse; + } + + @GetMapping("/balances/rooms/member") + public RoomMemberResponse getRoomMemberInfo(@CookieValue(name = "${cookie.rejoin-key}") String cookieValue) { + return roomFacade.getRoomMemberInfo(roomMemberCookieEncryptor.getDecodedCookieValue(cookieValue)); } @Polling @@ -55,15 +71,22 @@ public void updateRoomSetting(@PathVariable @Positive Long roomId, @ResponseStatus(HttpStatus.CREATED) @PostMapping("/balances/rooms/{uuid}/members") - public RoomJoinResponse joinRoom(@PathVariable String uuid, @Valid @RequestBody RoomJoinRequest request) { - return roomFacade.joinRoom(request.nickname(), uuid); + public RoomJoinResponse joinRoom(@PathVariable String uuid, + @Valid @RequestBody RoomJoinRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + RoomJoinResponse roomJoinResponse = roomFacade.joinRoom(request.nickname(), uuid); + setEncryptCookie(httpRequest, httpResponse, roomJoinResponse.member().memberId()); + return roomJoinResponse; } @ResponseStatus(HttpStatus.NO_CONTENT) @DeleteMapping("/balances/rooms/{roomId}/members/{memberId}") public void leaveRoom(@PathVariable @Positive Long roomId, - @PathVariable @Positive Long memberId) { + @PathVariable @Positive Long memberId, + HttpServletResponse response) { roomFacade.leaveRoom(roomId, memberId); + deleteCookie(response); } @ResponseStatus(HttpStatus.NO_CONTENT) @@ -101,4 +124,17 @@ public RoomStatusResponse getRoomStatus(@NotBlank @PathVariable String uuid) { public InitialRoomResponse isInitialRoom(@PathVariable @Positive Long roomId) { return roomFacade.isInitialRoom(roomId); } + + private void setEncryptCookie(HttpServletRequest request, + HttpServletResponse response, + Object cookieValue) { + String origin = request.getHeader(HttpHeaders.ORIGIN); + ResponseCookie encodedCookie = roomMemberCookieEncryptor.getEncodedCookie(cookieValue, origin); + response.addHeader(HttpHeaders.SET_COOKIE, encodedCookie.toString()); + } + + private void deleteCookie(HttpServletResponse response) { + ResponseCookie deleteCookie = roomMemberCookieEncryptor.deleteCookie(); + response.addHeader(HttpHeaders.SET_COOKIE, deleteCookie.toString()); + } } diff --git a/backend/src/main/java/ddangkong/controller/room/RoomMemberCookieEncryptor.java b/backend/src/main/java/ddangkong/controller/room/RoomMemberCookieEncryptor.java new file mode 100644 index 000000000..4cefb8af0 --- /dev/null +++ b/backend/src/main/java/ddangkong/controller/room/RoomMemberCookieEncryptor.java @@ -0,0 +1,53 @@ +package ddangkong.controller.room; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.server.Cookie.SameSite; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class RoomMemberCookieEncryptor { + + private static final String ROOT_PATH = "/"; + private static final String DEFAULT_PATH = "/api/balances/rooms"; + private static final String LOCALHOST = "http://localhost"; + + private final EncryptionUtils encryptionUtils; + + private final String rejoinKey; + + public RoomMemberCookieEncryptor(EncryptionUtils encryptionUtils, @Value("${cookie.rejoin-key}") String rejoinKey) { + this.encryptionUtils = encryptionUtils; + this.rejoinKey = rejoinKey; + } + + public ResponseCookie getEncodedCookie(Object value, String origin) { + String encrypt = encryptionUtils.encrypt(String.valueOf(value)); + return ResponseCookie.from(rejoinKey, encrypt) + .httpOnly(true) + .secure(true) + .path(DEFAULT_PATH) + .sameSite(getSameSiteOption(origin)) + .build(); + } + + public ResponseCookie deleteCookie() { + return ResponseCookie.from(rejoinKey, null) + .httpOnly(true) + .secure(true) + .path(ROOT_PATH) + .maxAge(0) + .build(); + } + + private String getSameSiteOption(String origin) { + if (origin != null && origin.startsWith(LOCALHOST)) { + return SameSite.NONE.attributeValue(); + } + return SameSite.LAX.attributeValue(); + } + + public Long getDecodedCookieValue(String cookieValue) { + return Long.parseLong(encryptionUtils.decrypt(cookieValue)); + } +} diff --git a/backend/src/main/java/ddangkong/domain/room/RoomSetting.java b/backend/src/main/java/ddangkong/domain/room/RoomSetting.java index 3b620ef17..eefbb0391 100644 --- a/backend/src/main/java/ddangkong/domain/room/RoomSetting.java +++ b/backend/src/main/java/ddangkong/domain/room/RoomSetting.java @@ -22,7 +22,7 @@ public class RoomSetting { private static final int DEFAULT_TOTAL_ROUND = 5; private static final int MIN_TOTAL_ROUND = 3; private static final int MAX_TOTAL_ROUND = 10; - private static final List ALLOWED_TIME_LIMIT = List.of(5_000, 10_000, 15_000); + private static final List ALLOWED_TIME_LIMIT = List.of(10_000, 15_000, 30_000, 60_000); private static final int DEFAULT_TIME_LIMIT_MSEC = 10_000; @Column(nullable = false) diff --git a/backend/src/main/java/ddangkong/exception/ClientErrorCode.java b/backend/src/main/java/ddangkong/exception/ClientErrorCode.java index ed13349cf..e8cbce480 100644 --- a/backend/src/main/java/ddangkong/exception/ClientErrorCode.java +++ b/backend/src/main/java/ddangkong/exception/ClientErrorCode.java @@ -24,7 +24,7 @@ public enum ClientErrorCode { // RoomSetting // todo s로 변경 - INVALID_TIME_LIMIT("시간 제한은 %dms / %dms / %dms 만 가능합니다. requested timeLimit: %d"), + INVALID_TIME_LIMIT("시간 제한은 %s 만 가능합니다. requested timeLimit: %d"), INVALID_RANGE_TOTAL_ROUND("총 라운드는 %d 이상, %d 이하만 가능합니다. requested totalRound: %d"), // Member @@ -36,6 +36,7 @@ public enum ClientErrorCode { EXCEED_MAX_MEMBER_COUNT("방의 최대 인원을 초과했습니다. 현재 멤버 수: %d"), NOT_ROOM_MEMBER("방에 존재하지 않는 멤버입니다."), INVALID_NICKNAME("닉네임은 최소 %d글자, 최대 %d글자여야 합니다."), + INVALID_MEMBER_ID("해당 ID에 일치하는 멤버가 없습니다."), // RoomContent NOT_FOUND_ROOM_CONTENT("방에 존재하지 않는 컨텐츠입니다."), @@ -48,6 +49,10 @@ public enum ClientErrorCode { VOTE_NOT_FINISHED("아직 투표가 종료되지 않았습니다."), CAN_NOT_CHECK_MATCHING_PERCENT("종료되지 않은 방의 투표 매칭도는 확인할 수 없습니다."), + // Cookie + NOT_FOUND_COOKIE("일치하는 쿠키가 없습니다."), + INVALID_COOKIE("유효하지 않는 쿠키입니다."), + // Common FIELD_ERROR("입력이 잘못되었습니다."), URL_PARAMETER_ERROR("입력이 잘못되었습니다."), diff --git a/backend/src/main/java/ddangkong/exception/ServerErrorCode.java b/backend/src/main/java/ddangkong/exception/ServerErrorCode.java index 00e6baf82..737eb7b77 100644 --- a/backend/src/main/java/ddangkong/exception/ServerErrorCode.java +++ b/backend/src/main/java/ddangkong/exception/ServerErrorCode.java @@ -7,6 +7,9 @@ @RequiredArgsConstructor public enum ServerErrorCode { + // Encryption + CIPHER_EXCEPTION("Cipher 사용 중 문제가 발생하였습니다."), + // BalanceContent NOT_ENOUGH_BALANCE_CONTENT("질문 수가 부족합니다. category: %s"), diff --git a/backend/src/main/java/ddangkong/exception/room/CipherException.java b/backend/src/main/java/ddangkong/exception/room/CipherException.java new file mode 100644 index 000000000..dcbb64022 --- /dev/null +++ b/backend/src/main/java/ddangkong/exception/room/CipherException.java @@ -0,0 +1,10 @@ +package ddangkong.exception.room; + +import ddangkong.exception.InternalServerException; +import ddangkong.exception.ServerErrorCode; + +public class CipherException extends InternalServerException { + public CipherException() { + super(ServerErrorCode.CIPHER_EXCEPTION.getMessage()); + } +} diff --git a/backend/src/main/java/ddangkong/exception/room/InvalidCookieException.java b/backend/src/main/java/ddangkong/exception/room/InvalidCookieException.java new file mode 100644 index 000000000..1d0ac48c0 --- /dev/null +++ b/backend/src/main/java/ddangkong/exception/room/InvalidCookieException.java @@ -0,0 +1,17 @@ +package ddangkong.exception.room; + +import static ddangkong.exception.ClientErrorCode.INVALID_COOKIE; + +import ddangkong.exception.BadRequestException; + +public class InvalidCookieException extends BadRequestException { + + public InvalidCookieException() { + super(INVALID_COOKIE.getMessage()); + } + + @Override + public String getErrorCode() { + return INVALID_COOKIE.name(); + } +} diff --git a/backend/src/main/java/ddangkong/exception/room/InvalidTimeLimitException.java b/backend/src/main/java/ddangkong/exception/room/InvalidTimeLimitException.java index 630348e94..9fc7894e9 100644 --- a/backend/src/main/java/ddangkong/exception/room/InvalidTimeLimitException.java +++ b/backend/src/main/java/ddangkong/exception/room/InvalidTimeLimitException.java @@ -9,8 +9,7 @@ public class InvalidTimeLimitException extends BadRequestException { public InvalidTimeLimitException(List allowedTimeLimits, int requestedTimeLimit) { super(INVALID_TIME_LIMIT.getMessage() - .formatted(allowedTimeLimits.get(0), allowedTimeLimits.get(1), allowedTimeLimits.get(2), - requestedTimeLimit)); + .formatted(allowedTimeLimits.toString(), requestedTimeLimit)); } @Override diff --git a/backend/src/main/java/ddangkong/exception/room/NotFoundCookieException.java b/backend/src/main/java/ddangkong/exception/room/NotFoundCookieException.java new file mode 100644 index 000000000..246fe226f --- /dev/null +++ b/backend/src/main/java/ddangkong/exception/room/NotFoundCookieException.java @@ -0,0 +1,17 @@ +package ddangkong.exception.room; + +import static ddangkong.exception.ClientErrorCode.NOT_FOUND_COOKIE; + +import ddangkong.exception.BadRequestException; + +public class NotFoundCookieException extends BadRequestException { + + public NotFoundCookieException() { + super(NOT_FOUND_COOKIE.getMessage()); + } + + @Override + public String getErrorCode() { + return NOT_FOUND_COOKIE.name(); + } +} diff --git a/backend/src/main/java/ddangkong/exception/room/member/InvalidMemberIdException.java b/backend/src/main/java/ddangkong/exception/room/member/InvalidMemberIdException.java new file mode 100644 index 000000000..4b50d9a6f --- /dev/null +++ b/backend/src/main/java/ddangkong/exception/room/member/InvalidMemberIdException.java @@ -0,0 +1,17 @@ +package ddangkong.exception.room.member; + +import static ddangkong.exception.ClientErrorCode.INVALID_MEMBER_ID; + +import ddangkong.exception.BadRequestException; + +public class InvalidMemberIdException extends BadRequestException { + + public InvalidMemberIdException() { + super(INVALID_MEMBER_ID.getMessage()); + } + + @Override + public String getErrorCode() { + return INVALID_MEMBER_ID.name(); + } +} diff --git a/backend/src/main/java/ddangkong/facade/room/RoomFacade.java b/backend/src/main/java/ddangkong/facade/room/RoomFacade.java index 2d54987f9..a4ce9fa18 100644 --- a/backend/src/main/java/ddangkong/facade/room/RoomFacade.java +++ b/backend/src/main/java/ddangkong/facade/room/RoomFacade.java @@ -8,6 +8,7 @@ import ddangkong.facade.room.dto.InitialRoomResponse; import ddangkong.facade.room.dto.RoomInfoResponse; import ddangkong.facade.room.dto.RoomJoinResponse; +import ddangkong.facade.room.dto.RoomMemberResponse; import ddangkong.facade.room.dto.RoomSettingRequest; import ddangkong.facade.room.dto.RoomStatusResponse; import ddangkong.facade.room.dto.RoundFinishedResponse; @@ -52,6 +53,13 @@ public RoomJoinResponse joinRoom(String nickname, String uuid) { return new RoomJoinResponse(room.getId(), room.getUuid(), new MemberResponse(member)); } + @Transactional(readOnly = true) + public RoomMemberResponse getRoomMemberInfo(Long memberId) { + Member member = memberService.getMemberById(memberId); + Room room = member.getRoom(); + return new RoomMemberResponse(room.getId(), room.getUuid(), new MemberResponse(member)); + } + @Transactional public void leaveRoom(Long roomId, Long memberId) { Room room = roomService.getRoom(roomId); diff --git a/backend/src/main/java/ddangkong/facade/room/dto/RoomMemberResponse.java b/backend/src/main/java/ddangkong/facade/room/dto/RoomMemberResponse.java new file mode 100644 index 000000000..9552c307f --- /dev/null +++ b/backend/src/main/java/ddangkong/facade/room/dto/RoomMemberResponse.java @@ -0,0 +1,10 @@ +package ddangkong.facade.room.dto; + +import ddangkong.facade.room.member.dto.MemberResponse; + +public record RoomMemberResponse( + Long roomId, + String roomUuid, + MemberResponse member +) { +} diff --git a/backend/src/main/java/ddangkong/service/room/member/MemberService.java b/backend/src/main/java/ddangkong/service/room/member/MemberService.java index 95bc24b08..8cd664fb2 100644 --- a/backend/src/main/java/ddangkong/service/room/member/MemberService.java +++ b/backend/src/main/java/ddangkong/service/room/member/MemberService.java @@ -8,6 +8,7 @@ import ddangkong.exception.room.member.AlreadyExistMasterException; import ddangkong.exception.room.member.ExceedMaxMemberCountException; import ddangkong.exception.room.member.InvalidMasterCreationException; +import ddangkong.exception.room.member.InvalidMemberIdException; import ddangkong.exception.room.member.NotExistMasterException; import ddangkong.exception.room.member.NotRoomMemberException; import java.util.List; @@ -94,4 +95,10 @@ public void deleteMember(Room room) { public Member getMaster(Room room) { return findRoomMembers(room).getMaster(); } + + @Transactional(readOnly = true) + public Member getMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(InvalidMemberIdException::new); + } } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index ee0e83a73..fb150d2e2 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -21,6 +21,13 @@ spring: cors: origin: ${secret.cors.origin} +cookie: + rejoin-key: ${secret.cookie.rejoin-key} + +encrypt: + secret-key: ${secret.encrypt.secret-key} + algorithm: ${secret.encrypt.algorithm} + logging: config: classpath:logback-dev.xml location : ${secret.application.log.location} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index ecddc1af3..57effdc74 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -26,12 +26,16 @@ spring: jdbc: batch_size: 1000 -server: - port: ${secret.application.port} - cors: origin: ${secret.cors.origin} +cookie: + rejoin-key: ${secret.cookie.rejoin-key} + +encrypt: + secret-key: ${secret.encrypt.secret-key} + algorithm: ${secret.encrypt.algorithm} + logging: config: classpath:logback-prod.xml discord: @@ -39,9 +43,6 @@ logging: location: ${secret.application.log.location} management: - server: - port: ${secret.actuator.port} - endpoints: enabled-by-default: false web: diff --git a/backend/src/main/resources/logback-dev.xml b/backend/src/main/resources/logback-dev.xml index f04989ca5..5c8d41b83 100644 --- a/backend/src/main/resources/logback-dev.xml +++ b/backend/src/main/resources/logback-dev.xml @@ -11,7 +11,8 @@ 14 - %d{yyyy-MM-dd HH:mm:ss,Asia/Seoul} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss,Asia/Seoul} [%thread] %-5level %logger{36} [%X{traceId:-NoTraceID}] - %msg%n + @@ -20,7 +21,8 @@ ${DISCORD_WEBHOOK_URL} - %d{yyyy-MM-dd HH:mm:ss,Asia/Seoul} [%thread] [%-5level] %logger{36} - %msg%n```%ex{full}``` + %d{yyyy-MM-dd HH:mm:ss,Asia/Seoul} [%thread] [%-5level] %logger{36} [%X{traceId:-NoTraceID}] - + %msg%n```%ex{full}``` dev-error-alert-bot diff --git a/backend/src/main/resources/logback-prod.xml b/backend/src/main/resources/logback-prod.xml index 4ed111f6b..e338737b8 100644 --- a/backend/src/main/resources/logback-prod.xml +++ b/backend/src/main/resources/logback-prod.xml @@ -6,11 +6,12 @@ ${LOG_PATH}/ddangkong.log - ${LOG_PATH}/ddangkong-%d{yyyy-MM-dd}.log + ${LOG_PATH}/ddangkong-%d{yyyy-MM-dd}.log.gz 365 - %d{yyyy-MM-dd HH:mm:ss,Asia/Seoul} [%thread] %-5level %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss,Asia/Seoul} [%thread] %-5level %logger{36} [%X{traceId:-NoTraceID}] - %msg%n + @@ -19,7 +20,8 @@ ${DISCORD_WEBHOOK_URL} - %d{yyyy-MM-dd HH:mm:ss,Asia/Seoul} [%thread] [%-5level] %logger{36} - %msg%n```%ex{full}``` + %d{yyyy-MM-dd HH:mm:ss,Asia/Seoul} [%thread] [%-5level] %logger{36} [%X{traceId:-NoTraceID}] - + %msg%n```%ex{full}``` prod-error-alert-bot diff --git a/backend/src/test/java/ddangkong/controller/room/EncryptionUtilsTest.java b/backend/src/test/java/ddangkong/controller/room/EncryptionUtilsTest.java new file mode 100644 index 000000000..e029cfc5c --- /dev/null +++ b/backend/src/test/java/ddangkong/controller/room/EncryptionUtilsTest.java @@ -0,0 +1,58 @@ +package ddangkong.controller.room; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import ddangkong.controller.BaseControllerTest; +import ddangkong.exception.room.InvalidCookieException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class EncryptionUtilsTest extends BaseControllerTest { + + @Autowired + private EncryptionUtils encryptionUtils; + + @Nested + class 암호화_테스트 { + + @Test + void 값을_인코딩_할_수_있다() { + // given + String value = "ThisIsMySecretKe"; + + // when + String encrypt = encryptionUtils.encrypt(value); + + // then + assertThat(encrypt).isNotEqualTo(value); + } + + @Test + void 값을_디코딩_할_수_있다() { + // given + String value = "ThisIsMySecretKe"; + String encoded = "GoyXxFK7Tzpwbe9IW9cfegUBh6DN5amHLLqwkatz5VM="; + + // when + String encrypt = encryptionUtils.decrypt(encoded); + + // then + assertThat(encrypt).isEqualTo(value); + } + + @Test + void 값을_디코딩_하지_못하는_경우_예외가_발생한다() { + // given + String value = "ThisIsMySecretKe"; + String encoded = "YWFhYWFhYWFhYWFhYWFhYQ=="; + + // when + assertThatThrownBy(() -> encryptionUtils.decrypt(encoded)) + .isExactlyInstanceOf(InvalidCookieException.class); + } + } +} + + diff --git a/backend/src/test/java/ddangkong/controller/room/RoomControllerTest.java b/backend/src/test/java/ddangkong/controller/room/RoomControllerTest.java index a372e5eb5..194262ad5 100644 --- a/backend/src/test/java/ddangkong/controller/room/RoomControllerTest.java +++ b/backend/src/test/java/ddangkong/controller/room/RoomControllerTest.java @@ -1,11 +1,11 @@ package ddangkong.controller.room; import static ddangkong.support.fixture.MemberFixture.PRIN; +import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import ddangkong.controller.BaseControllerTest; -import ddangkong.controller.exception.ErrorResponse; import ddangkong.domain.balance.content.BalanceContent; import ddangkong.domain.balance.content.Category; import ddangkong.domain.room.Room; @@ -13,7 +13,6 @@ import ddangkong.domain.room.RoomStatus; import ddangkong.domain.room.balance.roomcontent.RoomContent; import ddangkong.domain.room.member.Member; -import ddangkong.exception.ClientErrorCode; import ddangkong.facade.room.dto.InitialRoomResponse; import ddangkong.facade.room.dto.RoomInfoResponse; import ddangkong.facade.room.dto.RoomJoinRequest; @@ -27,8 +26,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpStatus; class RoomControllerTest extends BaseControllerTest { @@ -316,4 +313,92 @@ void setUp() { ); } } + + @Nested + class 쿠키 { + + @Test + void 방_생성시_쿠키를_생성한다() { + // given + RoomJoinRequest body = new RoomJoinRequest("방장"); + + // when + String cookie = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(body) + .when().post("/api/balances/rooms") + .getCookie("test_cookie"); + + // then + assertThat(cookie).isNotBlank(); + } + + @Test + void 방_참여시_쿠키를_생성한다() { + // given + RoomJoinRequest body = new RoomJoinRequest("참가자"); + + // when + String cookie = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(body) + .when().post("/api/balances/rooms") + .getCookie("test_cookie"); + + // then + assertThat(cookie).isNotBlank(); + } + + @Test + void 쿠키를_통해_사용자_정보를_조회_할_수_있다() { + // given + RoomJoinRequest body = new RoomJoinRequest("참가자"); + String cookie = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(body) + .when().post("/api/balances/rooms") + .getCookie("test_cookie"); + + // when + RoomJoinResponse roomJoinResponse = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .cookie("test_cookie", cookie) + .when().get("/api/balances/rooms/member") + .then().contentType(ContentType.JSON).log().all() + .statusCode(200) + .extract().as(RoomJoinResponse.class); + + // then + assertThat(body.nickname()).isEqualTo(roomJoinResponse.member().nickname()); + } + + @Test + void 방을_나가면_쿠키를_삭제한다() { + // given + RoomJoinRequest body = new RoomJoinRequest("참가자"); + String cookie = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(body) + .when().post("/api/balances/rooms") + .getCookie("test_cookie"); + + RoomJoinResponse roomJoinResponse = RestAssured.given().log().all() + .contentType(ContentType.JSON) + .cookie("test_cookie", cookie) + .when().get("/api/balances/rooms/member") + .then().contentType(ContentType.JSON).log().all() + .statusCode(200) + .extract().as(RoomJoinResponse.class); + + // when + String deleteCookie = RestAssured.given().log().all() + .pathParam("roomId", roomJoinResponse.roomId()) + .pathParam("memberId", roomJoinResponse.member().memberId()) + .cookie("test_cookie", cookie) + .when().delete("/api/balances/rooms/{roomId}/members/{memberId}") + .getCookie("test_cookie"); + + assertThat(deleteCookie).isBlank(); + } + } } diff --git a/backend/src/test/java/ddangkong/controller/room/RoomMemberCookieEncryptorTest.java b/backend/src/test/java/ddangkong/controller/room/RoomMemberCookieEncryptorTest.java new file mode 100644 index 000000000..e5cfa457e --- /dev/null +++ b/backend/src/test/java/ddangkong/controller/room/RoomMemberCookieEncryptorTest.java @@ -0,0 +1,45 @@ +package ddangkong.controller.room; + +import static org.assertj.core.api.Assertions.assertThat; + +import ddangkong.controller.BaseControllerTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseCookie; + +class RoomMemberCookieEncryptorTest extends BaseControllerTest { + + @Autowired + private RoomMemberCookieEncryptor roomMemberCookieEncryptor; + + @Nested + class 방_멤버_쿠키_암호화 { + + @Test + void 로컬_환경인_경우_SameSite는_None_이다() { + // given + String value = "ThisIsMySecretKe"; + String origin = "http://localhost:3306/api"; + + // when + ResponseCookie encodedCookie = roomMemberCookieEncryptor.getEncodedCookie(value, origin); + + // then + assertThat(encodedCookie.getSameSite()).isEqualTo("None"); + } + + @Test + void 로컬_환경이_아닌_경우_SameSite는_Lax_이다() { + // given + String value = "ThisIsMySecretKe"; + String origin = "ddangkong.kr"; + + // when + ResponseCookie encodedCookie = roomMemberCookieEncryptor.getEncodedCookie(value, origin); + + // then + assertThat(encodedCookie.getSameSite()).isEqualTo("Lax"); + } + } +} diff --git a/backend/src/test/java/ddangkong/controller/room/balance/roomvote/RoomBalanceVoteControllerTest.java b/backend/src/test/java/ddangkong/controller/room/balance/roomvote/RoomBalanceVoteControllerTest.java index e4aef0714..4efe024fb 100644 --- a/backend/src/test/java/ddangkong/controller/room/balance/roomvote/RoomBalanceVoteControllerTest.java +++ b/backend/src/test/java/ddangkong/controller/room/balance/roomvote/RoomBalanceVoteControllerTest.java @@ -174,7 +174,7 @@ void init() { "투표_매칭도_조회", 3, RoomStatus.FINISH, - new RoomSetting(3, 5000, Category.IF))); + new RoomSetting(3, 15_000, Category.IF))); BalanceContent balanceContent1 = balanceContentRepository.save(new BalanceContent(Category.IF, "if1")); balanceOptionRepository.save(new BalanceOption("option1", balanceContent1)); diff --git a/backend/src/test/java/ddangkong/documentation/room/RoomDocumentationTest.java b/backend/src/test/java/ddangkong/documentation/room/RoomDocumentationTest.java index 44eee0dc0..7ea92311e 100644 --- a/backend/src/test/java/ddangkong/documentation/room/RoomDocumentationTest.java +++ b/backend/src/test/java/ddangkong/documentation/room/RoomDocumentationTest.java @@ -5,6 +5,9 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; @@ -23,29 +26,35 @@ import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import ddangkong.controller.room.RoomMemberCookieEncryptor; +import ddangkong.controller.room.EncryptionUtils; import ddangkong.controller.room.RoomController; import ddangkong.documentation.BaseDocumentationTest; import ddangkong.domain.balance.content.Category; import ddangkong.facade.balance.content.BalanceCategoryResponse; import ddangkong.facade.room.RoomFacade; -import ddangkong.facade.room.dto.RoomStatusResponse; import ddangkong.facade.room.dto.InitialRoomResponse; import ddangkong.facade.room.dto.RoomInfoResponse; import ddangkong.facade.room.dto.RoomJoinRequest; import ddangkong.facade.room.dto.RoomJoinResponse; +import ddangkong.facade.room.dto.RoomMemberResponse; import ddangkong.facade.room.dto.RoomSettingRequest; import ddangkong.facade.room.dto.RoomSettingResponse; +import ddangkong.facade.room.dto.RoomStatusResponse; import ddangkong.facade.room.dto.RoundFinishedResponse; import ddangkong.facade.room.member.dto.MasterResponse; import ddangkong.facade.room.member.dto.MemberResponse; +import jakarta.servlet.http.Cookie; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; -@WebMvcTest(RoomController.class) +@WebMvcTest(value = RoomController.class) +@Import(value = {RoomMemberCookieEncryptor.class, EncryptionUtils.class}) class RoomDocumentationTest extends BaseDocumentationTest { @MockBean @@ -82,6 +91,9 @@ class 방_생성 { fieldWithPath("member.memberId").type(NUMBER).description("멤버 ID"), fieldWithPath("member.nickname").type(STRING).description("멤버 닉네임"), fieldWithPath("member.isMaster").type(BOOLEAN).description("방장 여부") + ), + responseCookies( + cookieWithName("test_cookie").description("방 재참여시 사용되는 쿠키") ) )); } @@ -192,6 +204,42 @@ class 방_참여 { requestFields( fieldWithPath("nickname").description("닉네임") ), + responseFields( + fieldWithPath("roomId").type(NUMBER).description("참여한 방 ID"), + fieldWithPath("roomUuid").type(STRING).description("참여한 방 UUID"), + fieldWithPath("member.memberId").type(NUMBER).description("멤버 ID"), + fieldWithPath("member.nickname").type(STRING).description("멤버 닉네임"), + fieldWithPath("member.isMaster").type(BOOLEAN).description("방장 여부") + ), + responseCookies( + cookieWithName("test_cookie").description("방 재참여시 사용되는 쿠키") + ) + )); + } + } + + @Nested + class 사용자_정보_조회 { + + private static final String ENDPOINT = "/api/balances/rooms/member"; + + @Test + void 사용자_정보를_조회한다() throws Exception { + // given + RoomMemberResponse response = new RoomMemberResponse(1L, "488fd79f92a34131bf2a628bd58c5d2c", + new MemberResponse(2L, "타콩", false)); + when(roomFacade.getRoomMemberInfo(anyLong())).thenReturn(response); + + //when & then + mockMvc.perform(get(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .cookie(new Cookie("test_cookie", "oNnHwjSR1G4E5L8Mute61w==")) + ) + .andExpect(status().isOk()) + .andDo(document("room/member", + requestCookies( + cookieWithName("test_cookie").description("사용자 인증에 필요한 쿠키(쿠키의 키 값은 UUID로 예측할 수 없는 값이 들어감)") + ), responseFields( fieldWithPath("roomId").type(NUMBER).description("참여한 방 ID"), fieldWithPath("roomUuid").type(STRING).description("참여한 방 UUID"), @@ -223,6 +271,9 @@ class 방_나가기 { pathParameters( parameterWithName("roomId").description("방 ID"), parameterWithName("memberId").description("멤버 ID") + ), + responseCookies( + cookieWithName("test_cookie").description("삭제 쿠키") ) )); } diff --git a/backend/src/test/java/ddangkong/domain/room/RoomSettingTest.java b/backend/src/test/java/ddangkong/domain/room/RoomSettingTest.java index 918b6ce7a..e13cb9580 100644 --- a/backend/src/test/java/ddangkong/domain/room/RoomSettingTest.java +++ b/backend/src/test/java/ddangkong/domain/room/RoomSettingTest.java @@ -1,35 +1,118 @@ package ddangkong.domain.room; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import ddangkong.domain.balance.content.Category; import ddangkong.exception.room.InvalidRangeTotalRoundException; import ddangkong.exception.room.InvalidTimeLimitException; +import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; class RoomSettingTest { + + private static final List ALLOWED_TIME_LIMIT = List.of(10_000, 15_000, 30_000, 60_000); + @Nested - class 방_설정_변경 { + class 방_설정 { + + @ParameterizedTest + @ValueSource(ints = {3, 10}) + void 라운드는_특정_범위_내_있어야_한다(int validTotalRound) { + // when & then + assertThatCode(() -> new RoomSetting(validTotalRound, 15_000, Category.IF)) + .doesNotThrowAnyException(); + } + @ParameterizedTest @ValueSource(ints = {2, 11}) - void 라운드는_3이상_10이하_여야한다(int notValidTotalRound) { + void 라운드_수가_범위를_벗어나는_경우_예외를_던진다(int notValidTotalRound) { // when & then - assertThatThrownBy(() -> new RoomSetting(notValidTotalRound, 5000, Category.IF)) + assertThatThrownBy(() -> new RoomSetting(notValidTotalRound, 15_000, Category.IF)) .isExactlyInstanceOf(InvalidRangeTotalRoundException.class) .hasMessage("총 라운드는 %d 이상, %d 이하만 가능합니다. requested totalRound: %d" .formatted(3, 10, notValidTotalRound)); } @ParameterizedTest - @ValueSource(ints = {5001, 10001, 15001}) - void 시간_제한은_5000_10000_15000중_하나_여야한다(int notValidTimeLimit) { + @ValueSource(ints = {10_000, 15_000, 30_000, 60_000}) + void 시간_제한은_허용된_시간_중_하나_여야한다(int validateTimeLimit) { + // when & then + assertThatCode(() -> new RoomSetting(5, validateTimeLimit, Category.IF)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(ints = {10_001, 15_001, 30_001, 59_999}) + void 시간_제한은_특정_시간이_아니라면_예외를_던진다(int notValidTimeLimit) { // when & then assertThatThrownBy(() -> new RoomSetting(5, notValidTimeLimit, Category.IF)) .isExactlyInstanceOf(InvalidTimeLimitException.class) - .hasMessage("시간 제한은 %dms / %dms / %dms 만 가능합니다. requested timeLimit: %d" - .formatted(5000, 10000, 15000, notValidTimeLimit)); + .hasMessage("시간 제한은 %s 만 가능합니다. requested timeLimit: %d" + .formatted(ALLOWED_TIME_LIMIT.toString(), notValidTimeLimit)); + } + } + + @Nested + class 방_라운드_변경 { + + @ParameterizedTest + @ValueSource(ints = {3, 10}) + void 라운드는_특정_범위_내_있어야_한다(int totalRound) { + // given + RoomSetting setting = new RoomSetting(5, 15_000, Category.IF); + + // when + setting.updateTotalRound(totalRound); + + // then + assertThat(setting.getTotalRound()).isEqualTo(totalRound); + } + + @ParameterizedTest + @ValueSource(ints = {2, 11}) + void 라운드_수가_범위를_벗어나는_경우_예외를_던진다(int notValidTotalRound) { + // given + RoomSetting setting = new RoomSetting(5, 15_000, Category.IF); + + // when & then + assertThatThrownBy(() -> setting.updateTotalRound(notValidTotalRound)) + .isExactlyInstanceOf(InvalidRangeTotalRoundException.class) + .hasMessage("총 라운드는 %d 이상, %d 이하만 가능합니다. requested totalRound: %d" + .formatted(3, 10, notValidTotalRound)); + } + } + + @Nested + class 제한_시간_설정 { + + @ParameterizedTest + @ValueSource(ints = {10_000, 15_000, 30_000, 60_000}) + void 시간_제한은_허용된_시간_중_하나_여야한다(int timeLimit) { + // given + RoomSetting setting = new RoomSetting(5, 15_000, Category.IF); + + // when + setting.updateTimeLimit(timeLimit); + + // then + assertThat(setting.getTimeLimit()).isEqualTo(timeLimit); + } + + @ParameterizedTest + @ValueSource(ints = {9_999, 15_001, 30_001, 59_999}) + void 시간_제한은_특정_시간이_아니라면_예외를_던진다(int notValidTimeLimit) { + // given + RoomSetting setting = new RoomSetting(5, 15_000, Category.IF); + + // when & then + assertThatThrownBy(() -> setting.updateTimeLimit(notValidTimeLimit)) + .isExactlyInstanceOf(InvalidTimeLimitException.class) + .hasMessage("시간 제한은 %s 만 가능합니다. requested timeLimit: %d" + .formatted(ALLOWED_TIME_LIMIT.toString(), notValidTimeLimit)); } } } diff --git a/backend/src/test/java/ddangkong/facade/room/RoomFacadeTest.java b/backend/src/test/java/ddangkong/facade/room/RoomFacadeTest.java index 20bd28da2..3d3c0bda4 100644 --- a/backend/src/test/java/ddangkong/facade/room/RoomFacadeTest.java +++ b/backend/src/test/java/ddangkong/facade/room/RoomFacadeTest.java @@ -21,10 +21,12 @@ import ddangkong.domain.support.EntityTestUtils; import ddangkong.exception.room.NotFinishedRoomException; import ddangkong.exception.room.NotFoundRoomException; +import ddangkong.exception.room.member.InvalidMemberIdException; import ddangkong.facade.BaseServiceTest; import ddangkong.facade.room.dto.InitialRoomResponse; import ddangkong.facade.room.dto.RoomInfoResponse; import ddangkong.facade.room.dto.RoomJoinResponse; +import ddangkong.facade.room.dto.RoomMemberResponse; import ddangkong.facade.room.dto.RoomSettingRequest; import ddangkong.facade.room.dto.RoomStatusResponse; import ddangkong.facade.room.dto.RoundFinishedResponse; @@ -124,6 +126,39 @@ class 방_참여 { } } + @Nested + class 사용자_정보_조회 { + + @Test + void 사용자_정보를_조회한다() { + // given + String nickname = "나는참가자"; + String uuid = "uuid4"; + MemberResponse expectedMemberResponse = new MemberResponse(14L, nickname, false); + roomFacade.joinRoom(nickname, uuid); + + // when + RoomMemberResponse actual = roomFacade.getRoomMemberInfo(14L); + + // then + assertAll( + () -> assertThat(actual.roomId()).isEqualTo(4L), + () -> assertThat(actual.roomUuid()).isEqualTo(uuid), + () -> assertThat(actual.member()).isEqualTo(expectedMemberResponse) + ); + } + + @Test + void 존재하지_않는_아이디로_사용자_정보를_조회할_수_없다() { + // given + Long notExistMemberId = 0L; + + // when & then + assertThatThrownBy(() -> roomFacade.getRoomMemberInfo(notExistMemberId)) + .isExactlyInstanceOf(InvalidMemberIdException.class); + } + } + @Nested class 방_나가기 { @@ -428,7 +463,7 @@ class 방_초기화 { private static final RoomStatus STATUS = RoomStatus.FINISH; - private static final RoomSetting ROOM_SETTING = new RoomSetting(5, 5000, Category.IF); + private static final RoomSetting ROOM_SETTING = new RoomSetting(5, 15_000, Category.IF); private BalanceContent content; diff --git a/backend/src/test/java/ddangkong/facade/room/balance/roomvote/RoomBalanceVoteFacadeTest.java b/backend/src/test/java/ddangkong/facade/room/balance/roomvote/RoomBalanceVoteFacadeTest.java index 00d46eaae..a0d9adcce 100644 --- a/backend/src/test/java/ddangkong/facade/room/balance/roomvote/RoomBalanceVoteFacadeTest.java +++ b/backend/src/test/java/ddangkong/facade/room/balance/roomvote/RoomBalanceVoteFacadeTest.java @@ -272,7 +272,7 @@ void init() { "투표_매칭도_조회", 3, RoomStatus.FINISH, - new RoomSetting(3, 5000, Category.IF))); + new RoomSetting(3, 15_000, Category.IF))); BalanceContent balanceContent1 = balanceContentRepository.save(new BalanceContent(Category.IF, "if1")); balanceOptionRepository.save(new BalanceOption("option1", balanceContent1)); diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index b86bfe3ad..05895ee4c 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -13,6 +13,13 @@ spring: cors: origin: "*" +cookie: + rejoin-key: test_cookie + +encrypt: + secret-key: 1234567890123456 + algorithm: AES + logging: level: org: diff --git a/frontend/.stylelintrc.json b/frontend/.stylelintrc.json index 53447c391..4a764618e 100644 --- a/frontend/.stylelintrc.json +++ b/frontend/.stylelintrc.json @@ -78,7 +78,8 @@ "text-align", "text-indent", "vertical-align", - "white-space" + "white-space", + "outline" ] }, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4f6ba7ed8..93cf6472c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@sentry/react": "^8.24.0", "@tanstack/react-query": "^5.51.1", "@tanstack/react-query-devtools": "^5.51.23", + "copy-webpack-plugin": "^12.0.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-qr-code": "^2.0.15", @@ -3886,7 +3887,6 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.6", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -4009,7 +4009,6 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -4021,7 +4020,6 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -4029,7 +4027,6 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4578,7 +4575,6 @@ }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6948,7 +6944,6 @@ }, "node_modules/@types/eslint": { "version": "8.56.10", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "*", @@ -6957,7 +6952,6 @@ }, "node_modules/@types/eslint-scope": { "version": "3.7.7", - "dev": true, "license": "MIT", "dependencies": { "@types/eslint": "*", @@ -6966,7 +6960,6 @@ }, "node_modules/@types/estree": { "version": "1.0.5", - "dev": true, "license": "MIT" }, "node_modules/@types/express": { @@ -7067,7 +7060,6 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -7100,7 +7092,6 @@ }, "node_modules/@types/node": { "version": "20.14.10", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -7506,7 +7497,6 @@ }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", @@ -7515,22 +7505,18 @@ }, "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.12.1", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", @@ -7540,12 +7526,10 @@ }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.12.1", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.12.1", @@ -7556,7 +7540,6 @@ }, "node_modules/@webassemblyjs/ieee754": { "version": "1.11.6", - "dev": true, "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" @@ -7564,7 +7547,6 @@ }, "node_modules/@webassemblyjs/leb128": { "version": "1.11.6", - "dev": true, "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" @@ -7572,12 +7554,10 @@ }, "node_modules/@webassemblyjs/utf8": { "version": "1.11.6", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.12.1", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.12.1", @@ -7592,7 +7572,6 @@ }, "node_modules/@webassemblyjs/wasm-gen": { "version": "1.12.1", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.12.1", @@ -7604,7 +7583,6 @@ }, "node_modules/@webassemblyjs/wasm-opt": { "version": "1.12.1", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.12.1", @@ -7615,7 +7593,6 @@ }, "node_modules/@webassemblyjs/wasm-parser": { "version": "1.12.1", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.12.1", @@ -7628,7 +7605,6 @@ }, "node_modules/@webassemblyjs/wast-printer": { "version": "1.12.1", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.12.1", @@ -7678,12 +7654,10 @@ }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", - "dev": true, "license": "Apache-2.0" }, "node_modules/@yarnpkg/fslib": { @@ -7739,7 +7713,6 @@ }, "node_modules/acorn": { "version": "8.12.1", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -7759,7 +7732,6 @@ }, "node_modules/acorn-import-attributes": { "version": "1.9.5", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8" @@ -7810,7 +7782,6 @@ }, "node_modules/ajv": { "version": "6.12.6", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -7825,7 +7796,6 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -7841,7 +7811,6 @@ }, "node_modules/ajv-formats/node_modules/ajv": { "version": "8.16.0", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -7856,12 +7825,10 @@ }, "node_modules/ajv-formats/node_modules/json-schema-traverse": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/ajv-keywords": { "version": "3.5.2", - "dev": true, "license": "MIT", "peerDependencies": { "ajv": "^6.9.1" @@ -8679,7 +8646,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -8694,7 +8660,6 @@ }, "node_modules/browserslist": { "version": "4.23.2", - "dev": true, "funding": [ { "type": "opencollective", @@ -8765,7 +8730,6 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "dev": true, "license": "MIT" }, "node_modules/bundle-name": { @@ -8843,7 +8807,6 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001641", - "dev": true, "funding": [ { "type": "opencollective", @@ -9003,7 +8966,6 @@ }, "node_modules/chrome-trace-event": { "version": "1.0.4", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0" @@ -9331,7 +9293,6 @@ }, "node_modules/commander": { "version": "2.20.3", - "dev": true, "license": "MIT" }, "node_modules/common-path-prefix": { @@ -9467,6 +9428,130 @@ "dev": true, "license": "MIT" }, + "node_modules/copy-webpack-plugin": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", + "dependencies": { + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.1", + "globby": "^14.0.0", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/copy-webpack-plugin/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/core-js": { "version": "3.37.1", "dev": true, @@ -10655,7 +10740,6 @@ }, "node_modules/electron-to-chromium": { "version": "1.4.825", - "dev": true, "license": "ISC" }, "node_modules/emittery": { @@ -10716,7 +10800,6 @@ }, "node_modules/enhanced-resolve": { "version": "5.17.0", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -10904,7 +10987,6 @@ }, "node_modules/es-module-lexer": { "version": "1.5.4", - "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -11007,7 +11089,6 @@ }, "node_modules/escalade": { "version": "3.1.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11516,7 +11597,6 @@ }, "node_modules/eslint-scope": { "version": "5.1.1", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -11789,7 +11869,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -11800,7 +11879,6 @@ }, "node_modules/esrecurse/node_modules/estraverse": { "version": "5.3.0", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -11808,7 +11886,6 @@ }, "node_modules/estraverse": { "version": "4.3.0", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -11851,7 +11928,6 @@ }, "node_modules/events": { "version": "3.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -12028,12 +12104,10 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.2", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -12053,7 +12127,6 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -12063,7 +12136,6 @@ }, "node_modules/fast-uri": { "version": "3.0.1", - "dev": true, "license": "MIT" }, "node_modules/fastest-levenshtein": { @@ -12076,7 +12148,6 @@ }, "node_modules/fastq": { "version": "1.17.1", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -12173,7 +12244,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -12900,7 +12970,6 @@ }, "node_modules/glob-parent": { "version": "5.1.2", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -12911,7 +12980,6 @@ }, "node_modules/glob-to-regexp": { "version": "0.4.1", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/global-dirs": { @@ -13040,7 +13108,6 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -13091,7 +13158,6 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13595,7 +13661,6 @@ }, "node_modules/ignore": { "version": "5.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -13880,7 +13945,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13929,7 +13993,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -14019,7 +14082,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -15978,7 +16040,6 @@ }, "node_modules/jest-worker": { "version": "27.5.1", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -16192,7 +16253,6 @@ }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -16638,7 +16698,6 @@ }, "node_modules/loader-runner": { "version": "4.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" @@ -17045,12 +17104,10 @@ }, "node_modules/merge-stream": { "version": "2.0.0", - "dev": true, "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -17066,7 +17123,6 @@ }, "node_modules/micromatch": { "version": "4.0.7", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -17089,7 +17145,6 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -17097,7 +17152,6 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -17399,7 +17453,6 @@ }, "node_modules/neo-async": { "version": "2.6.2", - "dev": true, "license": "MIT" }, "node_modules/no-case": { @@ -17512,12 +17565,10 @@ }, "node_modules/node-releases": { "version": "2.0.14", - "dev": true, "license": "MIT" }, "node_modules/normalize-path": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18264,7 +18315,6 @@ }, "node_modules/picomatch": { "version": "2.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -18649,7 +18699,6 @@ }, "node_modules/punycode": { "version": "2.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -18696,7 +18745,6 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", - "dev": true, "funding": [ { "type": "github", @@ -18715,7 +18763,6 @@ }, "node_modules/randombytes": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" @@ -19161,7 +19208,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -19247,7 +19293,6 @@ }, "node_modules/reusify": { "version": "1.0.4", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -19289,7 +19334,6 @@ }, "node_modules/run-parallel": { "version": "1.2.0", - "dev": true, "funding": [ { "type": "github", @@ -19342,7 +19386,6 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "dev": true, "funding": [ { "type": "github", @@ -19400,7 +19443,6 @@ }, "node_modules/schema-utils": { "version": "3.3.0", - "dev": true, "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.8", @@ -19483,7 +19525,6 @@ }, "node_modules/serialize-javascript": { "version": "6.0.2", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" @@ -19744,7 +19785,6 @@ }, "node_modules/source-map": { "version": "0.6.1", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -19760,7 +19800,6 @@ }, "node_modules/source-map-support": { "version": "0.5.21", - "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -20626,7 +20665,6 @@ }, "node_modules/supports-color": { "version": "8.1.1", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -20793,7 +20831,6 @@ }, "node_modules/tapable": { "version": "2.2.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -20953,7 +20990,6 @@ }, "node_modules/terser": { "version": "5.31.2", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -20970,7 +21006,6 @@ }, "node_modules/terser-webpack-plugin": { "version": "5.3.10", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.20", @@ -21125,7 +21160,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -21534,7 +21568,6 @@ }, "node_modules/undici-types": { "version": "5.26.5", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -21575,7 +21608,6 @@ }, "node_modules/unicorn-magic": { "version": "0.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -21678,7 +21710,6 @@ }, "node_modules/update-browserslist-db": { "version": "1.1.0", - "dev": true, "funding": [ { "type": "opencollective", @@ -21707,7 +21738,6 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -21855,7 +21885,6 @@ }, "node_modules/watchpack": { "version": "2.4.1", - "dev": true, "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", @@ -21891,7 +21920,6 @@ }, "node_modules/webpack": { "version": "5.92.1", - "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.3", @@ -22277,7 +22305,6 @@ }, "node_modules/webpack-sources": { "version": "3.2.3", - "dev": true, "license": "MIT", "engines": { "node": ">=10.13.0" diff --git a/frontend/package.json b/frontend/package.json index ab62dc39a..97c34f4a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@sentry/react": "^8.24.0", "@tanstack/react-query": "^5.51.1", "@tanstack/react-query-devtools": "^5.51.23", + "copy-webpack-plugin": "^12.0.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-qr-code": "^2.0.15", diff --git a/frontend/public/assets/thumbnail.png b/frontend/public/assets/thumbnail.png new file mode 100644 index 000000000..3bd0839fe Binary files /dev/null and b/frontend/public/assets/thumbnail.png differ diff --git a/frontend/public/index.html b/frontend/public/index.html index a23a5658c..d08116eaf 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,56 +1,100 @@ - + + + + + - - - - - 땅콩 - 단체 대화주제 제공 서비스 + + + + 땅콩 - 단체 대화주제 제공 서비스 - - + + - - + + - - - - - - - + + + + + + + + + + - - - -
- + gtag('config', 'G-3BFVVPQT0Z'); + + +
+ diff --git a/frontend/src/apis/balanceContent.ts b/frontend/src/apis/balanceContent.ts index a494600ac..04c0c72c1 100644 --- a/frontend/src/apis/balanceContent.ts +++ b/frontend/src/apis/balanceContent.ts @@ -87,7 +87,7 @@ export const checkMyGameStatus = async ({ // 다음 라운드로 이동하기 export const moveNextRound = async (roomId: number) => { - const res = await fetcher.patch({ + await fetcher.patch({ url: API_URL.moveNextRound(roomId), headers: { 'Content-Type': `application/json`, diff --git a/frontend/src/apis/fetcher.ts b/frontend/src/apis/fetcher.ts index 1d5423b59..fea16a575 100644 --- a/frontend/src/apis/fetcher.ts +++ b/frontend/src/apis/fetcher.ts @@ -16,6 +16,7 @@ const fetcher = { method, body: body && JSON.stringify(body), headers: headers && headers, + credentials: 'include', }); if (!response.ok) { diff --git a/frontend/src/apis/room.ts b/frontend/src/apis/room.ts index 214c3621e..0a904770d 100644 --- a/frontend/src/apis/room.ts +++ b/frontend/src/apis/room.ts @@ -1,7 +1,13 @@ import fetcher from './fetcher'; import { API_URL } from '@/constants/url'; -import { RoomInfo, CreateOrEnterRoomResponse, Category, RoomSettingApply } from '@/types/room'; +import { + RoomInfo, + CreateOrEnterRoomResponse, + Category, + RoomSettingApply, + RoomAndMember, +} from '@/types/room'; interface CategoryResponse { categories: Category[]; @@ -136,3 +142,12 @@ export const isJoinableRoom = async (roomUuid: string): Promise<{ isJoinable: bo return data; }; + +// 사용자 정보 조회 +export const getUserInfo = async (): Promise => { + const res = await fetcher.get({ + url: API_URL.getUserInfo, + }); + const data = await res.json(); + return data; +}; diff --git a/frontend/src/components/GameResult/GameResult.hook.ts b/frontend/src/components/GameResult/GameResult.hook.ts index 6f77e4cef..c702fe132 100644 --- a/frontend/src/components/GameResult/GameResult.hook.ts +++ b/frontend/src/components/GameResult/GameResult.hook.ts @@ -1,16 +1,11 @@ import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; - -import AlertModal from '../common/AlertModal/AlertModal'; import { fetchMatchingResult } from '@/apis/balanceContent'; import { resetRoom } from '@/apis/room'; import { QUERY_KEYS } from '@/constants/queryKeys'; -import useModal from '@/hooks/useModal'; -import { memberInfoState } from '@/recoil/atom'; +import useGetUserInfo from '@/hooks/useGetUserInfo'; import { MatchingResult, MemberMatchingInfo } from '@/types/balanceContent'; -import { CustomError } from '@/utils/error'; type MatchingResultQueryResponse = UseQueryResult & { matchedMembers?: MemberMatchingInfo[]; @@ -19,15 +14,17 @@ type MatchingResultQueryResponse = UseQueryResult & { export const useMatchingResultQuery = (): MatchingResultQueryResponse => { const { roomId } = useParams(); - const memberInfo = useRecoilValue(memberInfoState); + const { + member: { memberId }, + } = useGetUserInfo(); const matchingResultQuery = useQuery({ - queryKey: [QUERY_KEYS.matchingResult, roomId, memberInfo.memberId], + queryKey: [QUERY_KEYS.matchingResult, roomId, memberId], queryFn: async () => { - if (!memberInfo.memberId) { + if (!memberId) { throw new Error('Member ID is required'); } - return await fetchMatchingResult({ roomId: Number(roomId), memberId: memberInfo.memberId }); + return await fetchMatchingResult({ roomId: Number(roomId), memberId: memberId }); }, }); @@ -39,12 +36,7 @@ export const useMatchingResultQuery = (): MatchingResultQueryResponse => { }; export const useResetRoomMutation = (roomId: number) => { - const { show: showModal } = useModal(); - return useMutation({ mutationFn: async () => await resetRoom(roomId), - onError: (error: CustomError) => { - showModal(AlertModal, { title: '방 초기화 에러', message: error.message }); - }, }); }; diff --git a/frontend/src/components/GameResult/GameResult.styled.ts b/frontend/src/components/GameResult/GameResult.styled.ts index ccc03d8b9..3798b60c1 100644 --- a/frontend/src/components/GameResult/GameResult.styled.ts +++ b/frontend/src/components/GameResult/GameResult.styled.ts @@ -7,7 +7,7 @@ export const gameResultLayout = css` align-items: center; gap: 4.8rem; width: 100%; - height: calc(100vh - 15vh - 7.2rem); + height: calc(100dvh - 15dvh - 7.2rem); overflow-y: scroll; ::-webkit-scrollbar { @@ -35,8 +35,8 @@ export const noMatchingLayout = css` `; export const noMatchingImg = css` - width: 18rem; - height: 30vh; + width: 16rem; + height: 16rem; `; export const noMatchingText = css` diff --git a/frontend/src/components/GameResult/GameResult.tsx b/frontend/src/components/GameResult/GameResult.tsx index d2ba8b055..3a9b0f005 100644 --- a/frontend/src/components/GameResult/GameResult.tsx +++ b/frontend/src/components/GameResult/GameResult.tsx @@ -1,3 +1,5 @@ +import { useRef } from 'react'; + import { useMatchingResultQuery } from './GameResult.hook'; import { gameResultLayout, @@ -20,25 +22,57 @@ import SadDdangKong from '@/assets/images/sadDdangkong.webp'; const GameResult = () => { const { matchedMembers, existMatching, isLoading } = useMatchingResultQuery(); const { resultContainerRef, isAtTop, isAtBottom } = useScrollState(); + const firstRankLiRef = useRef(null); + const lastRankLiRef = useRef(null); const { scrollToTop, scrollToBottom } = useScrollControl(resultContainerRef); + const handleScrollToTop = () => { + scrollToTop(); + firstRankLiRef.current?.focus(); + }; + + const handleScrollToBottom = () => { + scrollToBottom(); + lastRankLiRef.current?.focus(); + }; + + const getRefForIndex = ( + index: number, + length: number, + firstRef: React.RefObject, + lastRef: React.RefObject, + ): React.RefObject | null => { + if (index === 0) return firstRef; + if (index === length - 1) return lastRef; + return null; + }; + return ( <>
{isLoading && } - + {existMatching && !isAtBottom && ( + + )} {existMatching && (
    {matchedMembers && - matchedMembers.map((memberMatchingInfo) => ( + matchedMembers.map((memberMatchingInfo, index) => ( ))}
)} - + {existMatching && !isAtTop && ( + + )} {!isLoading && !existMatching && (
서운한 땅콩 @@ -47,17 +81,6 @@ const GameResult = () => {
)} - - {existMatching && !isAtTop && ( - - )} - {existMatching && !isAtBottom && ( - - )}
diff --git a/frontend/src/components/GameResultItem/GameResultItem.styled.ts b/frontend/src/components/GameResultItem/GameResultItem.styled.ts index 1317dfed3..7ec5d3d9a 100644 --- a/frontend/src/components/GameResultItem/GameResultItem.styled.ts +++ b/frontend/src/components/GameResultItem/GameResultItem.styled.ts @@ -6,21 +6,29 @@ export const rankItem = css` display: flex; justify-content: space-between; align-items: center; + width: 100%; ${Theme.typography.headline3}; + + :focus { + outline: none; + } `; -export const rankInfoContainer = css` +export const rankNicknameWrapper = css` display: flex; - flex-basis: 85%; align-items: center; - gap: 1.2rem; + width: 85%; `; -export const rankNumber = css` +export const rankNumberContainer = css` display: flex; justify-content: center; - width: 2.4rem; - ${Theme.typography.headline1} + width: 15%; +`; + +export const rankNumber = css` + font-weight: bold; + font-size: 1.6rem; `; export const nicknameContainer = (percent: number) => css` @@ -37,8 +45,13 @@ export const nicknameContainer = (percent: number) => css` transition: all 2s; `; +export const rankPercentWrapper = css` + display: flex; + justify-content: center; + width: 15%; +`; + export const rankPercent = css` - width: 1.6rem; ${Theme.typography.headline3}; `; diff --git a/frontend/src/components/GameResultItem/GameResultItem.tsx b/frontend/src/components/GameResultItem/GameResultItem.tsx index ac0c341f4..75b493596 100644 --- a/frontend/src/components/GameResultItem/GameResultItem.tsx +++ b/frontend/src/components/GameResultItem/GameResultItem.tsx @@ -1,11 +1,14 @@ +import { forwardRef } from 'react'; + import { - nickname, nicknameContainer, profileImage, - rankInfoContainer, rankItem, + rankNicknameWrapper, rankNumber, + rankNumberContainer, rankPercent, + rankPercentWrapper, } from './GameResultItem.styled'; import SillyDdangkongMedium from '@/assets/images/sillyDdangkongMedium.webp'; @@ -16,24 +19,33 @@ interface GameResultItemProps { memberMatchingInfo: MemberMatchingInfo; } -const GameResultItem = ({ memberMatchingInfo }: GameResultItemProps) => { - const animatedRankPercent = useCountAnimation({ - target: memberMatchingInfo.matchingPercent, - duration: 3000, - }); +const GameResultItem = forwardRef( + ({ memberMatchingInfo }, targetRef) => { + const { rank, nickname, matchingPercent } = memberMatchingInfo; + const animatedRankPercent = useCountAnimation({ + target: matchingPercent, + duration: 3000, + }); - return ( -
  • -
    - {memberMatchingInfo.rank} -
    - - {memberMatchingInfo.nickname} + return ( +
  • +
    +
    + {`${rank}위`} +
    +
    + + {nickname} +
    +
    +
    + {`${animatedRankPercent}%`}
    - - {animatedRankPercent}% -
  • - ); -}; + + ); + }, +); + +GameResultItem.displayName = 'GameResultItem'; export default GameResultItem; diff --git a/frontend/src/components/NicknameItem/NicknameItem.tsx b/frontend/src/components/NicknameItem/NicknameItem.tsx index b5e3151c4..6c90eeddb 100644 --- a/frontend/src/components/NicknameItem/NicknameItem.tsx +++ b/frontend/src/components/NicknameItem/NicknameItem.tsx @@ -1,16 +1,15 @@ -import { useRecoilValue } from 'recoil'; - import { nicknameItemLayout, nicknameText, profileImage } from './NicknameItem.styled'; import SillyDdangkongMedium from '@/assets/images/sillyDdangkongMedium.webp'; -import { memberInfoState } from '@/recoil/atom'; +import useGetUserInfo from '@/hooks/useGetUserInfo'; + interface NicknameItemProp { nickName: string; } const NicknameItem = ({ nickName }: NicknameItemProp) => { - const memberInfo = useRecoilValue(memberInfoState); - const isMyNickname = memberInfo.nickname === nickName; + const { member } = useGetUserInfo(); + const isMyNickname = member.nickname === nickName; return (
  • diff --git a/frontend/src/components/OptionParticipants/OptionParticipants.tsx b/frontend/src/components/OptionParticipants/OptionParticipants.tsx index b7339c0b7..53838084a 100644 --- a/frontend/src/components/OptionParticipants/OptionParticipants.tsx +++ b/frontend/src/components/OptionParticipants/OptionParticipants.tsx @@ -13,11 +13,10 @@ export interface OptionParticipantsProps { } const OptionParticipants = ({ optionName, memberCount, members }: OptionParticipantsProps) => { + const screenReaderOptionParticipants = `${optionName}. ${memberCount}명`; return (
    - - {optionName}.{memberCount}명 - + {screenReaderOptionParticipants}

    {optionName}: {memberCount}

    diff --git a/frontend/src/components/OptionParticipantsContainer/OptionParticipantsContainer.tsx b/frontend/src/components/OptionParticipantsContainer/OptionParticipantsContainer.tsx index a1c0b18ae..781200ed8 100644 --- a/frontend/src/components/OptionParticipantsContainer/OptionParticipantsContainer.tsx +++ b/frontend/src/components/OptionParticipantsContainer/OptionParticipantsContainer.tsx @@ -1,51 +1,36 @@ -import { useParams } from 'react-router-dom'; - import { horizontalDivider, optionParticipantsContainerLayout, } from './OptionParticipantsContainer.styled'; import OptionParticipants from '../OptionParticipants/OptionParticipants'; -import useBalanceContentQuery from '@/hooks/useBalanceContentQuery'; -import useMyGameStatus from '@/hooks/useMyGameStatus'; -import useRoundVoteResultQuery from '@/hooks/useRoundVoteResultQuery'; - -const OptionParticipantsContainer = () => { - const { roomId } = useParams(); - const { balanceContent } = useBalanceContentQuery(Number(roomId)); - const { groupRoundResult } = useRoundVoteResultQuery({ - roomId: Number(roomId), - contentId: balanceContent?.contentId, - }); - - useMyGameStatus({ roomId: Number(roomId) }); +import { Group } from '@/types/roundVoteResult'; - if (!groupRoundResult) { - return
    데이터가 없습니다
    ; - } +interface OptionParticipantsContainerProps { + groupRoundResult: Group; +} +const OptionParticipantsContainer = ({ groupRoundResult }: OptionParticipantsContainerProps) => { return ( - <> -
    - -
    - -
    - -
    - +
    + +
    + +
    + +
    ); }; diff --git a/frontend/src/components/ReadyMembersContainer/ReadyMembersContainer.tsx b/frontend/src/components/ReadyMembersContainer/ReadyMembersContainer.tsx index 1d2ec20e9..efa9e4376 100644 --- a/frontend/src/components/ReadyMembersContainer/ReadyMembersContainer.tsx +++ b/frontend/src/components/ReadyMembersContainer/ReadyMembersContainer.tsx @@ -1,5 +1,5 @@ -import { useEffect } from 'react'; -import { useRecoilState } from 'recoil'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; import { readyMembersContainerLayout, @@ -17,32 +17,30 @@ import A11yOnly from '../common/a11yOnly/A11yOnly'; import crownIcon from '@/assets/images/crownIcon.webp'; import SillyDdangkongMedium from '@/assets/images/sillyDdangkongMedium.webp'; import InviteModal from '@/components/common/InviteModal/InviteModal'; +import { QUERY_KEYS } from '@/constants/queryKeys'; import { useGetRoomInfo } from '@/hooks/useGetRoomInfo'; import useModal from '@/hooks/useModal'; -import { memberInfoState } from '@/recoil/atom'; const ReadyMembersContainer = () => { const { members, master } = useGetRoomInfo(); const { show } = useModal(); - const [memberInfo, setMemberInfo] = useRecoilState(memberInfoState); + const queryClient = useQueryClient(); + const returnFocusRef = useRef(null); + const memberCountMessage = `총 인원 ${members.length}명`; const handleClickInvite = () => { - show(InviteModal); + show(InviteModal, { returnFocusRef }); }; - // 원래 방장이 아니다 + 방장의 memberId와 내 memberId가 같다 -> 방장으로 변경 useEffect(() => { - if (!memberInfo.isMaster && master.memberId === memberInfo.memberId) { - setMemberInfo({ ...memberInfo, isMaster: true }); - } - }, [master.memberId, memberInfo, setMemberInfo]); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getUserInfo] }); + }, [master.memberId]); return (
    - 총 인원 {members.length}명
    -
    총 인원 {members.length}명
    -
    diff --git a/frontend/src/components/RoomSetting/RoomSetting.styled.ts b/frontend/src/components/RoomSetting/RoomSetting.styled.ts index 5260d5261..452419e76 100644 --- a/frontend/src/components/RoomSetting/RoomSetting.styled.ts +++ b/frontend/src/components/RoomSetting/RoomSetting.styled.ts @@ -5,7 +5,8 @@ import getBorderRadius from '@/styles/utils/getBorderRadius'; export const roomSettingLayout = css` display: flex; - justify-content: space-evenly; + flex-direction: column; + justify-content: space-between; align-items: center; width: 100%; height: 10rem; @@ -16,22 +17,35 @@ export const roomSettingLayout = css` cursor: pointer; `; -export const roomSettingBox = css` - display: flex; - flex-direction: column; - gap: 1rem; -`; - export const bigTitle = css` + width: 10rem; + font-weight: 800; font-size: 2.8rem; `; export const smallTitle = css` + width: 10rem; + font-weight: 800; font-size: 2rem; `; -export const roomSettingLabel = css` +export const roomSettingKey = css` + width: 10rem; +`; + +export const roomSettingKeyBox = css` + display: flex; + justify-content: space-between; + width: 80%; + font-weight: 600; `; + +export const roomSettingValueBox = css` + display: flex; + justify-content: space-between; + align-items: center; + width: 80%; +`; diff --git a/frontend/src/components/RoomSetting/RoomSetting.test.tsx b/frontend/src/components/RoomSetting/RoomSetting.test.tsx index d687a7cb1..3d27f1fab 100644 --- a/frontend/src/components/RoomSetting/RoomSetting.test.tsx +++ b/frontend/src/components/RoomSetting/RoomSetting.test.tsx @@ -1,20 +1,18 @@ import { screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import type { MutableSnapshot } from 'recoil'; +import { http, HttpResponse } from 'msw'; import RoomSetting from './RoomSetting'; -import { memberInfoState } from '@/recoil/atom'; +import { MOCK_API_URL } from '@/constants/url'; +import { server } from '@/mocks/server'; import { customRender } from '@/utils/test-utils'; describe('RoomSetting 컴포넌트 테스트', () => { it('방장이 RoomSetting를 누르면 설정 modal이 뜬다', async () => { // Given - const initializeState = (snap: MutableSnapshot) => { - snap.set(memberInfoState, { memberId: 1, nickname: 'Test User', isMaster: true }); - }; const user = userEvent.setup(); - customRender(, { initializeState }); + customRender(); const settingButton = await screen.findByRole('button', { name: '방 설정' }); // When @@ -29,11 +27,20 @@ describe('RoomSetting 컴포넌트 테스트', () => { it('방장이 아닌 사람이 RoomSetting를 누르면 설정 modal이 뜨지 않는다', async () => { // Given - const initializeState = (snap: MutableSnapshot) => { - snap.set(memberInfoState, { memberId: 1, nickname: 'Test User', isMaster: false }); - }; + server.use( + http.get(MOCK_API_URL.getUserInfo, async () => { + return HttpResponse.json( + { + member: { + isMaster: false, + }, + }, + { status: 400 }, + ); + }), + ); const user = userEvent.setup(); - customRender(, { initializeState }); + customRender(); const optionButton = await screen.findByRole('button', { name: '방 설정' }); // When diff --git a/frontend/src/components/RoomSetting/RoomSetting.tsx b/frontend/src/components/RoomSetting/RoomSetting.tsx index e3fa55368..d6a5c91ab 100644 --- a/frontend/src/components/RoomSetting/RoomSetting.tsx +++ b/frontend/src/components/RoomSetting/RoomSetting.tsx @@ -1,31 +1,35 @@ -import { useRecoilValue } from 'recoil'; +import { useRef } from 'react'; import { roomSettingLayout, bigTitle, smallTitle, - roomSettingLabel, - roomSettingBox, + roomSettingKeyBox, + roomSettingValueBox, + roomSettingKey, } from './RoomSetting.styled'; import A11yOnly from '@/components/common/a11yOnly/A11yOnly'; import RoomSettingModal from '@/components/common/RoomSettingModal/RoomSettingModal'; import { useGetRoomInfo } from '@/hooks/useGetRoomInfo'; +import useGetUserInfo from '@/hooks/useGetUserInfo'; import useModal from '@/hooks/useModal'; -import { memberInfoState } from '@/recoil/atom'; const RoomSetting = () => { + const returnFocusRef = useRef(null); const { roomSetting } = useGetRoomInfo(); - const { isMaster } = useRecoilValue(memberInfoState); + const { + member: { isMaster }, + } = useGetUserInfo(); const { show } = useModal(); const screenReaderRoomSetting = ` 방 정보. 카테고리 ${roomSetting.category.label}. 라운드 ${roomSetting.totalRound}. - 타이머 ${roomSetting.timeLimit / 1000}초.`; + 제한시간 ${roomSetting.timeLimit / 1000}초.`; const handleClickCategory = () => { - show(RoomSettingModal); + show(RoomSettingModal, { returnFocusRef }); }; return ( @@ -35,17 +39,16 @@ const RoomSetting = () => { aria-label="방 설정" css={roomSettingLayout} onClick={isMaster ? handleClickCategory : () => {}} + ref={returnFocusRef} > -
    - 라운드 -

    {roomSetting.totalRound}

    +
    + 라운드 + 카테고리 + 제한 시간
    -
    - 카테고리 +
    +

    {roomSetting.totalRound}

    {roomSetting.category.label}

    -
    -
    - 타이머

    {roomSetting.timeLimit / 1000}초

    diff --git a/frontend/src/components/RoundVoteContainer/RoundVoteContainer.hook.ts b/frontend/src/components/RoundVoteContainer/RoundVoteContainer.hook.ts index 23cb0a357..5f59c3c97 100644 --- a/frontend/src/components/RoundVoteContainer/RoundVoteContainer.hook.ts +++ b/frontend/src/components/RoundVoteContainer/RoundVoteContainer.hook.ts @@ -1,10 +1,10 @@ import useCountAnimation from '@/hooks/useCountAnimation'; import { Group } from '@/types/roundVoteResult'; -const useTotalCountAnimation = (groupRoundResult?: Group) => { - const animatedFirstPercent = useCountAnimation({ target: groupRoundResult?.firstOption.percent }); +const useTotalCountAnimation = (groupRoundResult: Group) => { + const animatedFirstPercent = useCountAnimation({ target: groupRoundResult.firstOption.percent }); const animatedSecondPercent = useCountAnimation({ - target: groupRoundResult?.secondOption.percent, + target: groupRoundResult.secondOption.percent, }); return { diff --git a/frontend/src/components/SelectContainer/SelectContainer.test.tsx b/frontend/src/components/SelectContainer/SelectContainer.test.tsx index accacd872..d4cfa8f39 100644 --- a/frontend/src/components/SelectContainer/SelectContainer.test.tsx +++ b/frontend/src/components/SelectContainer/SelectContainer.test.tsx @@ -25,7 +25,7 @@ describe('SelectContainer', () => { customRender(); - const optionButton = await screen.findByRole('button', { name: SELECT_OPTION }); + const optionButton = await screen.findByRole('radio', { name: SELECT_OPTION }); await user.click(optionButton); const selectButton = await screen.findByRole('button', { name: '선택' }); diff --git a/frontend/src/components/SelectContainer/SelectContainer.tsx b/frontend/src/components/SelectContainer/SelectContainer.tsx index 73163412e..d026f590a 100644 --- a/frontend/src/components/SelectContainer/SelectContainer.tsx +++ b/frontend/src/components/SelectContainer/SelectContainer.tsx @@ -20,7 +20,7 @@ const SelectContainer = () => { isVoted={selectedOption.isCompleted} completeSelection={completeSelection} /> -
    +
    { isVoted, completeSelection, }); + const screenReaderLeftRoundTime = `${leftRoundTime}초 남았습니다.`; useVoteIsFinished({ contentId: balanceContent.contentId, @@ -42,8 +43,8 @@ const Timer = ({ selectedId, isVoted, completeSelection }: TimerProps) => {
    - - {leftRoundTime}초 남았습니다. + + {screenReaderLeftRoundTime} {formatLeftRoundTime(leftRoundTime)} diff --git a/frontend/src/components/SelectContainer/Timer/Timer.util.ts b/frontend/src/components/SelectContainer/Timer/Timer.util.ts index 6323bccca..241c38d01 100644 --- a/frontend/src/components/SelectContainer/Timer/Timer.util.ts +++ b/frontend/src/components/SelectContainer/Timer/Timer.util.ts @@ -15,7 +15,7 @@ export const convertMsecToSecond = (msec: number) => { return msec / UNIT_MSEC; }; -// Timer가 스크린 리더에 읽혀야하는 시점에 aria-live="polite" 설정하도록 boolean 값 반환 (제한 시간 절반 & 5초 남았을 때) +// Timer가 스크린 리더에 읽혀야하는 시점에 aria-live="assertive" 설정하도록 boolean 값 반환 (제한 시간 절반 & 5초 남았을 때) export const isAlertTimer = (leftRoundTime: number, timeLimit: number) => { return leftRoundTime === Math.floor(timeLimit / 2) || leftRoundTime === ALMOST_FINISH_SECOND; }; diff --git a/frontend/src/components/SelectOption/SelectOption.tsx b/frontend/src/components/SelectOption/SelectOption.tsx index 5a5ed6f24..21c0c6720 100644 --- a/frontend/src/components/SelectOption/SelectOption.tsx +++ b/frontend/src/components/SelectOption/SelectOption.tsx @@ -18,10 +18,11 @@ const SelectOption = ({ option, selectedOption, handleClickOption }: SelectOptio return ( diff --git a/frontend/src/components/StartButtonContainer/StartButton/StartButton.test.tsx b/frontend/src/components/StartButtonContainer/StartButton/StartButton.test.tsx index 723ca81c5..c0a008b20 100644 --- a/frontend/src/components/StartButtonContainer/StartButton/StartButton.test.tsx +++ b/frontend/src/components/StartButtonContainer/StartButton/StartButton.test.tsx @@ -7,7 +7,7 @@ import StartButton from './StartButton'; import { ERROR_MESSAGE } from '@/constants/message'; import { MOCK_API_URL } from '@/constants/url'; import { server } from '@/mocks/server'; -import { customRenderWithIsMaster } from '@/utils/test-utils'; +import { customRenderWithMaster } from '@/utils/test-utils'; describe('StartButton 테스트', () => { it('시작 버튼을 클릭했을 때, 게임 시작 API에서 에러가 발생하면 알림 모달이 뜬다.', async () => { @@ -24,7 +24,7 @@ describe('StartButton 테스트', () => { }), ); - customRenderWithIsMaster(, true); + customRenderWithMaster(); const startButton = await screen.findByRole('button', { name: '시작' }); await user.click(startButton); diff --git a/frontend/src/components/StartButtonContainer/StartButton/StartButton.tsx b/frontend/src/components/StartButtonContainer/StartButton/StartButton.tsx index 0311cb213..8bb25ae35 100644 --- a/frontend/src/components/StartButtonContainer/StartButton/StartButton.tsx +++ b/frontend/src/components/StartButtonContainer/StartButton/StartButton.tsx @@ -1,14 +1,15 @@ import { useGameStart } from './hooks/useGameStart'; +import getStartButtonText from './StartButton.utils'; import Button from '@/components/common/Button/Button'; const StartButton = () => { - const { memberInfo, handleGameStart } = useGameStart(); + const { isMaster, handleGameStart, isPending, isSuccess } = useGameStart(); return (
    diff --git a/frontend/src/components/common/AlertModal/AlertModal.tsx b/frontend/src/components/common/AlertModal/AlertModal.tsx index 1c81e6388..ddfbb66b7 100644 --- a/frontend/src/components/common/AlertModal/AlertModal.tsx +++ b/frontend/src/components/common/AlertModal/AlertModal.tsx @@ -1,4 +1,4 @@ -import { Fragment } from 'react'; +import { Fragment, RefObject } from 'react'; import { alertModalTitle, alertText, messageContainer } from './AlertModal.styled'; import Modal from '../Modal/Modal'; @@ -9,16 +9,24 @@ interface AlertModalProps { onConfirm?: () => void; message?: string; title?: string; + returnFocusRef?: RefObject; } -const AlertModal = ({ isOpen, onClose, onConfirm, message, title }: AlertModalProps) => { +const AlertModal = ({ + isOpen, + onClose, + onConfirm, + message, + title, + returnFocusRef, +}: AlertModalProps) => { const handleClick = () => { onConfirm && onConfirm(); onClose(); }; return ( - + {title || '알림'} diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index a8309f6bc..fee1e1e3c 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -1,4 +1,4 @@ -import React, { ButtonHTMLAttributes } from 'react'; +import React, { ButtonHTMLAttributes, forwardRef } from 'react'; import { buttonLayout } from './Button.styled'; @@ -13,26 +13,22 @@ interface ButtonProps extends ButtonHTMLAttributes { bottom?: boolean; } -const Button: React.FC = ({ - text, - onClick, - disabled, - size, - radius, - fontSize, - bottom, - ...props -}) => { - return ( - - ); -}; +const Button = forwardRef( + ({ text, onClick, disabled, size, radius, fontSize, bottom, ...props }, ref) => { + return ( + + ); + }, +); + +Button.displayName = 'Button'; export default Button; diff --git a/frontend/src/components/common/Dropdown/Dropdown.stories.tsx b/frontend/src/components/common/Dropdown/Dropdown.stories.tsx index c5c34819a..c8458f130 100644 --- a/frontend/src/components/common/Dropdown/Dropdown.stories.tsx +++ b/frontend/src/components/common/Dropdown/Dropdown.stories.tsx @@ -14,12 +14,12 @@ const meta = { optionList: { description: '드랍다운 내에 들어갈 옵션 배열을 넘겨줄 수 있습니다.', }, - handleClick: { + handleClickOption: { description: '옵션을 선택했을 때 동작하는 이벤트 핸들러입니다.', }, }, args: { - handleClick: fn(), + handleClickOption: fn(), }, } satisfies Meta; @@ -52,7 +52,7 @@ export const 기본_드랍다운: Story = { label: '음식', }, ]} - handleClick={(e) => setText(e.currentTarget.value)} + handleClickOption={(e) => setText(e.currentTarget.value)} /> ); }, diff --git a/frontend/src/components/common/Dropdown/Dropdown.styled.ts b/frontend/src/components/common/Dropdown/Dropdown.styled.ts index e825beb77..471f0b879 100644 --- a/frontend/src/components/common/Dropdown/Dropdown.styled.ts +++ b/frontend/src/components/common/Dropdown/Dropdown.styled.ts @@ -7,7 +7,7 @@ export const dropdownLayout = css` position: relative; align-items: center; - width: 12rem; + width: 16rem; height: 3.6rem; padding: 0.8rem; border: 1px solid black; diff --git a/frontend/src/components/common/Dropdown/Dropdown.tsx b/frontend/src/components/common/Dropdown/Dropdown.tsx index 86166d05e..a79dd34e0 100644 --- a/frontend/src/components/common/Dropdown/Dropdown.tsx +++ b/frontend/src/components/common/Dropdown/Dropdown.tsx @@ -16,64 +16,80 @@ import ArrowUp from '@/assets/images/arrowUp.svg'; interface DropdownProps { text: string; optionList: T[]; - handleClick: (e: React.MouseEvent) => void; + handleClickOption: (e: React.MouseEvent) => void; } const Dropdown = ({ text, optionList, - handleClick, + handleClickOption, }: DropdownProps) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const triggerRef = useRef(null); + const handleToggleDropdown = () => { setIsOpen((prev) => !prev); + triggerRef.current?.focus(); + }; + + const handleSelectOption = (e: React.MouseEvent) => { + handleClickOption(e); + handleToggleDropdown(); }; useEffect(() => { const handleOutsideClose = (e: MouseEvent) => { - const isOutsideDropdown = isOpen && !dropdownRef.current?.contains(e.target as Element); - - if (isOutsideDropdown) { + if (isOpen && dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setIsOpen(false); } }; document.addEventListener('click', handleOutsideClose); - return () => document.removeEventListener('click', handleOutsideClose); + return () => { + document.removeEventListener('click', handleOutsideClose); + }; }, [isOpen]); return ( -
    -
    +
    +
    -
      - {isOpen && - optionList.map((option) => ( -
    • + + + {isOpen && ( + +
    + )}
    ); }; diff --git a/frontend/src/components/common/ErrorBoundary/AsyncErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary/AsyncErrorBoundary.tsx index 60c7136a7..094fcebf8 100644 --- a/frontend/src/components/common/ErrorBoundary/AsyncErrorBoundary.tsx +++ b/frontend/src/components/common/ErrorBoundary/AsyncErrorBoundary.tsx @@ -4,7 +4,7 @@ import { PropsWithChildren, Suspense } from 'react'; import DeferredComponent from '../DeferredComponent/DeferredComponent'; import AsyncErrorFallback from '../ErrorFallback/AsyncErrorFallback/AsyncErrorFallback'; -import Spinner from '../Spinner/Spinner'; +import SpinnerErrorFallback from '../ErrorFallback/SpinnerErrorFallback/SpinnerErrorFallback'; import { CustomError } from '@/utils/error'; @@ -13,7 +13,7 @@ interface AsyncErrorBoundaryProps { } const AsyncErrorBoundary = ({ - pendingFallback = , + pendingFallback = , children, }: PropsWithChildren) => { const { reset } = useQueryErrorResetBoundary(); diff --git a/frontend/src/components/common/ErrorFallback/AsyncErrorFallback/AsyncErrorFallback.tsx b/frontend/src/components/common/ErrorFallback/AsyncErrorFallback/AsyncErrorFallback.tsx index a8b895ce6..e8f95882b 100644 --- a/frontend/src/components/common/ErrorFallback/AsyncErrorFallback/AsyncErrorFallback.tsx +++ b/frontend/src/components/common/ErrorFallback/AsyncErrorFallback/AsyncErrorFallback.tsx @@ -1,5 +1,3 @@ -import { useNavigate } from 'react-router-dom'; - import Button from '../../Button/Button'; import { errorFallbackLayout, @@ -17,10 +15,8 @@ interface AsyncErrorFallback { } const AsyncErrorFallback = ({ error, resetError }: AsyncErrorFallback) => { - const navigate = useNavigate(); - const goToHome = () => { - navigate('/'); + window.location.href = '/'; }; return ( diff --git a/frontend/src/components/common/ErrorFallback/RouterErrorFallback/RouterErrorFallback.tsx b/frontend/src/components/common/ErrorFallback/RouterErrorFallback/RouterErrorFallback.tsx index c499ab324..284e20286 100644 --- a/frontend/src/components/common/ErrorFallback/RouterErrorFallback/RouterErrorFallback.tsx +++ b/frontend/src/components/common/ErrorFallback/RouterErrorFallback/RouterErrorFallback.tsx @@ -1,15 +1,11 @@ -import { useNavigate } from 'react-router-dom'; - import Button from '../../Button/Button'; import { errorFallbackLayout, errorImage, errorText } from '../ErrorFallback.styled'; import ErrorDdangkong from '@/assets/images/errorDdangkong.webp'; const RouterErrorFallback = () => { - const navigate = useNavigate(); - const goToHome = () => { - navigate('/'); + window.location.href = '/'; }; return ( diff --git a/frontend/src/components/common/ErrorFallback/SpinnerErrorFallback/SpinnerErrorFallback.tsx b/frontend/src/components/common/ErrorFallback/SpinnerErrorFallback/SpinnerErrorFallback.tsx new file mode 100644 index 000000000..b523c9fa1 --- /dev/null +++ b/frontend/src/components/common/ErrorFallback/SpinnerErrorFallback/SpinnerErrorFallback.tsx @@ -0,0 +1,12 @@ +import Spinner from '../../Spinner/Spinner'; +import { errorFallbackLayout } from '../ErrorFallback.styled'; + +const SpinnerErrorFallback = () => { + return ( +
    + +
    + ); +}; + +export default SpinnerErrorFallback; diff --git a/frontend/src/components/common/FinalButton/FinalButton.test.tsx b/frontend/src/components/common/FinalButton/FinalButton.test.tsx index 564f7c9a3..aaba388ea 100644 --- a/frontend/src/components/common/FinalButton/FinalButton.test.tsx +++ b/frontend/src/components/common/FinalButton/FinalButton.test.tsx @@ -7,7 +7,7 @@ import FinalButton from './FinalButton'; import { ERROR_MESSAGE } from '@/constants/message'; import { MOCK_API_URL } from '@/constants/url'; import { server } from '@/mocks/server'; -import { customRenderWithIsMaster } from '@/utils/test-utils'; +import { customRenderWithMaster } from '@/utils/test-utils'; describe('FinalButton 테스트', () => { it('확인 버튼을 클릭했을 때, 방 초기화 API에서 에러가 발생하면 알림 모달이 뜬다.', async () => { @@ -24,9 +24,9 @@ describe('FinalButton 테스트', () => { }), ); - customRenderWithIsMaster(, true); + customRenderWithMaster(); - const finalButton = await screen.findByRole('button', { name: '확인' }); + const finalButton = await screen.findByRole('button', { name: '대기실로 이동' }); await user.click(finalButton); await waitFor(() => { diff --git a/frontend/src/components/common/FinalButton/FinalButton.tsx b/frontend/src/components/common/FinalButton/FinalButton.tsx index b3f9f364a..9daea289c 100644 --- a/frontend/src/components/common/FinalButton/FinalButton.tsx +++ b/frontend/src/components/common/FinalButton/FinalButton.tsx @@ -1,34 +1,27 @@ import { useParams } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; +import getFinalButtonText from './FinalButton.utils'; import Button from '../Button/Button'; import { bottomButtonLayout } from '../Button/Button.styled'; import { useResetRoomMutation } from '@/components/GameResult/GameResult.hook'; -import { memberInfoState } from '@/recoil/atom'; +import useGetUserInfo from '@/hooks/useGetUserInfo'; const FinalButton = () => { const { roomId } = useParams(); - const { mutate: resetRoom } = useResetRoomMutation(Number(roomId)); - const memberInfo = useRecoilValue(memberInfoState); + const { mutate: resetRoom, isPending } = useResetRoomMutation(Number(roomId)); + const { + member: { isMaster }, + } = useGetUserInfo(); return (
    - {memberInfo.isMaster ? ( -
    ); }; diff --git a/frontend/src/components/common/FinalButton/FinalButton.utils.ts b/frontend/src/components/common/FinalButton/FinalButton.utils.ts new file mode 100644 index 000000000..f5d24ebf8 --- /dev/null +++ b/frontend/src/components/common/FinalButton/FinalButton.utils.ts @@ -0,0 +1,7 @@ +const getFinalButtonText = (isMaster: boolean, isPending: boolean) => { + if (isMaster && isPending) return '로딩중...'; + if (isMaster) return '대기실로 이동'; + return '방장이 진행해 주세요'; +}; + +export default getFinalButtonText; diff --git a/frontend/src/components/common/InviteModal/InviteModal.tsx b/frontend/src/components/common/InviteModal/InviteModal.tsx index 34bed3c62..881af1e2f 100644 --- a/frontend/src/components/common/InviteModal/InviteModal.tsx +++ b/frontend/src/components/common/InviteModal/InviteModal.tsx @@ -1,5 +1,5 @@ +import { RefObject } from 'react'; import QRCode from 'react-qr-code'; -import { useRecoilValue } from 'recoil'; import { inviteModalLi, @@ -17,16 +17,17 @@ import Modal from '../Modal/Modal'; import CopyIcon from '@/assets/images/copyIcon.png'; import { INVITE_URL } from '@/constants/url'; +import useGetUserInfo from '@/hooks/useGetUserInfo'; import useToast from '@/hooks/useToast'; -import { roomUuidState } from '@/recoil/atom'; interface InviteModalProps { isOpen: boolean; onClose: () => void; + returnFocusRef?: RefObject; } -const InviteModal = ({ isOpen, onClose }: InviteModalProps) => { - const roomUuid = useRecoilValue(roomUuidState); +const InviteModal = ({ isOpen, onClose, returnFocusRef }: InviteModalProps) => { + const { roomUuid } = useGetUserInfo(); const inviteUrl = INVITE_URL(roomUuid); const { copyToClipboard } = useClipBoard(); @@ -38,7 +39,12 @@ const InviteModal = ({ isOpen, onClose }: InviteModalProps) => { }; return ( - + 초대하기 diff --git a/frontend/src/components/common/Modal/Modal.styled.ts b/frontend/src/components/common/Modal/Modal.styled.ts index 05e567835..bc7311b18 100644 --- a/frontend/src/components/common/Modal/Modal.styled.ts +++ b/frontend/src/components/common/Modal/Modal.styled.ts @@ -20,7 +20,7 @@ export const modalContentWrapper = ({ position }: Pick) left: 50%; flex-direction: column; gap: 1.6rem; - width: 24rem; + width: 28rem; height: fit-content; max-height: 70vh; min-height: 1.2rem; diff --git a/frontend/src/components/common/Modal/Modal.tsx b/frontend/src/components/common/Modal/Modal.tsx index 99ef66816..f9b263fa8 100644 --- a/frontend/src/components/common/Modal/Modal.tsx +++ b/frontend/src/components/common/Modal/Modal.tsx @@ -1,7 +1,8 @@ -import React, { ButtonHTMLAttributes, HTMLAttributes, useRef } from 'react'; +/* eslint-disable jsx-a11y/no-noninteractive-tabindex */ +/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ +import React, { ButtonHTMLAttributes, HTMLAttributes, RefObject, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; -import useDisableBackgroundScroll from './hooks/useDisableBackgroundScroll'; import useModalEscClose from './hooks/useModalEscClose'; import { modalBackdropLayout, @@ -17,6 +18,7 @@ import { } from './Modal.styled'; import CloseIcon from '@/assets/images/closeIcon.png'; +import useFocus from '@/hooks/useFocus'; export interface ModalProps extends React.PropsWithChildren<{ @@ -24,11 +26,19 @@ export interface ModalProps onClose: () => void; position?: 'top' | 'bottom' | 'center'; style?: React.CSSProperties; + returnFocusRef?: RefObject; }> {} -const Modal = ({ children, isOpen, onClose, position = 'center', ...restProps }: ModalProps) => { +const Modal = ({ + children, + isOpen, + onClose, + returnFocusRef, + position = 'center', + ...restProps +}: ModalProps) => { const modalRef = useRef(null); - + const focusRef = useFocus(); useModalEscClose(isOpen, onClose); const handleOutsideClick = (event: React.MouseEvent | React.KeyboardEvent) => { @@ -37,12 +47,28 @@ const Modal = ({ children, isOpen, onClose, position = 'center', ...restProps }: } }; + useEffect(() => { + return () => { + if (returnFocusRef?.current) { + returnFocusRef.current.focus(); + } + }; + }, [returnFocusRef?.current]); + if (!isOpen) return null; const modalContent = ( /* eslint jsx-a11y/no-static-element-interactions: "off" */ // 모달을 제외한 영역을 클릭시 모달이 꺼지도록 설정하기 위해 설정함 -
    +
    {children}
    diff --git a/frontend/src/components/common/Modal/hooks/useDisableBackgroundScroll.ts b/frontend/src/components/common/Modal/hooks/useDisableBackgroundScroll.ts deleted file mode 100644 index ccd6004c6..000000000 --- a/frontend/src/components/common/Modal/hooks/useDisableBackgroundScroll.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect } from 'react'; - -const useDisableBackgroundScroll = (isOpen: boolean) => { - useEffect(() => { - if (isOpen) { - document.body.style.overflow = 'hidden'; - - return () => { - document.body.style.overflow = 'auto'; - }; - } - }, [isOpen]); -}; - -export default useDisableBackgroundScroll; diff --git a/frontend/src/components/common/NextRoundButton/NextRoundButton.test.tsx b/frontend/src/components/common/NextRoundButton/NextRoundButton.test.tsx index e095c1811..66cd78603 100644 --- a/frontend/src/components/common/NextRoundButton/NextRoundButton.test.tsx +++ b/frontend/src/components/common/NextRoundButton/NextRoundButton.test.tsx @@ -3,11 +3,11 @@ import { userEvent } from '@testing-library/user-event'; import NextRoundButton from './NextRoundButton'; -import { customRenderWithIsMaster } from '@/test-utils'; +import { customRenderWithMaster, customRenderWithNotMaster } from '@/test-utils'; describe('NextRoundButton 컴포넌트 테스트', () => { it('방장은 활성화 되어 있는 "다음" 버튼이 화면에 보인다.', async () => { - customRenderWithIsMaster(, true); + customRenderWithMaster(); const button = await screen.findByRole('button', { name: '다음' }); @@ -15,7 +15,7 @@ describe('NextRoundButton 컴포넌트 테스트', () => { }); it('방장이 아닌 참여자는 비활성화 되어 있는 "방장이 진행해 주세요" 버튼이 화면에 보인다.', async () => { - customRenderWithIsMaster(, false); + customRenderWithNotMaster(); const button = await screen.findByRole('button', { name: '방장이 진행해 주세요' }); @@ -23,7 +23,7 @@ describe('NextRoundButton 컴포넌트 테스트', () => { }); it('방장이 "다음" 버튼을 클릭하면 안내 모달이 열린다.', async () => { - customRenderWithIsMaster(, true); + customRenderWithMaster(); const button = await screen.findByRole('button', { name: '다음' }); await userEvent.click(button); diff --git a/frontend/src/components/common/NextRoundButton/NextRoundButton.tsx b/frontend/src/components/common/NextRoundButton/NextRoundButton.tsx index 495017bf9..98f467b53 100644 --- a/frontend/src/components/common/NextRoundButton/NextRoundButton.tsx +++ b/frontend/src/components/common/NextRoundButton/NextRoundButton.tsx @@ -1,47 +1,43 @@ +import { useRef } from 'react'; import { useParams } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; import useMoveNextRoundMutation from './NextRoundButton.hook'; +import { createRandomNextRoundMessage, getNextRoundButtonText } from './NextRoundButton.utils'; import AlertModal from '../AlertModal/AlertModal'; import Button from '../Button/Button'; import { bottomButtonLayout } from '../Button/Button.styled'; import useBalanceContentQuery from '@/hooks/useBalanceContentQuery'; +import useGetUserInfo from '@/hooks/useGetUserInfo'; import useModal from '@/hooks/useModal'; -import createRandomNextRoundMessage from '@/pages/RoundResultPage/createRandomNextRoundMessage'; -import { memberInfoState } from '@/recoil/atom'; const NextRoundButton = () => { const { roomId } = useParams(); const { balanceContent } = useBalanceContentQuery(Number(roomId)); - const { mutate: moveNextRound } = useMoveNextRoundMutation(Number(roomId)); - const memberInfo = useRecoilValue(memberInfoState); + const { mutate: moveNextRound, isPending, isSuccess } = useMoveNextRoundMutation(Number(roomId)); + const { + member: { isMaster }, + } = useGetUserInfo(); const { show } = useModal(); + const returnFocusRef = useRef(null); + const randomRoundNextMessage = createRandomNextRoundMessage(); const isLastRound = balanceContent?.currentRound === balanceContent?.totalRound; const showModal = () => { - show(AlertModal, { message: randomRoundNextMessage, onConfirm: moveNextRound }); + show(AlertModal, { message: randomRoundNextMessage, onConfirm: moveNextRound, returnFocusRef }); }; return (
    - {memberInfo.isMaster ? ( -
    ); }; diff --git a/frontend/src/pages/RoundResultPage/createRandomNextRoundMessage.ts b/frontend/src/components/common/NextRoundButton/NextRoundButton.utils.ts similarity index 71% rename from frontend/src/pages/RoundResultPage/createRandomNextRoundMessage.ts rename to frontend/src/components/common/NextRoundButton/NextRoundButton.utils.ts index 05fb5d9b5..08715231b 100644 --- a/frontend/src/pages/RoundResultPage/createRandomNextRoundMessage.ts +++ b/frontend/src/components/common/NextRoundButton/NextRoundButton.utils.ts @@ -1,6 +1,17 @@ import randomPicker from '@/utils/randomPicker'; -const createRandomNextRoundMessage = () => { +export const getNextRoundButtonText = ( + isMaster: boolean, + isLastRound: boolean, + isPending: boolean, +) => { + if (isMaster && isPending) return '로딩중...'; + if (isMaster && isLastRound) return '결과 확인'; + if (isMaster && !isLastRound) return '다음'; + return '방장이 진행해 주세요'; +}; + +export const createRandomNextRoundMessage = () => { const nextRoundMessage = [ '대화를 충분히 나누셨나요?\n확인을 누르면 다음 라운드로 진행됩니다 :)', '충분히 이야기 나누셨나요?\n다음 라운드로 넘어가려면 확인을 눌러주세요 :)', @@ -15,5 +26,3 @@ const createRandomNextRoundMessage = () => { return randomNextRoundMessage; }; - -export default createRandomNextRoundMessage; diff --git a/frontend/src/components/common/QueryClientDefaultOptionProvider/QueryClientDefaultOptionProvider.tsx b/frontend/src/components/common/QueryClientDefaultOptionProvider/QueryClientDefaultOptionProvider.tsx new file mode 100644 index 000000000..ed8c2736c --- /dev/null +++ b/frontend/src/components/common/QueryClientDefaultOptionProvider/QueryClientDefaultOptionProvider.tsx @@ -0,0 +1,43 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { PropsWithChildren } from 'react'; + +import AlertModal from '../AlertModal/AlertModal'; + +import useModal from '@/hooks/useModal'; +import useToast from '@/hooks/useToast'; +import { CustomError, NetworkError } from '@/utils/error'; + +const isServerError = (status: number) => status >= 500 && status !== 555; + +// QueryClient는 모든 Provider에 공유되면서 공통 에러 핸들링 로직에 Toast와 Modal을 넣기 위해 setDefaultOptions 사용 +// 테스트 환경에서 retry 값이 있을 경우 에러 폴백 테스트가 돌지 않아 분기 처리 +const QueryClientDefaultOptionProvider = ({ children }: PropsWithChildren) => { + const queryClient = useQueryClient(); + const { show } = useToast(); + const { show: showModal } = useModal(); + + queryClient.setDefaultOptions({ + queries: { + retry: process.env.NODE_ENV === 'test' ? false : 3, + throwOnError: true, + }, + mutations: { + onError: (error) => { + if (error instanceof NetworkError) { + show(error.message); + return; + } + showModal(AlertModal, { title: '에러', message: error.message }); + }, + throwOnError: (err) => { + const error = err as CustomError; + return isServerError(error.status); + }, + networkMode: 'always', + }, + }); + + return <>{children}; +}; + +export default QueryClientDefaultOptionProvider; diff --git a/frontend/src/components/common/RoomSettingModal/CategoryDropdown/CategoryDropdown.tsx b/frontend/src/components/common/RoomSettingModal/CategoryDropdown/CategoryDropdown.tsx index 7c3972db9..abe54b844 100644 --- a/frontend/src/components/common/RoomSettingModal/CategoryDropdown/CategoryDropdown.tsx +++ b/frontend/src/components/common/RoomSettingModal/CategoryDropdown/CategoryDropdown.tsx @@ -17,7 +17,11 @@ const CategoryDropdown = ({ category, handleClickOption }: CategoryDropdownProps if (!categoryList || !category) return
    카테고리가 없습니다.
    ; return ( - text={category} optionList={categoryList} handleClick={handleClickOption} /> + + text={category} + optionList={categoryList} + handleClickOption={handleClickOption} + /> ); }; diff --git a/frontend/src/components/common/RoomSettingModal/RoomSettingContainer/RoomSettingContainer.styled.ts b/frontend/src/components/common/RoomSettingModal/RoomSettingContainer/RoomSettingContainer.styled.ts index 7109d9681..dedfba358 100644 --- a/frontend/src/components/common/RoomSettingModal/RoomSettingContainer/RoomSettingContainer.styled.ts +++ b/frontend/src/components/common/RoomSettingModal/RoomSettingContainer/RoomSettingContainer.styled.ts @@ -5,6 +5,7 @@ import { Theme } from '@/styles/Theme'; export const roomSettingTitleContainer = css` display: flex; flex-direction: column; + align-items: center; gap: 1rem; `; diff --git a/frontend/src/components/common/RoomSettingModal/RoomSettingContainer/RoomSettingContainer.tsx b/frontend/src/components/common/RoomSettingModal/RoomSettingContainer/RoomSettingContainer.tsx index 74d409146..0a79fbf25 100644 --- a/frontend/src/components/common/RoomSettingModal/RoomSettingContainer/RoomSettingContainer.tsx +++ b/frontend/src/components/common/RoomSettingModal/RoomSettingContainer/RoomSettingContainer.tsx @@ -8,7 +8,7 @@ import { } from './RoomSettingContainer.styled'; interface RoomSettingContainerProps { - title: '카테고리' | '총 라운드' | '라운드 당 타이머'; + title: '카테고리' | '총 라운드' | '제한 시간'; } const RoomSettingContainer = ({ @@ -20,7 +20,13 @@ const RoomSettingContainer = ({
    {title}
    -
      {children}
    + {title === '카테고리' ? ( + children + ) : ( +
      + {children} +
    + )}
    ); }; diff --git a/frontend/src/components/common/RoomSettingModal/RoomSettingModal.test.tsx b/frontend/src/components/common/RoomSettingModal/RoomSettingModal.test.tsx index 90f2c94a2..56c085f48 100644 --- a/frontend/src/components/common/RoomSettingModal/RoomSettingModal.test.tsx +++ b/frontend/src/components/common/RoomSettingModal/RoomSettingModal.test.tsx @@ -1,5 +1,5 @@ import { renderHook, screen, waitFor } from '@testing-library/react'; -import { userEvent } from '@testing-library/user-event'; +import userEvent from '@testing-library/user-event'; import RoomSettingModal from './RoomSettingModal'; @@ -28,44 +28,58 @@ describe('RoomSettingModal 방 설정 모달 테스트', () => { it('방의 카테고리를 변경한 후 적용 버튼을 클릭하면 카테고리 설정이 변경된다.', async () => { const user = userEvent.setup(); - const CATEGORY = '연애'; + const NEW_CATEGORY = '음식'; + const clickButton = async (name: string) => { const button = await screen.findByRole('button', { name }); await user.click(button); }; - const { result } = renderHook(() => useGetRoomInfo(), { wrapper }); customRender(); + const { result } = renderHook(() => useGetRoomInfo(), { wrapper }); await waitFor(() => { expect(result.current.roomSetting).toEqual(ROOM_INFO.roomSetting); }); - await clickButton('음식 드랍다운 화살표'); - await clickButton(CATEGORY); + const dropdownButton = await screen.findByRole('button', { + name: /선택해주세요|카테고리 선택/, + }); + await user.click(dropdownButton); + + const categoryOption = await screen.findByRole('option', { name: NEW_CATEGORY }); + await user.click(categoryOption); + await clickButton('적용'); await waitFor(() => { - expect(result.current.roomSetting?.category.label).toBe(CATEGORY); + expect(result.current.roomSetting?.category.label).toBe(NEW_CATEGORY); }); }); it('방의 총 라운드를 변경한 후 적용 버튼을 클릭하면 총 라운드 설정이 변경된다.', async () => { const user = userEvent.setup(); const TOTAL_ROUND = 7; + + const clickRadio = async (name: string) => { + const radio = await screen.findByRole('radio', { name }); + await user.click(radio); + }; + const clickButton = async (name: string) => { const button = await screen.findByRole('button', { name }); await user.click(button); }; - const { result } = renderHook(() => useGetRoomInfo(), { wrapper }); customRender(); + const { result } = renderHook(() => useGetRoomInfo(), { wrapper }); await waitFor(() => { expect(result.current.roomSetting).toEqual(ROOM_INFO.roomSetting); }); - await clickButton(TOTAL_ROUND.toString()); + await clickRadio(TOTAL_ROUND.toString()); + await clickButton('적용'); await waitFor(() => { @@ -76,19 +90,26 @@ describe('RoomSettingModal 방 설정 모달 테스트', () => { it('방의 라운드 당 타이머를 변경한 후 적용 버튼을 클릭하면 타이머 설정이 변경된다.', async () => { const user = userEvent.setup(); const TIME_LIMIT = 10000; + + const clickRadio = async (name: string) => { + const radio = await screen.findByRole('radio', { name }); + await user.click(radio); + }; + const clickButton = async (name: string) => { const button = await screen.findByRole('button', { name }); await user.click(button); }; - const { result } = renderHook(() => useGetRoomInfo(), { wrapper }); customRender(); + const { result } = renderHook(() => useGetRoomInfo(), { wrapper }); await waitFor(() => { expect(result.current.roomSetting).toEqual(ROOM_INFO.roomSetting); }); - await clickButton(`${TIME_LIMIT / POLLING_DELAY}초`); + await clickRadio(`${TIME_LIMIT / POLLING_DELAY}초`); + await clickButton('적용'); await waitFor(() => { diff --git a/frontend/src/components/common/RoomSettingModal/RoomSettingModal.tsx b/frontend/src/components/common/RoomSettingModal/RoomSettingModal.tsx index 300fa7208..35509e09e 100644 --- a/frontend/src/components/common/RoomSettingModal/RoomSettingModal.tsx +++ b/frontend/src/components/common/RoomSettingModal/RoomSettingModal.tsx @@ -1,3 +1,5 @@ +import { RefObject } from 'react'; + import CategoryDropdown from './CategoryDropdown/CategoryDropdown'; import useRoomSetting from './hooks/useRoomSetting'; import RoomSettingContainer from './RoomSettingContainer/RoomSettingContainer'; @@ -12,14 +14,15 @@ import Modal from '../Modal/Modal'; import { POLLING_DELAY } from '@/constants/config'; const TOTAL_ROUND_LIST = [5, 7, 10]; -const TIMER_PER_ROUND_LIST = [5000, 10000, 15000]; +const TIMER_PER_ROUND_LIST = [10000, 15000, 30000, 60000]; interface RoomSettingModalProps { isOpen: boolean; onClose: () => void; + returnFocusRef?: RefObject; } -const RoomSettingModal = ({ isOpen, onClose }: RoomSettingModalProps) => { +const RoomSettingModal = ({ isOpen, onClose, returnFocusRef }: RoomSettingModalProps) => { const { roomSetting, handleClickOption, @@ -31,7 +34,12 @@ const RoomSettingModal = ({ isOpen, onClose }: RoomSettingModalProps) => { const { category, totalRound, timeLimitPerRound } = roomSetting; return ( - + 방 설정 @@ -44,19 +52,26 @@ const RoomSettingModal = ({ isOpen, onClose }: RoomSettingModalProps) => { {TOTAL_ROUND_LIST.map((round) => (
  • -
  • ))}
    - + {TIMER_PER_ROUND_LIST.map((timeLimit) => (
  • diff --git a/frontend/src/components/common/SelectButton/SelectButton.hook.ts b/frontend/src/components/common/SelectButton/SelectButton.hook.ts index 122507a85..cb53e7149 100644 --- a/frontend/src/components/common/SelectButton/SelectButton.hook.ts +++ b/frontend/src/components/common/SelectButton/SelectButton.hook.ts @@ -1,16 +1,8 @@ import { useMutation } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; - -import AlertModal from '../AlertModal/AlertModal'; import { voteBalanceContent } from '@/apis/balanceContent'; -import useModal from '@/hooks/useModal'; -import useToast from '@/hooks/useToast'; -import { memberInfoState } from '@/recoil/atom'; -import { CustomError, NetworkError } from '@/utils/error'; - -const isServerError = (status: number) => status >= 500 && status !== 555; +import useGetUserInfo from '@/hooks/useGetUserInfo'; interface UseSelectCompleteMutationProps { selectedId: number; @@ -24,36 +16,25 @@ const useCompleteSelectionMutation = ({ completeSelection, }: UseSelectCompleteMutationProps) => { const { roomId } = useParams(); - const memberInfo = useRecoilValue(memberInfoState); - const { show } = useToast(); - const { show: showModal } = useModal(); + const { + member: { memberId }, + } = useGetUserInfo(); return useMutation({ mutationFn: async () => { if (typeof contentId === 'undefined') { throw new Error('contentId 가 존재하지 않습니다.'); } - return await voteBalanceContent({ roomId: Number(roomId), optionId: selectedId, contentId, - memberId: Number(memberInfo.memberId), + memberId: Number(memberId), }); }, onSuccess: () => { completeSelection(); }, - onError: (error: CustomError) => { - if (error instanceof NetworkError) { - show(error.message); - return; - } - - showModal(AlertModal, { title: '선택 에러', message: error.message }); - }, - networkMode: 'always', - throwOnError: (error: CustomError) => isServerError(error.status), }); }; diff --git a/frontend/src/components/common/SelectButton/SelectButton.tsx b/frontend/src/components/common/SelectButton/SelectButton.tsx index 18b413311..f80c6623a 100644 --- a/frontend/src/components/common/SelectButton/SelectButton.tsx +++ b/frontend/src/components/common/SelectButton/SelectButton.tsx @@ -18,7 +18,6 @@ const SelectButton = ({ contentId, selectedId, completeSelection }: SelectButton contentId, completeSelection, }); - return (
    ); diff --git a/frontend/src/components/common/Spinner/Spinner.styled.ts b/frontend/src/components/common/Spinner/Spinner.styled.ts index 44c3840c3..8a6bbdc62 100644 --- a/frontend/src/components/common/Spinner/Spinner.styled.ts +++ b/frontend/src/components/common/Spinner/Spinner.styled.ts @@ -11,7 +11,7 @@ export const spinnerWrapper = css` export const rotatingImage = (size: number) => css` width: ${size}rem; - height: 20vh; + height: ${size * 2}rem; animation: spin 2s linear infinite; /* 2초 동안 한 바퀴 회전하는 애니메이션 */ diff --git a/frontend/src/components/common/Spinner/Spinner.tsx b/frontend/src/components/common/Spinner/Spinner.tsx index f47f06e26..a6f4ed318 100644 --- a/frontend/src/components/common/Spinner/Spinner.tsx +++ b/frontend/src/components/common/Spinner/Spinner.tsx @@ -11,7 +11,7 @@ interface SpinnerProps { const Spinner = ({ message = '로딩 중입니다...', imageSrc = SpinDdangKong, - imageSize = 9, + imageSize = 12, }: SpinnerProps) => { return (
    diff --git a/frontend/src/components/common/a11yOnly/A11yOnly.tsx b/frontend/src/components/common/a11yOnly/A11yOnly.tsx index 534d0a4bf..4b5130c99 100644 --- a/frontend/src/components/common/a11yOnly/A11yOnly.tsx +++ b/frontend/src/components/common/a11yOnly/A11yOnly.tsx @@ -9,13 +9,12 @@ interface A11yOnlyProps { const A11yOnly = ({ as, - role = 'text', children, ...props }: PropsWithChildren>) => { const Component = as || 'span'; return ( - + {children} ); diff --git a/frontend/src/components/layout/Content/Content.styled.ts b/frontend/src/components/layout/Content/Content.styled.ts index 8f3ec30ad..680e7b31e 100644 --- a/frontend/src/components/layout/Content/Content.styled.ts +++ b/frontend/src/components/layout/Content/Content.styled.ts @@ -5,6 +5,6 @@ export const contentLayout = css` flex-direction: column; align-items: center; gap: 1.6rem; - height: 85vh; + height: 88vh; padding: 0 2.4rem; `; diff --git a/frontend/src/components/layout/Header/Header.styled.ts b/frontend/src/components/layout/Header/Header.styled.ts index 61c5a9273..3ac32dafe 100644 --- a/frontend/src/components/layout/Header/Header.styled.ts +++ b/frontend/src/components/layout/Header/Header.styled.ts @@ -6,8 +6,12 @@ export const headerLayout = (isCenter?: boolean) => css` display: flex; justify-content: ${isCenter ? 'center' : 'space-between'}; align-items: center; - height: 15vh; + height: 12vh; padding: 0 2.4rem; + + :focus { + outline: none; + } `; export const roundText = css` diff --git a/frontend/src/components/layout/Header/Header.test.tsx b/frontend/src/components/layout/Header/Header.test.tsx index 249f2c986..1035e7520 100644 --- a/frontend/src/components/layout/Header/Header.test.tsx +++ b/frontend/src/components/layout/Header/Header.test.tsx @@ -3,12 +3,12 @@ import { userEvent } from '@testing-library/user-event'; import { RoomSettingHeader } from './Header'; -import { customRenderWithIsMaster } from '@/utils/test-utils'; +import { customRenderWithMaster } from '@/utils/test-utils'; describe('Header 테스트', () => { - it('방 설정 버튼을 클릭했을 때, 방 설정 모달이 뜬다.', async () => { + it('방장이 방 설정 버튼을 클릭했을 때, 방 설정 모달이 뜬다.', async () => { const user = userEvent.setup(); - customRenderWithIsMaster(, true); + customRenderWithMaster(); const roomSettingButton = await screen.findByAltText('방 설정'); await user.click(roomSettingButton); diff --git a/frontend/src/components/layout/Header/Header.tsx b/frontend/src/components/layout/Header/Header.tsx index e22dedb99..288060272 100644 --- a/frontend/src/components/layout/Header/Header.tsx +++ b/frontend/src/components/layout/Header/Header.tsx @@ -1,5 +1,6 @@ +/* eslint-disable jsx-a11y/no-noninteractive-tabindex */ +import { useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; import { buttonWrapper, @@ -23,8 +24,9 @@ import AlertModal from '@/components/common/AlertModal/AlertModal'; import RoomSettingModal from '@/components/common/RoomSettingModal/RoomSettingModal'; import { convertMsecToSecond } from '@/components/SelectContainer/Timer/Timer.util'; import useBalanceContentQuery from '@/hooks/useBalanceContentQuery'; +import useFocus from '@/hooks/useFocus'; +import useGetUserInfo from '@/hooks/useGetUserInfo'; import useModal from '@/hooks/useModal'; -import { memberInfoState } from '@/recoil/atom'; interface HeaderProps { title: string; @@ -42,14 +44,17 @@ const Header = () => { }; // 1. 가운데 제목과 설명이 있는 헤더 : 최종 게임 결과 화면 -export const MatchingResultHeader = ({ title }: HeaderProps) => ( -
    -
    -

    {title}

    -

    매칭도를 통해 당신과 가장 잘 맞는 사람을 알아보세요😊

    -
    -
    -); +export const MatchingResultHeader = ({ title }: HeaderProps) => { + const focusRef = useFocus(); + return ( +
    +
    +

    {title}

    +

    매칭도를 통해 당신과 가장 잘 맞는 사람을 알아보세요😊

    +
    +
    + ); +}; // 2. 가운데 제목만 차지하는 헤더 : 닉네임 설정 화면 export const TitleHeader = ({ title }: HeaderProps) => ( @@ -61,11 +66,16 @@ export const TitleHeader = ({ title }: HeaderProps) => ( // 3. 가운데 제목, 우측 상단 차지하는 헤더 : 게임 대기 화면 export const RoomSettingHeader = ({ title }: HeaderProps) => { const { show } = useModal(); - const { isMaster } = useRecoilValue(memberInfoState); + const { + member: { isMaster }, + } = useGetUserInfo(); + const { handleExit } = useExit(); + const returnFocusRef = useRef(null); + const focusRef = useFocus(); const handleClickRoomSetting = () => { - show(RoomSettingModal); + show(RoomSettingModal, { returnFocusRef }); }; const handleClickExit = () => { @@ -73,13 +83,13 @@ export const RoomSettingHeader = ({ title }: HeaderProps) => { }; return ( -
    +

    {title}

    {isMaster ? ( - ) : ( @@ -94,9 +104,10 @@ export const RoundResultHeader = () => { const { roomId } = useParams(); const { balanceContent } = useBalanceContentQuery(Number(roomId)); const screenReaderRoundResult = `${balanceContent.totalRound}라운드 중. ${balanceContent.currentRound}라운드. 투표 결과 페이지`; + const focusRef = useFocus(); return ( -
    +
    {screenReaderRoundResult} {balanceContent.currentRound}/{balanceContent.totalRound} @@ -116,9 +127,10 @@ export const GameHeader = () => { const { totalRound, currentRound, timeLimit } = balanceContent; const screenReaderHeader = `${totalRound}라운드.중.${currentRound}라운드. 밸런스 게임 페이지. 제한 시간 ${convertMsecToSecond(timeLimit)}초.`; + const focusRef = useFocus(); return ( -
    +
    {screenReaderHeader} {currentRound}/{totalRound} @@ -134,13 +146,13 @@ export const GameHeader = () => { // 5. 좌측 상단 뒤로가기, 가운데 제목 차지하는 헤더 (API 호출 X) : 라운드 투표 현황 export const BackHeader = ({ title }: HeaderProps) => { const navigate = useNavigate(); - + const focusRef = useFocus(); const goToBack = () => { navigate(-1); }; return ( -
    +
    diff --git a/frontend/src/components/layout/Header/hooks/useExit.ts b/frontend/src/components/layout/Header/hooks/useExit.ts index 2f0e1e014..c617c6198 100644 --- a/frontend/src/components/layout/Header/hooks/useExit.ts +++ b/frontend/src/components/layout/Header/hooks/useExit.ts @@ -1,18 +1,21 @@ import { useMutation } from '@tanstack/react-query'; import { useNavigate, useParams } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; import { exitRoom } from '@/apis/room'; -import { memberInfoState } from '@/recoil/atom'; +import useGetUserInfo from '@/hooks/useGetUserInfo'; +import { deleteAllCookies } from '@/utils/cookie'; export const useExit = () => { - const { memberId } = useRecoilValue(memberInfoState); + const { + member: { memberId }, + } = useGetUserInfo(); const navigate = useNavigate(); const { roomId } = useParams(); const exitRoomMutation = useMutation({ mutationFn: ({ roomId, memberId }) => exitRoom(roomId, memberId), - onSuccess: () => { + onSettled: () => { + deleteAllCookies(); navigate('/'); }, }); diff --git a/frontend/src/constants/message.ts b/frontend/src/constants/message.ts index a5b309bc8..8adc8bdc9 100644 --- a/frontend/src/constants/message.ts +++ b/frontend/src/constants/message.ts @@ -17,7 +17,7 @@ export const ERROR_MESSAGE: Record = { NOT_EXIST_COMMON: '일반 멤버가 존재하지 않습니다.', // 방 설정 관련 에러 (roomSetting) - INVALID_TIME_LIMIT: '타이머는 5초, 10초, 15초로만 설정 가능합니다.', + INVALID_TIME_LIMIT: '타이머는 10초, 15초, 30초, 60초로만 설정 가능합니다.', INVALID_RANGE_TOTAL_ROUND: '총 라운드는 5, 7, 10 라운드로만 설정 가능합니다.', EMPTY_VOTE_DEADLINE: '라운드 종료 시간이 설정되지 않았습니다.', @@ -50,4 +50,10 @@ export const ERROR_MESSAGE: Record = { // 서버 에러 INTERNAL_SERVER_ERROR: '서버에 오류가 발생했어요. 다시 시도해 주세요!', + + // 쿠키 관련 에러(권한) + NOT_FOUND_COOKIE: + '사용자 정보가 있어야 방에 참여할 수 있어요. 홈화면으로 이동하여 방을 새로 만들어주세요!', + INVALID_COOKIE: + '사용자 정보가 있어야 방에 참여할 수 있어요. 홈화면으로 이동하여 방을 새로 만들어주세요!', }; diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index c496271ee..fe05a9204 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -7,4 +7,5 @@ export const QUERY_KEYS = { roomMembers: 'roomMembers', isRoomInitial: 'isRoomInitial', categoryList: 'categoryList', + getUserInfo: 'getUserInfo', } as const; diff --git a/frontend/src/constants/url.ts b/frontend/src/constants/url.ts index 6aa537e6d..6b7946020 100644 --- a/frontend/src/constants/url.ts +++ b/frontend/src/constants/url.ts @@ -1,3 +1,5 @@ +import { getUserInfo } from '@/apis/room'; + const BASE_URL = process.env.API_BASE_URL; export const API_URL = { @@ -25,11 +27,14 @@ export const API_URL = { deleteRoom: (roomId: number, memberId: number) => `${BASE_URL}/api/balances/rooms/${roomId}/members/${memberId}`, isJoinableRoom: (roomUuid: string) => `${BASE_URL}/api/balances/rooms/${roomUuid}/status`, + getUserInfo: `${BASE_URL}/api/balances/rooms/member`, }; type API_URL_KEYS = keyof typeof API_URL; export const MOCK_API_URL: Record = { + getUserInfo: `${BASE_URL}/api/balances/rooms/member`, + getRoomInfo: `${BASE_URL}/api/balances/rooms/:roomId`, balanceContent: `${BASE_URL}/api/balances/rooms/:roomId/content`, vote: `${BASE_URL}/api/balances/rooms/:roomId/contents/:contentId/votes`, roundVoteResult: `${BASE_URL}/api/balances/rooms/:roomId/contents/:contentId/vote-result`, @@ -38,7 +43,6 @@ export const MOCK_API_URL: Record = { matchingResult: `${BASE_URL}/api/balances/rooms/:roomId/members/:memberId/matching`, room: `${BASE_URL}/api/balances/rooms`, enterRoom: `${BASE_URL}/api/balances/rooms/:roomUuid/members`, - getRoomInfo: `${BASE_URL}/api/balances/rooms/:roomId`, startGame: `${BASE_URL}/api/balances/rooms/:roomId/start`, voteIsFinished: `${BASE_URL}/api/balances/rooms/:roomId/contents/:contentId/vote-finished`, resetRoom: `${BASE_URL}/api/balances/rooms/:roomId/reset`, diff --git a/frontend/src/hooks/useButtonHeightOnKeyboard.ts b/frontend/src/hooks/useButtonHeightOnKeyboard.ts new file mode 100644 index 000000000..9ca8cebd0 --- /dev/null +++ b/frontend/src/hooks/useButtonHeightOnKeyboard.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +const INITIAL_BOTTOM_BUTTON_HEIGHT = 0; + +const useButtonHeightOnKeyboard = () => { + const [bottomButtonHeight, setBottomButtonHeight] = useState(INITIAL_BOTTOM_BUTTON_HEIGHT); + + useEffect(() => { + const handleLockScroll = (e: TouchEvent) => { + e.preventDefault(); + }; + + document.body.addEventListener('touchmove', handleLockScroll, { passive: false }); + return () => { + document.body.removeEventListener('touchmove', handleLockScroll); + }; + }, []); + + useEffect(() => { + const handleResizeScreen = () => { + if (!visualViewport) return; + + setBottomButtonHeight(window.innerHeight - visualViewport.height); + }; + + visualViewport?.addEventListener('resize', handleResizeScreen); + + return () => { + visualViewport?.removeEventListener('resize', handleResizeScreen); + }; + }, []); + + return { bottomButtonHeight }; +}; + +export default useButtonHeightOnKeyboard; diff --git a/frontend/src/hooks/useFocus.ts b/frontend/src/hooks/useFocus.ts new file mode 100644 index 000000000..5265480e0 --- /dev/null +++ b/frontend/src/hooks/useFocus.ts @@ -0,0 +1,15 @@ +import { useEffect, useRef } from 'react'; + +const useFocus = () => { + const focusRef = useRef(null); + + useEffect(() => { + if (focusRef.current) { + focusRef.current.focus(); + } + }, []); + + return focusRef; +}; + +export default useFocus; diff --git a/frontend/src/hooks/useGetUserInfo.ts b/frontend/src/hooks/useGetUserInfo.ts new file mode 100644 index 000000000..ea21e3152 --- /dev/null +++ b/frontend/src/hooks/useGetUserInfo.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getUserInfo } from '@/apis/room'; +import { QUERY_KEYS } from '@/constants/queryKeys'; +import { RoomAndMember } from '@/types/room'; + +const USER_INFO_STALE_TIME = 2 * 60 * 60 * 1000; + +const useGetUserInfo = (): RoomAndMember => { + const { data } = useQuery({ + queryKey: [QUERY_KEYS.getUserInfo], + queryFn: getUserInfo, + staleTime: USER_INFO_STALE_TIME, + }); + + return { + roomId: data?.roomId || 0, + roomUuid: data?.roomUuid || '', + member: { + memberId: data?.member?.memberId || 0, + nickname: data?.member?.nickname || '', + isMaster: data?.member?.isMaster || false, + }, + }; +}; + +export default useGetUserInfo; diff --git a/frontend/src/hooks/useKeyboardUp.ts b/frontend/src/hooks/useKeyboardUp.ts deleted file mode 100644 index b7674c269..000000000 --- a/frontend/src/hooks/useKeyboardUp.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useState } from 'react'; - -const useKeyboardUp = () => { - const [isKeyboardUp, setIsKeyboardUp] = useState(false); - - useEffect(() => { - const initialHeight = window.visualViewport?.height; - - const handleResize = () => { - const currentHeight = window.visualViewport?.height; - if (initialHeight && currentHeight && currentHeight < initialHeight) { - setIsKeyboardUp(true); - } else { - setIsKeyboardUp(false); - } - }; - window.addEventListener('resize', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - }; - }, []); - return { isKeyboardUp }; -}; -export default useKeyboardUp; diff --git a/frontend/src/hooks/useRoundVoteResultQuery.ts b/frontend/src/hooks/useRoundVoteResultQuery.ts index 09d9cb4ea..87ebc8a98 100644 --- a/frontend/src/hooks/useRoundVoteResultQuery.ts +++ b/frontend/src/hooks/useRoundVoteResultQuery.ts @@ -1,6 +1,4 @@ -import { useQuery, UseQueryResult } from '@tanstack/react-query'; - -import INITIAL_VALUE from '../mocks/data/roundVoteResultInitialValue.json'; +import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; import { fetchRoundVoteResult } from '@/apis/balanceContent'; import { QUERY_KEYS } from '@/constants/queryKeys'; @@ -11,16 +9,16 @@ interface UseRoundVoteResultQueryProps { contentId?: number; } -type RoundVoteResultQueryResponse = UseQueryResult & { - groupRoundResult?: Group; - totalResult?: Total; +type RoundVoteResultQueryResponse = UseSuspenseQueryResult & { + groupRoundResult: Group; + totalResult: Total; }; const useRoundVoteResultQuery = ({ roomId, contentId, }: UseRoundVoteResultQueryProps): RoundVoteResultQueryResponse => { - const roundVoteResultQuery = useQuery({ + const roundVoteResultQuery = useSuspenseQuery({ queryKey: [QUERY_KEYS.roundVoteResult, roomId, contentId], queryFn: async () => { if (typeof contentId === 'undefined') { @@ -33,14 +31,12 @@ const useRoundVoteResultQuery = ({ return await fetchRoundVoteResult({ roomId, contentId }); }, - placeholderData: INITIAL_VALUE, - enabled: !!contentId, }); return { ...roundVoteResultQuery, - groupRoundResult: roundVoteResultQuery.data?.group, - totalResult: roundVoteResultQuery.data?.total, + groupRoundResult: roundVoteResultQuery.data.group, + totalResult: roundVoteResultQuery.data.total, }; }; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 7ba7c7146..f0df24c56 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -38,7 +38,13 @@ enableMocking().then(() => { - +
    + +
    , diff --git a/frontend/src/mocks/data/roomAndMaster.json b/frontend/src/mocks/data/roomAndMaster.json new file mode 100644 index 000000000..e35156ed5 --- /dev/null +++ b/frontend/src/mocks/data/roomAndMaster.json @@ -0,0 +1,9 @@ +{ + "roomId": 142, + "roomUuid": "bc950f33f12f467da159a263a905bb40", + "member": { + "memberId": 217, + "nickname": "땅콩", + "isMaster": true + } +} diff --git a/frontend/src/mocks/data/roomAndNotMaster.json b/frontend/src/mocks/data/roomAndNotMaster.json new file mode 100644 index 000000000..b2746809c --- /dev/null +++ b/frontend/src/mocks/data/roomAndNotMaster.json @@ -0,0 +1,9 @@ +{ + "roomId": 142, + "roomUuid": "bc950f33f12f467da159a263a905bb40", + "member": { + "memberId": 217, + "nickname": "땅콩", + "isMaster": false + } +} diff --git a/frontend/src/mocks/handlers/roomHandler.ts b/frontend/src/mocks/handlers/roomHandler.ts index 40bf75fd4..65c2ba2d8 100644 --- a/frontend/src/mocks/handlers/roomHandler.ts +++ b/frontend/src/mocks/handlers/roomHandler.ts @@ -4,6 +4,7 @@ import CATEGORY_LIST from '../data/categoryList.json'; import CREATE_ROOM_RESPONSE from '../data/createRoomResponse.json'; import ENTER_ROOM_RESPONSE from '../data/enterRoomResponse.json'; import MASTER_AND_INITIAL from '../data/masterAndInitial.json'; +import ROOM_AND_MASTER from '../data/roomAndMaster.json'; import ROOM_INFO from '../data/roomInfo.json'; import { MOCK_API_URL } from '@/constants/url'; @@ -62,7 +63,12 @@ const isJoinableRoomHandler = () => { return HttpResponse.json({ isJoinable: false }, { status: 200 }); }; +const getUserInfoHandler = () => { + return HttpResponse.json(ROOM_AND_MASTER, { status: 200 }); +}; + export const roomHandler = [ + http.get(MOCK_API_URL.getUserInfo, getUserInfoHandler), http.get(MOCK_API_URL.getRoomInfo, getRoomInfoHandler), http.post(MOCK_API_URL.room, createRoomHandler), http.post(MOCK_API_URL.enterRoom, enterRoomHandler), diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx index 0f39c0303..be1d92193 100644 --- a/frontend/src/pages/MainPage/MainPage.tsx +++ b/frontend/src/pages/MainPage/MainPage.tsx @@ -24,7 +24,7 @@ const MainPage = () => {

    땅콩

    어색한 분위기를 주도해봐요

    - +
  • ); }; diff --git a/frontend/src/pages/MainPage/useCreateRoom.ts b/frontend/src/pages/MainPage/useCreateRoom.ts index fa6ad3bd3..8665e63dd 100644 --- a/frontend/src/pages/MainPage/useCreateRoom.ts +++ b/frontend/src/pages/MainPage/useCreateRoom.ts @@ -1,19 +1,11 @@ import { useNavigate } from 'react-router-dom'; -import { useSetRecoilState } from 'recoil'; -import { memberInfoState } from '@/recoil/atom'; +import { ROUTES } from '@/constants/routes'; export const useCreateRoom = () => { const navigate = useNavigate(); - const setMemberInfo = useSetRecoilState(memberInfoState); - - const goToNicknamePage = () => { - navigate('/nickname'); - }; - const handleRoomCreate = () => { - goToNicknamePage(); - setMemberInfo((memberInfo) => ({ ...memberInfo, isMaster: true })); + navigate(ROUTES.nickname); }; return { handleRoomCreate }; diff --git a/frontend/src/pages/NicknamePage/NicknameInput/NicknameInput.styled.ts b/frontend/src/pages/NicknamePage/NicknameInput/NicknameInput.styled.ts index f5055c113..6f3633e69 100644 --- a/frontend/src/pages/NicknamePage/NicknameInput/NicknameInput.styled.ts +++ b/frontend/src/pages/NicknamePage/NicknameInput/NicknameInput.styled.ts @@ -18,6 +18,8 @@ export const nicknameInput = css` border: 0; background-color: ${Theme.color.gray200}; + + font-size: 1.6rem; outline: none; `; diff --git a/frontend/src/pages/NicknamePage/NicknamePage.styled.ts b/frontend/src/pages/NicknamePage/NicknamePage.styled.ts index e0c054e16..ca8adbe30 100644 --- a/frontend/src/pages/NicknamePage/NicknamePage.styled.ts +++ b/frontend/src/pages/NicknamePage/NicknamePage.styled.ts @@ -22,7 +22,7 @@ export const nicknameContainer = css` flex-direction: column; gap: 1.2rem; width: 26.8rem; - margin: 2rem 0; + margin-bottom: 2rem; `; export const nicknameTitle = css` @@ -46,6 +46,7 @@ export const nicknameInput = css` border: 0; background-color: ${Theme.color.gray200}; + outline: none; `; diff --git a/frontend/src/pages/NicknamePage/NicknamePage.tsx b/frontend/src/pages/NicknamePage/NicknamePage.tsx index a54c5c2dd..f9665446a 100644 --- a/frontend/src/pages/NicknamePage/NicknamePage.tsx +++ b/frontend/src/pages/NicknamePage/NicknamePage.tsx @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { useSetRecoilState } from 'recoil'; import NicknameInput from './NicknameInput/NicknameInput'; import { @@ -10,7 +9,6 @@ import { noVoteTextContainer, noVoteText, angryImage, - nicknameTitle, nicknameContainer, } from './NicknamePage.styled'; import useMakeOrEnterRoom from './useMakeOrEnterRoom'; @@ -20,14 +18,12 @@ import AngryDdangkong from '@/assets/images/angryDdangkong.webp'; import SillyDdangkong from '@/assets/images/sillyDdangkong.webp'; import Button from '@/components/common/Button/Button'; import Content from '@/components/layout/Content/Content'; -import useKeyboardUp from '@/hooks/useKeyboardUp'; -import { roomUuidState } from '@/recoil/atom'; +import useButtonHeightOnKeyboard from '@/hooks/useButtonHeightOnKeyboard'; const NicknamePage = () => { const { nicknameInputRef, handleMakeOrEnterRoom, isLoading } = useMakeOrEnterRoom(); const { roomUuid } = useParams(); - const setRoomUuidState = useSetRecoilState(roomUuidState); - const { isKeyboardUp } = useKeyboardUp(); + const { bottomButtonHeight } = useButtonHeightOnKeyboard(); const { data, isLoading: isJoinableLoading } = useQuery({ queryKey: ['isJoinable', roomUuid], @@ -36,14 +32,10 @@ const NicknamePage = () => { }); useEffect(() => { - if (roomUuid) { - setRoomUuidState(roomUuid); - } - if (nicknameInputRef.current) { nicknameInputRef.current.focus(); } - }, [roomUuid, setRoomUuidState, nicknameInputRef]); + }, [roomUuid, nicknameInputRef]); if (!isJoinableLoading && roomUuid && !data?.isJoinable) return ( @@ -59,7 +51,6 @@ const NicknamePage = () => { 사용자 프로필
    - 닉네임 { onClick={handleMakeOrEnterRoom} disabled={isLoading} text={isLoading ? '접속 중...' : '확인'} - bottom={!isKeyboardUp} - radius={isKeyboardUp ? 'small' : undefined} - size={isKeyboardUp ? 'small' : undefined} - style={{ width: '100%' }} + style={{ width: '100%', bottom: bottomButtonHeight }} + bottom />
    diff --git a/frontend/src/pages/NicknamePage/useMakeOrEnterRoom.ts b/frontend/src/pages/NicknamePage/useMakeOrEnterRoom.ts index b0f05034d..93ce84057 100644 --- a/frontend/src/pages/NicknamePage/useMakeOrEnterRoom.ts +++ b/frontend/src/pages/NicknamePage/useMakeOrEnterRoom.ts @@ -1,38 +1,25 @@ import { useMutation } from '@tanstack/react-query'; import { useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useRecoilState } from 'recoil'; import { enterRoom, createRoom } from '@/apis/room'; -import AlertModal from '@/components/common/AlertModal/AlertModal'; import { ROUTES } from '@/constants/routes'; -import useModal from '@/hooks/useModal'; -import { memberInfoState, roomUuidState } from '@/recoil/atom'; import { CreateOrEnterRoomResponse } from '@/types/room'; import { CustomError } from '@/utils/error'; const useMakeOrEnterRoom = () => { const nicknameInputRef = useRef(null); const navigate = useNavigate(); - const [{ isMaster }, setMemberInfo] = useRecoilState(memberInfoState); - - const [, setRoomUuidState] = useRecoilState(roomUuidState); const { roomUuid } = useParams(); - const { show: showModal } = useModal(); + + // roomUuId가 없다 -> 초대링크를 받지 않은 master이다. + const isMaster = !roomUuid; const createRoomMutation = useMutation({ mutationFn: createRoom, onSuccess: (data) => { - setMemberInfo((prev) => ({ - ...prev, - memberId: data.member.memberId, - })); - setRoomUuidState(data.roomUuid || ''); navigate(ROUTES.ready(Number(data.roomId)), { replace: true }); }, - onError: (error) => { - showModal(AlertModal, { title: '방 생성 에러', message: error.message }); - }, }); const enterRoomMutation = useMutation< @@ -42,13 +29,8 @@ const useMakeOrEnterRoom = () => { >({ mutationFn: ({ nickname, roomUuid }) => enterRoom(roomUuid, nickname), onSuccess: (data) => { - setMemberInfo((prev) => ({ ...prev, memberId: data.member.memberId })); - setRoomUuidState(data.roomUuid || ''); navigate(ROUTES.ready(Number(data.roomId)), { replace: true }); }, - onError: (error: CustomError) => { - showModal(AlertModal, { title: '방 참여 에러', message: error.message }); - }, }); const handleMakeOrEnterRoom = () => { diff --git a/frontend/src/providers/ModalProvider/ModalProvider.tsx b/frontend/src/providers/ModalProvider/ModalProvider.tsx index 38dcbf19e..b7d4b1825 100644 --- a/frontend/src/providers/ModalProvider/ModalProvider.tsx +++ b/frontend/src/providers/ModalProvider/ModalProvider.tsx @@ -1,9 +1,10 @@ -import { createContext, PropsWithChildren, useMemo, useState } from 'react'; +import { createContext, PropsWithChildren, RefObject, useMemo, useState } from 'react'; interface ModalProps { title?: string; message?: string; onConfirm?: () => void; + returnFocusRef?: RefObject; } interface ModalState extends ModalProps { @@ -39,6 +40,7 @@ const ModalProvider = ({ children }: PropsWithChildren) => { message: props?.message, onConfirm: props?.onConfirm, isOpen: true, + returnFocusRef: props?.returnFocusRef, }); }; diff --git a/frontend/src/recoil/atom.ts b/frontend/src/recoil/atom.ts deleted file mode 100644 index 46c2dd872..000000000 --- a/frontend/src/recoil/atom.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { atom } from 'recoil'; - -interface MemberInfoState { - memberId: number | null; - nickname: string | null; - isMaster: boolean; -} - -export const memberInfoState = atom({ - key: 'memberInfo', - default: { - memberId: null, - nickname: null, - isMaster: false, - }, -}); - -export const roomUuidState = atom({ - key: 'roomUuid', - default: '', -}); diff --git a/frontend/src/router/HeaderLayout.tsx b/frontend/src/router/HeaderLayout.tsx index 84066738c..cd0700a55 100644 --- a/frontend/src/router/HeaderLayout.tsx +++ b/frontend/src/router/HeaderLayout.tsx @@ -1,13 +1,14 @@ import { Outlet } from 'react-router-dom'; +import QueryClientDefaultOptionProvider from '@/components/common/QueryClientDefaultOptionProvider/QueryClientDefaultOptionProvider'; import Header from '@/components/layout/Header/Header'; const HeaderLayout = () => { return ( - <> +
    - + ); }; diff --git a/frontend/src/router/MainLayout.tsx b/frontend/src/router/MainLayout.tsx index 47b51ce90..cb8241db6 100644 --- a/frontend/src/router/MainLayout.tsx +++ b/frontend/src/router/MainLayout.tsx @@ -1,14 +1,17 @@ import { Outlet } from 'react-router-dom'; +import AsyncErrorBoundary from '@/components/common/ErrorBoundary/AsyncErrorBoundary'; import RootErrorBoundary from '@/components/common/ErrorBoundary/RootErrorBoundary'; import ModalProvider from '@/providers/ModalProvider/ModalProvider'; const MainLayout = () => { return ( - - - + + + + + ); }; diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 9267a6085..bdf5d798f 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -24,25 +24,21 @@ export const router = createBrowserRouter([ children: [ { path: '/', - element: , + element: ( + + + + ), }, { path: '/', element: , children: [ - { - path: ':roomId/game', - element: ( - }> - - - ), - }, { path: 'nickname/:roomUuid?', element: ( - , + ), }, @@ -54,6 +50,14 @@ export const router = createBrowserRouter([ ), }, + { + path: ':roomId/game', + element: ( + }> + + + ), + }, { path: ':roomId/round/result', element: ( diff --git a/frontend/src/styles/GlobalStyle.ts b/frontend/src/styles/GlobalStyle.ts index 201bc26f5..992655f67 100644 --- a/frontend/src/styles/GlobalStyle.ts +++ b/frontend/src/styles/GlobalStyle.ts @@ -137,8 +137,13 @@ const reset = css` border: none; background-color: inherit; + outline: none; cursor: pointer; + + :enabled { + color: black; + } } `; diff --git a/frontend/src/types/error.ts b/frontend/src/types/error.ts index 3dd88a8b1..eae747010 100644 --- a/frontend/src/types/error.ts +++ b/frontend/src/types/error.ts @@ -29,7 +29,9 @@ export type ErrorCode = | 'METHOD_ARGUMENT_TYPE_MISMATCH' | 'NO_RESOURCE_FOUND' | 'METHOD_NOT_SUPPORTED' - | 'INTERNAL_SERVER_ERROR'; + | 'INTERNAL_SERVER_ERROR' + | 'NOT_FOUND_COOKIE' + | 'INVALID_COOKIE'; export interface UrlParameterError extends ResponseError { errorCode: 'URL_PARAMETER_ERROR'; diff --git a/frontend/src/types/room.ts b/frontend/src/types/room.ts index a14dbad68..8aeef5640 100644 --- a/frontend/src/types/room.ts +++ b/frontend/src/types/room.ts @@ -40,3 +40,9 @@ export interface Category { value: CategoryValue; label: CategoryLabel; } + +export interface RoomAndMember { + roomId: number; + roomUuid: string; + member: Member; +} diff --git a/frontend/src/utils/cookie.ts b/frontend/src/utils/cookie.ts new file mode 100644 index 000000000..9bca22531 --- /dev/null +++ b/frontend/src/utils/cookie.ts @@ -0,0 +1,7 @@ +export const deleteAllCookies = () => { + document.cookie.split(';').forEach((cookie) => { + document.cookie = cookie + .replace(/^ +/, '') + .replace(/=.*/, '=;expires=' + new Date(0).toUTCString() + ';path=/'); + }); +}; diff --git a/frontend/src/utils/test-utils.tsx b/frontend/src/utils/test-utils.tsx index 9fda43f21..e3ae1414c 100644 --- a/frontend/src/utils/test-utils.tsx +++ b/frontend/src/utils/test-utils.tsx @@ -2,6 +2,7 @@ import { Global, ThemeProvider } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render } from '@testing-library/react'; import type { RenderOptions } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; import { PropsWithChildren } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; @@ -9,10 +10,14 @@ import type { MutableSnapshot } from 'recoil'; import AsyncErrorBoundary from '@/components/common/ErrorBoundary/AsyncErrorBoundary'; import RootErrorBoundary from '@/components/common/ErrorBoundary/RootErrorBoundary'; +import QueryClientDefaultOptionProvider from '@/components/common/QueryClientDefaultOptionProvider/QueryClientDefaultOptionProvider'; import Spinner from '@/components/common/Spinner/Spinner'; +import { MOCK_API_URL } from '@/constants/url'; +import ROOM_AND_MASTER from '@/mocks/data/roomAndMaster.json'; +import ROOM_AND_NOT_MASTER from '@/mocks/data/roomAndNotMaster.json'; +import { server } from '@/mocks/server'; import ModalProvider from '@/providers/ModalProvider/ModalProvider'; import ToastProvider from '@/providers/ToastProvider/ToastProvider'; -import { memberInfoState } from '@/recoil/atom'; import GlobalStyle from '@/styles/GlobalStyle'; import { Theme } from '@/styles/Theme'; @@ -38,13 +43,15 @@ const wrapper = ({ - - - - {children} - - - + + + + + {children} + + + + @@ -66,12 +73,22 @@ const customRender = (ui: React.ReactNode, options: CustomRenderOptions = {}) => }); }; -const customRenderWithIsMaster = (Component: React.ReactNode, isMaster: boolean) => { - const initializeState = (snap: MutableSnapshot) => { - snap.set(memberInfoState, { memberId: 1, nickname: 'Test User', isMaster }); - }; +const customRenderWithMaster = (Component: React.ReactNode) => { + server.use( + http.get(MOCK_API_URL.getUserInfo, async () => { + return HttpResponse.json(ROOM_AND_MASTER, { status: 200 }); + }), + ); + customRender(Component); +}; - customRender(Component, { initializeState }); +const customRenderWithNotMaster = (Component: React.ReactNode) => { + server.use( + http.get(MOCK_API_URL.getUserInfo, async () => { + return HttpResponse.json(ROOM_AND_NOT_MASTER, { status: 200 }); + }), + ); + customRender(Component); }; -export { wrapper, customRender, customRenderWithIsMaster }; +export { wrapper, customRender, customRenderWithMaster, customRenderWithNotMaster }; diff --git a/frontend/webpack.config.common.js b/frontend/webpack.config.common.js index 195ee52f2..a1318fef5 100644 --- a/frontend/webpack.config.common.js +++ b/frontend/webpack.config.common.js @@ -2,6 +2,7 @@ const path = require('path'); const HTMLWebpackPlugin = require('html-webpack-plugin'); const webpack = require('webpack'); const dotenv = require('dotenv'); +const CopyPlugin = require('copy-webpack-plugin'); dotenv.config(); @@ -13,6 +14,11 @@ module.exports = { publicPath: '/', clean: true, }, + devServer: { + open: true, + port: 3000, + historyApiFallback: true, + }, resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'], alias: { @@ -51,5 +57,13 @@ module.exports = { new webpack.DefinePlugin({ 'process.env': JSON.stringify(process.env), }), + new CopyPlugin({ + patterns: [ + { + from: path.resolve(__dirname, 'public', 'assets'), + to: path.resolve(__dirname, 'dist', 'assets'), + }, + ], + }), ], }; diff --git a/frontend/webpack.config.dev.js b/frontend/webpack.config.dev.js index 94efe6300..a691b33f6 100644 --- a/frontend/webpack.config.dev.js +++ b/frontend/webpack.config.dev.js @@ -5,11 +5,6 @@ const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); module.exports = merge(common, { mode: 'development', devtool: 'eval-source-map', - devServer: { - open: true, - port: 3000, - historyApiFallback: true, - }, module: { rules: [ { diff --git a/frontend/webpack.config.prod.js b/frontend/webpack.config.prod.js index 2fca7f2bd..0dfff2c4f 100644 --- a/frontend/webpack.config.prod.js +++ b/frontend/webpack.config.prod.js @@ -4,10 +4,6 @@ const { sentryWebpackPlugin } = require('@sentry/webpack-plugin'); module.exports = merge(common, { mode: 'production', - devServer: { - port: 3000, - historyApiFallback: true, - }, devtool: 'hidden-source-map', plugins: [ sentryWebpackPlugin({