Compare commits

..

41 Commits

Author SHA1 Message Date
1cb1a97703 test: 토스페이 계좌이체 클라이언트 테스트 추가 2025-09-09 09:34:03 +09:00
211edcaffd test: 예약 API 테스트 추가 2025-09-09 09:14:53 +09:00
b847e59d6f test: 결제 API 테스트 추가 2025-09-09 09:14:49 +09:00
865026aff2 style: import 정리 & 코드 포맷 정렬 2025-09-09 09:14:42 +09:00
cb14e1d6a5 feat: 테스트에서 로그인 유저 정보 조회 기능 추가 2025-09-09 09:12:25 +09:00
c057fa85e2 feat: TosspayClient 테스트 추가 2025-09-09 09:12:08 +09:00
9ce66d8074 refactor: 토스페이 응답 JSON 기록을 위한 RestClient 응답 타입 수정 2025-09-09 09:11:30 +09:00
c318f5fc7f refactor: 결제 취소시 CanceledPaymentEntity를 반환하도록 수정 2025-09-09 09:11:01 +09:00
a4334c224f refactor: 로그 가독성 향상을 위한 컨트롤러 응답 로그에 엔드포인트 추가 2025-09-09 09:08:16 +09:00
9660d5438d feat: 예약에서의 검증 케이스 추가 및 예약 확정 API Http 메서드 변경(PATCH -> POST) 2025-09-09 09:07:29 +09:00
36e846ded3 refactor: 다른 서비스에서 사용하는 일정 조회 DTO에 상태 반환 추가 2025-09-09 09:05:59 +09:00
ed618e1699 refactor: 테마 반환 DTO 이름 수정 및 테스트 추가 2025-09-09 09:05:35 +09:00
fc3c6e42b0 refactor: 실패 테스트 케이스에 사용할 INVALID PK 상수화 2025-09-09 09:04:10 +09:00
c717e1cb5b feat: 테스트 데이터 삽입 전용 클래스 추가 2025-09-09 09:02:11 +09:00
680f5a9010 refactor: 프론트엔드 예약 API 엔드포인트 수정 2025-09-09 09:00:54 +09:00
752943a9f7 chore: Tosspayment -> Tosspay 네이밍 통일 2025-09-07 22:22:07 +09:00
a4f384a242 test: 통합 테스트 전환을 위해 이전의 테스트 전체 제거 2025-09-07 22:18:45 +09:00
e4b9214d75 remove: 기능 변경 완료로 인한 기존 테마 코드 제거 2025-09-07 22:18:17 +09:00
e7f69aaee4 feat: 통합 테스트 전환 & API 기능 변경으로 인한 기존 테스트 제거 2025-09-07 22:08:40 +09:00
11fd345d5e remove: 이전 스키마 제거 2025-09-07 22:07:21 +09:00
7670e9acc1 remove: schedule 도입으로 미사용인 time 도메인 코드 전체 제거 2025-09-07 21:48:21 +09:00
7c77f50e66 remove: 예약 & 결제 도매인 기능 변경으로 인한 기존 코드 제거 2025-09-07 21:47:58 +09:00
5a9d992cb4 refactor: 예약 API 명세 및 컨트롤러 재정의 2025-09-07 21:46:59 +09:00
ddf366c587 refactor: 예약 API 변경에 따른 서비스 로직 재정의 2025-09-07 21:42:59 +09:00
9e8cb87641 feat: 예약 API 변경에 따른 DTO 재정의 2025-09-07 21:42:32 +09:00
dd34a901b3 refactor: 예약 도메인 변경에 따른 미사용 에러코드 제거 2025-09-07 21:42:09 +09:00
85c8db1866 refactor: 기존의 예약 entity를 새로 정의한 entity로 대체 2025-09-07 21:41:57 +09:00
485f8bd3f2 feat: 테마 서비스 내 조회 기능 추가 2025-09-07 21:40:03 +09:00
0ff7702c83 feat: schedule의 요약 정보를 제공하는 메서드 추가 & 날짜로 조회할 때 DISTINCT 쿼리 추가 2025-09-07 21:39:28 +09:00
3243c936c7 delete: Payment 기능 구현 완료로 인한 기존 코드 제거 2025-09-07 21:30:20 +09:00
1c961803e0 feat: 결제 승인 / 취소 API 추가 2025-09-07 21:29:27 +09:00
7f4af4770d refactor: payment 전용 API 구성을 위한 PaymentService 개선 2025-09-07 21:29:04 +09:00
d62bd444f1 refactor: payment 도메인 내 일부 DTO 수정 및 Tosspayment -> Tosspay 접두사 수정 2025-09-07 21:27:44 +09:00
6c093aeb39 refactor: payment 도메인 내 Entity & Repository 재정의 및 V2 접미사 제거 2025-09-07 21:26:59 +09:00
6d8b85a9e3 refactor: 새로운 엔티티에 맞춘 PaymentWriter 수정 2025-09-07 21:23:01 +09:00
f3707cbc69 refactor: payment 도메인에서의 dto 응답 형식 수정 및 일부 추가 2025-09-07 21:21:54 +09:00
c43836c2dc refactor: member 도매인 내 findById가 Entity가 아닌 DTO를 반혼하도록 수정 2025-09-07 21:20:30 +09:00
0ac0277714 feat: 새로운 예약 / 취소된 예약 Schema & Entity 정의 2025-09-07 18:33:26 +09:00
04d1510bd1 feat: Schedule을 반영한 예약 및 조회 프론트엔드 페이지 추가 2025-09-07 18:31:31 +09:00
d1d81b89b8 refactor: BaseEntity를 단순 PK / Audit 으로 구분 2025-09-04 18:55:24 +09:00
acfe787d5f feat: 일정 선택 후 예약 페이지로 넘어갈 때 해당 일정의 상태를 변경하는 API 추가 2025-09-04 18:54:39 +09:00
446 changed files with 13407 additions and 21231 deletions

4
.gitignore vendored
View File

@ -37,7 +37,3 @@ out/
.vscode/
logs
.kotlin
### sql
data/*.sql
data/*.txt

View File

@ -1,9 +1,10 @@
FROM amazoncorretto:17
FROM gradle:8-jdk17 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootjar --no-daemon
COPY service/build/libs/service.jar app.jar
FROM amazoncorretto:17
WORKDIR /app
EXPOSE 8080
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# issue-pr-template
공통으로 사용하게 될 이슈, PR 템플릿 저장소

View File

@ -1,58 +1,96 @@
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
val springBootVersion = "3.5.3"
val kotlinVersion = "2.2.0"
id("io.spring.dependency-management") version "1.1.7" apply false
id("org.springframework.boot") version springBootVersion apply false
kotlin("jvm") version kotlinVersion apply false
kotlin("kapt") version kotlinVersion apply false
kotlin("plugin.spring") version kotlinVersion apply false
kotlin("plugin.jpa") version kotlinVersion apply false
id("org.springframework.boot") version springBootVersion
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion
kotlin("kapt") version kotlinVersion
}
group = "com.sangdol"
version = "0.0.1-SNAPSHOT"
allprojects {
repositories {
mavenCentral()
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.kapt")
apply(plugin = "io.spring.dependency-management")
tasks.jar {
enabled = false
}
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kapt {
keepJavacAnnotationProcessors = true
}
extensions.configure<KaptExtension> {
keepJavacAnnotationProcessors = true
}
repositories {
mavenCentral()
}
dependencies {
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3")
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
}
dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
tasks.withType<Test> {
useJUnitPlatform()
}
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
// DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View File

@ -1,6 +0,0 @@
#!/bin/bash
IMAGE_NAME="roomescape-backend"
IMAGE_TAG=$1
./gradlew build -x test && docker buildx build --platform=linux/amd64 -t ${PRIVATE_REGISTRY}/$IMAGE_NAME:$IMAGE_TAG . --push

View File

@ -1,11 +0,0 @@
dependencies {
api("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.20.0")
implementation(project(":common:utils"))
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,9 +0,0 @@
package com.sangdol.common.log.constant
enum class LogType {
INCOMING_HTTP_REQUEST,
CONTROLLER_INVOKED,
SUCCEED,
APPLICATION_FAILURE,
UNHANDLED_EXCEPTION
}

View File

@ -1,20 +0,0 @@
package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
import javax.sql.DataSource
object SlowQueryDataSourceFactory {
fun create(dataSource: DataSource, loggerName: String, logLevel: String, thresholdMs: Long): DataSource {
val mdcAwareListener = MDCAwareSlowQueryListenerWithoutParams(
logLevel = SLF4JLogLevel.nullSafeValueOf(logLevel.uppercase()),
thresholdMs = thresholdMs
)
return ProxyDataSourceBuilder.create(dataSource)
.name(loggerName)
.listener(mdcAwareListener)
.buildProxy()
}
}

View File

@ -1,57 +0,0 @@
package com.sangdol.common.log
import ch.qos.logback.classic.spi.ILoggingEvent
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sangdol.common.log.message.AbstractLogMaskingConverter
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
class TestLogMaskingConverter : AbstractLogMaskingConverter(
sensitiveKeys = setOf("account", "address"),
objectMapper = jacksonObjectMapper()
)
class AbstractLogMaskingConverterTest : FunSpec({
val converter = TestLogMaskingConverter()
val event: ILoggingEvent = mockk()
val account = "sangdol@example.com"
val address = "서울특별시 강북구 수유1동 123-456"
context("sensitiveKeys=${converter.sensitiveKeys}에 있는 항목은 가린다.") {
context("평문 로그를 처리할 때, 여러 key / value가 있는 경우 서로 간의 구분자는 trim 처리한다.") {
listOf(":", "=", " : ", " = ").forEach { keyValueDelimiter ->
listOf(",", ", ").forEach { valueDelimiter ->
test("key1${keyValueDelimiter}value1${valueDelimiter}key2${keyValueDelimiter}value2 형식을 처리한다.") {
every {
event.formattedMessage
} returns "account$keyValueDelimiter$account${valueDelimiter}address$keyValueDelimiter$address"
assertSoftly(converter.convert(event)) {
this shouldBe "account${keyValueDelimiter}${account.first()}${converter.mask}${account.last()}${valueDelimiter}address${keyValueDelimiter}${address.first()}${converter.mask}${address.last()}"
}
}
}
}
}
context("JSON 로그") {
test("정상 처리") {
val json = "{\"request_body\":{\"account\":\"%s\",\"address\":\"%s\"}}"
every {
event.formattedMessage
} returns json.format(account, address)
converter.convert(event) shouldBeEqual json.format(
"${account.first()}${converter.mask}${account.last()}",
"${address.first()}${converter.mask}${address.last()}"
)
}
}
}
})

View File

@ -1,29 +0,0 @@
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-data-jpa")
api("com.github.f4b6a3:tsid-creator:5.2.6")
implementation(project(":common:utils"))
implementation(project(":common:types"))
testRuntimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,33 +0,0 @@
package com.sangdol.common.persistence
import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedBy
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class AuditingBaseEntity(
id: Long,
) : PersistableBaseEntity(id) {
@Column(updatable = false)
@CreatedDate
lateinit var createdAt: Instant
@Column(updatable = false)
@CreatedBy
var createdBy: Long = 0L
@Column
@LastModifiedDate
lateinit var updatedAt: Instant
@Column
@LastModifiedBy
var updatedBy: Long = 0L
}

View File

@ -1,13 +0,0 @@
package com.sangdol.common.persistence
import com.github.f4b6a3.tsid.TsidFactory
interface IDGenerator {
fun create(): Long
}
class TsidIDGenerator(
private val tsidFactory: TsidFactory
) : IDGenerator {
override fun create(): Long = tsidFactory.create().toLong()
}

View File

@ -1,25 +0,0 @@
package com.sangdol.common.persistence
import jakarta.persistence.*
import org.springframework.data.domain.Persistable
import kotlin.jvm.Transient
@MappedSuperclass
abstract class PersistableBaseEntity(
@Id
@Column(name = "id")
private val _id: Long,
@Transient
private var isNewEntity: Boolean = true
) : Persistable<Long> {
@PostLoad
@PrePersist
fun markNotNew() {
isNewEntity = false
}
override fun getId(): Long = _id
override fun isNew(): Boolean = isNewEntity
}

View File

@ -1,44 +0,0 @@
package com.sangdol.common.persistence
import com.github.f4b6a3.tsid.TsidFactory
import com.sangdol.common.utils.MdcPrincipalIdUtil
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.data.domain.AuditorAware
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.transaction.PlatformTransactionManager
import java.util.*
@Configuration
@EnableJpaAuditing
class PersistenceConfig {
@Value("\${POD_NAME:app-0}")
private lateinit var podName: String
@Bean
fun auditorAware(): AuditorAware<Long> = MdcAuditorAware()
@Bean
@Primary
fun idGenerator(): IDGenerator {
val node = podName.substringAfterLast("-").toInt()
val tsidFactory = TsidFactory.builder().withNode(node).build()
return TsidIDGenerator(tsidFactory)
}
@Bean
fun transactionExecutionUtil(
transactionManager: PlatformTransactionManager
): TransactionExecutionUtil {
return TransactionExecutionUtil(transactionManager)
}
}
class MdcAuditorAware : AuditorAware<Long> {
override fun getCurrentAuditor(): Optional<Long> = MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty()
}

View File

@ -1,53 +0,0 @@
package com.sangdol.common.persistence
import com.sangdol.common.utils.MdcPrincipalIdUtil
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.date.shouldBeBefore
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import java.time.LocalDateTime
@SpringBootTest
class BaseEntityTest(
private val testPersistableBaseEntityRepository: TestPersistableBaseEntityRepository,
private val testAuditingBaseEntityRepository: TestAuditingBaseEntityRepository,
private val idGenerator: IDGenerator
) : FunSpec({
context("TestPersistableBaseEntityRepository") {
test("PK를 지정하여 INSERT 쿼리를 한번만 전송한다.") {
val entity = TestPersistableBaseEntity(idGenerator.create(), "hello").also {
testPersistableBaseEntityRepository.saveAndFlush(it)
}
testPersistableBaseEntityRepository.findById(entity.id).also {
it.shouldNotBeNull()
it.get() shouldBeEqualUsingFields entity
}
}
}
context("TestAuditingBaseEntityRepository") {
test("Entity 저장 후 Audit 정보를 확인한다.") {
val id = idGenerator.create()
.also {
MdcPrincipalIdUtil.set(it.toString())
}.also {
testAuditingBaseEntityRepository.saveAndFlush(TestAuditingBaseEntity(it, "hello"))
}
testAuditingBaseEntityRepository.findById(id).also {
it.shouldNotBeNull()
assertSoftly(it.get()) {
this.createdAt shouldBeBefore LocalDateTime.now()
this.updatedAt shouldBeBefore LocalDateTime.now()
this.createdBy shouldBe id
this.updatedBy shouldBe id
}
}
}
}
})

View File

@ -1,6 +0,0 @@
package com.sangdol.common.persistence
import org.springframework.boot.autoconfigure.SpringBootApplication
@SpringBootApplication
class TestApplication

View File

@ -1,12 +0,0 @@
package com.sangdol.common.persistence
import jakarta.persistence.Entity
import org.springframework.data.jpa.repository.JpaRepository
@Entity
class TestAuditingBaseEntity(
id: Long,
val name: String
) : AuditingBaseEntity(id)
interface TestAuditingBaseEntityRepository : JpaRepository<TestAuditingBaseEntity, Long>

View File

@ -1,12 +0,0 @@
package com.sangdol.common.persistence
import jakarta.persistence.Entity
import org.springframework.data.jpa.repository.JpaRepository
@Entity
class TestPersistableBaseEntity(
id: Long,
val name: String
) : PersistableBaseEntity(id)
interface TestPersistableBaseEntityRepository : JpaRepository<TestPersistableBaseEntity, Long>

View File

@ -1,71 +0,0 @@
package com.sangdol.common.persistence
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equality.shouldBeEqualUsingFields
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.*
import org.junit.jupiter.api.assertThrows
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.DefaultTransactionDefinition
class TransactionExecutionUtilTest() : FunSpec() {
private val transactionManager = mockk<PlatformTransactionManager>(relaxed = true)
private val transactionExecutionUtil = TransactionExecutionUtil(transactionManager)
init {
context("withNewTransaction") {
beforeTest {
clearMocks(transactionManager)
}
val body = TestPersistableBaseEntity(123458192L, "hello")
test("지정한 action이 성공하면, 해당 값을 반환하고 트랜잭션을 커밋한다.") {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
body
}.also {
it.shouldNotBeNull()
it shouldBeEqualUsingFields body
verify { transactionManager.commit(any()) }
verify(exactly = 0) { transactionManager.rollback(any()) }
}
}
test("지정한 action 실행 도중 예외가 발생하면, 예외를 던지고 트랜잭션을 롤백한다.") {
assertThrows<RuntimeException> {
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
throw RuntimeException()
}
}.also {
verify { transactionManager.rollback(any()) }
verify(exactly = 0) { transactionManager.commit(any()) }
}
}
test("isReadOnly=true 지정시 읽기 전용 트랜잭션으로 실행한다.") {
val transactionStatus = mockk<TransactionStatus>(relaxed = true)
val transactionDefinitionSlot = slot<TransactionDefinition>()
every {
transactionManager.getTransaction(capture(transactionDefinitionSlot))
} returns transactionStatus
transactionExecutionUtil.withNewTransaction(isReadOnly = true) {
"hello"
}.also {
assertSoftly(transactionDefinitionSlot.captured) {
this.isReadOnly shouldBe true
this.propagationBehavior shouldBe DefaultTransactionDefinition.PROPAGATION_REQUIRES_NEW
}
verify { transactionManager.commit(any()) }
}
}
}
}
}

View File

@ -1,18 +0,0 @@
spring:
jpa:
properties:
hibernate:
format_sql: true
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
path: /h2-console
datasource:
hikari:
jdbc-url: jdbc:h2:mem:database
driver-class-name: org.h2.Driver
username: sa
password:

View File

@ -1,3 +0,0 @@
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,24 +0,0 @@
package com.sangdol.common.types.web
enum class HttpStatus(
val code: Int
) {
OK(200),
CREATED(201),
NO_CONTENT(204),
BAD_REQUEST(400),
UNAUTHORIZED(401),
FORBIDDEN(403),
NOT_FOUND(404),
CONFLICT(409),
INTERNAL_SERVER_ERROR(500)
;
fun isClientError(): Boolean {
return code in 400..<500
}
fun value(): Int {
return code
}
}

View File

@ -1,7 +0,0 @@
dependencies {
implementation("org.slf4j:slf4j-api:2.0.17")
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,21 +0,0 @@
package com.sangdol.common.utils
import java.time.*
import java.time.temporal.ChronoUnit
private val KST_CLOCK = Clock.system(ZoneId.of("Asia/Seoul"))
object KoreaDate {
fun today(): LocalDate = LocalDate.now(KST_CLOCK)
}
object KoreaTime {
fun now(): LocalTime = LocalTime.now(KST_CLOCK).truncatedTo(ChronoUnit.MINUTES)
}
object KoreaDateTime {
fun now(): LocalDateTime = LocalDateTime.now(KST_CLOCK)
fun nowWithOffset(): OffsetDateTime = OffsetDateTime.now(KST_CLOCK)
}
fun Instant.toKoreaDateTime(): LocalDateTime = this.atZone(KST_CLOCK.zone).toLocalDateTime()

View File

@ -1,26 +0,0 @@
package com.sangdol.common.utils
import org.slf4j.MDC
import java.util.*
object MdcPrincipalIdUtil {
const val MDC_PRINCIPAL_ID_KEY = "principal_id"
fun extractAsLongOrNull(): Long? {
return MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong()
}
fun extractAsOptionalLongOrEmpty(): Optional<Long> {
return MDC.get(MDC_PRINCIPAL_ID_KEY)?.let {
Optional.of(it.toLong())
} ?: Optional.empty()
}
fun set(id: String) {
MDC.put(MDC_PRINCIPAL_ID_KEY, id)
}
fun clear() {
MDC.remove(MDC_PRINCIPAL_ID_KEY)
}
}

View File

@ -1,25 +0,0 @@
package com.sangdol.common.utils
import org.slf4j.MDC
object MdcStartTimeUtil {
const val MDC_START_TIME_KEY = "start_time"
fun extractDurationMsOrNull(): Long? {
return extractOrNull()?.let { System.currentTimeMillis() - it }
}
fun setCurrentTime() {
extractOrNull() ?: run {
MDC.put(MDC_START_TIME_KEY, System.currentTimeMillis().toString())
}
}
fun clear() {
MDC.remove(MDC_START_TIME_KEY)
}
private fun extractOrNull(): Long? {
return MDC.get(MDC_START_TIME_KEY)?.toLong()
}
}

View File

@ -1,45 +0,0 @@
package com.sangdol.common.utils
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.*
class KoreaDateTimeExtensionsTest : FunSpec({
test("한국 시간 기준으로 현재 시간을 가져오며, 초 단위는 제외한다.") {
assertSoftly(KoreaTime.now()) {
val utcNow = LocalTime.now(ZoneId.of("UTC"))
this.hour shouldBe utcNow.hour.plus(9)
this.minute shouldBe utcNow.minute
this.second shouldBe 0
this.nano shouldBe 0
}
}
test("한국 시간 기준으로 현재 날짜 + 시간을 LocalDateTime 타입으로 가져온다.") {
assertSoftly(KoreaDateTime.now()) {
val utcNow = LocalDateTime.now(ZoneId.of("UTC"))
this.withSecond(0).withNano(0) shouldBe utcNow.plusHours(9).withSecond(0).withNano(0)
}
}
test("한국 시간 기준으로 현재 날짜 + 시간을 OffsetDateTime 타입으로 가져온다.") {
assertSoftly(KoreaDateTime.nowWithOffset()) {
val utcNow = OffsetDateTime.now(ZoneId.of("UTC"))
this.toLocalDateTime().withSecond(0).withNano(0) shouldBe utcNow.toLocalDateTime().plusHours(9)
.withSecond(0).withNano(0)
}
}
test("UTC 시간을 LocalDateTime 타입의 한국 시간으로 변환한다.") {
val now = Instant.now()
val kstConverted = now.toKoreaDateTime()
val utc = now.atZone(ZoneId.of("UTC")).toLocalDateTime()
kstConverted.withSecond(0).withNano(0) shouldBe utc.plusHours(9).withSecond(0).withNano(0)
}
})

View File

@ -1,28 +0,0 @@
package com.sangdol.common.utils
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.util.*
class MdcPrincipalIdUtilTest : StringSpec({
val id = 1872847943L
"값을 설정한다." {
MdcPrincipalIdUtil.set(id.toString()).also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe id
MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty() shouldBe Optional.of(id)
}
}
"값을 제거한다." {
MdcPrincipalIdUtil.set(id.toString()).also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe id
}
MdcPrincipalIdUtil.clear().also {
MdcPrincipalIdUtil.extractAsLongOrNull() shouldBe null
MdcPrincipalIdUtil.extractAsOptionalLongOrEmpty() shouldBe Optional.empty()
}
}
})

View File

@ -1,34 +0,0 @@
package com.sangdol.common.utils
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
class MdcStartTimeUtilTest : FunSpec({
test("기존에 등록된 startTime 값을 기준으로 duration_ms를 구한다.") {
MdcStartTimeUtil.setCurrentTime()
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("기존에 등록된 startTime 값이 없으면 duration_ms는 null이다.") {
MdcStartTimeUtil.extractDurationMsOrNull() shouldBe null
}
test("현재 시간을 등록한다.") {
MdcStartTimeUtil.setCurrentTime()
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("등록된 시간을 지운다.") {
MdcStartTimeUtil.setCurrentTime().also {
MdcStartTimeUtil.extractDurationMsOrNull().shouldNotBeNull()
}
MdcStartTimeUtil.clear().also {
MdcStartTimeUtil.extractDurationMsOrNull() shouldBe null
}
}
})

View File

@ -1,28 +0,0 @@
import org.gradle.kotlin.dsl.named
import org.springframework.boot.gradle.tasks.bundling.BootJar
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
}
dependencies {
api("org.springframework.boot:spring-boot-starter-web")
api("org.springframework.boot:spring-boot-starter-aop")
api("com.fasterxml.jackson.module:jackson-module-kotlin")
api(project(":common:log"))
api(project(":common:utils"))
api(project(":common:types"))
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.mockk:mockk:1.14.4")
}
tasks.named<BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}

View File

@ -1,26 +0,0 @@
package com.sangdol.common.web.asepct
import io.micrometer.observation.Observation
import io.micrometer.observation.ObservationRegistry
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Pointcut
@Aspect
class ServiceObservationAspect(
private val observationRegistry: ObservationRegistry
) {
@Pointcut("execution(* com.sangdol..business..*Service*.*(..))")
fun allServices() {
}
@Around("allServices()")
fun runWithObserve(joinPoint: ProceedingJoinPoint): Any? {
val methodName: String = joinPoint.signature.toShortString()
return Observation.createNotStarted(methodName, observationRegistry)
.observe<Any?> { joinPoint.proceed() }
}
}

View File

@ -1,50 +0,0 @@
package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@Configuration
class JacksonConfig {
companion object {
private val LOCAL_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm")
}
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule())
.registerModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer(
LocalDate::class.java,
LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE)
)
.addDeserializer(
LocalDate::class.java,
LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE)
)
.addSerializer(
LocalTime::class.java,
LocalTimeSerializer(LOCAL_TIME_FORMATTER)
)
.addDeserializer(
LocalTime::class.java,
LocalTimeDeserializer(LOCAL_TIME_FORMATTER)
) as JavaTimeModule
}

View File

@ -1,41 +0,0 @@
package com.sangdol.common.web.config
import com.sangdol.common.web.asepct.ServiceObservationAspect
import io.micrometer.observation.ObservationPredicate
import io.micrometer.observation.ObservationRegistry
import io.micrometer.observation.aop.ObservedAspect
import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.server.observation.ServerRequestObservationContext
@Configuration
class ObservationConfig(
@Value("\${management.endpoints.web.base-path}") private val actuatorPath: String
) {
@Bean
fun observedAspect(observationRegistry: ObservationRegistry): ObservedAspect {
return ObservedAspect(observationRegistry)
}
@Bean
fun serviceObservationAspect(observationRegistry: ObservationRegistry): ServiceObservationAspect {
return ServiceObservationAspect(observationRegistry)
}
@Bean
fun excludeActuatorPredicate(): ObservationPredicate {
return ObservationPredicate { _, context ->
if (context !is ServerRequestObservationContext) {
return@ObservationPredicate true
}
val servletRequest: HttpServletRequest = context.carrier
val requestUri = servletRequest.requestURI
!requestUri.contains(actuatorPath)
}
}
}

View File

@ -1,40 +0,0 @@
package com.sangdol.common.web.config
import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.web.asepct.ControllerLoggingAspect
import com.sangdol.common.web.servlet.HttpRequestLoggingFilter
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.micrometer.tracing.CurrentTraceContext
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.DependsOn
import org.springframework.core.Ordered
import org.springframework.web.filter.OncePerRequestFilter
@Configuration
class WebLoggingConfig {
@Bean
@DependsOn(value = ["webLogMessageConverter"])
fun filterRegistrationBean(
webLogMessageConverter: WebLogMessageConverter,
currentTraceContext: CurrentTraceContext
): FilterRegistrationBean<OncePerRequestFilter> {
val filter = HttpRequestLoggingFilter(webLogMessageConverter, currentTraceContext)
return FilterRegistrationBean<OncePerRequestFilter>(filter)
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
}
@Bean
@DependsOn(value = ["webLogMessageConverter"])
fun apiLoggingAspect(webLogMessageConverter: WebLogMessageConverter): ControllerLoggingAspect {
return ControllerLoggingAspect(webLogMessageConverter)
}
@Bean
fun webLogMessageConverter(objectMapper: ObjectMapper): WebLogMessageConverter {
return WebLogMessageConverter(objectMapper)
}
}

View File

@ -1,75 +0,0 @@
package com.sangdol.common.web.support.log
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import jakarta.servlet.http.HttpServletRequest
class LogPayloadBuilder(
private val type: LogType,
private val servletRequest: HttpServletRequest,
private val payload: MutableMap<String, Any> = mutableMapOf("type" to type)
) {
fun endpoint(): LogPayloadBuilder {
payload["endpoint"] = "${servletRequest.method} ${servletRequest.requestURI}"
return this
}
fun clientIp(): LogPayloadBuilder {
servletRequest.remoteAddr?.let { payload["client_ip"] = it }
return this
}
fun userAgent(): LogPayloadBuilder {
servletRequest.getHeader("User-Agent")?.let { payload["user_agent"] = it }
return this
}
fun queryString(): LogPayloadBuilder {
servletRequest.queryString?.let { payload["query_params"] = it }
return this
}
fun httpStatus(statusCode: Int?): LogPayloadBuilder {
statusCode?.let { payload["status_code"] = it }
return this
}
fun responseBody(body: Any?): LogPayloadBuilder {
body?.let { payload["response_body"] = it }
return this
}
fun durationMs(): LogPayloadBuilder {
MdcStartTimeUtil.extractDurationMsOrNull()?.let { payload["duration_ms"] = it }
return this
}
fun principalId(): LogPayloadBuilder {
MdcPrincipalIdUtil.extractAsLongOrNull()
?.let { payload["principal_id"] = it }
?: run { payload["principal_id"] = "UNKNOWN" }
return this
}
fun exception(exception: Exception?): LogPayloadBuilder {
exception?.let {
payload["exception"] = mapOf(
"class" to it.javaClass.simpleName,
"message" to it.message
)
}
return this
}
fun additionalPayloads(payload: Map<String, Any>): LogPayloadBuilder {
this.payload.putAll(payload)
return this
}
fun build(): Map<String, Any> = payload
}

View File

@ -1,68 +0,0 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.databind.ObjectMapper
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverter(
private val objectMapper: ObjectMapper
) {
fun convertToHttpRequestMessage(servletRequest: HttpServletRequest): String {
val payload = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.queryString()
.clientIp()
.userAgent()
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToControllerInvokedMessage(
servletRequest: HttpServletRequest,
controllerPayload: Map<String, Any>
): String {
val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest)
.endpoint()
.principalId()
.additionalPayloads(controllerPayload)
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToResponseMessage(
type: LogType,
servletRequest: HttpServletRequest,
httpStatusCode: Int,
responseBody: Any? = null,
exception: Exception? = null,
): String {
val payload = LogPayloadBuilder(type = type, servletRequest = servletRequest)
.endpoint()
.httpStatus(httpStatusCode)
.durationMs()
.principalId()
.responseBody(responseBody)
.exception(exception)
.build()
return objectMapper.writeValueAsString(payload)
}
fun convertToErrorResponseMessage(
servletRequest: HttpServletRequest,
httpStatus: HttpStatus,
responseBody: Any? = null,
exception: Exception? = null,
): String {
val type = if (httpStatus.isClientError()) {
LogType.APPLICATION_FAILURE
} else {
LogType.UNHANDLED_EXCEPTION
}
return convertToResponseMessage(type, servletRequest, httpStatus.value(), responseBody, exception)
}
}

View File

@ -1,233 +0,0 @@
package com.sangdol.common.web.support.log
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
class LogPayloadBuilderTest : FunSpec({
val servletRequest: HttpServletRequest = mockk()
lateinit var method: String
lateinit var requestUri: String
lateinit var remoteAddr: String
lateinit var userAgent: String
lateinit var queryString: String
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
afterSpec {
clearMocks(servletRequest)
}
context("endpoint") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.build()
result["endpoint"] shouldBe "$method $requestUri"
}
test("ServletRequest에서 null이 반환되면 그대로 들어간다.") {
every { servletRequest.method } returns null
every { servletRequest.requestURI } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.endpoint()
.build()
result["endpoint"] shouldBe "null null"
}
}
context("clientIp") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.clientIp()
.build()
result["client_ip"] shouldBe remoteAddr
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.remoteAddr } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.clientIp()
.build()
result["client_ip"] shouldBe null
}
}
context("userAgent") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.userAgent()
.build()
result["user_agent"] shouldBe userAgent
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.getHeader("User-Agent") } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.userAgent()
.build()
result["user_agent"] shouldBe null
}
}
context("queryString") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.queryString()
.build()
result["query_params"] shouldBe queryString
}
test("ServletRequest에서 null이 반환되면 추가되지 않는다.") {
every { servletRequest.queryString } returns null
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.queryString()
.build()
result["query_params"] shouldBe null
}
}
context("httpStatus") {
test("정상 응답") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.httpStatus(200)
.build()
result["status_code"] shouldBe 200
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.httpStatus(null)
.build()
result["status_code"] shouldBe null
}
}
context("responseBody") {
test("정상 응답") {
val body = mapOf("key" to "value")
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.responseBody(body)
.build()
result["response_body"] shouldBe body
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.responseBody(null)
.build()
result["response_body"] shouldBe null
}
}
context("durationMs") {
test("정상 응답") {
MdcStartTimeUtil.setCurrentTime()
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.durationMs()
.build()
result["duration_ms"].shouldNotBeNull()
MdcStartTimeUtil.clear()
}
test("MDC에서 값을 가져올 수 없으면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.durationMs()
.build()
result["duration_ms"] shouldBe null
}
}
context("principalId") {
test("정상 응답") {
val principalId = 759980174446956066L.also {
MdcPrincipalIdUtil.set(it.toString())
}
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.principalId()
.build()
result["principal_id"] shouldBe principalId
MdcPrincipalIdUtil.clear()
}
test("MDC에서 값을 가져올 수 없으면 UNKNOWN 으로 표기된다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.principalId()
.build()
result["principal_id"] shouldBe "UNKNOWN"
}
}
context("exception") {
test("정상 응답") {
val exception = RuntimeException("hello")
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.exception(exception)
.build()
result["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
test("null을 입력하면 추가되지 않는다.") {
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.exception(null)
.build()
result["exception"] shouldBe null
}
}
context("additionalPayloads") {
test("정상 응답") {
val payload = mapOf(
"key1" to "value1",
"key2" to "value2"
)
val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest)
.additionalPayloads(payload)
.build()
result["key1"] shouldBe "value1"
result["key2"] shouldBe "value2"
}
}
})

View File

@ -1,194 +0,0 @@
package com.sangdol.common.web.support.log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sangdol.common.log.constant.LogType
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.common.utils.MdcStartTimeUtil
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
class WebLogMessageConverterTest : FunSpec({
val objectMapper = jacksonObjectMapper()
val converter = WebLogMessageConverter(objectMapper)
val servletRequest: HttpServletRequest = mockk()
lateinit var method: String
lateinit var requestUri: String
lateinit var remoteAddr: String
lateinit var userAgent: String
lateinit var queryString: String
beforeTest {
method = "GET".also { every { servletRequest.method } returns it }
requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it }
remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it }
userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it }
queryString = "key=value".also { every { servletRequest.queryString } returns it }
}
afterSpec {
clearMocks(servletRequest)
}
context("Http 요청 메시지를 변환한다.") {
test("정상 응답") {
val result = converter.convertToHttpRequestMessage(servletRequest)
result shouldBe """
{"type":"${LogType.INCOMING_HTTP_REQUEST.name}","endpoint":"$method $requestUri","query_params":"$queryString","client_ip":"$remoteAddr","user_agent":"$userAgent"}
""".trimIndent()
}
}
context("Controller 요청 메시지를 변환한다") {
val principalId = 759980174446956066L.also {
MdcPrincipalIdUtil.set(it.toString())
}
test("정상 응답") {
val controllerPayload: Map<String, Any> = mapOf(
"controller_method" to "ThemeController.findThemeById(..)",
"path_variable" to mapOf("id" to "7599801744469560667")
)
val result = converter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
result shouldBe """
{"type":"${LogType.CONTROLLER_INVOKED.name}","endpoint":"$method $requestUri","principal_id":$principalId,"controller_method":"${controllerPayload["controller_method"]}","path_variable":{"id":"${7599801744469560667}"}}
""".trimIndent()
}
}
context("응답 메시지를 변환한다.") {
val principalId = 7599801744469560666
val body = mapOf(
"id" to 7599801744469560667,
"name" to "sangdol"
)
val exception = RuntimeException("hello")
beforeTest {
MdcPrincipalIdUtil.set(principalId.toString())
MdcStartTimeUtil.setCurrentTime()
}
afterTest {
MdcPrincipalIdUtil.clear()
MdcStartTimeUtil.clear()
}
test("응답 본문을 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
responseBody = body
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body
this["exception"] shouldBe null
}
}
test("예외를 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
exception = exception
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null
this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
}
test("예외 + 응답 본문을 모두 포함한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
responseBody = body,
exception = exception
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe body
this["exception"] shouldBe mapOf(
"class" to exception.javaClass.simpleName,
"message" to exception.message
)
}
}
test("예외, 응답 본문 모두 제외한다.") {
val result = converter.convertToResponseMessage(
type = LogType.SUCCEED,
servletRequest = servletRequest,
httpStatusCode = HttpStatus.OK.value(),
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.SUCCEED.name
this["endpoint"] shouldBe "$method $requestUri"
this["status_code"] shouldBe HttpStatus.OK.value()
this["duration_ms"].shouldNotBeNull()
this["principal_id"] shouldBe principalId
this["response_body"] shouldBe null
this["exception"] shouldBe null
}
}
test("4xx 에러가 발생하면 ${LogType.APPLICATION_FAILURE} 타입으로 변환한다.") {
val result = converter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = HttpStatus.BAD_REQUEST,
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.APPLICATION_FAILURE.name
}
}
test("5xx 에러가 발생하면 ${LogType.UNHANDLED_EXCEPTION} 타입으로 변환한다.") {
val result = converter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
)
assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) {
this["type"] shouldBe LogType.UNHANDLED_EXCEPTION.name
}
}
}
})

Binary file not shown.

View File

@ -1,14 +0,0 @@
services:
mysql-local:
image: mysql:8.4
container_name: mysql-local
restart: always
ports:
- "23306:3306"
environment:
MYSQL_ROOT_PASSWORD: init
MYSQL_DATABASE: roomescape_local
TZ: UTC
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci

View File

@ -1,18 +1,6 @@
.git
.gitignore
# Node.js
node_modules
npm-debug.log
# Build output
build
dist
# Editor/OS specific
.vscode
.idea
.git
.DS_Store
# Environment variables
.env*
npm-debug.log
dist
build

View File

@ -1,17 +1,18 @@
FROM node:24-alpine AS builder
# Stage 1: Build the React app
FROM node:24 AS builder
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
COPY package.json package-lock.json ./
RUN npm ci
RUN npm install --frozen-lockfile
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
# Stage 2: Serve with Nginx
FROM nginx:latest
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -3,7 +3,7 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import {globalIgnores} from 'eslint/config'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),

View File

@ -1,2 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88"
height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,22 +1,45 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout';
import { AdminAuthProvider } from './context/AdminAuthContext';
import { AuthProvider } from './context/AuthContext';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import SignupPage from './pages/SignupPage';
import ReservationPage from './pages/ReservationPage';
import MyReservationPage from './pages/MyReservationPage';
import AdminLayout from './pages/admin/AdminLayout';
import AdminLoginPage from './pages/admin/AdminLoginPage';
import AdminPage from './pages/admin/AdminPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
import AdminStorePage from './pages/admin/AdminStorePage';
import AdminReservationPage from './pages/admin/ReservationPage';
import AdminTimePage from './pages/admin/TimePage';
import AdminThemePage from './pages/admin/ThemePage';
import AdminWaitingPage from './pages/admin/WaitingPage';
import { AuthProvider } from './context/AuthContext';
import AdminRoute from './components/AdminRoute';
import ReservationStep1Page from './pages/v2/ReservationStep1Page';
import ReservationStep2Page from './pages/v2/ReservationStep2Page';
import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
import ReservationStep1PageV21 from './pages/v2/ReservationStep1PageV21';
import ReservationStep2PageV21 from './pages/v2/ReservationStep2PageV21';
import ReservationSuccessPageV21 from './pages/v2/ReservationSuccessPageV21';
import HomePageV2 from './pages/v2/HomePageV2';
import LoginPageV2 from './pages/v2/LoginPageV2';
import SignupPageV2 from './pages/v2/SignupPageV2';
import ReservationFormPage from './pages/v2/ReservationFormPage';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminThemePage from './pages/admin/AdminThemePage';
import HomePage from '@_pages/HomePage';
import LoginPage from '@_pages/LoginPage';
import MyReservationPage from '@_pages/MyReservationPage';
import ReservationFormPage from '@_pages/ReservationFormPage';
import ReservationStep1Page from '@_pages/ReservationStep1Page';
import ReservationStep2Page from '@_pages/ReservationStep2Page';
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
import SignupPage from '@_pages/SignupPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
const AdminRoutes = () => (
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/reservation" element={<AdminReservationPage />} />
<Route path="/time" element={<AdminTimePage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/waiting" element={<AdminWaitingPage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>
);
function App() {
return (
@ -24,34 +47,35 @@ function App() {
<Router>
<Routes>
<Route path="/admin/*" element={
<AdminAuthProvider>
<Routes>
<Route path="/login" element={<AdminLoginPage />} />
<Route path="/*" element={
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/store" element={<AdminStorePage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>
} />
</Routes>
</AdminAuthProvider>
<AdminRoute>
<AdminRoutes />
</AdminRoute>
} />
<Route path="/*" element={
<Layout>
<Routes>
<Route path="/" element={<HomePage/>} />
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/reservation" element={<ReservationStep1Page />} />
<Route path="/reservation/form" element={<ReservationFormPage />} />
<Route path="/reservation/payment" element={<ReservationStep2Page />} />
<Route path="/reservation/success" element={<ReservationSuccessPage />} />
<Route path="/reservation" element={<ReservationPage />} />
<Route path="/my-reservation" element={<MyReservationPage />} />
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
{/* V2 Pages */}
<Route path="/v2/home" element={<HomePageV2 />} />
<Route path="/v2/login" element={<LoginPageV2 />} />
<Route path="/v2/signup" element={<SignupPageV2 />} />
{/* V2 Reservation Flow */}
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
{/* V2.1 Reservation Flow */}
<Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} />
<Route path="/v2/reservation/form" element={<ReservationFormPage />} />
<Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} />
<Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} />
</Routes>
</Layout>
} />

View File

@ -1,6 +1,5 @@
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
import JSONbig from 'json-bigint';
import { PrincipalType } from './auth/authTypes';
// Create a JSONbig instance that stores big integers as strings
const JSONbigString = JSONbig({ storeAsString: true });
@ -39,7 +38,7 @@ async function request<T>(
method: Method,
endpoint: string,
data: object = {},
type: PrincipalType,
isRequiredAuth: boolean = false
): Promise<T> {
const config: AxiosRequestConfig = {
method,
@ -49,9 +48,8 @@ async function request<T>(
},
};
const accessTokenKey = type === PrincipalType.ADMIN ? 'adminAccessToken' : 'accessToken';
const accessToken = localStorage.getItem(accessTokenKey);
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
if (!config.headers) {
config.headers = {};
@ -59,6 +57,7 @@ async function request<T>(
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
if (method.toUpperCase() !== 'GET') {
config.data = data;
}
@ -73,50 +72,30 @@ async function request<T>(
}
}
async function get<T>(endpoint: string): Promise<T> {
return request<T>('GET', endpoint, {}, PrincipalType.USER);
async function get<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('GET', endpoint, {}, isRequiredAuth);
}
async function adminGet<T>(endpoint: string): Promise<T> {
return request<T>('GET', endpoint, {}, PrincipalType.ADMIN);
async function post<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('POST', endpoint, data, isRequiredAuth);
}
async function post<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('POST', endpoint, data, PrincipalType.USER);
async function put<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('PUT', endpoint, data, isRequiredAuth);
}
async function adminPost<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('POST', endpoint, data, PrincipalType.ADMIN);
async function patch<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('PATCH', endpoint, data, isRequiredAuth);
}
async function put<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PUT', endpoint, data, PrincipalType.USER);
}
async function adminPut<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PUT', endpoint, data, PrincipalType.ADMIN);
}
async function patch<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PATCH', endpoint, data, PrincipalType.USER);
}
async function adminPatch<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PATCH', endpoint, data, PrincipalType.ADMIN);
}
async function del<T>(endpoint: string): Promise<T> {
return request<T>('DELETE', endpoint, {}, PrincipalType.USER);
}
async function adminDel<T>(endpoint: string): Promise<T> {
return request<T>('DELETE', endpoint, {}, PrincipalType.ADMIN);
async function del<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('DELETE', endpoint, {}, isRequiredAuth);
}
export default {
get, adminGet,
post, adminPost,
put, adminPut,
patch, adminPatch,
del, adminDel,
get,
post,
put,
patch,
del
};

View File

@ -1,33 +1,19 @@
import apiClient from '@_api/apiClient';
import {
type AdminLoginSuccessResponse,
type LoginRequest,
PrincipalType,
type UserLoginSuccessResponse,
} from './authTypes';
import type { LoginRequest, LoginResponse, LoginCheckResponse } from './authTypes';
export const userLogin = async (
data: Omit<LoginRequest, 'principalType'>,
): Promise<UserLoginSuccessResponse> => {
return await apiClient.post<UserLoginSuccessResponse>(
'/auth/login',
{ ...data, principalType: PrincipalType.USER },
);
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/login', data, false);
localStorage.setItem('accessToken', response.accessToken);
return response;
};
export const adminLogin = async (
data: Omit<LoginRequest, 'principalType'>,
): Promise<AdminLoginSuccessResponse> => {
return await apiClient.adminPost<AdminLoginSuccessResponse>(
'/auth/login',
{ ...data, principalType: PrincipalType.ADMIN },
);
export const checkLogin = async (): Promise<LoginCheckResponse> => {
return await apiClient.get<LoginCheckResponse>('/login/check', true);
};
export const logout = async (): Promise<void> => {
await apiClient.post('/auth/logout', {});
await apiClient.post('/logout', {}, true);
localStorage.removeItem('accessToken');
};
export const adminLogout = async (): Promise<void> => {
await apiClient.adminPost('/auth/logout', {});
}

View File

@ -1,38 +1,14 @@
export const PrincipalType = {
ADMIN: 'ADMIN',
USER: 'USER',
} as const;
export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType];
export const AdminType = {
HQ: 'HQ',
STORE: 'STORE',
} as const;
export type AdminType = typeof AdminType[keyof typeof AdminType];
export interface LoginRequest {
account: string,
email: string;
password: string;
principalType: PrincipalType;
}
export interface LoginSuccessResponse {
export interface LoginResponse {
accessToken: string;
}
export interface LoginCheckResponse {
name: string;
role: 'ADMIN' | 'MEMBER';
}
export interface UserLoginSuccessResponse extends LoginSuccessResponse {
}
export interface AdminLoginSuccessResponse extends LoginSuccessResponse {
type: AdminType;
storeId: string | null;
}
export interface CurrentUserContext {
id: string;
name: string;
type: PrincipalType;
}

View File

@ -1,11 +0,0 @@
export interface OperatorInfo {
id: string;
name: string;
}
export interface AuditInfo {
createdAt: string;
updatedAt: string;
createdBy: OperatorInfo;
updatedBy: OperatorInfo;
}

View File

@ -0,0 +1,10 @@
import apiClient from "@_api/apiClient";
import type { MemberRetrieveListResponse, SignupRequest, SignupResponse } from "./memberTypes";
export const fetchMembers = async (): Promise<MemberRetrieveListResponse> => {
return await apiClient.get<MemberRetrieveListResponse>('/members', true);
};
export const signup = async (data: SignupRequest): Promise<SignupResponse> => {
return await apiClient.post('/members', data, false);
};

View File

@ -0,0 +1,25 @@
export interface MemberRetrieveResponse {
id: string;
name: string;
}
export interface MemberRetrieveListResponse {
members: MemberRetrieveResponse[];
}
export interface SignupRequest {
email: string;
password: string;
name: string;
}
export interface SignupResponse {
id: string;
name: string;
}
export interface MemberSummaryRetrieveResponse {
id: string;
name: string;
email: string;
}

View File

@ -1,12 +0,0 @@
import apiClient from "@_api/apiClient";
import type { PaymentConfirmRequest } from "@_api/payment/PaymentTypes";
export const confirm = async (
reservationId: string,
data: PaymentConfirmRequest,
): Promise<void> => {
return await apiClient.post<void>(
`/orders/${reservationId}/confirm`,
data
);
};

View File

@ -1,5 +0,0 @@
export interface OrderErrorResponse {
code: string;
message: string;
trial: number;
}

View File

@ -2,6 +2,7 @@ export interface PaymentConfirmRequest {
paymentKey: string;
orderId: string;
amount: number;
paymentType: PaymentType;
}
export interface PaymentCancelRequest {
@ -33,8 +34,8 @@ export interface PaymentRetrieveResponse {
status: 'DONE' | 'CANCELED';
requestedAt: string;
approvedAt: string;
detail?: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail;
cancel?: CanceledPaymentDetailResponse;
detail: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail;
cancellation?: CanceledPaymentDetailResponse;
}
export interface CardPaymentDetail {

View File

@ -1,5 +1,5 @@
import apiClient from "@_api/apiClient";
import type {PaymentCancelRequest, PaymentConfirmRequest, PaymentCreateResponseV2} from "./PaymentTypes";
import type { PaymentCancelRequest, PaymentConfirmRequest, PaymentCreateResponseV2 } from "./PaymentTypes";
export const confirmPayment = async (reservationId: string, request: PaymentConfirmRequest): Promise<PaymentCreateResponseV2> => {
return await apiClient.post<PaymentCreateResponseV2>(`/payments?reservationId=${reservationId}`, request);

View File

@ -1,14 +0,0 @@
import apiClient from "@_api/apiClient";
import type { RegionCodeResponse, SidoListResponse, SigunguListResponse } from "./regionTypes";
export const fetchSidoList = async (): Promise<SidoListResponse> => {
return await apiClient.get(`/regions/sido`);
};
export const fetchSigunguList = async (sidoCode: string): Promise<SigunguListResponse> => {
return await apiClient.get(`/regions/sigungu?sidoCode=${sidoCode}`);
}
export const fetchRegionCode = async (sidoCode: string, sigunguCode: string): Promise<RegionCodeResponse> => {
return await apiClient.get(`/regions/code?sidoCode=${sidoCode}&sigunguCode=${sigunguCode}`);
}

View File

@ -1,27 +0,0 @@
export interface SidoResponse {
code: string,
name: string,
}
export interface SidoListResponse {
sidoList: SidoResponse[]
}
export interface SigunguResponse {
code: string,
name: string,
}
export interface SigunguListResponse {
sigunguList: SigunguResponse[]
}
export interface RegionCodeResponse {
code: string
}
export interface RegionInfoResponse {
code: string,
sidoName: string,
sigunguName: string,
}

View File

@ -1,33 +1,98 @@
import apiClient from '../apiClient';
import apiClient from "@_api/apiClient";
import type {
MostReservedThemeIdListResponse,
PendingReservationCreateRequest,
PendingReservationCreateResponse,
ReservationDetailRetrieveResponse,
ReservationOverviewListResponse
} from './reservationTypes';
AdminReservationCreateRequest,
MyReservationRetrieveListResponse,
ReservationCreateRequest,
ReservationCreateResponse,
ReservationCreateWithPaymentRequest,
ReservationDetailV2,
ReservationPaymentRequest,
ReservationPaymentResponse,
ReservationRetrieveListResponse,
ReservationRetrieveResponse,
ReservationSearchQuery,
ReservationSummaryListV2,
WaitingCreateRequest
} from "./reservationTypes";
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request);
// GET /reservations
export const fetchReservations = async (): Promise<ReservationRetrieveListResponse> => {
return await apiClient.get<ReservationRetrieveListResponse>('/reservations', true);
};
export const confirmReservation = async (reservationId: string): Promise<void> => {
await apiClient.post(`/reservations/${reservationId}/confirm`, {});
// GET /reservations-mine
export const fetchMyReservations = async (): Promise<MyReservationRetrieveListResponse> => {
return await apiClient.get<MyReservationRetrieveListResponse>('/reservations-mine', true);
};
// GET /reservations/search
export const searchReservations = async (params: ReservationSearchQuery): Promise<ReservationRetrieveListResponse> => {
const query = new URLSearchParams();
if (params.themeId) query.append('themeId', params.themeId.toString());
if (params.memberId) query.append('memberId', params.memberId.toString());
if (params.dateFrom) query.append('dateFrom', params.dateFrom);
if (params.dateTo) query.append('dateTo', params.dateTo);
return await apiClient.get<ReservationRetrieveListResponse>(`/reservations/search?${query.toString()}`, true);
};
// DELETE /reservations/{id}
export const cancelReservationByAdmin = async (id: string): Promise<void> => {
return await apiClient.del(`/reservations/${id}`, true);
};
// POST /reservations
export const createReservationWithPayment = async (data: ReservationCreateWithPaymentRequest): Promise<ReservationRetrieveResponse> => {
return await apiClient.post<ReservationRetrieveResponse>('/reservations', data, true);
};
// POST /reservations/admin
export const createReservationByAdmin = async (data: AdminReservationCreateRequest): Promise<ReservationRetrieveResponse> => {
return await apiClient.post<ReservationRetrieveResponse>('/reservations/admin', data, true);
};
// GET /reservations/waiting
export const fetchWaitingReservations = async (): Promise<ReservationRetrieveListResponse> => {
return await apiClient.get<ReservationRetrieveListResponse>('/reservations/waiting', true);
};
// POST /reservations/waiting
export const createWaiting = async (data: WaitingCreateRequest): Promise<ReservationRetrieveResponse> => {
return await apiClient.post<ReservationRetrieveResponse>('/reservations/waiting', data, true);
};
// DELETE /reservations/waiting/{id}
export const cancelWaiting = async (id: string): Promise<void> => {
return await apiClient.del(`/reservations/waiting/${id}`, true);
};
// POST /reservations/waiting/{id}/confirm
export const confirmWaiting = async (id: string): Promise<void> => {
return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true);
};
// POST /reservations/waiting/{id}/reject
export const rejectWaiting = async (id: string): Promise<void> => {
return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true);
};
// POST /v2/reservations
export const createPendingReservation = async (data: ReservationCreateRequest): Promise<ReservationCreateResponse> => {
return await apiClient.post<ReservationCreateResponse>('/v2/reservations', data, true);
};
// POST /v2/reservations/{id}/pay
export const confirmReservationPayment = async (id: string, data: ReservationPaymentRequest): Promise<ReservationPaymentResponse> => {
return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true);
};
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason });
// GET /v2/reservations
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {
return await apiClient.get<ReservationSummaryListV2>('/v2/reservations', true);
};
export const fetchAllOverviewByUser = async (): Promise<ReservationOverviewListResponse> => {
return await apiClient.get<ReservationOverviewListResponse>('/reservations/overview');
}
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
return await apiClient.get<ReservationDetailRetrieveResponse>(`/reservations/${reservationId}/detail`);
}
export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => {
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`);
}
// GET /v2/reservations/{id}/details
export const fetchReservationDetailV2 = async (id: string): Promise<ReservationDetailV2> => {
return await apiClient.get<ReservationDetailV2>(`/v2/reservations/${id}/details`, true);
};

View File

@ -0,0 +1,23 @@
import apiClient from '../apiClient';
import type { PendingReservationCreateRequest, PendingReservationCreateResponse, ReservationDetailRetrieveResponse, ReservationSummaryRetrieveListResponse } from './reservationTypesV2';
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request);
};
export const confirmReservation = async (reservationId: string): Promise<void> => {
await apiClient.post(`/reservations/${reservationId}/confirm`, {});
};
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }, true);
};
export const fetchSummaryByMember = async (): Promise<ReservationSummaryRetrieveListResponse> => {
return await apiClient.get<ReservationSummaryRetrieveListResponse>('/reservations/summary');
}
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
return await apiClient.get<ReservationDetailRetrieveResponse>(`/reservations/${reservationId}/detail`);
}

View File

@ -1,88 +1,135 @@
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
export interface ReservationData {
scheduleId: string;
store: {
id: string;
name: string;
}
theme: {
id: string;
name: string;
price: number;
minParticipants: number;
maxParticipants: number;
}
date: string; // "yyyy-MM-dd"
startFrom: string; // "HH:mm ~ HH:mm"
endAt: string;
}
import type { MemberRetrieveResponse, MemberSummaryRetrieveResponse } from '@_api/member/memberTypes';
import type { PaymentRetrieveResponse, PaymentType } from '@_api/payment/PaymentTypes';
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
export const ReservationStatus = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
CANCELED: 'CANCELED',
FAILED: 'FAILED',
EXPIRED: 'EXPIRED'
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
WAITING: 'WAITING',
CANCELED_BY_USER: 'CANCELED_BY_USER',
AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED'
} as const;
export type ReservationStatus =
| typeof ReservationStatus.PENDING
| typeof ReservationStatus.CONFIRMED
| typeof ReservationStatus.CANCELED
| typeof ReservationStatus.FAILED
| typeof ReservationStatus.EXPIRED;
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
| typeof ReservationStatus.WAITING
| typeof ReservationStatus.CANCELED_BY_USER
| typeof ReservationStatus.AUTOMATICALLY_CANCELED;
export interface PendingReservationCreateRequest {
scheduleId: string,
reserverName: string,
reserverContact: string,
participantCount: number,
requirement: string
export interface MyReservationRetrieveResponse {
id: string;
themeName: string;
date: string;
time: string;
status: ReservationStatus;
rank: number;
paymentKey: string | null;
amount: number | null;
}
export interface PendingReservationCreateResponse {
id: string
export interface MyReservationRetrieveListResponse {
reservations: MyReservationRetrieveResponse[];
}
export interface ReservationOverviewResponse {
export interface ReservationRetrieveResponse {
id: string;
date: string;
member: MemberRetrieveResponse;
time: TimeRetrieveResponse;
theme: ThemeRetrieveResponse;
status: ReservationStatus;
}
export interface ReservationRetrieveListResponse {
reservations: ReservationRetrieveResponse[];
}
export interface AdminReservationCreateRequest {
date: string;
timeId: string;
themeId: string;
memberId: string;
}
export interface ReservationCreateWithPaymentRequest {
date: string;
timeId: string;
themeId: string;
paymentKey: string;
orderId: string;
amount: number;
paymentType: string;
}
export interface WaitingCreateRequest {
date: string;
timeId: string;
themeId: string;
}
export interface ReservationSearchQuery {
themeId?: string;
memberId?: string;
dateFrom?: string;
dateTo?: string;
}
export const PaymentStatus = {
IN_PROGRESS: '결제 진행 중',
DONE: '결제 완료',
CANCELED: '결제 취소',
ABORTED: '결제 중단',
EXPIRED: '시간 만료',
}
export type PaymentStatus =
| typeof PaymentStatus.IN_PROGRESS
| typeof PaymentStatus.DONE
| typeof PaymentStatus.CANCELED
| typeof PaymentStatus.ABORTED
| typeof PaymentStatus.EXPIRED;
export interface ReservationCreateRequest {
date: string;
timeId: string;
themeId: string;
}
export interface ReservationCreateResponse {
reservationId: string;
memberEmail: string;
date: string;
startAt: string;
themeName: string;
}
export interface ReservationPaymentRequest {
paymentKey: string;
orderId: string;
amount: number;
paymentType: PaymentType;
}
export interface ReservationPaymentResponse {
reservationId: string;
reservationStatus: ReservationStatus;
paymentId: string;
paymentStatus: PaymentStatus;
}
export interface ReservationDetailV2 {
id: string;
storeName: string;
user: MemberSummaryRetrieveResponse;
themeName: string;
date: string;
startFrom: string;
endAt: string;
status: ReservationStatus;
}
export interface ReservationOverviewListResponse {
reservations: ReservationOverviewResponse[];
}
export interface ReserverInfo {
name: string;
contact: string;
participantCount: number;
requirement: string;
}
export interface ReservationDetailRetrieveResponse {
id: string;
reserver: ReserverInfo;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}
export interface ReservationDetail {
overview: ReservationOverviewResponse;
reserver: ReserverInfo;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment?: PaymentRetrieveResponse;
}
export interface MostReservedThemeIdListResponse {
themeIds: string[];
startAt: string;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}

View File

@ -0,0 +1,58 @@
import type { MemberSummaryRetrieveResponse } from "@_api/member/memberTypes";
import type { PaymentRetrieveResponse } from "@_api/payment/PaymentTypes";
export const ReservationStatusV2 = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
CANCELED: 'CANCELED',
FAILED: 'FAILED',
EXPIRED: 'EXPIRED'
} as const;
export type ReservationStatusV2 =
| typeof ReservationStatusV2.PENDING
| typeof ReservationStatusV2.CONFIRMED
| typeof ReservationStatusV2.CANCELED
| typeof ReservationStatusV2.FAILED
| typeof ReservationStatusV2.EXPIRED;
export interface PendingReservationCreateRequest {
scheduleId: string,
reserverName: string,
reserverContact: string,
participantCount: number,
requirement: string
}
export interface PendingReservationCreateResponse {
id: string
}
export interface ReservationSummaryRetrieveResponse {
id: string;
themeName: string;
date: string;
startAt: string;
status: ReservationStatusV2;
}
export interface ReservationSummaryRetrieveListResponse {
reservations: ReservationSummaryRetrieveResponse[];
}
export interface ReservationDetailRetrieveResponse {
id: string;
member: MemberSummaryRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}
export interface ReservationDetail {
id: string;
themeName: string;
date: string;
startAt: string;
member: MemberSummaryRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
}

View File

@ -1,55 +1,36 @@
import apiClient from "@_api/apiClient";
import type {AuditInfo} from "@_api/common/commonTypes";
import apiClient from '../apiClient';
import type {
AdminScheduleSummaryListResponse,
AvailableThemeIdListResponse,
ScheduleCreateRequest,
ScheduleCreateResponse,
ScheduleUpdateRequest,
ScheduleWithThemeListResponse
} from "./scheduleTypes";
ScheduleCreateResponse, ScheduleDetailRetrieveResponse,
ScheduleRetrieveListResponse,
ScheduleUpdateRequest
} from './scheduleTypes';
// admin
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
const queryParams: string[] = [];
export const findAvailableThemesByDate = async (date: string): Promise<AvailableThemeIdListResponse> => {
return await apiClient.get<AvailableThemeIdListResponse>(`/schedules/themes?date=${date}`);
};
if (date && date.trim() !== '') {
queryParams.push(`date=${date}`);
}
export const findSchedules = async (date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => {
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?date=${date}&themeId=${themeId}`);
};
if (themeId && themeId.trim() !== '') {
queryParams.push(`themeId=${themeId}`);
}
// 기본 URL에 쿼리 파라미터 추가
const baseUrl = `/admin/stores/${storeId}/schedules`;
const fullUrl = queryParams.length > 0
? `${baseUrl}?${queryParams.join('&')}`
: baseUrl;
return await apiClient.adminGet<AdminScheduleSummaryListResponse>(fullUrl);
export const findScheduleById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => {
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/schedules/${id}`);
}
export const fetchScheduleAudit = async (scheduleId: string): Promise<AuditInfo> => {
return await apiClient.adminGet<AuditInfo>(`/admin/schedules/${scheduleId}/audits`);
}
export const createSchedule = async (storeId: string, request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
return await apiClient.adminPost<ScheduleCreateResponse>(`/admin/stores/${storeId}/schedules`, request);
export const createSchedule = async (request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
return await apiClient.post<ScheduleCreateResponse>('/schedules', request);
};
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
return await apiClient.adminPatch<void>(`/admin/schedules/${id}`, request);
await apiClient.patch(`/schedules/${id}`, request);
};
export const deleteSchedule = async (id: string): Promise<void> => {
return await apiClient.adminDel<void>(`/admin/schedules/${id}`);
await apiClient.del(`/schedules/${id}`);
};
// public
export const holdSchedule = async (id: string): Promise<void> => {
return await apiClient.post<void>(`/schedules/${id}/hold`);
};
export const fetchSchedules = async (storeId: string, date: string): Promise<ScheduleWithThemeListResponse> => {
return await apiClient.get<ScheduleWithThemeListResponse>(`/stores/${storeId}/schedules?date=${date}`);
await apiClient.patch(`/schedules/${id}/hold`, {});
};

View File

@ -1,17 +1,28 @@
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
export enum ScheduleStatus {
AVAILABLE = 'AVAILABLE',
HOLD = 'HOLD',
RESERVED = 'RESERVED',
BLOCKED = 'BLOCKED',
}
export const ScheduleStatus = {
AVAILABLE: 'AVAILABLE' as ScheduleStatus,
HOLD: 'HOLD' as ScheduleStatus,
RESERVED: 'RESERVED' as ScheduleStatus,
BLOCKED: 'BLOCKED' as ScheduleStatus,
};
export interface AvailableThemeIdListResponse {
themeIds: string[];
}
export interface ScheduleRetrieveResponse {
id: string;
time: string; // "HH:mm"
status: ScheduleStatus;
}
export interface ScheduleRetrieveListResponse {
schedules: ScheduleRetrieveResponse[];
}
// Admin
export interface ScheduleCreateRequest {
date: string;
date: string; // "yyyy-MM-dd"
time: string; // "HH:mm"
themeId: string;
time: string;
}
export interface ScheduleCreateResponse {
@ -25,48 +36,13 @@ export interface ScheduleUpdateRequest {
status?: ScheduleStatus;
}
export interface AdminScheduleSummaryResponse {
id: string,
themeName: string,
startFrom: string,
endAt: string,
status: ScheduleStatus,
}
export interface AdminScheduleSummaryListResponse {
schedules: AdminScheduleSummaryResponse[];
}
// Public
export interface ScheduleResponse {
export interface ScheduleDetailRetrieveResponse {
id: string;
date: string;
startFrom: string;
endAt: string;
date: string; // "yyyy-MM-dd"
time: string; // "HH:mm"
status: ScheduleStatus;
}
export interface ScheduleThemeInfo {
id: string;
name: string;
}
export interface ScheduleStoreInfo {
id: string;
name: string;
}
export interface ScheduleWithStoreAndThemeResponse {
schedule: ScheduleResponse,
theme: ScheduleThemeInfo,
store: ScheduleStoreInfo,
}
export interface ScheduleWithThemeResponse {
schedule: ScheduleResponse,
theme: ScheduleThemeInfo
}
export interface ScheduleWithThemeListResponse {
schedules: ScheduleWithThemeResponse[];
createdAt: string; // or Date
createdBy: string;
updatedAt: string; // or Date
updatedBy: string;
}

View File

@ -1,48 +0,0 @@
import apiClient from '@_api/apiClient';
import type {
SimpleStoreListResponse,
StoreCreateResponse,
StoreDetailResponse,
StoreInfoResponse,
StoreRegisterRequest,
UpdateStoreRequest
} from './storeTypes';
export const getStores = async (sidoCode?: string, sigunguCode?: string): Promise<SimpleStoreListResponse> => {
const queryParams: string[] = [];
if (sidoCode && sidoCode.trim() !== '') {
queryParams.push(`sido=${sidoCode}`);
}
if (sigunguCode && sigunguCode.trim() !== '') {
queryParams.push(`sigungu=${sigunguCode}`);
}
const baseUrl = `/stores`;
const fullUrl = queryParams.length > 0
? `${baseUrl}?${queryParams.join('&')}`
: baseUrl;
return await apiClient.get(fullUrl);
};
export const getStoreInfo = async (id: string): Promise<StoreInfoResponse> => {
return await apiClient.get(`/stores/${id}`);
}
export const getStoreDetail = async (id: string): Promise<StoreDetailResponse> => {
return await apiClient.adminGet(`/admin/stores/${id}/detail`);
};
export const createStore = async (request: StoreRegisterRequest): Promise<StoreCreateResponse> => {
return await apiClient.adminPost<StoreCreateResponse>('/admin/stores', request);
};
export const updateStore = async (id: string, request: UpdateStoreRequest): Promise<void> => {
await apiClient.adminPatch(`/admin/stores/${id}`, request);
};
export const deleteStore = async (id: string): Promise<void> => {
await apiClient.adminPost(`/admin/stores/${id}/disable`, {});
};

View File

@ -1,48 +0,0 @@
import {type AuditInfo} from '@_api/common/commonTypes';
import type {RegionInfoResponse} from '@_api/region/regionTypes';
export interface SimpleStoreResponse {
id: string;
name: string;
}
export interface SimpleStoreListResponse {
stores: SimpleStoreResponse[];
}
export interface StoreDetailResponse {
id: string;
name: string;
address: string;
contact: string;
businessRegNum: string;
region: RegionInfoResponse;
audit: AuditInfo;
}
export interface StoreRegisterRequest {
name: string;
address: string;
contact: string;
businessRegNum: string;
regionCode: string;
}
export interface UpdateStoreRequest {
name?: string;
address?: string;
contact?: string;
regionCode?: string;
}
export interface StoreInfoResponse {
id: string;
name: string;
address: string;
contact: string;
businessRegNum: string;
}
export interface StoreCreateResponse {
id: string;
}

View File

@ -1,48 +1,55 @@
import apiClient from '@_api/apiClient';
import type {
AdminThemeDetailResponse,
AdminThemeSummaryListResponse,
SimpleActiveThemeListResponse,
AdminThemeDetailRetrieveResponse,
AdminThemeSummaryRetrieveListResponse,
ThemeCreateRequest,
ThemeCreateResponse,
ThemeIdListResponse,
ThemeInfoListResponse,
ThemeInfoResponse,
ThemeUpdateRequest
ThemeCreateRequestV2, ThemeCreateResponse,
ThemeCreateResponseV2, ThemeListRetrieveRequest, ThemeRetrieveListResponse,
ThemeRetrieveListResponseV2,
ThemeUpdateRequest,
UserThemeRetrieveListResponse
} from './themeTypes';
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryListResponse> => {
return await apiClient.adminGet<AdminThemeSummaryListResponse>('/admin/themes');
export const createTheme = async (data: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
return await apiClient.post<ThemeCreateResponse>('/themes', data, true);
};
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailResponse> => {
return await apiClient.adminGet<AdminThemeDetailResponse>(`/admin/themes/${id}`);
export const fetchThemes = async (): Promise<ThemeRetrieveListResponse> => {
return await apiClient.get<ThemeRetrieveListResponse>('/themes', true);
};
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
return await apiClient.adminPost<ThemeCreateResponse>('/admin/themes', themeData);
export const mostReservedThemes = async (count: number = 10): Promise<ThemeRetrieveListResponse> => {
return await apiClient.get<ThemeRetrieveListResponse>(`/themes/most-reserved-last-week?count=${count}`, false);
};
export const delTheme = async (id: string): Promise<void> => {
return await apiClient.del(`/themes/${id}`, true);
};
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryRetrieveListResponse> => {
return await apiClient.get<AdminThemeSummaryRetrieveListResponse>('/admin/themes');
};
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailRetrieveResponse> => {
return await apiClient.get<AdminThemeDetailRetrieveResponse>(`/admin/themes/${id}`);
};
export const createThemeV2 = async (themeData: ThemeCreateRequestV2): Promise<ThemeCreateResponseV2> => {
return await apiClient.post<ThemeCreateResponseV2>('/admin/themes', themeData);
};
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
await apiClient.adminPatch<any>(`/admin/themes/${id}`, themeData);
await apiClient.patch<any>(`/admin/themes/${id}`, themeData);
};
export const deleteTheme = async (id: string): Promise<void> => {
await apiClient.adminDel<any>(`/admin/themes/${id}`);
await apiClient.del<any>(`/admin/themes/${id}`);
};
export const fetchActiveThemes = async (): Promise<SimpleActiveThemeListResponse> => {
return await apiClient.adminGet<SimpleActiveThemeListResponse>('/admin/themes/active');
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes');
};
export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
return await apiClient.post<ThemeInfoListResponse>('/themes/batch', request);
};
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
}
export const fetchMostReservedThemes = async (count: number): Promise<ThemeInfoListResponse> => {
return await apiClient.get<ThemeInfoListResponse>(`/themes/most-reserved?count=${count}`);
export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise<ThemeRetrieveListResponseV2> => {
return await apiClient.post<ThemeRetrieveListResponseV2>('/themes/retrieve', request);
};

View File

@ -1,12 +1,30 @@
import type { AuditInfo } from '@_api/common/commonTypes';
export interface AdminThemeDetailResponse {
theme: ThemeInfoResponse;
isActive: boolean;
audit: AuditInfo
export interface ThemeCreateRequest {
name: string;
description: string;
thumbnail: string;
}
export interface ThemeCreateRequest {
export interface ThemeCreateResponse {
id: string;
name: string;
description: string;
thumbnail: string;
}
export interface ThemeRetrieveResponse {
id: string;
name: string;
description: string;
thumbnail: string;
}
export interface ThemeRetrieveListResponse {
themes: ThemeRetrieveResponse[];
}
export interface ThemeV2 {
id: string;
name: string;
description: string;
thumbnailUrl: string;
@ -17,10 +35,28 @@ export interface ThemeCreateRequest {
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isActive: boolean;
isOpen: boolean;
createDate: string; // Assuming ISO string format
updatedDate: string; // Assuming ISO string format
createdBy: string;
updatedBy: string;
}
export interface ThemeCreateResponse {
export interface ThemeCreateRequestV2 {
name: string;
description: string;
thumbnailUrl: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isOpen: boolean;
}
export interface ThemeCreateResponseV2 {
id: string;
}
@ -35,22 +71,41 @@ export interface ThemeUpdateRequest {
availableMinutes?: number;
expectedMinutesFrom?: number;
expectedMinutesTo?: number;
isActive?: boolean;
isOpen?: boolean;
}
export interface AdminThemeSummaryResponse {
export interface AdminThemeSummaryRetrieveResponse {
id: string;
name: string;
difficulty: Difficulty;
price: number;
isActive: boolean;
isOpen: boolean;
}
export interface AdminThemeSummaryListResponse {
themes: AdminThemeSummaryResponse[];
export interface AdminThemeSummaryRetrieveListResponse {
themes: AdminThemeSummaryRetrieveResponse[];
}
export interface ThemeInfoResponse {
export interface AdminThemeDetailRetrieveResponse {
id: string;
name: string;
description: string;
thumbnailUrl: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isOpen: boolean;
createdAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
createdBy: string;
updatedAt: string; // LocalDateTime in Kotlin, map to string (ISO format)
updatedBy: string;
}
export interface UserThemeRetrieveResponse {
id: string;
name: string;
thumbnailUrl: string;
@ -64,14 +119,33 @@ export interface ThemeInfoResponse {
expectedMinutesTo: number;
}
export interface ThemeInfoListResponse {
themes: ThemeInfoResponse[];
export interface UserThemeRetrieveListResponse {
themes: UserThemeRetrieveResponse[];
}
export interface ThemeIdListResponse {
export interface ThemeListRetrieveRequest {
themeIds: string[];
}
export interface ThemeRetrieveResponseV2 {
id: string;
name: string;
thumbnailUrl: string;
description: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
}
export interface ThemeRetrieveListResponseV2 {
themes: ThemeRetrieveResponseV2[];
}
// @ts-ignore
export enum Difficulty {
VERY_EASY = 'VERY_EASY',
EASY = 'EASY',
@ -79,27 +153,3 @@ export enum Difficulty {
HARD = 'HARD',
VERY_HARD = 'VERY_HARD',
}
export const DifficultyKoreanMap: Record<Difficulty, string> = {
[Difficulty.VERY_EASY]: '매우 쉬움',
[Difficulty.EASY]: '쉬움',
[Difficulty.NORMAL]: '보통',
[Difficulty.HARD]: '어려움',
[Difficulty.VERY_HARD]: '매우 어려움',
};
export function mapThemeResponse(res: any): ThemeInfoResponse {
return {
...res,
difficulty: Difficulty[res.difficulty as keyof typeof Difficulty],
}
}
export interface SimpleActiveThemeResponse {
id: string;
name: string;
}
export interface SimpleActiveThemeListResponse {
themes: SimpleActiveThemeResponse[];
}

View File

@ -0,0 +1,18 @@
import apiClient from "@_api/apiClient";
import type { TimeCreateRequest, TimeCreateResponse, TimeRetrieveListResponse, TimeWithAvailabilityListResponse } from "./timeTypes";
export const createTime = async (data: TimeCreateRequest): Promise<TimeCreateResponse> => {
return await apiClient.post<TimeCreateResponse>('/times', data, true);
}
export const fetchTimes = async (): Promise<TimeRetrieveListResponse> => {
return await apiClient.get<TimeRetrieveListResponse>('/times', true);
};
export const delTime = async (id: string): Promise<void> => {
return await apiClient.del(`/times/${id}`, true);
};
export const fetchTimesWithAvailability = async (date: string, themeId: string): Promise<TimeWithAvailabilityListResponse> => {
return await apiClient.get<TimeWithAvailabilityListResponse>(`/times/search?date=${date}&themeId=${themeId}`, true);
};

View File

@ -0,0 +1,27 @@
export interface TimeCreateRequest {
startAt: string;
}
export interface TimeCreateResponse {
id: string;
startAt: string;
}
export interface TimeRetrieveResponse {
id: string;
startAt: string;
}
export interface TimeRetrieveListResponse {
times: TimeCreateResponse[];
}
export interface TimeWithAvailabilityResponse {
id: string;
startAt: string;
isAvailable: boolean;
}
export interface TimeWithAvailabilityListResponse {
times: TimeWithAvailabilityResponse[];
}

View File

@ -1,10 +0,0 @@
import apiClient from "@_api/apiClient";
import type {UserContactRetrieveResponse, UserCreateRequest, UserCreateResponse} from "./userTypes";
export const signup = async (data: UserCreateRequest): Promise<UserCreateResponse> => {
return await apiClient.post('/users', data);
};
export const fetchContact = async (): Promise<UserContactRetrieveResponse> => {
return await apiClient.get<UserContactRetrieveResponse>('/users/contact');
}

View File

@ -1,32 +0,0 @@
export interface UserCreateRequest {
/** not empty */
name: string;
/** not empty, email format */
email: string;
/** length >= 8 */
password: string;
/** not empty, pattern: ^010([0-9]{3,4})([0-9]{4})$ */
phone: string;
/** nullable */
regionCode?: string | null;
}
export interface UserCreateResponse {
id: string;
name: string;
}
export interface UserContactRetrieveResponse {
id: string;
name: string;
phone: string;
}
export interface OperatorInfo {
id: string;
name: string;
}

View File

@ -1,2 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93"
height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,28 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const AdminRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { loggedIn, role, loading } = useAuth();
const location = useLocation();
if (loading) {
return <div>Loading...</div>; // Or a proper spinner component
}
if (!loggedIn) {
// Not logged in, redirect to login page. No alert needed here
// as the user is simply redirected.
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (role !== 'ADMIN') {
// Logged in but not an admin, show alert and redirect.
alert('접근 권한이 없어요. 관리자에게 문의해주세요.');
return <Navigate to="/" replace />;
}
return children;
};
export default AdminRoute;

View File

@ -1,4 +1,4 @@
import React, {type ReactNode} from 'react';
import React, { type ReactNode } from 'react';
import Navbar from './Navbar';
interface LayoutProps {

View File

@ -1,6 +1,6 @@
import React from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useAuth} from 'src/context/AuthContext';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from 'src/context/AuthContext';
import 'src/css/navbar.css';
const Navbar: React.FC = () => {
@ -21,20 +21,20 @@ const Navbar: React.FC = () => {
<nav className="navbar-container">
<div className="nav-links">
<Link className="nav-link" to="/"></Link>
<Link className="nav-link" to="/reservation"></Link>
<Link className="nav-link" to="/v2/reservation"></Link>
</div>
<div className="nav-actions">
{!loggedIn ? (
<>
<button className="btn btn-secondary" onClick={() => navigate('/login')}></button>
<button className="btn btn-primary" onClick={() => navigate('/signup')}></button>
<button className="btn btn-secondary" onClick={() => navigate('/v2/login')}></button>
<button className="btn btn-primary" onClick={() => navigate('/v2/signup')}></button>
</>
) : (
<div className="profile-info">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<span>{userName}</span>
<div className="dropdown-menu">
<Link className="dropdown-item" to="/my-reservation"> </Link>
<Link className="dropdown-item" to="/my-reservation/v2"> </Link>
<div className="dropdown-divider" />
<a className="dropdown-item" href="#" onClick={handleLogout}></a>
</div>

View File

@ -1,96 +0,0 @@
import { adminLogin as apiLogin, adminLogout as apiLogout } from '@_api/auth/authAPI';
import {
type AdminLoginSuccessResponse,
type AdminType,
type LoginRequest,
} from '@_api/auth/authTypes';
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
interface AdminAuthContextType {
isAdmin: boolean;
name: string | null;
type: AdminType | null;
storeId: string | null;
loading: boolean;
login: (data: Omit<LoginRequest, 'principalType'>) => Promise<AdminLoginSuccessResponse>;
logout: () => Promise<void>;
}
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
export const AdminAuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isAdmin, setIsAdmin] = useState(false);
const [name, setName] = useState<string | null>(null);
const [type, setType] = useState<AdminType | null>(null);
const [storeId, setStoreId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
try {
const token = localStorage.getItem('adminAccessToken');
const storedName = localStorage.getItem('adminName');
const storedType = localStorage.getItem('adminType') as AdminType | null;
const storedStoreId = localStorage.getItem('adminStoreId');
if (token && storedName && storedType) {
setIsAdmin(true);
setName(storedName);
setType(storedType);
setStoreId(storedStoreId ? storedStoreId : null);
}
} catch (error) {
console.error("Failed to load admin auth state from storage", error);
} finally {
setLoading(false);
}
}, []);
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
const response = await apiLogin(data);
localStorage.setItem('adminAccessToken', response.accessToken);
localStorage.setItem('adminName', response.name);
localStorage.setItem('adminType', response.type);
if (response.storeId) {
localStorage.setItem('adminStoreId', response.storeId.toString());
} else {
localStorage.removeItem('adminStoreId');
}
setIsAdmin(true);
setName(response.name);
setType(response.type);
setStoreId(response.storeId);
return response;
};
const logout = async () => {
try {
await apiLogout();
} finally {
localStorage.removeItem('adminAccessToken');
localStorage.removeItem('adminName');
localStorage.removeItem('adminType');
localStorage.removeItem('adminStoreId');
setIsAdmin(false);
setName(null);
setType(null);
setStoreId(null);
}
};
return (
<AdminAuthContext.Provider value={{ isAdmin, name, type, storeId, loading, login, logout }}>
{children}
</AdminAuthContext.Provider>
);
};
export const useAdminAuth = (): AdminAuthContextType => {
const context = useContext(AdminAuthContext);
if (!context) {
throw new Error('useAdminAuth must be used within an AdminAuthProvider');
}
return context;
};

View File

@ -1,13 +1,15 @@
import { logout as apiLogout, userLogin as apiLogin } from '@_api/auth/authAPI';
import { type LoginRequest, type UserLoginSuccessResponse } from '@_api/auth/authTypes';
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
import { checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout } from '@_api/auth/authAPI';
import type { LoginRequest, LoginResponse } from '@_api/auth/authTypes';
import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
interface AuthContextType {
loggedIn: boolean;
userName: string | null;
loading: boolean;
login: (data: Omit<LoginRequest, 'principalType'>) => Promise<UserLoginSuccessResponse>;
role: 'ADMIN' | 'MEMBER' | null;
loading: boolean; // Add loading state to type
login: (data: LoginRequest) => Promise<LoginResponse>;
logout: () => Promise<void>;
checkLogin: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -15,33 +17,32 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [loggedIn, setLoggedIn] = useState(false);
const [userName, setUserName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [role, setRole] = useState<'ADMIN' | 'MEMBER' | null>(null);
const [loading, setLoading] = useState(true); // Add loading state
const checkLogin = async () => {
try {
const response = await apiCheckLogin();
setLoggedIn(true);
setUserName(response.name);
setRole(response.role);
} catch (error) {
setLoggedIn(false);
setUserName(null);
setRole(null);
localStorage.removeItem('accessToken');
} finally {
setLoading(false); // Set loading to false after check is complete
}
};
useEffect(() => {
try {
const token = localStorage.getItem('accessToken');
const storedUserName = localStorage.getItem('userName');
if (token && storedUserName) {
setLoggedIn(true);
setUserName(storedUserName);
}
} catch (error) {
console.error("Failed to load user auth state from storage", error);
} finally {
setLoading(false);
}
checkLogin();
}, []);
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
const login = async (data: LoginRequest) => {
const response = await apiLogin(data);
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('userName', response.name);
setLoggedIn(true);
setUserName(response.name);
await checkLogin();
return response;
};
@ -49,15 +50,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try {
await apiLogout();
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('userName');
setLoggedIn(false);
setUserName(null);
setRole(null);
localStorage.removeItem('accessToken');
}
};
return (
<AuthContext.Provider value={{ loggedIn, userName, loading, login, logout }}>
<AuthContext.Provider value={{ loggedIn, userName, role, loading, login, logout, checkLogin }}>
{children}
</AuthContext.Provider>
);

View File

@ -1,13 +1,11 @@
/* New CSS content */
.admin-schedule-container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
font-size: 0.95rem; /* Slightly smaller base font */
}
.page-title {
font-size: 1.8rem; /* smaller */
font-size: 2rem;
font-weight: bold;
margin-bottom: 2rem;
text-align: center;
@ -20,7 +18,7 @@
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
align-items: flex-end; /* Align to bottom */
align-items: center;
}
.schedule-controls .form-group {
@ -28,29 +26,18 @@
flex-direction: column;
}
/* Width adjustments */
.schedule-controls .store-selector-group,
.schedule-controls .date-selector-group {
flex: 1 1 180px;
}
.schedule-controls .theme-selector-group {
flex: 2 1 300px;
}
.schedule-controls .form-label {
font-size: 0.85rem; /* smaller */
font-size: 0.9rem;
margin-bottom: 0.5rem;
color: #555;
}
.schedule-controls .form-input,
.schedule-controls .form-select {
padding: 0.6rem; /* smaller */
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem; /* smaller */
font-size: 1rem;
}
.section-card {
@ -76,11 +63,10 @@ table {
}
th, td {
padding: 0.8rem; /* smaller */
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: middle;
font-size: 0.9rem; /* smaller */
}
th {
@ -89,11 +75,11 @@ th {
}
.btn {
padding: 0.4rem 0.8rem; /* smaller */
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem; /* smaller */
font-size: 0.9rem;
transition: background-color 0.2s;
white-space: nowrap;
}
@ -188,8 +174,8 @@ th {
font-size: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
height: auto; /* remove fixed height */
box-sizing: border-box;
height: 3rem;
box-sizing: border-box; /* Ensures padding/border are included in height */
}
.details-form-container .button-group {
@ -204,7 +190,7 @@ th {
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: #fff;
margin-bottom: 1.5rem;
margin-bottom: 1.5rem; /* Add margin to separate from buttons */
}
.audit-title {
@ -226,95 +212,3 @@ th {
color: #212529;
margin-right: 0.5rem;
}
.theme-selector-button-group {
display: flex;
flex-direction: row !important;
align-items: flex-end;
gap: 0.5rem;
}
.theme-selector-button-group .form-select {
flex-grow: 1;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: #ffffff !important;
padding: 2rem !important;
border-radius: 8px !important;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
width: 90% !important;
max-width: 600px !important;
position: relative !important;
}
.modal-close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #888;
}
.modal-title {
font-size: 1.75rem;
font-weight: bold;
margin-top: 0;
margin-bottom: 1.5rem;
text-align: center;
}
.theme-modal-thumbnail {
width: 100%;
max-height: 300px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.theme-modal-description {
font-size: 1rem;
line-height: 1.6;
color: #555;
margin-bottom: 1.5rem;
}
.theme-details-button {
white-space: nowrap;
}
.view-mode-buttons {
justify-content: flex-end;
}
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
}

View File

@ -1,6 +1,6 @@
/* /src/css/admin-store-page.css */
.admin-store-container {
max-width: 1400px;
/* /src/css/admin-time-page.css */
.admin-time-container {
max-width: 800px;
margin: 40px auto;
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@ -8,7 +8,7 @@
border-radius: 16px;
}
.admin-store-container .page-title {
.admin-time-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
@ -16,19 +16,6 @@
text-align: center;
}
.filter-controls {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
}
.filter-controls .form-group {
flex: 1;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
@ -70,7 +57,7 @@
background-color: #f4f6f8;
}
.form-input, .form-select, .form-textarea {
.form-input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
@ -80,7 +67,7 @@
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
.form-input:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
@ -123,85 +110,11 @@
background-color: #c53030;
}
.action-buttons {
display: flex;
gap: 0.5rem;
.editing-row td {
padding-top: 20px;
padding-bottom: 20px;
}
.details-row td {
padding: 0;
background-color: #f8f9fa;
}
.details-container {
padding: 1.5rem;
}
.details-form-card {
background-color: #fff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.form-row {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.form-group {
flex: 1;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #4E5968;
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.audit-info {
padding: 1.5rem;
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: #fff;
margin-bottom: 1.5rem;
}
.audit-title {
font-size: 1.1rem;
font-weight: 600;
color: #343a40;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #dee2e6;
}
.audit-body p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: #495057;
}
.audit-body p strong {
color: #212529;
margin-right: 0.5rem;
}
.add-store-form {
padding: 1.5rem;
background-color: #fdfdff;
border: 1px solid #e5e8eb;
border-radius: 8px;
margin-bottom: 2rem;
.editing-row .btn {
margin-right: 8px;
}

View File

@ -0,0 +1,81 @@
/* /src/css/admin-waiting-page.css */
.admin-waiting-container {
max-width: 1200px;
margin: 40px auto;
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.admin-waiting-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-container table {
width: 100%;
border-collapse: collapse;
font-size: 15px;
}
.table-container th,
.table-container td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #e5e8eb;
vertical-align: middle;
}
.table-container th {
background-color: #f9fafb;
color: #505a67;
font-weight: 600;
}
.table-container tr:last-child td {
border-bottom: none;
}
.table-container tr:hover {
background-color: #f4f6f8;
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
margin-right: 8px;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}

View File

@ -31,7 +31,6 @@
gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
cursor: pointer;
}
.theme-ranking-item-v2:hover {
@ -65,116 +64,3 @@
color: #505a67;
margin: 0;
}
/* Modal Styles */
.theme-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.theme-modal-content {
background-color: #ffffff !important;
padding: 30px !important;
border-radius: 16px !important;
width: 90% !important;
max-width: 600px !important;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2) !important;
display: flex !important;
flex-direction: column !important;
gap: 20px !important;
}
.modal-thumbnail {
width: 100%;
height: 250px;
object-fit: cover;
border-radius: 12px;
}
.modal-theme-info h2 {
font-size: 26px;
font-weight: 700;
color: #333d4b;
margin: 0 0 10px;
}
.modal-theme-info p {
font-size: 16px;
color: #505a67;
line-height: 1.6;
margin: 0 0 15px;
}
.theme-details {
background-color: #f8f9fa;
border-radius: 8px;
padding: 15px;
}
.theme-details p {
margin: 5px 0;
font-size: 15px;
color: #333d4b;
}
.theme-details strong {
color: #191919;
}
.modal-buttons {
display: flex;
gap: 15px;
margin-top: 15px;
}
.modal-button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.modal-button.reserve {
background-color: #007bff;
color: white;
}
.modal-button.reserve:hover {
background-color: #0056b3;
}
.modal-button.close {
background-color: #6c757d;
color: white;
}
.modal-button.close:hover {
background-color: #5a6268;
}
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
}

View File

@ -49,24 +49,10 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.summary-subdetails-v2 {
display: flex;
flex-direction: column;
margin: 0px;
gap: 0px;
}
.summary-store-name-v2 {
font-size: 16px;
font-weight: bold;
color: #505a67;
margin: 0 0 5px 0;
}
.summary-details-v2 {
display: flex;
flex-direction: column;
gap: 10px;
gap: 4px;
}
.summary-theme-name-v2 {
@ -79,79 +65,19 @@
.summary-datetime-v2 {
font-size: 16px;
color: #505a67;
margin-bottom: 5px;
margin: 0;
}
/* --- Status Badge --- */
.card-status-badge {
position: absolute;
top: 30px;
right: 10px;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* --- Card Status Styles --- */
.reservation-summary-card-v2 {
position: relative; /* For badge positioning */
}
/* Confirmed (Upcoming) */
.reservation-summary-card-v2.status-confirmed {
border-left: 5px solid #28a745; /* Green accent */
}
.status-confirmed .card-status-badge {
background-color: #28a745;
color: white;
}
/* Completed (Past) */
.reservation-summary-card-v2.status-completed {
/* Canceled Card Style */
.reservation-summary-card-v2.status-canceled_by_user {
background-color: #f8f9fa;
border-left: 5px solid #6c757d; /* Gray accent */
}
.reservation-summary-card-v2.status-completed .summary-theme-name-v2,
.reservation-summary-card-v2.status-completed .summary-datetime-v2 {
color: #6c757d;
}
.reservation-summary-card-v2.status-completed .detail-button-v2 {
background-color: #6c757d;
}
.status-completed .card-status-badge {
background-color: #6c757d;
color: white;
opacity: 0.6;
}
/* Canceled */
.reservation-summary-card-v2.status-canceled {
background-color: #f8f9fa;
border-left: 5px solid #dc3545; /* Red accent */
opacity: 0.7;
}
.reservation-summary-card-v2.status-canceled .summary-theme-name-v2,
.reservation-summary-card-v2.status-canceled .summary-datetime-v2 {
.reservation-summary-card-v2.status-canceled_by_user .summary-theme-name-v2,
.reservation-summary-card-v2.status-canceled_by_user .summary-datetime-v2,
.reservation-summary-card-v2.status-canceled_by_user .summary-details-v2 strong {
color: #6c757d;
text-decoration: line-through;
}
.reservation-summary-card-v2.status-canceled .detail-button-v2 {
background-color: #6c757d;
}
.status-canceled .card-status-badge {
background-color: #dc3545;
color: white;
}
/* Pending */
.reservation-summary-card-v2.status-pending {
border-left: 5px solid #ffc107; /* Yellow accent */
}
.status-pending .card-status-badge {
background-color: #ffc107;
color: #212529;
}
/* Detail Button */
@ -191,16 +117,16 @@
}
.modal-content-v2 {
background: #ffffff !important;
padding: 30px !important;
border-radius: 16px !important;
width: 90% !important;
max-width: 500px !important;
position: relative !important;
box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important;
animation: slide-up 0.3s ease-out !important;
max-height: 90vh !important; /* Prevent modal from being too tall */
overflow-y: auto !important; /* Allow scrolling for long content */
background: #ffffff;
padding: 30px;
border-radius: 16px;
width: 90%;
max-width: 500px;
position: relative;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
animation: slide-up 0.3s ease-out;
max-height: 90vh; /* Prevent modal from being too tall */
overflow-y: auto; /* Allow scrolling for long content */
}
@keyframes slide-up {
@ -254,6 +180,13 @@
color: #505a67;
}
.modal-section-v2 p strong {
color: #333d4b;
font-weight: 600;
min-width: 100px;
display: inline-block;
}
.cancellation-section-v2 {
background-color: #fcf2f2;
padding: 15px;
@ -353,18 +286,3 @@
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
}

View File

@ -1,43 +1,43 @@
/* General Container */
.reservation-v21-container {
width: 100%;
padding: 40px;
max-width: 900px;
margin: 2rem auto;
padding: 2rem;
font-family: 'Pretendard', sans-serif;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin: 40px auto;
background-color: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
font-family: 'Toss Product Sans', sans-serif;
color: #333D4B;
}
.page-title {
text-align: center;
font-size: 2rem;
font-size: 28px;
font-weight: 700;
margin-bottom: 2.5rem;
color: #212529;
margin-bottom: 40px;
color: #191F28;
text-align: center;
}
/* Step Section */
/* Step Sections */
.step-section {
margin-bottom: 3rem;
padding: 1.5rem;
border: 1px solid #f1f3f5;
border-radius: 8px;
background-color: #f8f9fa;
margin-bottom: 40px;
padding: 24px;
border: 1px solid #E5E8EB;
border-radius: 12px;
transition: all 0.3s ease;
}
.step-section.disabled {
opacity: 0.5;
pointer-events: none;
background-color: #F9FAFB;
}
.step-section h3 {
font-size: 1.5rem;
font-size: 20px;
font-weight: 600;
margin-top: 0;
margin-bottom: 1.5rem;
color: #343a40;
margin-bottom: 20px;
color: #191F28;
}
/* Date Carousel */
@ -45,241 +45,274 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.carousel-arrow {
background: none;
border: none;
font-size: 2rem;
color: #868e96;
cursor: pointer;
padding: 0 1rem;
gap: 10px;
margin: 20px 0;
}
.date-options-container {
display: flex;
gap: 10px;
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
gap: 8px;
overflow-x: hidden;
flex-grow: 1;
justify-content: space-between;
margin: 0px 15px;
}
.date-options-container::-webkit-scrollbar {
display: none;
}
.date-option {
text-align: center;
cursor: pointer;
padding: 10px;
.carousel-arrow, .today-button {
background-color: #F2F4F6;
border: 1px solid #E5E8EB;
border-radius: 50%;
width: 60px;
height: 60px;
width: 36px;
height: 36px;
font-size: 20px;
font-weight: bold;
color: #4E5968;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: background-color 0.3s, color 0.3s;
}
.date-option .day-of-week {
font-size: 0.8rem;
margin-bottom: 4px;
}
.date-option .day-circle {
font-weight: 600;
}
.date-option.active {
background-color: #0064FF;
color: white;
}
.date-option:not(.active):hover {
background-color: #f1f3f5;
}
.date-option.disabled {
color: #ced4da;
cursor: not-allowed;
justify-content: center;
flex-shrink: 0;
transition: background-color 0.2s;
}
.today-button {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 20px;
padding: 0.5rem 1rem;
cursor: pointer;
margin-left: 1rem;
font-weight: 500;
}
/* --- Region & Store Selectors --- */
.region-store-selectors {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.region-store-selectors select {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fff;
font-size: 1rem;
font-size: 14px;
font-weight: 600;
width: auto;
padding: 0 15px;
}
.carousel-arrow:hover, .today-button:hover {
background-color: #E5E8EB;
}
.date-option {
cursor: pointer;
transition: border-color 0.2s;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23868e96%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-position: right .7em top 50%;
background-size: .65em auto;
}
.region-store-selectors select:disabled {
background-color: #f8f9fa;
cursor: not-allowed;
color: #adb5bd;
}
.region-store-selectors select:focus {
outline: none;
border-color: #0064FF;
}
/* --- Schedule List --- */
.schedule-list {
padding: 8px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.theme-schedule-group {
background-color: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
}
.theme-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #f1f3f5;
justify-content: center;
border: 1px solid transparent;
transition: all 0.3s ease;
width: 60px;
flex-shrink: 0;
}
.theme-header h4 {
margin: 0;
font-size: 1.25rem;
.date-option:hover {
background-color: #f0f0f0;
}
.date-option.active {
border: 1px solid #007bff;
background-color: #e7f3ff;
}
.date-option .day-of-week {
font-size: 12px;
color: #888;
}
.date-option.active .day-of-week {
color: #007bff;
}
.date-option .day-circle {
font-size: 16px;
font-weight: bold;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-top: 4px;
background-color: #f0f0f0;
color: #333;
}
.date-option.active .day-circle {
background-color: #007bff;
color: white;
}
.date-option.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.date-option.disabled .day-circle {
background-color: #E5E8EB;
color: #B0B8C1;
}
/* Theme List */
.theme-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.theme-card {
cursor: pointer;
border-radius: 12px;
overflow: hidden;
border: 2px solid #E5E8EB;
transition: all 0.2s ease-in-out;
background-color: #fff;
display: flex;
flex-direction: column;
}
.theme-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.theme-card.active {
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.theme-thumbnail {
width: 100%;
height: 120px;
object-fit: cover;
}
.theme-info {
padding: 16px;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.theme-info h4 {
font-size: 16px;
font-weight: 600;
color: #343a40;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.theme-info p {
font-size: 14px;
color: #6B7684;
margin: 0;
}
.theme-meta {
font-size: 14px;
color: #4E5968;
margin-bottom: 12px;
flex-grow: 1;
}
.theme-meta p {
margin: 2px 0;
}
.theme-meta strong {
color: #333D4B;
}
.theme-detail-button {
padding: 0.5rem 1rem;
font-size: 0.9rem;
background-color: transparent;
color: #0064FF;
border: 1px solid #0064FF;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s, color 0.2s;
}
.theme-detail-button:hover {
background-color: #0064FF;
color: #fff;
}
/* Time Slots */
.time-slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.time-slot {
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 6px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
}
.time-slot:hover:not(.disabled) {
border-color: #0064FF;
color: #0064FF;
}
.time-slot.active {
background-color: #0064FF;
color: white;
border-color: #0064FF;
font-weight: 600;
}
.time-slot.disabled {
background-color: #f8f9fa;
color: #adb5bd;
cursor: not-allowed;
text-decoration: line-through;
}
.time-availability {
display: block;
font-size: 0.8rem;
margin-top: 4px;
}
.no-times {
color: #868e96;
padding: 2rem;
text-align: center;
background-color: #fff;
border-radius: 8px;
}
/* --- Next Step Button --- */
.next-step-button-container {
margin-top: 2rem;
text-align: center;
}
.next-step-button {
width: 100%;
max-width: 400px;
padding: 1rem;
font-size: 1.2rem;
font-weight: 700;
color: #fff;
background-color: #0064FF;
padding: 8px;
font-size: 14px;
font-weight: 600;
border: none;
background-color: #F2F4F6;
color: #4E5968;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.next-step-button:hover:not(:disabled) {
background-color: #0053d1;
.theme-detail-button:hover {
background-color: #E5E8EB;
}
/* Time Slots */
.time-slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.time-slot {
cursor: pointer;
padding: 16px;
border-radius: 8px;
text-align: center;
background-color: #F2F4F6;
font-weight: 600;
transition: all 0.2s ease-in-out;
position: relative;
}
.time-slot:hover {
background-color: #E5E8EB;
}
.time-slot.active {
background-color: #3182F6;
color: #ffffff;
}
.time-slot.disabled {
background-color: #F9FAFB;
color: #B0B8C1;
cursor: not-allowed;
text-decoration: line-through;
}
.time-availability {
font-size: 12px;
display: block;
margin-top: 4px;
font-weight: 500;
}
.no-times {
text-align: center;
padding: 20px;
color: #8A94A2;
}
/* Next Step Button */
.next-step-button-container {
display: flex;
justify-content: flex-end;
margin-top: 30px;
}
.next-step-button {
padding: 14px 28px;
font-size: 18px;
font-weight: 700;
border: none;
background-color: #3182F6;
color: #ffffff;
border-radius: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.next-step-button:disabled {
background-color: #a0a0a0;
background-color: #B0B8C1;
cursor: not-allowed;
}
.next-step-button:hover:not(:disabled) {
background-color: #1B64DA;
}
/* --- Modal Styles --- */
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
@ -294,159 +327,171 @@
}
.modal-content {
background-color: #ffffff !important;
padding: 2rem !important;
border-radius: 12px !important;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
width: 90% !important;
max-width: 500px !important;
position: relative !important;
max-height: 90vh !important;
overflow-y: auto !important;
background-color: #ffffff;
padding: 32px;
border-radius: 16px;
width: 90%;
max-width: 500px;
position: relative;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.modal-close-button {
position: absolute;
top: 1rem;
right: 1rem;
top: 16px;
right: 16px;
background: none;
border: none;
font-size: 1.5rem;
color: #868e96;
font-size: 24px;
cursor: pointer;
color: #8A94A2;
}
.modal-theme-thumbnail {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 1.5rem;
border-radius: 12px;
margin-bottom: 24px;
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 2rem;
text-align: center;
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
color: #191F28;
}
.modal-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f1f3f5;
}
.modal-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
margin-bottom: 20px;
}
.modal-section h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.1rem;
color: #495057;
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
border-bottom: 1px solid #E5E8EB;
padding-bottom: 8px;
}
.modal-section p {
margin: 0.5rem 0;
color: #495057;
font-size: 16px;
line-height: 1.6;
margin-bottom: 8px;
color: #4E5968;
}
.modal-section p strong {
color: #333D4B;
margin-right: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
gap: 12px;
margin-top: 30px;
}
.modal-actions .cancel-button,
.modal-actions .confirm-button {
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: none;
font-size: 1rem;
.modal-actions button {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.modal-actions .cancel-button {
background-color: #f1f3f5;
color: #495057;
background-color: #E5E8EB;
color: #4E5968;
}
.modal-actions .cancel-button:hover {
background-color: #D1D6DB;
}
.modal-actions .confirm-button {
background-color: #0064FF;
color: #fff;
background-color: #3182F6;
color: #ffffff;
}
.modal-actions .confirm-button:hover {
background-color: #1B64DA;
}
/* --- Form Styles for ReservationFormPage --- */
/* Styles for ReservationFormPage */
.form-group {
margin-bottom: 1rem;
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #495057;
font-weight: bold;
margin-bottom: 8px;
color: #333;
}
.form-input {
.form-group input[type="text"],
.form-group input[type="tel"],
.form-group input[type="number"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 1rem;
font-size: 16px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
/* Success Page */
.success-icon {
font-size: 4rem;
color: #0064FF;
text-align: center;
margin-bottom: 1.5rem;
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.success-page-actions {
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.participant-control {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2.5rem;
align-items: center;
}
.success-page-actions .action-button {
padding: 0.8rem 1.6rem;
border-radius: 8px;
text-decoration: none;
font-size: 1rem;
font-weight: 600;
.participant-control input {
text-align: center;
border-left: none;
border-right: none;
width: 60px;
border-radius: 0;
}
.participant-control button {
width: 44px;
height: 44px;
border: 1px solid #ccc;
background-color: #f0f0f0;
font-size: 20px;
cursor: pointer;
transition: background-color 0.2s;
}
.success-page-actions .action-button.secondary {
background-color: #f1f3f5;
color: #495057;
.participant-control button:hover:not(:disabled) {
background-color: #e0e0e0;
}
.success-page-actions .action-button:not(.secondary) {
background-color: #0064FF;
color: #fff;
.participant-control button:disabled {
background-color: #e9ecef;
cursor: not-allowed;
color: #aaa;
}
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
.participant-control button:first-of-type {
border-radius: 8px 0 0 8px;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
.participant-control button:last-of-type {
border-radius: 0 8px 8px 0;
}

View File

@ -0,0 +1,175 @@
#root .flatpickr-input {
display: none;
}
#root .modal-backdrop {
position: fixed;
top: 0;
left: 0;
z-index: 1050;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5) !important;
display: flex;
justify-content: center;
align-items: center;
}
#root .modal-dialog {
max-width: 500px;
width: 90%;
margin: 1.75rem auto;
}
/* Toss-style Modal */
#root .modal-content {
background-color: #fff !important;
border-radius: 16px;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1.5rem;
display: flex;
flex-direction: column;
pointer-events: auto;
position: relative;
}
#root .modal-header {
border-bottom: none;
padding: 0 0 1rem 0;
display: flex;
justify-content: space-between;
align-items: center;
}
#root .modal-title {
font-size: 1.25rem;
font-weight: 600;
}
#root .btn-close {
background: transparent;
border: 0;
font-size: 1.5rem;
opacity: 0.5;
}
#root .modal-body {
padding: 1rem 0;
color: #333;
}
#root .modal-body p {
margin-bottom: 0.5rem;
}
#root .modal-footer {
border-top: none;
padding: 1rem 0 0 0;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* --- Generic Button Styles --- */
#root .btn-primary,
#root .modal-footer .btn-primary,
#root .btn-wrapper .btn-primary,
#root .button-group .btn-primary,
#root .success-page-actions .btn-primary {
background-color: #007bff;
border-color: #007bff;
color: #fff;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
}
#root .btn-secondary,
#root .modal-footer .btn-secondary,
#root .success-page-actions .btn-secondary {
background-color: #f0f2f5;
border-color: #f0f2f5;
color: #333;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: background-color 0.15s ease-in-out;
}
#root .btn-primary:hover,
#root .modal-footer .btn-primary:hover,
#root .btn-wrapper .btn-primary:hover,
#root .button-group .btn-primary:hover,
#root .success-page-actions .btn-primary:hover {
background-color: #0069d9;
border-color: #0062cc;
}
#root .btn-secondary:hover,
#root .modal-footer .btn-secondary:hover,
#root .success-page-actions .btn-secondary:hover {
background-color: #e2e6ea;
}
/* --- Reservation Success Page Styles --- */
.reservation-success-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
width: 100%;
max-width: 100%;
}
.reservation-success-page .content-container-title {
font-size: 2rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #333;
}
.reservation-info-box {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 2rem;
background-color: #fff;
min-width: 380px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
text-align: left;
}
.reservation-info-box h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
text-align: center;
}
.reservation-info-box .info-item {
font-size: 1.1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
}
.reservation-info-box .info-item strong {
font-weight: 500;
color: #495057;
width: 70px;
flex-shrink: 0;
}
.success-page-actions {
margin-top: 2.5rem;
display: flex;
gap: 1rem;
}

View File

@ -0,0 +1,15 @@
.disabled {
pointer-events: none;
opacity: 0.6;
}
#theme-slots .theme-slot.active, #time-slots .time-slot.active {
background-color: #0a3711 !important;
color: white;
}
#time-slots .time-slot.disabled {
background-color: #cccccc;
color: #666666;
cursor: not-allowed;
}

View File

@ -63,18 +63,3 @@
.signup-form-v2 .btn-primary:hover {
background-color: #1B64DA;
}
.error-text {
color: #E53E3E;
font-size: 12px;
margin-top: 4px;
}
.region-select-group {
display: flex;
gap: 10px;
}
.region-select-group select {
flex: 1;
}

View File

@ -3,8 +3,9 @@ import ReactDOM from 'react-dom/client';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import '@_css/style.css';
import '@_css/toss-style.css';
import './css/style.css';
import './css/reservation.css';
import './css/toss-style.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@ -1,79 +1,31 @@
import '@_css/home-page-v2.css';
import React, {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {fetchMostReservedThemes} from '@_api/theme/themeAPI';
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
import React, { useEffect, useState } from 'react';
import { mostReservedThemes } from '@_api/theme/themeAPI';
const HomePage: React.FC = () => {
const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
const navigate = useNavigate();
const [ranking, setRanking] = useState<any[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const themeFetchCount = 10;
const response = await fetchMostReservedThemes(themeFetchCount);
setRanking(response.themes.map(mapThemeResponse));
} catch (err) {
console.error('Error fetching ranking:', err);
}
await mostReservedThemes(10).then(response => setRanking(response.themes))
};
fetchData();
fetchData().catch(err => console.error('Error fetching ranking:', err));
}, []);
const handleThemeClick = (theme: ThemeInfoResponse) => {
setSelectedTheme(theme);
};
const handleCloseModal = () => {
setSelectedTheme(null);
};
const handleReservationClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (selectedTheme) {
navigate('/reservation', { state: { themeId: selectedTheme.id } });
}
};
return (
<div className="home-container-v2">
<h2 className="page-title"> </h2>
<div className="theme-ranking-list-v2">
<div className="content-container">
<h2 className="content-container-title"> </h2>
<ul className="list-unstyled" id="theme-ranking">
{ranking.map(theme => (
<div key={theme.id} className="theme-ranking-item-v2" onClick={() => handleThemeClick(theme)}>
<img className="thumbnail" src={theme.thumbnailUrl} alt={theme.name} />
<div className="theme-info">
<h5 className="theme-name">{theme.name}</h5>
<li key={theme.id} className="d-flex my-4">
<img className="me-3 img-thumbnail" src={theme.thumbnail} alt={theme.name} style={{ width: '150px' }} />
<div className="media-body">
<h5 className="mt-0 mb-1">{theme.name}</h5>
{theme.description}
</div>
</div>
</li>
))}
</div>
{selectedTheme && (
<div className="theme-modal-overlay" onClick={handleCloseModal}>
<div className="theme-modal-content" onClick={(e) => e.stopPropagation()}>
<img className="modal-thumbnail" src={selectedTheme.thumbnailUrl} alt={selectedTheme.name} />
<div className="modal-theme-info">
<h2>{selectedTheme.name}</h2>
<p>{selectedTheme.description}</p>
<div className="theme-details modal-info-grid">
<p><strong>:</strong><span>{DifficultyKoreanMap[selectedTheme.difficulty]}</span></p>
<p><strong> :</strong><span>{selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</span></p>
<p><strong>1 :</strong><span>{selectedTheme.price.toLocaleString()}</span></p>
<p><strong> :</strong><span>{selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</span></p>
<p><strong> :</strong><span>{selectedTheme.availableMinutes}</span></p>
</div>
</div>
<div className="modal-buttons">
<button onClick={handleReservationClick} className="modal-button reserve"></button>
<button onClick={handleCloseModal} className="modal-button close"></button>
</div>
</div>
</div>
)}
</ul>
</div>
);
};

View File

@ -1,7 +1,6 @@
import React, {useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import {useAuth} from '@_context/AuthContext';
import '@_css/login-page-v2.css';
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const LoginPage: React.FC = () => {
const [email, setEmail] = useState('');
@ -12,51 +11,34 @@ const LoginPage: React.FC = () => {
const from = location.state?.from?.pathname || '/';
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const handleLogin = async () => {
try {
await login({ account: email, password: password });
await login({email, password});
alert('로그인에 성공했어요!');
const redirectTo = from.startsWith('/admin') ? '/' : from;
navigate(redirectTo, { replace: true });
navigate(from, { replace: true });
} catch (error: any) {
const message = error.response?.data?.message || '로그인에 실패했어요. 이메일과 비밀번호를 확인해주세요.';
alert(message);
console.error('로그인 실패:', error);
setEmail('');
setPassword('');
}
}
return (
<div className="login-container-v2">
<h2 className="page-title"></h2>
<form className="login-form-v2" onSubmit={handleLogin}>
<div className="form-group">
<input
type="email"
className="form-input"
placeholder="이메일"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<input
type="password"
className="form-input"
placeholder="비밀번호"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="button-group">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/signup')}></button>
<button type="submit" className="btn btn-primary"></button>
</div>
</form>
<div className="content-container" style={{ width: '300px' }}>
<h2 className="content-container-title">Login</h2>
<div className="form-group">
<input type="email" className="form-control" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group">
<input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
</div>
<div className="button-group full-width">
<button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
<button className="btn btn-custom" onClick={handleLogin}>Login</button>
</div>
</div>
);
};

View File

@ -1,316 +1,88 @@
import { cancelPayment } from '@_api/payment/paymentAPI';
import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
import { cancelReservation, fetchDetailById, fetchAllOverviewByUser } from '@_api/reservation/reservationAPI';
import {
ReservationStatus,
type ReservationDetail,
type ReservationOverviewResponse
} from '@_api/reservation/reservationTypes';
import '@_css/my-reservation-v2.css';
import { formatDate, formatDisplayDateTime, formatTime } from '@_util/DateTimeFormatter';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { cancelWaiting, fetchMyReservations } from '@_api/reservation/reservationAPI';
import type { MyReservationRetrieveResponse } from '@_api/reservation/reservationTypes';
import { ReservationStatus } from '@_api/reservation/reservationTypes';
import { isLoginRequiredError } from '@_api/apiClient';
const getReservationStatus = (reservation: ReservationOverviewResponse): { className: string, text: string } => {
const now = new Date();
const reservationDateTime = new Date(`${reservation.date}T${reservation.startFrom}`);
switch (reservation.status) {
case ReservationStatus.CANCELED:
return { className: 'status-canceled', text: '취소됨' };
case ReservationStatus.CONFIRMED:
if (reservationDateTime < now) {
return { className: 'status-completed', text: '이용완료' };
}
return { className: 'status-confirmed', text: '예약확정' };
case ReservationStatus.PENDING:
return { className: 'status-pending', text: '입금대기' };
default:
return { className: `status-${reservation.status.toLowerCase()}`, text: reservation.status };
}
};
// --- Cancellation View Component ---
const CancellationView: React.FC<{
reservation: ReservationDetail;
onCancelSubmit: (reason: string) => void;
onBack: () => void;
isCancelling: boolean;
}> = ({ reservation, onCancelSubmit, onBack, isCancelling }) => {
const [reason, setReason] = useState('');
const handleSubmit = () => {
if (!reason.trim()) {
alert('취소 사유를 입력해주세요.');
return;
}
onCancelSubmit(reason);
};
return (
<div className="cancellation-view-v2">
<h3> </h3>
<div className="cancellation-summary-v2 modal-info-grid">
<p><strong>:</strong><span>{reservation.overview.themeName}</span></p>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
{reservation.payment && <p><strong> :</strong><span>{reservation.payment.totalAmount.toLocaleString()}</span></p>}
</div>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="취소 사유를 입력해주세요."
className="cancellation-reason-textarea-v2"
rows={4}
/>
<div className="modal-actions-v2">
<button onClick={onBack} className="back-button-v2" disabled={isCancelling}></button>
<button onClick={handleSubmit} className="cancel-submit-button-v2" disabled={isCancelling}>
{isCancelling ? '취소 중...' : '취소 요청'}
</button>
</div>
</div>
);
};
const ReservationDetailView: React.FC<{
reservation: ReservationDetail;
onGoToCancel: () => void;
}> = ({ reservation, onGoToCancel }) => {
const renderPaymentSubDetails = (payment: PaymentRetrieveResponse) => {
if (!payment.detail) {
return <p> . .</p>;
}
const { detail } = payment;
switch (detail.type) {
case 'CARD':
return (
<>
{payment.totalAmount !== detail.amount && (
<>
<p><strong> :</strong><span>{detail.amount.toLocaleString()}</span></p>
{detail.easypayDiscountAmount && (
<p><strong> :</strong><span>{detail.easypayDiscountAmount.toLocaleString()}</span></p>
)}
</>
)}
{detail.easypayProviderName && (
<p><strong>: </strong><span>{detail.easypayProviderName}</span></p>
)}
<p><strong> / :</strong><span>{detail.issuerCode}({detail.ownerType}) / {detail.cardType}</span></p>
<p><strong> :</strong><span>{detail.cardNumber}</span></p>
<p><strong>:</strong><span>{detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</span></p>
<p><strong> :</strong><span>{detail.approvalNumber}</span></p>
</>
);
case 'BANK_TRANSFER':
return (
<>
<p><strong>:</strong><span>{detail.bankName}</span></p>
<p><strong> :</strong><span>{detail.settlementStatus}</span></p>
</>
);
case 'EASYPAY_PREPAID':
return (
<>
<p><strong> :</strong><span>{detail.amount.toLocaleString()}</span></p>
{detail.discountAmount > 0 && <p><strong> :</strong><span>{detail.discountAmount.toLocaleString()}</span></p>}
</>
);
default:
return <p> .</p>;
}
};
return (
<>
<div className="modal-section-v2 modal-info-grid">
<h3> </h3>
<p><strong>:</strong><span>{reservation.overview.storeName}</span></p>
<p><strong>:</strong><span>{reservation.overview.themeName}</span></p>
<p><strong>:</strong><span>{formatDate(reservation.overview.date)} {formatTime(reservation.overview.startFrom)} ~ {formatTime(reservation.overview.endAt)}</span></p>
<p><strong> :</strong><span>{reservation.reserver.name}</span></p>
<p><strong> :</strong><span>{reservation.reserver.contact}</span></p>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.applicationDateTime)}</span></p>
</div>
{!reservation.payment ? (
<div className="modal-section-v2">
<h3> </h3>
<p> .</p>
</div>
) : (
<>
<div className="modal-section-v2 modal-info-grid">
<h3> </h3>
<p><strong> ID:</strong><span>{reservation.payment.orderId}</span></p>
<p><strong> :</strong><span>{reservation.payment.totalAmount.toLocaleString()}</span></p>
<p><strong> :</strong><span>{reservation.payment.method}</span></p>
{reservation.payment.approvedAt && <p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.approvedAt)}</span></p>}
</div>
<div className="modal-section-v2 modal-info-grid">
<h3> </h3>
{renderPaymentSubDetails(reservation.payment)}
</div>
</>
)}
{reservation.payment && reservation.payment.cancel && (
<div className="modal-section-v2 cancellation-section-v2 modal-info-grid">
<h3> </h3>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationRequestedAt)}</span></p>
<p><strong> :</strong><span>{formatDisplayDateTime(reservation.payment.cancel.cancellationApprovedAt)}</span></p>
<p><strong> :</strong><span>{reservation.payment.cancel.cancelReason}</span></p>
<p><strong> :</strong><span>{reservation.payment.cancel.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</span></p>
</div>
)}
{reservation.payment && reservation.payment.status !== 'CANCELED' && (
<div className="modal-actions-v2">
<button onClick={onGoToCancel} className="cancel-button-v2"> </button>
</div>
)}
</>
);
};
// --- Main Page Component ---
const MyReservationPage: React.FC = () => {
const [reservations, setReservations] = useState<ReservationOverviewResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reservations, setReservations] = useState<MyReservationRetrieveResponse[]>([]);
const navigate = useNavigate();
const [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [modalView, setModalView] = useState<'detail' | 'cancel'>('detail');
const [isCancelling, setIsCancelling] = useState(false);
const loadReservations = async () => {
try {
setIsLoading(true);
const data = await fetchAllOverviewByUser();
setReservations(data.reservations);
setError(null);
} catch (err) {
setError('예약 목록을 불러오는 데 실패했습니다.');
} finally {
setIsLoading(false);
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => {
loadReservations();
fetchMyReservations()
.then(res => setReservations(res.reservations))
.catch(handleError);
}, []);
const handleShowDetail = async (overview: ReservationOverviewResponse) => {
try {
setIsDetailLoading(true);
setDetailError(null);
setModalView('detail');
const detailData = await fetchDetailById(overview.id);
setSelectedReservation({
overview: overview,
reserver: detailData.reserver,
user: detailData.user,
applicationDateTime: detailData.applicationDateTime,
payment: detailData.payment
});
setIsModalOpen(true);
} catch (err) {
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
} finally {
setIsDetailLoading(false);
}
const _cancelWaiting = (id: string) => {
cancelWaiting(id)
.then(() => {
alert('예약 대기가 취소되었습니다.');
setReservations(reservations.filter(r => r.id.toString() !== id));
})
.catch(handleError);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedReservation(null);
};
const handleCancelSubmit = async (reason: string) => {
if (!selectedReservation) return;
if (!window.confirm('정말 취소하시겠어요?')) {
return;
const getStatusText = (status: ReservationStatus, rank: number) => {
if (status === ReservationStatus.CONFIRMED) {
return '예약';
}
try {
setIsCancelling(true);
setDetailError(null);
await cancelPayment({ reservationId: selectedReservation.overview.id, cancelReason: reason });
await cancelReservation(selectedReservation.overview.id, reason);
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
handleCloseModal();
await loadReservations(); // Refresh the list
} catch (err) {
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
} finally {
setIsCancelling(true);
if (status === ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) {
return '예약 - 결제 필요';
}
if (status === ReservationStatus.WAITING) {
return `${rank}번째 예약 대기`;
}
return '';
};
console.log("reservations=", reservations);
return (
<div className="my-reservation-container-v2">
<h1> </h1>
{isLoading && <p> ...</p>}
{error && <p className="error-message-v2">{error}</p>}
{!isLoading && !error && (
<div className="reservation-list-v2">
{reservations.map((res) => {
const status = getReservationStatus(res);
return (
<div key={res.id} className={`reservation-summary-card-v2 ${status.className}`}>
<div className="card-status-badge">{status.text}</div>
<div className="summary-details-v2">
<div><h3 className="summary-theme-name-v2">{res.themeName}</h3></div>
<div className="summary-subdetails-v2">
<p className="summary-store-name-v2">{res.storeName}</p>
<p className="summary-datetime-v2">{formatDate(res.date)} {formatTime(res.startFrom)} ~ {formatTime(res.endAt)}</p>
</div>
</div>
<button
onClick={() => handleShowDetail(res)}
disabled={isDetailLoading}
className="detail-button-v2"
>
{isDetailLoading && selectedReservation?.overview.id === res.id ? '로딩중...' : '상세보기'}
</button>
</div>
);
})}
</div>
)}
{isModalOpen && selectedReservation && (
<div className="modal-overlay-v2" onClick={handleCloseModal}>
<div className="modal-content-v2" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button-v2" onClick={handleCloseModal}>×</button>
<h2>{modalView === 'detail' ? '예약 상세 정보' : '예약 취소'}</h2>
{detailError && <p className="error-message-v2">{detailError}</p>}
{modalView === 'detail' ? (
<ReservationDetailView
reservation={selectedReservation}
onGoToCancel={() => setModalView('cancel')}
/>
) : (
<CancellationView
reservation={selectedReservation}
onCancelSubmit={handleCancelSubmit}
onBack={() => setModalView('detail')}
isCancelling={isCancelling}
/>
)}
</div>
</div>
)}
<div className="content-container">
<h2 className="content-container-title"> </h2>
<div className="table-container"></div>
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th> </th>
<th>paymentKey</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{reservations.map(r => (
<tr key={r.id}>
<td>{r.themeName}</td>
<td>{r.date}</td>
<td>{r.time}</td>
<td>{getStatusText(r.status, r.rank)}</td>
<td>
{r.status === ReservationStatus.WAITING &&
<button className="btn btn-danger" onClick={() => _cancelWaiting(r.id.toString())}></button>}
</td>
<td>{r.paymentKey}</td>
<td>{r.amount}</td>
<td></td>
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@ -1,149 +0,0 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPI';
import type { ReservationData } from '@_api/reservation/reservationTypes';
import { fetchContact } from '@_api/user/userAPI';
import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
const ReservationFormPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const reservationData = location.state as ReservationData;
const [reserverName, setReserverName] = useState('');
const [reserverContact, setReserverContact] = useState('');
const [participantCount, setParticipantCount] = useState(reservationData.theme.minParticipants || 2);
const [requirement, setRequirement] = useState('');
const [isLoadingUserInfo, setIsLoadingUserInfo] = useState(true);
useEffect(() => {
const fetchUserInfo = async () => {
try {
const userContact = await fetchContact();
setReserverName(userContact.name || '');
setReserverContact(userContact.phone || '');
} catch (err) {
console.warn('사용자 정보를 가져오지 못했습니다:', err);
} finally {
setIsLoadingUserInfo(false);
}
};
fetchUserInfo();
}, []);
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
const handlePayment = () => {
if (!reserverName || !reserverContact) {
alert('예약자명과 연락처를 입력해주세요.');
return;
}
createPendingReservation({
scheduleId: reservationData.scheduleId,
reserverName,
reserverContact,
participantCount,
requirement,
}).then(res => {
navigate('/reservation/payment', {
state: {
reservationId: res.id,
storeName: reservationData.store.name,
themeName: reservationData.theme.name,
date: reservationData.date,
time: formatTime(reservationData.startFrom) + ' ~ ' + formatTime(reservationData.endAt),
themePrice: reservationData.theme.price,
totalPrice: reservationData.theme.price * participantCount,
participantCount: participantCount,
}
});
}).catch(handleError);
};
if (!reservationData) {
return (
<div className="reservation-v21-container">
<h2 className="page-title"> </h2>
<p> . .</p>
<button onClick={() => navigate('/v2-1/reservation')} className="next-step-button"> </button>
</div>
);
}
return (
<div className="reservation-v21-container">
<h2 className="page-title"> </h2>
<div className="step-section">
<h3> </h3>
<p><strong>:</strong> {reservationData.store.name}</p>
<p><strong>:</strong> {reservationData.theme.name}</p>
<p><strong>:</strong> {formatDate(reservationData.date)}</p>
<p><strong>:</strong> {formatTime(reservationData.startFrom)} ~ {formatTime(reservationData.endAt)}</p>
</div>
<div className="step-section">
<h3> </h3>
<div className="form-group">
<label htmlFor="reserverName"></label>
<input
type="text"
id="reserverName"
value={reserverName}
onChange={e => setReserverName(e.target.value)}
disabled={isLoadingUserInfo}
placeholder={isLoadingUserInfo ? "로딩 중..." : "예약자명을 입력하세요"}
/>
</div>
<div className="form-group">
<label htmlFor="reserverContact"></label>
<input
type="tel"
id="reserverContact"
value={reserverContact}
onChange={e => setReserverContact(e.target.value)}
disabled={isLoadingUserInfo}
placeholder={isLoadingUserInfo ? "로딩 중..." : "'-' 없이 입력"}
/>
</div>
<div className="form-group">
<label></label>
<div className="participant-control">
<input
type="number"
value={participantCount}
onChange={e => setParticipantCount(Math.max(reservationData.theme.minParticipants, Math.min(reservationData.theme.maxParticipants, Number(e.target.value))))}
min={reservationData.theme.minParticipants}
max={reservationData.theme.maxParticipants}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="requirement"></label>
<textarea id="requirement" value={requirement} onChange={e => setRequirement(e.target.value)} />
</div>
</div>
<div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button">
</button>
</div>
</div>
);
};
export default ReservationFormPage;

View File

@ -0,0 +1,198 @@
import React, { useEffect, useState, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import Flatpickr from 'react-flatpickr';
import 'flatpickr/dist/flatpickr.min.css';
import { fetchThemes } from '@_api/theme/themeAPI';
import { fetchTimesWithAvailability } from '@_api/time/timeAPI';
import { createReservationWithPayment, createWaiting } from '@_api/reservation/reservationAPI';
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
import { isLoginRequiredError } from '@_api/apiClient';
declare global {
interface Window {
PaymentWidget: any;
}
}
const ReservationPage: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
const [themes, setThemes] = useState<ThemeRetrieveResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<string | null>(null);
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
const [selectedTime, setSelectedTime] = useState<{ id: string, isAvailable: boolean } | null>(null);
const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null);
const navigate = useNavigate();
const location = useLocation();
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://js.tosspayments.com/v1/payment-widget';
script.async = true;
document.head.appendChild(script);
script.onload = () => {
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
const paymentWidget = window.PaymentWidget(widgetClientKey, window.PaymentWidget.ANONYMOUS);
paymentWidgetRef.current = paymentWidget;
const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method",
{ value: 1000 },
{ variantKey: "DEFAULT" }
);
paymentMethodsRef.current = paymentMethods;
};
fetchThemes().then(res => setThemes(res.themes)).catch(handleError);
}, []);
useEffect(() => {
if (selectedDate && selectedTheme) {
const dateStr = selectedDate.toLocaleDateString('en-CA');
fetchTimesWithAvailability(dateStr, selectedTheme)
.then(res => {
setTimes(res.times);
setSelectedTime(null);
})
.catch(handleError);
}
}, [selectedDate, selectedTheme]);
const handleReservation = () => {
if (!selectedDate || !selectedTheme || !selectedTime || !paymentWidgetRef.current) {
alert('날짜, 테마, 시간을 모두 선택해주세요.');
return;
}
const reservationData = {
date: selectedDate.toLocaleDateString('en-CA'),
themeId: selectedTheme,
timeId: selectedTime.id,
};
const generateRandomString = () =>
crypto.randomUUID().replace(/-/g, '');
paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(),
orderName: "테스트 방탈출 예약 결제 1건",
amount: 1000,
}).then(function (data: any) {
const reservationPaymentRequest = {
...reservationData,
paymentKey: data.paymentKey,
orderId: data.orderId,
amount: data.amount,
paymentType: data.paymentType,
};
createReservationWithPayment(reservationPaymentRequest)
.then(() => {
alert("예약이 완료되었습니다.");
window.location.href = "/";
})
.catch(handleError);
}).catch(function (error: any) {
// This is a client-side error from Toss Payments, not our API
console.error("Payment request error:", error);
alert("결제 요청 중 오류가 발생했습니다.");
});
};
const handleWaiting = () => {
if (!selectedDate || !selectedTheme || !selectedTime) {
alert('날짜, 테마, 시간을 모두 선택해주세요.');
return;
}
const reservationData = {
date: selectedDate.toLocaleDateString('en-CA'),
themeId: selectedTheme,
timeId: selectedTime.id,
};
createWaiting(reservationData)
.then(() => {
alert('예약 대기가 완료되었습니다.');
window.location.href = "/";
})
.catch(handleError);
}
const isReserveButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
const isWaitButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || selectedTime.isAvailable;
return (
<>
<div className="content-container col-md-10 offset-md-1 p-5">
<h2 className="content-container-title"> </h2>
<div className="d-flex" id="reservation-container">
<div className="section border rounded col-md-4 p-3" id="date-section">
<h3 className="fs-5 text-center mb-3"> </h3>
<div className="d-flex justify-content-center p-3">
<Flatpickr
value={selectedDate || undefined}
onChange={([date]) => setSelectedDate(date)}
options={{ inline: true, defaultDate: new Date() }}
/>
</div>
</div>
<div className={`section border rounded col-md-4 p-3 ${!selectedDate ? 'disabled' : ''}`} id="theme-section">
<h3 className="fs-5 text-center mb-3"> </h3>
<div className="p-3" id="theme-slots">
{themes.map(theme => (
<div key={theme.id}
className={`theme-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTheme === theme.id ? 'active' : ''}`}
onClick={() => setSelectedTheme(theme.id)}>
{theme.name}
</div>
))}
</div>
</div>
<div className={`section border rounded col-md-4 p-3 ${!selectedTheme ? 'disabled' : ''}`} id="time-section">
<h3 className="fs-5 text-center mb-3"> </h3>
<div className="p-3" id="time-slots">
{times.length > 0 ? times.map(time => (
<div key={time.id}
className={`time-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTime?.id === time.id ? 'active' : ''}`}
onClick={() => setSelectedTime({ id: time.id, isAvailable: time.isAvailable })}>
{time.startAt}
</div>
)) : <div className="no-times"> .</div>}
</div>
</div>
</div>
<div className="button-group float-end">
<button id="wait-button" className="btn btn-secondary mt-3" disabled={isWaitButtonDisabled} onClick={handleWaiting}></button>
</div>
</div>
<div className="wrapper w-100">
<div className="max-w-540 w-100">
<div id="payment-method" className="w-100"></div>
<div id="agreement" className="w-100"></div>
<div className="btn-wrapper w-100">
<button id="reserve-button" className="btn primary w-100" disabled={isReserveButtonDisabled} onClick={handleReservation}></button>
</div>
</div>
</div>
</>
);
};
export default ReservationPage;

View File

@ -1,330 +0,0 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { fetchSidoList, fetchSigunguList } from '@_api/region/regionAPI';
import { type SidoResponse, type SigunguResponse } from '@_api/region/regionTypes';
import { type ReservationData } from '@_api/reservation/reservationTypes';
import { fetchSchedules, holdSchedule } from '@_api/schedule/scheduleAPI';
import { ScheduleStatus, type ScheduleWithThemeResponse } from '@_api/schedule/scheduleTypes';
import { getStores } from '@_api/store/storeAPI';
import { type SimpleStoreResponse } from '@_api/store/storeTypes';
import { fetchThemeById } from '@_api/theme/themeAPI';
import { DifficultyKoreanMap, type ThemeInfoResponse } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewDate, setViewDate] = useState<Date>(new Date());
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
const [storeList, setStoreList] = useState<SimpleStoreResponse[]>([]);
const [selectedSido, setSelectedSido] = useState('');
const [selectedSigungu, setSelectedSigungu] = useState('');
const [selectedStore, setSelectedStore] = useState<SimpleStoreResponse | null>(null);
const [schedulesByTheme, setSchedulesByTheme] = useState<Record<string, ScheduleWithThemeResponse[]>>({});
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleWithThemeResponse | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [modalThemeDetails, setModalThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', {state: {from: location}});
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => {
fetchSidoList().then(res => setSidoList(res.sidoList)).catch(handleError);
}, []);
useEffect(() => {
if (selectedSido) {
fetchSigunguList(selectedSido).then(res => setSigunguList(res.sigunguList)).catch(handleError);
} else {
setSigunguList([]);
}
setSelectedSigungu('');
}, [selectedSido]);
useEffect(() => {
if (selectedSido) {
getStores(selectedSido, selectedSigungu)
.then(res => setStoreList(res.stores))
.catch(handleError);
} else {
setStoreList([]);
}
setSelectedStore(null);
}, [selectedSido, selectedSigungu]);
useEffect(() => {
if (selectedDate && selectedStore) {
const dateStr = selectedDate.toLocaleDateString('en-CA');
fetchSchedules(selectedStore.id, dateStr)
.then(res => {
const grouped = res.schedules.reduce((acc, schedule) => {
const key = schedule.theme.name;
if (!acc[key]) acc[key] = [];
acc[key].push(schedule);
return acc;
}, {} as Record<string, ScheduleWithThemeResponse[]>);
setSchedulesByTheme(grouped);
})
.catch(handleError);
} else {
setSchedulesByTheme({});
}
setSelectedSchedule(null);
}, [selectedDate, selectedStore]);
const handleDateSelect = (date: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date < today) {
alert("지난 날짜는 선택할 수 없습니다.");
return;
}
setSelectedDate(date);
};
const handleNextStep = () => {
if (!selectedSchedule) {
alert('예약할 시간을 선택해주세요.');
return;
}
setIsConfirmModalOpen(true);
};
const handleConfirmReservation = () => {
if (!selectedSchedule) return;
holdSchedule(selectedSchedule.schedule.id)
.then(() => {
fetchThemeById(selectedSchedule.theme.id).then(res => {
const reservationData: ReservationData = {
scheduleId: selectedSchedule.schedule.id,
store: {
id: selectedStore!.id,
name: selectedStore!.name,
},
theme: {
id: res.id,
name: res.name,
price: res.price,
minParticipants: res.minParticipants,
maxParticipants: res.maxParticipants,
},
date: selectedDate.toLocaleDateString('en-CA'),
startFrom: selectedSchedule.schedule.startFrom,
endAt: selectedSchedule.schedule.endAt,
};
navigate('/reservation/form', {state: reservationData});
}).catch(handleError);
})
.catch(handleError);
};
const openThemeModal = (themeId: string) => {
fetchThemeById(themeId)
.then(themeDetails => {
setModalThemeDetails(themeDetails);
setIsThemeModalOpen(true);
})
.catch(handleError);
};
const renderDateCarousel = () => {
const dates = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = 0; i < 7; i++) {
const date = new Date(viewDate);
date.setDate(viewDate.getDate() + i);
dates.push(date);
}
const handlePrev = () => {
const newViewDate = new Date(viewDate);
newViewDate.setDate(viewDate.getDate() - 1);
if (newViewDate < today) {
alert("지난 날짜는 조회할 수 없습니다.");
return;
}
setViewDate(newViewDate);
}
const handleNext = () => {
const newViewDate = new Date(viewDate);
newViewDate.setDate(viewDate.getDate() + 1);
setViewDate(newViewDate);
}
const goToToday = () => {
setViewDate(new Date());
setSelectedDate(new Date());
}
return (
<div className="date-carousel">
<button onClick={handlePrev} className="carousel-arrow"></button>
<div className="date-options-container">
{dates.map(date => {
const isSelected = selectedDate.toDateString() === date.toDateString();
const isPast = date < today;
return (
<div
key={date.toISOString()}
className={`date-option ${isSelected ? 'active' : ''} ${isPast ? 'disabled' : ''}`}
onClick={() => handleDateSelect(date)}
>
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div>
<div className="day-circle">{date.getDate()}</div>
</div>
);
})}
</div>
<button onClick={handleNext} className="carousel-arrow"></button>
<button onClick={goToToday} className="today-button"></button>
</div>
);
};
const getStatusText = (status: ScheduleStatus) => {
switch (status) {
case ScheduleStatus.AVAILABLE:
return '예약가능';
case ScheduleStatus.HOLD:
return '예약 진행중';
default:
return '예약불가';
}
};
return (
<div className="reservation-v21-container">
<h2 className="page-title"></h2>
<div className="step-section">
<h3>1. </h3>
{renderDateCarousel()}
</div>
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
<h3>2. </h3>
<div className="region-store-selectors">
<select value={selectedSido} onChange={e => setSelectedSido(e.target.value)}>
<option value="">/</option>
{sidoList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
<select value={selectedSigungu} onChange={e => setSelectedSigungu(e.target.value)}
disabled={!selectedSido}>
<option value="">// ()</option>
{sigunguList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
<select value={selectedStore?.id || ''}
onChange={e => setSelectedStore(storeList.find(s => s.id === e.target.value) || null)}
disabled={storeList.length === 0}>
<option value=""> </option>
{storeList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
</div>
<div className={`step-section ${!selectedStore ? 'disabled' : ''}`}>
<h3>3. </h3>
<div className="schedule-list">
{Object.keys(schedulesByTheme).length > 0 ? (
Object.entries(schedulesByTheme).map(([themeName, scheduleAndTheme]) => (
<div key={themeName} className="theme-schedule-group">
<div className="theme-header">
<h4>{themeName}</h4>
<button onClick={() => openThemeModal(scheduleAndTheme[0].theme.id)}
className="theme-detail-button">
</button>
</div>
<div className="time-slots">
{scheduleAndTheme.map(schedule => (
<div
key={schedule.schedule.id}
className={`time-slot ${selectedSchedule?.schedule.id === schedule.schedule.id ? 'active' : ''} ${schedule.schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
onClick={() => schedule.schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
>
{`${schedule.schedule.startFrom} ~ ${schedule.schedule.endAt}`}
<span className="time-availability">{getStatusText(schedule.schedule.status)}</span>
</div>
))}
</div>
</div>
))
) : (
<div className="no-times"> .</div>
)}
</div>
</div>
<div className="next-step-button-container">
<button className="next-step-button" disabled={!selectedSchedule} onClick={handleNextStep}>
</button>
</div>
{isThemeModalOpen && modalThemeDetails && (
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
<img src={modalThemeDetails.thumbnailUrl} alt={modalThemeDetails.name}
className="modal-theme-thumbnail"/>
<h2>{modalThemeDetails.name}</h2>
<div className="modal-section modal-info-grid">
<h3> </h3>
<p><strong>:</strong><span>{DifficultyKoreanMap[modalThemeDetails.difficulty]}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.minParticipants} ~ {modalThemeDetails.maxParticipants}</span></p>
<p><strong>1 :</strong><span>{modalThemeDetails.price.toLocaleString()}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.expectedMinutesFrom} ~ {modalThemeDetails.expectedMinutesTo}</span></p>
<p><strong> :</strong><span>{modalThemeDetails.availableMinutes}</span></p>
</div>
<div className="modal-section">
<h3></h3>
<p>{modalThemeDetails.description}</p>
</div>
</div>
</div>
)}
{isConfirmModalOpen && selectedSchedule && (
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
<h2> </h2>
<div className="modal-section modal-info-grid">
<p><strong>:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
<p><strong>:</strong><span>{selectedStore?.name}</span></p>
<p><strong>:</strong><span>{selectedSchedule.theme.name}</span></p>
<p><strong>:</strong><span>{`${selectedSchedule.schedule.startFrom} ~ ${selectedSchedule.schedule.endAt}`}</span></p>
</div>
<div className="modal-actions">
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}></button>
<button className="confirm-button" onClick={handleConfirmReservation}></button>
</div>
</div>
</div>
)}
</div>
);
};
export default ReservationStep1Page;

View File

@ -1,163 +0,0 @@
import { confirm } from '@_api/order/orderAPI';
import type { OrderErrorResponse } from '@_api/order/orderTypes';
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import { confirmReservation } from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css';
import type { AxiosError } from 'axios';
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter';
declare global {
interface Window {
PaymentWidget: any;
}
}
const ReservationStep2Page: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null);
const { reservationId, storeName, themeName, themePrice, totalPrice, date, time, participantCount } = location.state || {};
useEffect(() => {
if (!reservationId) {
alert('잘못된 접근입니다.');
navigate('/reservation');
return;
}
const script = document.createElement('script');
script.src = 'https://js.tosspayments.com/v1/payment-widget';
script.async = true;
document.head.appendChild(script);
script.onload = () => {
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
const paymentWidget = window.PaymentWidget(widgetClientKey, window.PaymentWidget.ANONYMOUS);
paymentWidgetRef.current = paymentWidget;
const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method",
{ value: totalPrice, currency: "KRW" },
{ variantKey: "DEFAULT" }
);
paymentMethodsRef.current = paymentMethods;
};
}, [reservationId, totalPrice, navigate]);
const handlePayment = () => {
if (!paymentWidgetRef.current || !reservationId) {
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
return;
}
const generateRandomString = () =>
crypto.randomUUID().replace(/-/g, '');
paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(),
orderName: `${themeName} 예약 결제`,
amount: totalPrice,
}).then((data: any) => {
const paymentData: PaymentConfirmRequest = {
paymentKey: data.paymentKey,
orderId: data.orderId,
amount: totalPrice,
};
confirm(reservationId, paymentData)
.then(() => {
alert('결제가 완료되었어요!');
navigate('/reservation/success', {
state: {
storeName: storeName,
themeName: themeName,
date: date,
time: time,
participantCount: participantCount,
totalPrice: totalPrice,
}
});
})
.catch(err => {
const error = err as AxiosError<OrderErrorResponse>;
const errorCode = error.response?.data?.code;
const errorMessage = error.response?.data?.message;
if (errorCode === 'B000') {
alert(`예약을 완료할 수 없어요.(${errorMessage})`);
navigate('/reservation');
return;
}
const trial = error.response?.data?.trial || 0;
if (trial < 2) {
alert(errorMessage);
return;
}
alert(errorMessage);
setTimeout(() => {
const agreeToOnsitePayment = window.confirm('재시도 횟수를 초과했어요. 현장결제를 하시겠어요?');
if (agreeToOnsitePayment) {
confirmReservation(reservationId)
.then(() => {
navigate('/reservation/success', {
state: {
storeName,
themeName,
date,
time,
participantCount,
totalPrice,
},
});
});
} else {
alert('다음에 다시 시도해주세요. 메인 페이지로 이동할게요.');
navigate('/');
}
}, 100);
});
}).catch((error: any) => {
console.error("Payment request error:", error);
alert("결제 요청 중 오류가 발생했어요. 새로고침 후 다시 시도해주세요.");
});
};
if (!reservationId) {
return null;
}
return (
<div className="reservation-v21-container">
<h2 className="page-title"></h2>
<div className="step-section">
<h3> </h3>
<p><strong>:</strong> {formatDate(date)}</p>
<p><strong>:</strong> {time}</p>
<p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {storeName}</p>
<p><strong>:</strong> {participantCount}</p>
<p><strong>1 :</strong> {themePrice.toLocaleString()}</p>
<p><strong> :</strong> {totalPrice.toLocaleString()}</p>
</div>
<div className="step-section">
<h3> </h3>
<div id="payment-method" className="w-100"></div>
<div id="agreement" className="w-100"></div>
</div>
<div className="next-step-button-container">
<button onClick={handlePayment} className="next-step-button">
{totalPrice.toLocaleString()}
</button>
</div>
</div>
);
};
export default ReservationStep2Page;

View File

@ -1,245 +1,42 @@
import {
fetchRegionCode,
fetchSidoList,
fetchSigunguList,
} from '@_api/region/regionAPI';
import type {
SidoResponse,
SigunguResponse,
} from '@_api/region/regionTypes';
import { signup } from '@_api/user/userAPI';
import type { UserCreateRequest, UserCreateResponse } from '@_api/user/userTypes';
import '@_css/signup-page-v2.css';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
const MIN_PASSWORD_LENGTH = 8;
import { signup } from '@_api/member/memberAPI';
import type { SignupRequest } from '@_api/member/memberTypes';
const SignupPage: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [hasSubmitted, setHasSubmitted] = useState(false);
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
const [selectedSidoCode, setSelectedSidoCode] = useState('');
const [selectedSigunguCode, setSelectedSigunguCode] = useState('');
const navigate = useNavigate();
useEffect(() => {
const fetchSido = async () => {
try {
const response = await fetchSidoList();
setSidoList(response.sidoList);
} catch (error) {
console.error('시/도 목록을 불러오는 데 실패했습니다.', error);
}
};
fetchSido();
}, []);
useEffect(() => {
if (selectedSidoCode) {
const fetchSigungu = async () => {
try {
const response = await fetchSigunguList(selectedSidoCode);
setSigunguList(response.sigunguList);
setSelectedSigunguCode('');
} catch (error) {
console.error('시/군/구 목록을 불러오는 데 실패했습니다.', error);
}
};
fetchSigungu();
} else {
setSigunguList([]);
setSelectedSigunguCode('');
}
}, [selectedSidoCode]);
const validate = () => {
const newErrors: Record<string, string> = {};
if (!name.trim()) {
newErrors.name = '이름을 입력해주세요.';
}
if (!email.trim()) {
newErrors.email = '이메일을 입력해주세요.';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = '올바른 이메일 형식을 입력해주세요.';
}
if (password.length < MIN_PASSWORD_LENGTH) {
newErrors.password = `비밀번호는 최소 ${MIN_PASSWORD_LENGTH}자리 이상이어야 합니다.`;
}
if (!phone.trim()) {
newErrors.phone = '전화번호를 입력해주세요.';
} else if (!/^010([0-9]{3,4})([0-9]{4})$/.test(phone)) {
newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)';
}
if (selectedSidoCode || selectedSigunguCode) {
if (!selectedSidoCode || !selectedSigunguCode) {
newErrors.region = '모든 지역 정보를 선택해주세요.';
}
}
return newErrors;
};
// 제출 이후에는 입력값이 바뀔 때마다 다시 validate 실행
useEffect(() => {
if (hasSubmitted) {
setErrors(validate());
}
}, [email, password, name, phone, hasSubmitted, selectedSidoCode, selectedSigunguCode]);
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setHasSubmitted(true);
const newErrors = validate();
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
let regionCode: string | null = null;
if (selectedSidoCode && selectedSigunguCode) {
try {
const response = await fetchRegionCode(
selectedSidoCode,
selectedSigunguCode,
);
regionCode = response.code;
} catch (error) {
alert('지역 코드를 가져오는 데 실패했습니다.');
const handleSignup = async () => {
const request: SignupRequest = { email, password, name };
await signup(request)
.then((response) => {
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
navigate('/login')
})
.catch(error => {
console.error(error);
return;
}
}
const request: UserCreateRequest = { email, password, name, phone, regionCode };
try {
const response: UserCreateResponse = await signup(request);
alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
navigate('/login');
} catch (error: any) {
const message =
error.response?.data?.message ||
'회원가입에 실패했어요. 입력 정보를 확인해주세요.';
alert(message);
console.error(error);
}
});
};
return (
<div className="signup-container-v2">
<h2 className="page-title"></h2>
<form className="signup-form-v2" onSubmit={handleSignup}>
<div className="form-group">
<label className="form-label"></label>
<input
type="email"
className="form-input"
placeholder="이메일을 입력하세요"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
{hasSubmitted && errors.email && (
<p className="error-text">{errors.email}</p>
)}
</div>
<div className="form-group">
<label className="form-label"></label>
<input
type="password"
className="form-input"
placeholder="비밀번호를 입력하세요"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
{hasSubmitted && errors.password && (
<p className="error-text">{errors.password}</p>
)}
</div>
<div className="form-group">
<label className="form-label"></label>
<input
type="text"
className="form-input"
placeholder="이름을 입력하세요"
value={name}
onChange={e => setName(e.target.value)}
required
/>
{hasSubmitted && errors.name && (
<p className="error-text">{errors.name}</p>
)}
</div>
<div className="form-group">
<label className="form-label"></label>
<input
type="text"
className="form-input"
placeholder="전화번호를 입력하세요('-' 제외)"
value={phone}
onChange={e => setPhone(e.target.value)}
required
/>
{hasSubmitted && errors.phone && (
<p className="error-text">{errors.phone}</p>
)}
</div>
<div className="form-group">
<label className="form-label"> ()</label>
<div className="region-select-group">
<select
className="form-input"
value={selectedSidoCode}
onChange={e => setSelectedSidoCode(e.target.value)}
>
<option value="">/</option>
{sidoList.map(s => (
<option key={s.code} value={s.code}>
{s.name}
</option>
))}
</select>
<select
className="form-input"
value={selectedSigunguCode}
onChange={e => setSelectedSigunguCode(e.target.value)}
disabled={!selectedSidoCode}
>
<option value="">//</option>
{sigunguList.map(s => (
<option key={s.code} value={s.code}>
{s.name}
</option>
))}
</select>
</div>
{hasSubmitted && errors.region && (
<p className="error-text">{errors.region}</p>
)}
</div>
<button
type="submit"
className="btn-primary"
disabled={hasSubmitted && Object.keys(errors).length > 0}
>
</button>
</form>
<div className="content-container" style={{ width: '400px' }}>
<h2 className="content-container-title">Signup</h2>
<div className="form-group">
<label>Email address</label>
<input type="email" className="form-control" placeholder="Enter email" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" className="form-control" placeholder="Enter password" value={password} onChange={e => setPassword(e.target.value)} />
</div>
<div className="form-group">
<label>Name</label>
<input type="text" className="form-control" placeholder="Enter name" value={name} onChange={e => setName(e.target.value)} />
</div>
<button className="btn btn-custom" onClick={handleSignup}>Register</button>
</div>
);
};

View File

@ -1,6 +1,4 @@
import { useAdminAuth } from '@_context/AdminAuthContext';
import React, { type ReactNode, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { type ReactNode } from 'react';
import AdminNavbar from './AdminNavbar';
interface AdminLayoutProps {
@ -8,23 +6,6 @@ interface AdminLayoutProps {
}
const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
const { isAdmin, loading } = useAdminAuth();
const navigate = useNavigate();
useEffect(() => {
if (!loading && !isAdmin) {
navigate('/admin/login');
}
}, [isAdmin, loading, navigate]);
if (loading) {
return <div>Loading...</div>;
}
if (!isAdmin) {
return null;
}
return (
<>
<AdminNavbar />

View File

@ -1,61 +0,0 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAdminAuth } from '@_context/AdminAuthContext';
import '@_css/login-page-v2.css';
const AdminLoginPage: React.FC = () => {
const [account, setAccount] = useState('');
const [password, setPassword] = useState('');
const { login } = useAdminAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/admin';
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login({ account: account, password: password });
alert('관리자 로그인에 성공했어요!');
navigate(from, { replace: true });
} catch (error: any) {
const message = error.response?.data?.message || '로그인에 실패했어요. 계정과 비밀번호를 확인해주세요.';
alert(message);
console.error('관리자 로그인 실패:', error);
setPassword('');
}
};
return (
<div className="login-container-v2">
<h2 className="page-title"> </h2>
<form className="login-form-v2" onSubmit={handleLogin}>
<div className="form-group">
<input
type="text"
className="form-input"
placeholder="계정"
value={account}
onChange={e => setAccount(e.target.value)}
required
/>
</div>
<div className="form-group">
<input
type="password"
className="form-input"
placeholder="비밀번호"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="button-group">
<button type="submit" className="btn btn-primary"></button>
</div>
</form>
</div>
);
};
export default AdminLoginPage;

View File

@ -1,10 +1,10 @@
import { useAdminAuth } from '@_context/AdminAuthContext';
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import '@_css/navbar.css';
import { useAuth } from '../../context/AuthContext';
import '../../css/navbar.css';
const AdminNavbar: React.FC = () => {
const { isAdmin, name, type, logout } = useAdminAuth();
const { loggedIn, userName, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async (e: React.MouseEvent) => {
@ -21,17 +21,19 @@ const AdminNavbar: React.FC = () => {
<nav className="navbar-container">
<div className="nav-links">
<Link className="nav-link" to="/admin"></Link>
{type === 'HQ' && <Link className="nav-link" to="/admin/theme"></Link>}
{type === 'HQ' && <Link className="nav-link" to="/admin/store"></Link>}
<Link className="nav-link" to="/admin/reservation"></Link>
<Link className="nav-link" to="/admin/waiting"></Link>
<Link className="nav-link" to="/admin/theme"></Link>
<Link className="nav-link" to="/admin/time"></Link>
<Link className="nav-link" to="/admin/schedule"></Link>
</div>
<div className="nav-actions">
{!isAdmin ? (
<button className="btn btn-primary" onClick={() => navigate('/admin/login')}>Login</button>
{!loggedIn ? (
<button className="btn btn-primary" onClick={() => navigate('/v2/login')}>Login</button>
) : (
<div className="profile-info">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<span>{name || 'Profile'}</span>
<span>{userName || 'Profile'}</span>
<div className="dropdown-menu">
<a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a>
</div>

View File

@ -1,5 +1,5 @@
import React from 'react';
import '@_css/admin-page.css';
import '../../css/admin-page.css';
const AdminPage: React.FC = () => {
return (

View File

@ -1,22 +1,11 @@
import {isLoginRequiredError} from '@_api/apiClient';
import type {AuditInfo} from '@_api/common/commonTypes';
import {
createSchedule,
deleteSchedule,
fetchAdminSchedules,
fetchScheduleAudit,
updateSchedule
} from '@_api/schedule/scheduleAPI';
import {type AdminScheduleSummaryResponse, ScheduleStatus,} from '@_api/schedule/scheduleTypes';
import {getStores} from '@_api/store/storeAPI';
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
import {fetchActiveThemes} from '@_api/theme/themeAPI';
import {DifficultyKoreanMap, type SimpleActiveThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
import {useAdminAuth} from '@_context/AdminAuthContext';
import { isLoginRequiredError } from '@_api/apiClient';
import { createSchedule, deleteSchedule, findScheduleById, findSchedules, updateSchedule } from '@_api/schedule/scheduleAPI';
import { ScheduleStatus, type ScheduleDetailRetrieveResponse, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import { fetchAdminThemes } from '@_api/theme/themeAPI';
import type { AdminThemeSummaryRetrieveResponse } from '@_api/theme/themeTypes';
import '@_css/admin-schedule-page.css';
import {formatDisplayDateTime} from '@_util/DateTimeFormatter';
import React, {Fragment, useEffect, useState} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import React, { Fragment, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
const getScheduleStatusText = (status: ScheduleStatus): string => {
switch (status) {
@ -33,41 +22,28 @@ const getScheduleStatusText = (status: ScheduleStatus): string => {
}
};
type ScheduleDetail = AdminScheduleSummaryResponse & { audit?: AuditInfo };
type EditingSchedule = ScheduleDetail & { time: string };
const AdminSchedulePage: React.FC = () => {
const [schedules, setSchedules] = useState<AdminScheduleSummaryResponse[]>([]);
const [themes, setThemes] = useState<SimpleActiveThemeResponse[]>([]);
const [stores, setStores] = useState<SimpleStoreResponse[]>([]);
const [selectedStoreId, setSelectedStoreId] = useState<string>('');
const [selectedTheme, setSelectedTheme] = useState<SimpleActiveThemeResponse | null>(null);
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
const [selectedThemeId, setSelectedThemeId] = useState<string>('');
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
const [isAdding, setIsAdding] = useState(false);
const [newScheduleTime, setNewScheduleTime] = useState('');
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetail }>({});
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({});
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<EditingSchedule | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedThemeDetails] = useState<ThemeInfoResponse | null>(null);
const [isLoadingThemeDetails] = useState<boolean>(false);
const [editingSchedule, setEditingSchedule] = useState<ScheduleDetailRetrieveResponse | null>(null);
const navigate = useNavigate();
const location = useLocation();
const {type: adminType, storeId: adminStoreId} = useAdminAuth();
const storeIdForFetch = adminType === 'HQ' ? selectedStoreId : adminStoreId;
const showThemeColumn = !selectedTheme?.id;
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/admin/login', {state: {from: location}});
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
@ -76,36 +52,19 @@ const AdminSchedulePage: React.FC = () => {
};
useEffect(() => {
if (!adminType) return;
const fetchPrerequisites = async () => {
try {
// Fetch themes
const themeRes = await fetchActiveThemes();
const themeData = themeRes.themes.map(t => ({id: String(t.id), name: t.name}));
const allThemesOption = {id: '', name: '전체'};
setThemes([allThemesOption, ...themeData]);
setSelectedTheme(allThemesOption);
// Fetch stores for HQ admin
if (adminType === 'HQ') {
const storeRes = (await getStores()).stores;
setStores(storeRes);
if (storeRes.length > 0) {
setSelectedStoreId(String(storeRes[0].id));
}
fetchAdminThemes()
.then(res => {
setThemes(res.themes);
if (res.themes.length > 0) {
setSelectedThemeId(String(res.themes[0].id));
}
} catch (error) {
handleError(error);
}
};
fetchPrerequisites();
}, [adminType]);
})
.catch(handleError);
}, []);
const fetchSchedules = () => {
if (storeIdForFetch) {
fetchAdminSchedules(storeIdForFetch, selectedDate, selectedTheme?.id === '' ? undefined : selectedTheme?.id)
if (selectedDate && selectedThemeId) {
findSchedules(selectedDate, selectedThemeId)
.then(res => setSchedules(res.schedules))
.catch(err => {
setSchedules([]);
@ -113,14 +72,12 @@ const AdminSchedulePage: React.FC = () => {
handleError(err);
}
});
} else {
setSchedules([]);
}
}
useEffect(() => {
fetchSchedules();
}, [selectedDate, selectedTheme, storeIdForFetch]);
}, [selectedDate, selectedThemeId]);
const handleAddSchedule = async () => {
if (!newScheduleTime) {
@ -131,18 +88,10 @@ const AdminSchedulePage: React.FC = () => {
alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.');
return;
}
if (adminType !== 'STORE' || !adminStoreId) {
alert('매장 관리자만 일정을 추가할 수 있습니다.');
return;
}
if (!selectedDate || !selectedTheme?.id) {
alert('날짜와 특정 테마를 선택해주세요.');
return;
}
try {
await createSchedule(adminStoreId, {
await createSchedule({
date: selectedDate,
themeId: selectedTheme.id,
themeId: selectedThemeId,
time: newScheduleTime,
});
fetchSchedules();
@ -157,7 +106,7 @@ const AdminSchedulePage: React.FC = () => {
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
try {
await deleteSchedule(scheduleId);
fetchSchedules();
setSchedules(schedules.filter(s => s.id !== scheduleId));
setExpandedScheduleId(null); // Close the details view after deletion
} catch (error) {
handleError(error);
@ -167,22 +116,16 @@ const AdminSchedulePage: React.FC = () => {
const handleToggleDetails = async (scheduleId: string) => {
const isAlreadyExpanded = expandedScheduleId === scheduleId;
setIsEditing(false);
setIsEditing(false); // Reset editing state whenever toggling
if (isAlreadyExpanded) {
setExpandedScheduleId(null);
} else {
setExpandedScheduleId(scheduleId);
const scheduleInList = schedules.find(s => s.id === scheduleId);
if (!scheduleInList) return;
if (!detailedSchedules[scheduleId]?.audit) {
if (!detailedSchedules[scheduleId]) {
setIsLoadingDetails(true);
try {
const auditInfo = await fetchScheduleAudit(scheduleId);
setDetailedSchedules(prev => ({
...prev,
[scheduleId]: {...scheduleInList, audit: auditInfo}
}));
const details = await findScheduleById(scheduleId);
setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details }));
} catch (error) {
handleError(error);
} finally {
@ -194,15 +137,7 @@ const AdminSchedulePage: React.FC = () => {
const handleEditClick = () => {
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
const scheduleToEdit = detailedSchedules[expandedScheduleId];
setEditingSchedule({
...scheduleToEdit,
time: new Date(scheduleToEdit.startFrom).toLocaleTimeString('en-CA', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}),
});
setEditingSchedule({ ...detailedSchedules[expandedScheduleId] });
setIsEditing(true);
}
};
@ -213,9 +148,9 @@ const AdminSchedulePage: React.FC = () => {
};
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const {name, value} = e.target;
const { name, value } = e.target;
if (editingSchedule) {
setEditingSchedule({...editingSchedule, [name]: value});
setEditingSchedule({ ...editingSchedule, [name]: value });
}
};
@ -227,39 +162,24 @@ const AdminSchedulePage: React.FC = () => {
time: editingSchedule.time,
status: editingSchedule.status,
});
fetchSchedules();
setExpandedScheduleId(null);
setIsEditing(false);
setEditingSchedule(null);
// Refresh data
const details = await findScheduleById(editingSchedule.id);
setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details }));
setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s));
alert('일정이 성공적으로 업데이트되었습니다.');
setIsEditing(false);
} catch (error) {
handleError(error);
}
};
const canModify = adminType === 'STORE';
return (
<div className="admin-schedule-container">
<h2 className="page-title"> </h2>
<div className="schedule-controls">
{adminType === 'HQ' && (
<div className="form-group store-selector-group">
<label className="form-label" htmlFor="store-filter"></label>
<select
id="store-filter"
className="form-select"
value={selectedStoreId}
onChange={e => setSelectedStoreId(e.target.value)}
>
{stores.map(store => (
<option key={store.id} value={store.id}>{store.name}</option>
))}
</select>
</div>
)}
<div className="form-group date-selector-group">
<div className="form-group">
<label className="form-label" htmlFor="date-filter"></label>
<input
id="date-filter"
@ -269,189 +189,125 @@ const AdminSchedulePage: React.FC = () => {
onChange={e => setSelectedDate(e.target.value)}
/>
</div>
<div className="form-group theme-selector-group">
<div className="form-group">
<label className="form-label" htmlFor="theme-filter"></label>
<div className='theme-selector-button-group'>
<select
id="theme-filter"
className="form-select"
value={selectedTheme?.id || ''}
onChange={e => {
const theme = themes.find(t => t.id === e.target.value);
setSelectedTheme(theme || null);
}}
>
{themes.map(theme => (
<option key={theme.id} value={theme.id}>{theme.name}</option>
))}
</select>
</div>
<select
id="theme-filter"
className="form-select"
value={selectedThemeId}
onChange={e => setSelectedThemeId(e.target.value)}
>
{themes.map(theme => (
<option key={theme.id} value={theme.id}>{theme.name}</option>
))}
</select>
</div>
</div>
<div className="section-card">
{canModify && (
<div className="table-header">
<button className="btn btn-primary" onClick={() => setIsAdding(true)}> </button>
</div>
)}
<div className="table-header">
<button className="btn btn-primary" onClick={() => setIsAdding(true)}> </button>
</div>
<div className="table-container">
<table>
<thead>
<tr>
{showThemeColumn && <th></th>}
<th></th>
<th></th>
<th></th>
</tr>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{schedules.map(schedule => (
<Fragment key={schedule.id}>
<tr>
{showThemeColumn && <td>{schedule.themeName}</td>}
<td>{schedule.startFrom}</td>
<td>{getScheduleStatusText(schedule.status)}</td>
<td className="action-buttons">
<button
className="btn btn-secondary"
onClick={() => handleToggleDetails(schedule.id)}
>
{expandedScheduleId === schedule.id ? '닫기' : '상세'}
</button>
</td>
</tr>
{expandedScheduleId === schedule.id && (
<tr className="schedule-details-row">
<td colSpan={showThemeColumn ? 4 : 3}>
{isLoadingDetails ? (
<p> ...</p>
) : detailedSchedules[schedule.id] ? (
<div className="details-form-container">
{detailedSchedules[schedule.id].audit ? (
{schedules.map(schedule => (
<Fragment key={schedule.id}>
<tr>
<td>{schedule.time}</td>
<td>{getScheduleStatusText(schedule.status)}</td>
<td className="action-buttons">
<button
className="btn btn-secondary"
onClick={() => handleToggleDetails(schedule.id)}
>
{expandedScheduleId === schedule.id ? '닫기' : '상세'}
</button>
</td>
</tr>
{expandedScheduleId === schedule.id && (
<tr className="schedule-details-row">
<td colSpan={3}>
{isLoadingDetails ? (
<p> ...</p>
) : detailedSchedules[schedule.id] ? (
<div className="details-form-container">
<div className="audit-info">
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p>
<strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.createdAt)}
</p>
<p>
<strong>:</strong> {formatDisplayDateTime(detailedSchedules[schedule.id].audit!.updatedAt)}
</p>
<p>
<strong>:</strong> {detailedSchedules[schedule.id].audit!.createdBy.name}({detailedSchedules[schedule.id].audit!.createdBy.id})
</p>
<p>
<strong>:</strong> {detailedSchedules[schedule.id].audit!.updatedBy.name}({detailedSchedules[schedule.id].audit!.updatedBy.id})
</p>
<p><strong>:</strong> {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}</p>
<p><strong>:</strong> {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}</p>
<p><strong>:</strong> {detailedSchedules[schedule.id].createdBy}</p>
<p><strong>:</strong> {detailedSchedules[schedule.id].updatedBy}</p>
</div>
</div>
) : <p> ...</p>}
{isEditing && editingSchedule?.id === schedule.id ? (
// --- EDIT MODE ---
<div className="form-card">
<div className="form-section">
<div className="form-row">
<div className="form-group">
<label className="form-label"></label>
<input type="time" name="time"
className="form-input"
value={editingSchedule.time}
onChange={handleEditChange}/>
</div>
<div className="form-group">
<label className="form-label"></label>
<select name="status" className="form-select"
value={editingSchedule.status}
onChange={handleEditChange}>
{Object.values(ScheduleStatus).map(s =>
<option key={s}
value={s}>{getScheduleStatusText(s)}</option>)}
</select>
{isEditing && editingSchedule ? (
// --- EDIT MODE ---
<div className="form-card">
<div className="form-section">
<div className="form-row">
<div className="form-group">
<label className="form-label"></label>
<input type="time" name="time" className="form-input" value={editingSchedule.time} onChange={handleEditChange} />
</div>
<div className="form-group">
<label className="form-label"></label>
<select name="status" className="form-select" value={editingSchedule.status} onChange={handleEditChange}>
{Object.values(ScheduleStatus).map(s => <option key={s} value={s}>{getScheduleStatusText(s)}</option>)}
</select>
</div>
</div>
</div>
<div className="button-group">
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}></button>
<button type="button" className="btn btn-primary" onClick={handleSave}></button>
</div>
</div>
<div className="button-group">
<button type="button" className="btn btn-secondary"
onClick={handleCancelEdit}>
</button>
<button type="button" className="btn btn-primary"
onClick={handleSave}>
</button>
</div>
</div>
) : (
// --- VIEW MODE ---
canModify && (
) : (
// --- VIEW MODE ---
<div className="button-group view-mode-buttons">
<button type="button" className="btn btn-danger"
onClick={() => handleDeleteSchedule(schedule.id)}>
</button>
<button type="button" className="btn btn-primary"
onClick={handleEditClick}>
</button>
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}></button>
<button type="button" className="btn btn-primary" onClick={handleEditClick}></button>
</div>
)
)}
</div>
) : (
<p> .</p>
)}
</td>
</tr>
)}
</Fragment>
))}
{isAdding && canModify && (
<tr className="editing-row">
{showThemeColumn && <td></td>}
<td>
<input
type="time"
className="form-input"
value={newScheduleTime}
onChange={e => setNewScheduleTime(e.target.value)}
/>
</td>
<td></td>
<td className="action-buttons">
<button className="btn btn-primary" onClick={handleAddSchedule}></button>
<button className="btn btn-secondary" onClick={() => setIsAdding(false)}></button>
</td>
</tr>
)}
)}
</div>
) : (
<p> .</p>
)}
</td>
</tr>
)}
</Fragment>
))}
{isAdding && (
<tr className="editing-row">
<td>
<input
type="time"
className="form-input"
value={newScheduleTime}
onChange={e => setNewScheduleTime(e.target.value)}
/>
</td>
<td></td>
<td className="action-buttons">
<button className="btn btn-primary" onClick={handleAddSchedule}></button>
<button className="btn btn-secondary" onClick={() => setIsAdding(false)}></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content">
<button className="modal-close-btn" onClick={() => setIsModalOpen(false)}>×</button>
{isLoadingThemeDetails ? (
<p> ...</p>
) : selectedThemeDetails ? (
<div className="theme-details-modal">
<h3 className="modal-title">{selectedThemeDetails.name}</h3>
<img src={selectedThemeDetails.thumbnailUrl} alt={selectedThemeDetails.name}
className="theme-modal-thumbnail"/>
<p className="theme-modal-description">{selectedThemeDetails.description}</p>
<div className="modal-info-grid">
<p><strong></strong><span>{DifficultyKoreanMap[selectedThemeDetails.difficulty]}</span></p>
<p><strong> </strong><span>{selectedThemeDetails.minParticipants} ~ {selectedThemeDetails.maxParticipants}</span></p>
<p><strong>1 </strong><span>{selectedThemeDetails.price.toLocaleString()}</span></p>
<p><strong> </strong><span>{selectedThemeDetails.expectedMinutesFrom} ~ {selectedThemeDetails.expectedMinutesTo}</span></p>
<p><strong> </strong><span>{selectedThemeDetails.availableMinutes}</span></p>
</div>
</div>
) : (
<p> .</p>
)}
</div>
</div>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More