Compare commits

..

13 Commits

528 changed files with 19412 additions and 22927 deletions

6
.gitignore vendored
View File

@ -36,8 +36,4 @@ out/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
logs logs
.kotlin .kotlin
### sql
data/*.sql
data/*.txt

View File

@ -1,9 +1,10 @@
FROM amazoncorretto:17 FROM gradle:8-jdk17 AS builder
WORKDIR /app WORKDIR /app
COPY . .
RUN ./gradlew bootjar --no-daemon
COPY service/build/libs/service.jar app.jar FROM amazoncorretto:17
WORKDIR /app
EXPOSE 8080 EXPOSE 8080
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-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 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
val springBootVersion = "3.5.3" val springBootVersion = "3.5.3"
val kotlinVersion = "2.2.0" val kotlinVersion = "2.2.0"
id("io.spring.dependency-management") version "1.1.7" apply false id("org.springframework.boot") version springBootVersion
id("org.springframework.boot") version springBootVersion apply false id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version kotlinVersion apply false kotlin("jvm") version kotlinVersion
kotlin("kapt") version kotlinVersion apply false kotlin("plugin.spring") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion apply false kotlin("plugin.jpa") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion apply false kotlin("kapt") version kotlinVersion
} }
group = "com.sangdol" group = "com.sangdol"
version = "0.0.1-SNAPSHOT" version = "0.0.1-SNAPSHOT"
allprojects { java {
repositories { toolchain {
mavenCentral() languageVersion = JavaLanguageVersion.of(17)
} }
} }
subprojects { tasks.jar {
apply(plugin = "org.jetbrains.kotlin.jvm") enabled = false
apply(plugin = "org.jetbrains.kotlin.kapt") }
apply(plugin = "io.spring.dependency-management")
extensions.configure<JavaPluginExtension> { kapt {
sourceCompatibility = JavaVersion.VERSION_17 keepJavacAnnotationProcessors = true
targetCompatibility = JavaVersion.VERSION_17 }
}
extensions.configure<KaptExtension> { repositories {
keepJavacAnnotationProcessors = true mavenCentral()
} }
dependencies { dependencies {
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3") // Spring
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1") implementation("org.springframework.boot:spring-boot-starter-web")
add("implementation", "ch.qos.logback:logback-classic:1.5.18") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
} implementation("org.springframework.boot:spring-boot-starter-validation")
tasks.withType<Test> { // API docs
useJUnitPlatform() implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
}
tasks.withType<KotlinCompile> { // DB
compilerOptions { implementation("com.github.f4b6a3:tsid-creator:5.2.6")
freeCompilerArgs.addAll( runtimeOnly("com.h2database:h2")
"-Xjsr305=strict", runtimeOnly("com.mysql:mysql-connector-j")
"-Xannotation-default-target=param-property"
) // Jwt
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) 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,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,99 +0,0 @@
package com.sangdol.common.web.exception
import com.sangdol.common.types.exception.CommonErrorCode
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException
import com.sangdol.common.types.web.CommonErrorResponse
import com.sangdol.common.types.web.HttpStatus
import com.sangdol.common.web.support.log.WebLogMessageConverter
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
private val log: KLogger = KotlinLogging.logger {}
@RestControllerAdvice
class GlobalExceptionHandler(
private val messageConverter: WebLogMessageConverter
) {
@ExceptionHandler(value = [RoomescapeException::class])
fun handleRoomException(
servletRequest: HttpServletRequest,
e: RoomescapeException
): ResponseEntity<CommonErrorResponse> {
val errorCode: ErrorCode = e.errorCode
val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode)
log.info { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity
.status(httpStatus.value())
.body(errorResponse)
}
@ExceptionHandler(value = [MethodArgumentNotValidException::class, HttpMessageNotReadableException::class])
fun handleInvalidRequestValueException(
servletRequest: HttpServletRequest,
e: Exception
): ResponseEntity<CommonErrorResponse> {
if (e is MethodArgumentNotValidException) {
e.bindingResult.allErrors
.mapNotNull { it.defaultMessage }
.joinToString(", ")
} else {
e.message!!
}.also {
log.warn { "[ExceptionControllerAdvice] Invalid Request Value Exception occurred: $it" }
}
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode)
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity
.status(httpStatus.value())
.body(errorResponse)
}
@ExceptionHandler(value = [Exception::class])
fun handleException(
servletRequest: HttpServletRequest,
e: Exception
): ResponseEntity<CommonErrorResponse> {
log.error(e) { "[ExceptionControllerAdvice] Unexpected exception occurred: ${e.message}" }
val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR
val httpStatus: HttpStatus = errorCode.httpStatus
val errorResponse = CommonErrorResponse(errorCode)
log.warn { convertExceptionLogMessage(servletRequest, httpStatus, errorResponse, e) }
return ResponseEntity
.status(httpStatus.value())
.body(errorResponse)
}
private fun convertExceptionLogMessage(
servletRequest: HttpServletRequest,
httpStatus: HttpStatus,
errorResponse: CommonErrorResponse,
exception: Exception
): String {
val actualException: Exception? = if (errorResponse.message == exception.message) null else exception
return messageConverter.convertToErrorResponseMessage(
servletRequest = servletRequest,
httpStatus = httpStatus,
responseBody = errorResponse,
exception = actualException
)
}
}

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 node_modules
npm-debug.log .git
# Build output
build
dist
# Editor/OS specific
.vscode
.idea
.DS_Store .DS_Store
npm-debug.log
# Environment variables dist
.env* 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 WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
COPY package.json package-lock.json ./ RUN npm install --frozen-lockfile
RUN npm ci
COPY . . COPY . .
RUN npm run build 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 --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

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

View File

@ -11,7 +11,6 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"json-bigint": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-flatpickr": "^3.10.13", "react-flatpickr": "^3.10.13",
@ -19,7 +18,6 @@
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"devDependencies": { "devDependencies": {
"@types/json-bigint": "^1.0.4",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/react-flatpickr": "^3.8.11", "@types/react-flatpickr": "^3.8.11",
@ -1325,13 +1323,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/json-bigint": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz",
"integrity": "sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -1699,15 +1690,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.7", "version": "5.3.7",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
@ -2851,15 +2833,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",

View File

@ -13,7 +13,6 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"json-bigint": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-flatpickr": "^3.10.13", "react-flatpickr": "^3.10.13",
@ -21,7 +20,6 @@
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"devDependencies": { "devDependencies": {
"@types/json-bigint": "^1.0.4",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/react-flatpickr": "^3.8.11", "@types/react-flatpickr": "^3.8.11",

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

View File

@ -1,29 +1,8 @@
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 });
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000, timeout: 10000,
// transformResponse is used to parse JSON with big integers (Long type from backend) as strings.
// This prevents precision loss in JavaScript.
transformResponse: [(data) => {
// Do not transform if data is not a string or is empty
if (!data || typeof data !== 'string') {
return data;
}
try {
// Use the configured JSONbig instance to parse the data
return JSONbigString.parse(data);
} catch (e) {
// If parsing fails, it might not be JSON, so return original data
// This is the default behavior of axios if parsing fails
return data;
}
}],
}); });
export const isLoginRequiredError = (error: any): boolean => { export const isLoginRequiredError = (error: any): boolean => {
@ -39,7 +18,7 @@ async function request<T>(
method: Method, method: Method,
endpoint: string, endpoint: string,
data: object = {}, data: object = {},
type: PrincipalType, isRequiredAuth: boolean = false
): Promise<T> { ): Promise<T> {
const config: AxiosRequestConfig = { const config: AxiosRequestConfig = {
method, method,
@ -49,9 +28,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 (accessToken) {
if (!config.headers) { if (!config.headers) {
config.headers = {}; config.headers = {};
@ -59,6 +37,7 @@ async function request<T>(
config.headers['Authorization'] = `Bearer ${accessToken}`; config.headers['Authorization'] = `Bearer ${accessToken}`;
} }
if (method.toUpperCase() !== 'GET') { if (method.toUpperCase() !== 'GET') {
config.data = data; config.data = data;
} }
@ -73,50 +52,30 @@ async function request<T>(
} }
} }
async function get<T>(endpoint: string): Promise<T> { async function get<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('GET', endpoint, {}, PrincipalType.USER); return request<T>('GET', endpoint, {}, isRequiredAuth);
} }
async function adminGet<T>(endpoint: string): Promise<T> { async function post<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('GET', endpoint, {}, PrincipalType.ADMIN); return request<T>('POST', endpoint, data, isRequiredAuth);
} }
async function post<T>(endpoint: string, data: object = {}): Promise<T> { async function put<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('POST', endpoint, data, PrincipalType.USER); return request<T>('PUT', endpoint, data, isRequiredAuth);
} }
async function adminPost<T>(endpoint: string, data: object = {}): Promise<T> { async function patch<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('POST', endpoint, data, PrincipalType.ADMIN); return request<T>('PATCH', endpoint, data, isRequiredAuth);
} }
async function put<T>(endpoint: string, data: object = {}): Promise<T> { async function del<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('PUT', endpoint, data, PrincipalType.USER); return request<T>('DELETE', endpoint, {}, isRequiredAuth);
}
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);
} }
export default { export default {
get, adminGet, get,
post, adminPost, post,
put, adminPut, put,
patch, adminPatch, patch,
del, adminDel, del
}; };

View File

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

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

@ -1,72 +0,0 @@
export interface PaymentConfirmRequest {
paymentKey: string;
orderId: string;
amount: number;
}
export interface PaymentCancelRequest {
reservationId: string,
cancelReason: String
}
// V2 types
export const PaymentType = {
NORMAL: 'NORMAL',
BILLING: 'BILLING',
BRANDPAY: 'BRANDPAY'
} as const;
export type PaymentType =
| typeof PaymentType.NORMAL
| typeof PaymentType.BILLING
| typeof PaymentType.BRANDPAY;
export interface PaymentCreateResponseV2 {
paymentId: string;
detailId: string;
}
export interface PaymentRetrieveResponse {
orderId: string;
totalAmount: number;
method: string;
status: 'DONE' | 'CANCELED';
requestedAt: string;
approvedAt: string;
detail?: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail;
cancel?: CanceledPaymentDetailResponse;
}
export interface CardPaymentDetail {
type: 'CARD';
issuerCode: string;
cardType: 'CREDIT' | 'CHECK' | 'GIFT';
ownerType: 'PERSONAL' | 'CORPORATE';
cardNumber: string;
amount: number;
approvalNumber: string;
installmentPlanMonths: number;
isInterestFree: boolean;
easypayProviderName?: string;
easypayDiscountAmount?: number;
}
export interface BankTransferPaymentDetail {
type: 'BANK_TRANSFER';
bankName: string;
settlementStatus: string;
}
export interface EasyPayPrepaidPaymentDetail {
type: 'EASYPAY_PREPAID';
providerName: string;
amount: number;
discountAmount: number;
}
export interface CanceledPaymentDetailResponse {
cancellationRequestedAt: string; // ISO 8601 format
cancellationApprovedAt: string; // ISO 8601 format
cancelReason: string;
canceledBy: string;
}

View File

@ -1,10 +0,0 @@
import apiClient from "@_api/apiClient";
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);
};
export const cancelPayment = async (request: PaymentCancelRequest): Promise<void> => {
return await apiClient.post(`/payments/cancel`, 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,101 @@
import apiClient from '../apiClient'; import apiClient from "@_api/apiClient";
import type { import type {
MostReservedThemeIdListResponse, AdminReservationCreateRequest,
PendingReservationCreateRequest, MyReservationRetrieveListResponse,
PendingReservationCreateResponse, ReservationCreateRequest,
ReservationDetailRetrieveResponse, ReservationCreateResponse,
ReservationOverviewListResponse ReservationCreateWithPaymentRequest,
} from './reservationTypes'; ReservationDetailV2,
ReservationPaymentRequest,
ReservationPaymentResponse,
ReservationRetrieveListResponse,
ReservationRetrieveResponse,
ReservationSearchQuery,
ReservationSummaryListV2,
WaitingCreateRequest
} from "./reservationTypes";
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => { // GET /reservations
return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request); export const fetchReservations = async (): Promise<ReservationRetrieveListResponse> => {
return await apiClient.get<ReservationRetrieveListResponse>('/reservations', true);
}; };
export const confirmReservation = async (reservationId: string): Promise<void> => { // GET /reservations-mine
await apiClient.post(`/reservations/${reservationId}/confirm`, {}); export const fetchMyReservations = async (): Promise<MyReservationRetrieveListResponse> => {
return await apiClient.get<MyReservationRetrieveListResponse>('/reservations-mine', true);
}; };
// GET /reservations/search
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => { export const searchReservations = async (params: ReservationSearchQuery): Promise<ReservationRetrieveListResponse> => {
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason }); 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);
}; };
export const fetchAllOverviewByUser = async (): Promise<ReservationOverviewListResponse> => { // DELETE /reservations/{id}
return await apiClient.get<ReservationOverviewListResponse>('/reservations/overview'); export const cancelReservationByAdmin = async (id: string): Promise<void> => {
} return await apiClient.del(`/reservations/${id}`, true);
};
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => { // POST /reservations
return await apiClient.get<ReservationDetailRetrieveResponse>(`/reservations/${reservationId}/detail`); export const createReservationWithPayment = async (data: ReservationCreateWithPaymentRequest): Promise<ReservationRetrieveResponse> => {
} return await apiClient.post<ReservationRetrieveResponse>('/reservations', data, true);
};
export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => { // POST /reservations/admin
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`); 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);
};
// POST /v2/reservations/{id}/cancel
export const cancelReservationV2 = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/v2/reservations/${id}/cancel`, { cancelReason }, true);
};
// GET /v2/reservations
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {
return await apiClient.get<ReservationSummaryListV2>('/v2/reservations', true);
};
// 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

@ -1,88 +1,207 @@
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes"; import type { MemberRetrieveResponse } from '@_api/member/memberTypes';
import type {UserContactRetrieveResponse} from "@_api/user/userTypes"; import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
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;
}
export const ReservationStatus = { export const ReservationStatus = {
PENDING: 'PENDING', PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED', CONFIRMED: 'CONFIRMED',
CANCELED: 'CANCELED', CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
FAILED: 'FAILED', WAITING: 'WAITING',
EXPIRED: 'EXPIRED' CANCELED_BY_USER: 'CANCELED_BY_USER',
AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED'
} as const; } as const;
export type ReservationStatus = export type ReservationStatus =
| typeof ReservationStatus.PENDING | typeof ReservationStatus.PENDING
| typeof ReservationStatus.CONFIRMED | typeof ReservationStatus.CONFIRMED
| typeof ReservationStatus.CANCELED | typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
| typeof ReservationStatus.FAILED | typeof ReservationStatus.WAITING
| typeof ReservationStatus.EXPIRED; | typeof ReservationStatus.CANCELED_BY_USER
| typeof ReservationStatus.AUTOMATICALLY_CANCELED;
export interface PendingReservationCreateRequest { export interface MyReservationRetrieveResponse {
scheduleId: string, id: string;
reserverName: string, themeName: string;
reserverContact: string, date: string;
participantCount: number, time: string;
requirement: string status: ReservationStatus;
rank: number;
paymentKey: string | null;
amount: number | null;
} }
export interface PendingReservationCreateResponse { export interface MyReservationRetrieveListResponse {
id: string 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;
}
// V2 types
export const PaymentType = {
NORMAL: 'NORMAL',
BILLING: 'BILLING',
BRANDPAY: 'BRANDPAY'
} as const;
export type PaymentType =
| typeof PaymentType.NORMAL
| typeof PaymentType.BILLING
| typeof PaymentType.BRANDPAY;
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 ReservationSummaryV2 {
id: string; id: string;
storeName: string;
themeName: string; themeName: string;
date: string; date: string;
startFrom: string; startAt: string;
endAt: string; status: string; // 'CONFIRMED', 'CANCELED_BY_USER', etc.
status: ReservationStatus;
} }
export interface ReservationOverviewListResponse { export interface ReservationSummaryListV2 {
reservations: ReservationOverviewResponse[]; reservations: ReservationSummaryV2[];
} }
export interface ReserverInfo { export interface ReservationDetailV2 {
name: string; id: string;
contact: string; user: UserDetailV2;
participantCount: number; themeName: string;
requirement: string; date: string;
startAt: string;
applicationDateTime: string;
payment: PaymentV2;
cancellation: CancellationV2 | null;
} }
export interface ReservationDetailRetrieveResponse { export interface UserDetailV2 {
id: string; id: string;
reserver: ReserverInfo; name: string;
user: UserContactRetrieveResponse; email: string;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
} }
export interface ReservationDetail { export interface PaymentV2 {
overview: ReservationOverviewResponse; orderId: string;
reserver: ReserverInfo; totalAmount: number;
user: UserContactRetrieveResponse; method: string;
applicationDateTime: string; status: 'DONE' | 'CANCELED';
payment?: PaymentRetrieveResponse; requestedAt: string;
approvedAt: string;
detail: CardPaymentDetailV2 | BankTransferPaymentDetailV2 | EasyPayPrepaidPaymentDetailV2;
} }
export interface MostReservedThemeIdListResponse { export interface CardPaymentDetailV2 {
themeIds: string[]; type: 'CARD';
issuerCode: string;
cardType: 'CREDIT' | 'CHECK' | 'GIFT';
ownerType: 'PERSONAL' | 'CORPORATE';
cardNumber: string;
amount: number;
approvalNumber: string;
installmentPlanMonths: number;
isInterestFree: boolean;
easypayProviderName?: string;
easypayDiscountAmount?: number;
}
export interface BankTransferPaymentDetailV2 {
type: 'BANK_TRANSFER';
bankName: string;
settlementStatus: string;
}
export interface EasyPayPrepaidPaymentDetailV2 {
type: 'EASYPAY_PREPAID';
providerName: string;
amount: number;
discountAmount: number;
}
export interface CancellationV2 {
cancellationRequestedAt: string; // ISO 8601 format
cancellationApprovedAt: string; // ISO 8601 format
cancelReason: string;
canceledBy: string;
} }

View File

@ -1,55 +0,0 @@
import apiClient from "@_api/apiClient";
import type {AuditInfo} from "@_api/common/commonTypes";
import type {
AdminScheduleSummaryListResponse,
ScheduleCreateRequest,
ScheduleCreateResponse,
ScheduleUpdateRequest,
ScheduleWithThemeListResponse
} from "./scheduleTypes";
// admin
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
const queryParams: string[] = [];
if (date && date.trim() !== '') {
queryParams.push(`date=${date}`);
}
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 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 updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
return await apiClient.adminPatch<void>(`/admin/schedules/${id}`, request);
};
export const deleteSchedule = async (id: string): Promise<void> => {
return await apiClient.adminDel<void>(`/admin/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}`);
};

View File

@ -1,72 +0,0 @@
export type ScheduleStatus = 'AVAILABLE' | 'HOLD' | 'RESERVED' | 'BLOCKED';
export const ScheduleStatus = {
AVAILABLE: 'AVAILABLE' as ScheduleStatus,
HOLD: 'HOLD' as ScheduleStatus,
RESERVED: 'RESERVED' as ScheduleStatus,
BLOCKED: 'BLOCKED' as ScheduleStatus,
};
// Admin
export interface ScheduleCreateRequest {
date: string;
themeId: string;
time: string;
}
export interface ScheduleCreateResponse {
id: string;
}
export interface ScheduleUpdateRequest {
date?: string; // "yyyy-MM-dd"
time?: string; // "HH:mm"
themeId?: string;
status?: ScheduleStatus;
}
export interface AdminScheduleSummaryResponse {
id: string,
themeName: string,
startFrom: string,
endAt: string,
status: ScheduleStatus,
}
export interface AdminScheduleSummaryListResponse {
schedules: AdminScheduleSummaryResponse[];
}
// Public
export interface ScheduleResponse {
id: string;
date: string;
startFrom: string;
endAt: string;
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[];
}

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,50 @@
import apiClient from '@_api/apiClient'; import apiClient from '@_api/apiClient';
import type { import type {
AdminThemeDetailResponse, AdminThemeDetailRetrieveResponse,
AdminThemeSummaryListResponse, AdminThemeSummaryRetrieveListResponse,
SimpleActiveThemeListResponse,
ThemeCreateRequest, ThemeCreateRequest,
ThemeCreateResponse, ThemeCreateRequestV2, ThemeCreateResponse,
ThemeIdListResponse, ThemeCreateResponseV2, ThemeRetrieveListResponse,
ThemeInfoListResponse, ThemeUpdateRequest,
ThemeInfoResponse, UserThemeRetrieveListResponse
ThemeUpdateRequest
} from './themeTypes'; } from './themeTypes';
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryListResponse> => { export const createTheme = async (data: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
return await apiClient.adminGet<AdminThemeSummaryListResponse>('/admin/themes'); return await apiClient.post<ThemeCreateResponse>('/themes', data, true);
}; };
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailResponse> => { export const fetchThemes = async (): Promise<ThemeRetrieveListResponse> => {
return await apiClient.adminGet<AdminThemeDetailResponse>(`/admin/themes/${id}`); return await apiClient.get<ThemeRetrieveListResponse>('/themes', true);
}; };
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => { export const mostReservedThemes = async (count: number = 10): Promise<ThemeRetrieveListResponse> => {
return await apiClient.adminPost<ThemeCreateResponse>('/admin/themes', themeData); 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> => { 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> => { 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> => { export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
return await apiClient.adminGet<SimpleActiveThemeListResponse>('/admin/themes/active'); 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}`);
}; };

View File

@ -1,12 +1,30 @@
import type { AuditInfo } from '@_api/common/commonTypes'; export interface ThemeCreateRequest {
name: string;
export interface AdminThemeDetailResponse { description: string;
theme: ThemeInfoResponse; thumbnail: string;
isActive: boolean;
audit: AuditInfo
} }
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; name: string;
description: string; description: string;
thumbnailUrl: string; thumbnailUrl: string;
@ -17,10 +35,28 @@ export interface ThemeCreateRequest {
availableMinutes: number; availableMinutes: number;
expectedMinutesFrom: number; expectedMinutesFrom: number;
expectedMinutesTo: 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; id: string;
} }
@ -35,22 +71,41 @@ export interface ThemeUpdateRequest {
availableMinutes?: number; availableMinutes?: number;
expectedMinutesFrom?: number; expectedMinutesFrom?: number;
expectedMinutesTo?: number; expectedMinutesTo?: number;
isActive?: boolean; isOpen?: boolean;
} }
export interface AdminThemeSummaryResponse { export interface AdminThemeSummaryRetrieveResponse {
id: string; id: string;
name: string; name: string;
difficulty: Difficulty; difficulty: Difficulty;
price: number; price: number;
isActive: boolean; isOpen: boolean;
} }
export interface AdminThemeSummaryListResponse { export interface AdminThemeSummaryRetrieveListResponse {
themes: AdminThemeSummaryResponse[]; 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; id: string;
name: string; name: string;
thumbnailUrl: string; thumbnailUrl: string;
@ -64,42 +119,15 @@ export interface ThemeInfoResponse {
expectedMinutesTo: number; expectedMinutesTo: number;
} }
export interface ThemeInfoListResponse { export interface UserThemeRetrieveListResponse {
themes: ThemeInfoResponse[]; themes: UserThemeRetrieveResponse[];
}
export interface ThemeIdListResponse {
themeIds: string[];
} }
// @ts-ignore
export enum Difficulty { export enum Difficulty {
VERY_EASY = 'VERY_EASY', VERY_EASY = 'VERY_EASY',
EASY = 'EASY', EASY = 'EASY',
NORMAL = 'NORMAL', NORMAL = 'NORMAL',
HARD = 'HARD', HARD = 'HARD',
VERY_HARD = 'VERY_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" <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>
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'; import Navbar from './Navbar';
interface LayoutProps { interface LayoutProps {

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import {Link, useNavigate} from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import {useAuth} from 'src/context/AuthContext'; import { useAuth } from 'src/context/AuthContext';
import 'src/css/navbar.css'; import 'src/css/navbar.css';
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
@ -21,20 +21,20 @@ const Navbar: React.FC = () => {
<nav className="navbar-container"> <nav className="navbar-container">
<div className="nav-links"> <div className="nav-links">
<Link className="nav-link" to="/"></Link> <Link className="nav-link" to="/"></Link>
<Link className="nav-link" to="/reservation"></Link> <Link className="nav-link" to="/v2/reservation"></Link>
</div> </div>
<div className="nav-actions"> <div className="nav-actions">
{!loggedIn ? ( {!loggedIn ? (
<> <>
<button className="btn btn-secondary" onClick={() => navigate('/login')}></button> <button className="btn btn-secondary" onClick={() => navigate('/v2/login')}></button>
<button className="btn btn-primary" onClick={() => navigate('/signup')}></button> <button className="btn btn-primary" onClick={() => navigate('/v2/signup')}></button>
</> </>
) : ( ) : (
<div className="profile-info"> <div className="profile-info">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" /> <img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<span>{userName}</span> <span>{userName}</span>
<div className="dropdown-menu"> <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" /> <div className="dropdown-divider" />
<a className="dropdown-item" href="#" onClick={handleLogout}></a> <a className="dropdown-item" href="#" onClick={handleLogout}></a>
</div> </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 { checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout } from '@_api/auth/authAPI';
import { type LoginRequest, type UserLoginSuccessResponse } from '@_api/auth/authTypes'; import type { LoginRequest, LoginResponse } from '@_api/auth/authTypes';
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
interface AuthContextType { interface AuthContextType {
loggedIn: boolean; loggedIn: boolean;
userName: string | null; userName: string | null;
loading: boolean; role: 'ADMIN' | 'MEMBER' | null;
login: (data: Omit<LoginRequest, 'principalType'>) => Promise<UserLoginSuccessResponse>; loading: boolean; // Add loading state to type
login: (data: LoginRequest) => Promise<LoginResponse>;
logout: () => Promise<void>; logout: () => Promise<void>;
checkLogin: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -15,33 +17,32 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [userName, setUserName] = useState<string | null>(null); 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(() => { useEffect(() => {
try { checkLogin();
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);
}
}, []); }, []);
const login = async (data: Omit<LoginRequest, 'principalType'>) => { const login = async (data: LoginRequest) => {
const response = await apiLogin(data); const response = await apiLogin(data);
await checkLogin();
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('userName', response.name);
setLoggedIn(true);
setUserName(response.name);
return response; return response;
}; };
@ -49,15 +50,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try { try {
await apiLogout(); await apiLogout();
} finally { } finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('userName');
setLoggedIn(false); setLoggedIn(false);
setUserName(null); setUserName(null);
setRole(null);
localStorage.removeItem('accessToken');
} }
}; };
return ( return (
<AuthContext.Provider value={{ loggedIn, userName, loading, login, logout }}> <AuthContext.Provider value={{ loggedIn, userName, role, loading, login, logout, checkLogin }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@ -1,320 +0,0 @@
/* 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-weight: bold;
margin-bottom: 2rem;
text-align: center;
}
.schedule-controls {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
align-items: flex-end; /* Align to bottom */
}
.schedule-controls .form-group {
display: flex;
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 */
margin-bottom: 0.5rem;
color: #555;
}
.schedule-controls .form-input,
.schedule-controls .form-select {
padding: 0.6rem; /* smaller */
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem; /* smaller */
}
.section-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.table-header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.8rem; /* smaller */
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: middle;
font-size: 0.9rem; /* smaller */
}
th {
font-weight: 600;
background-color: #f7f7f7;
}
.btn {
padding: 0.4rem 0.8rem; /* smaller */
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem; /* smaller */
transition: background-color 0.2s;
white-space: nowrap;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.editing-row td {
padding-top: 1rem;
padding-bottom: 1rem;
}
.editing-row .form-input {
width: 100%;
padding: 0.5rem;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
/* Styles for schedule details row */
.schedule-details-row td {
padding: 0;
background-color: #f8f9fa;
}
.details-form-container {
padding: 1.5rem;
}
.details-form-container .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;
}
.details-form-container .form-section {
margin-bottom: 1.5rem;
}
.details-form-container .form-row {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.details-form-container .form-group {
flex: 1;
display: flex;
flex-direction: column;
}
.details-form-container .form-label {
font-size: 0.9rem;
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
}
.details-form-container .form-input,
.details-form-container .form-select {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
height: auto; /* remove fixed height */
box-sizing: border-box;
}
.details-form-container .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;
}
.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 */ /* /src/css/admin-time-page.css */
.admin-store-container { .admin-time-container {
max-width: 1400px; max-width: 800px;
margin: 40px auto; margin: 40px auto;
padding: 40px; padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@ -8,7 +8,7 @@
border-radius: 16px; border-radius: 16px;
} }
.admin-store-container .page-title { .admin-time-container .page-title {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: #333d4b; color: #333d4b;
@ -16,19 +16,6 @@
text-align: center; 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 { .section-card {
background-color: #ffffff; background-color: #ffffff;
border-radius: 12px; border-radius: 12px;
@ -70,7 +57,7 @@
background-color: #f4f6f8; background-color: #f4f6f8;
} }
.form-input, .form-select, .form-textarea { .form-input {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
font-size: 15px; font-size: 15px;
@ -80,7 +67,7 @@
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
.form-input:focus, .form-select:focus, .form-textarea:focus { .form-input:focus {
outline: none; outline: none;
border-color: #3182F6; border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2); box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
@ -123,85 +110,11 @@
background-color: #c53030; background-color: #c53030;
} }
.action-buttons { .editing-row td {
display: flex; padding-top: 20px;
gap: 0.5rem; padding-bottom: 20px;
} }
.details-row td { .editing-row .btn {
padding: 0; margin-right: 8px;
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;
} }

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; gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); 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; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
cursor: pointer;
} }
.theme-ranking-item-v2:hover { .theme-ranking-item-v2:hover {
@ -65,116 +64,3 @@
color: #505a67; color: #505a67;
margin: 0; 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); 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 { .summary-details-v2 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 4px;
} }
.summary-theme-name-v2 { .summary-theme-name-v2 {
@ -79,79 +65,19 @@
.summary-datetime-v2 { .summary-datetime-v2 {
font-size: 16px; font-size: 16px;
color: #505a67; color: #505a67;
margin-bottom: 5px; margin: 0;
} }
/* --- Status Badge --- */ /* Canceled Card Style */
.card-status-badge { .reservation-summary-card-v2.status-canceled_by_user {
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 {
background-color: #f8f9fa; background-color: #f8f9fa;
border-left: 5px solid #6c757d; /* Gray accent */ opacity: 0.6;
}
.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;
} }
/* Canceled */ .reservation-summary-card-v2.status-canceled_by_user .summary-theme-name-v2,
.reservation-summary-card-v2.status-canceled { .reservation-summary-card-v2.status-canceled_by_user .summary-datetime-v2,
background-color: #f8f9fa; .reservation-summary-card-v2.status-canceled_by_user .summary-details-v2 strong {
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 {
color: #6c757d; 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 */ /* Detail Button */
@ -191,16 +117,16 @@
} }
.modal-content-v2 { .modal-content-v2 {
background: #ffffff !important; background: #ffffff;
padding: 30px !important; padding: 30px;
border-radius: 16px !important; border-radius: 16px;
width: 90% !important; width: 90%;
max-width: 500px !important; max-width: 500px;
position: relative !important; position: relative;
box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important; box-shadow: 0 5px 15px rgba(0,0,0,0.3);
animation: slide-up 0.3s ease-out !important; animation: slide-up 0.3s ease-out;
max-height: 90vh !important; /* Prevent modal from being too tall */ max-height: 90vh; /* Prevent modal from being too tall */
overflow-y: auto !important; /* Allow scrolling for long content */ overflow-y: auto; /* Allow scrolling for long content */
} }
@keyframes slide-up { @keyframes slide-up {
@ -254,6 +180,13 @@
color: #505a67; color: #505a67;
} }
.modal-section-v2 p strong {
color: #333d4b;
font-weight: 600;
min-width: 100px;
display: inline-block;
}
.cancellation-section-v2 { .cancellation-section-v2 {
background-color: #fcf2f2; background-color: #fcf2f2;
padding: 15px; padding: 15px;
@ -353,18 +286,3 @@
border-color: #007bff; border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); 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,285 +1,247 @@
/* General Container */ /* General Container */
.reservation-v21-container { .reservation-v21-container {
width: 100%; padding: 40px;
max-width: 900px; max-width: 900px;
margin: 2rem auto; margin: 40px auto;
padding: 2rem; background-color: #ffffff;
font-family: 'Pretendard', sans-serif; border-radius: 16px;
background-color: #fff; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
border-radius: 12px; font-family: 'Toss Product Sans', sans-serif;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); color: #333D4B;
} }
.page-title { .page-title {
text-align: center; font-size: 28px;
font-size: 2rem;
font-weight: 700; font-weight: 700;
margin-bottom: 2.5rem; margin-bottom: 40px;
color: #212529; color: #191F28;
text-align: center;
} }
/* Step Section */ /* Step Sections */
.step-section { .step-section {
margin-bottom: 3rem; margin-bottom: 40px;
padding: 1.5rem; padding: 24px;
border: 1px solid #f1f3f5; border: 1px solid #E5E8EB;
border-radius: 8px; border-radius: 12px;
background-color: #f8f9fa; transition: all 0.3s ease;
} }
.step-section.disabled { .step-section.disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
background-color: #F9FAFB;
} }
.step-section h3 { .step-section h3 {
font-size: 1.5rem; font-size: 20px;
font-weight: 600; font-weight: 600;
margin-top: 0; margin-bottom: 20px;
margin-bottom: 1.5rem; color: #191F28;
color: #343a40;
} }
/* Date Carousel */ /* Date Selector */
.date-carousel { .date-selector {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 1rem;
}
.carousel-arrow {
background: none;
border: none;
font-size: 2rem;
color: #868e96;
cursor: pointer;
padding: 0 1rem;
}
.date-options-container {
display: flex;
gap: 10px; gap: 10px;
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
.date-options-container::-webkit-scrollbar {
display: none;
} }
.date-option { .date-option {
text-align: center;
cursor: pointer; cursor: pointer;
padding: 10px; padding: 12px 16px;
border-radius: 50%; border-radius: 8px;
width: 60px; text-align: center;
height: 60px; border: 2px solid transparent;
display: flex; background-color: #F2F4F6;
flex-direction: column; transition: all 0.2s ease-in-out;
justify-content: center; flex-grow: 1;
align-items: center;
transition: background-color 0.3s, color 0.3s;
} }
.date-option .day-of-week { .date-option:hover {
font-size: 0.8rem; background-color: #E5E8EB;
margin-bottom: 4px;
}
.date-option .day-circle {
font-weight: 600;
} }
.date-option.active { .date-option.active {
background-color: #0064FF; background-color: #3182F6;
color: white; color: #ffffff;
border-color: #3182F6;
font-weight: 600;
} }
.date-option:not(.active):hover { .date-option .day-of-week {
background-color: #f1f3f5; font-size: 14px;
margin-bottom: 4px;
} }
.date-option.disabled { .date-option .day {
color: #ced4da; font-size: 18px;
cursor: not-allowed; font-weight: 700;
} }
.today-button { /* Theme List */
background-color: #f8f9fa; .theme-list {
border: 1px solid #dee2e6; display: grid;
border-radius: 20px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
padding: 0.5rem 1rem; gap: 16px;
}
.theme-card {
cursor: pointer; cursor: pointer;
margin-left: 1rem; border-radius: 12px;
font-weight: 500; overflow: hidden;
} border: 2px solid #E5E8EB;
transition: all 0.2s ease-in-out;
/* --- 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; background-color: #fff;
font-size: 1rem;
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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem;
} }
.theme-schedule-group { .theme-card:hover {
background-color: #fff; transform: translateY(-4px);
border: 1px solid #e9ecef; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-radius: 8px;
padding: 1.5rem;
} }
.theme-header { .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; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; flex-grow: 1;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #f1f3f5;
} }
.theme-header h4 { .theme-info h4 {
margin: 0; font-size: 16px;
font-size: 1.25rem;
font-weight: 600; 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 { .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%; width: 100%;
max-width: 400px; padding: 8px;
padding: 1rem; font-size: 14px;
font-size: 1.2rem; font-weight: 600;
font-weight: 700;
color: #fff;
background-color: #0064FF;
border: none; border: none;
background-color: #F2F4F6;
color: #4E5968;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.next-step-button:hover:not(:disabled) { .theme-detail-button:hover {
background-color: #0053d1; 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 { .next-step-button:disabled {
background-color: #a0a0a0; background-color: #B0B8C1;
cursor: not-allowed; cursor: not-allowed;
} }
.next-step-button:hover:not(:disabled) {
background-color: #1B64DA;
}
/* --- Modal Styles --- */ /* Modal Styles */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@ -294,159 +256,94 @@
} }
.modal-content { .modal-content {
background-color: #ffffff !important; background-color: #ffffff;
padding: 2rem !important; padding: 32px;
border-radius: 12px !important; border-radius: 16px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important; width: 90%;
width: 90% !important; max-width: 500px;
max-width: 500px !important; position: relative;
position: relative !important; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
max-height: 90vh !important;
overflow-y: auto !important;
} }
.modal-close-button { .modal-close-button {
position: absolute; position: absolute;
top: 1rem; top: 16px;
right: 1rem; right: 16px;
background: none; background: none;
border: none; border: none;
font-size: 1.5rem; font-size: 24px;
color: #868e96;
cursor: pointer; cursor: pointer;
color: #8A94A2;
} }
.modal-theme-thumbnail { .modal-theme-thumbnail {
width: 100%; width: 100%;
height: 200px; height: 200px;
object-fit: cover; object-fit: cover;
border-radius: 8px; border-radius: 12px;
margin-bottom: 1.5rem; margin-bottom: 24px;
} }
.modal-content h2 { .modal-content h2 {
margin-top: 0; font-size: 24px;
margin-bottom: 2rem; font-weight: 700;
text-align: center; margin-bottom: 24px;
color: #191F28;
} }
.modal-section { .modal-section {
margin-bottom: 1.5rem; margin-bottom: 20px;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f1f3f5;
}
.modal-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
} }
.modal-section h3 { .modal-section h3 {
margin-top: 0; font-size: 18px;
margin-bottom: 1rem; font-weight: 600;
font-size: 1.1rem; margin-bottom: 12px;
color: #495057; border-bottom: 1px solid #E5E8EB;
padding-bottom: 8px;
} }
.modal-section p { .modal-section p {
margin: 0.5rem 0; font-size: 16px;
color: #495057;
line-height: 1.6; line-height: 1.6;
margin-bottom: 8px;
color: #4E5968;
}
.modal-section p strong {
color: #333D4B;
margin-right: 8px;
} }
.modal-actions { .modal-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 1rem; gap: 12px;
margin-top: 2rem; margin-top: 30px;
} }
.modal-actions .cancel-button, .modal-actions button {
.modal-actions .confirm-button { padding: 12px 24px;
padding: 0.75rem 1.5rem; font-size: 16px;
border-radius: 8px;
border: none;
font-size: 1rem;
font-weight: 600; font-weight: 600;
border-radius: 8px;
cursor: pointer; cursor: pointer;
} border: none;
.modal-actions .cancel-button {
background-color: #f1f3f5;
color: #495057;
}
.modal-actions .confirm-button {
background-color: #0064FF;
color: #fff;
}
/* --- Form Styles for ReservationFormPage --- */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #495057;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
}
/* Success Page */
.success-icon {
font-size: 4rem;
color: #0064FF;
text-align: center;
margin-bottom: 1.5rem;
}
.success-page-actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2.5rem;
}
.success-page-actions .action-button {
padding: 0.8rem 1.6rem;
border-radius: 8px;
text-decoration: none;
font-size: 1rem;
font-weight: 600;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.success-page-actions .action-button.secondary { .modal-actions .cancel-button {
background-color: #f1f3f5; background-color: #E5E8EB;
color: #495057; color: #4E5968;
}
.modal-actions .cancel-button:hover {
background-color: #D1D6DB;
} }
.success-page-actions .action-button:not(.secondary) { .modal-actions .confirm-button {
background-color: #0064FF; background-color: #3182F6;
color: #fff; color: #ffffff;
} }
.modal-actions .confirm-button:hover {
/* Added for modal info alignment */ background-color: #1B64DA;
.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

@ -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 { .signup-form-v2 .btn-primary:hover {
background-color: #1B64DA; 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

@ -60,20 +60,3 @@
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
/* =================================== */
/* Button Group */
/* =================================== */
.button-group {
display: flex;
justify-content: flex-end; /* Aligns buttons to the right by default */
gap: 0.75rem; /* 12px */
}
.button-group.full-width {
justify-content: stretch;
}
.button-group.full-width .btn {
flex-grow: 1;
}

View File

@ -81,15 +81,11 @@ a {
margin-top: 72px; margin-top: 72px;
} }
.form-select {
margin: 10px 0px !important;
}
.button-group { .button-group {
margin-top: 32px; margin-top: 32px;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: center !important; justify-content: center;
gap: 16px; gap: 16px;
} }

View File

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

View File

@ -1,79 +1,31 @@
import '@_css/home-page-v2.css'; import React, { useEffect, useState } from 'react';
import React, {useEffect, useState} from 'react'; import { mostReservedThemes } from '@_api/theme/themeAPI';
import {useNavigate} from 'react-router-dom';
import {fetchMostReservedThemes} from '@_api/theme/themeAPI';
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
const HomePage: React.FC = () => { const HomePage: React.FC = () => {
const [ranking, setRanking] = useState<ThemeInfoResponse[]>([]); const [ranking, setRanking] = useState<any[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { await mostReservedThemes(10).then(response => setRanking(response.themes))
const themeFetchCount = 10;
const response = await fetchMostReservedThemes(themeFetchCount);
setRanking(response.themes.map(mapThemeResponse));
} catch (err) {
console.error('Error fetching ranking:', err);
}
}; };
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 ( return (
<div className="home-container-v2"> <div className="content-container">
<h2 className="page-title"> </h2> <h2 className="content-container-title"> </h2>
<div className="theme-ranking-list-v2"> <ul className="list-unstyled" id="theme-ranking">
{ranking.map(theme => ( {ranking.map(theme => (
<div key={theme.id} className="theme-ranking-item-v2" onClick={() => handleThemeClick(theme)}> <li key={theme.id} className="d-flex my-4">
<img className="thumbnail" src={theme.thumbnailUrl} alt={theme.name} /> <img className="me-3 img-thumbnail" src={theme.thumbnail} alt={theme.name} style={{ width: '150px' }} />
<div className="theme-info"> <div className="media-body">
<h5 className="theme-name">{theme.name}</h5> <h5 className="mt-0 mb-1">{theme.name}</h5>
{theme.description}
</div> </div>
</div> </li>
))} ))}
</div> </ul>
{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>
)}
</div> </div>
); );
}; };

View File

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

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 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 MyReservationPage: React.FC = () => {
const [reservations, setReservations] = useState<ReservationOverviewResponse[]>([]); const [reservations, setReservations] = useState<MyReservationRetrieveResponse[]>([]);
const [isLoading, setIsLoading] = useState(true); const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [selectedReservation, setSelectedReservation] = useState<ReservationDetail | null>(null); const handleError = (err: any) => {
const [isModalOpen, setIsModalOpen] = useState(false); if (isLoginRequiredError(err)) {
const [isDetailLoading, setIsDetailLoading] = useState(false); alert('로그인이 필요해요.');
const [detailError, setDetailError] = useState<string | null>(null); navigate('/login', { state: { from: location } });
} else {
const [modalView, setModalView] = useState<'detail' | 'cancel'>('detail'); const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
const [isCancelling, setIsCancelling] = useState(false); alert(message);
console.error(err);
const loadReservations = async () => {
try {
setIsLoading(true);
const data = await fetchAllOverviewByUser();
setReservations(data.reservations);
setError(null);
} catch (err) {
setError('예약 목록을 불러오는 데 실패했습니다.');
} finally {
setIsLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
loadReservations(); fetchMyReservations()
.then(res => setReservations(res.reservations))
.catch(handleError);
}, []); }, []);
const handleShowDetail = async (overview: ReservationOverviewResponse) => { const _cancelWaiting = (id: string) => {
try { cancelWaiting(id)
setIsDetailLoading(true); .then(() => {
setDetailError(null); alert('예약 대기가 취소되었습니다.');
setModalView('detail'); setReservations(reservations.filter(r => r.id.toString() !== id));
const detailData = await fetchDetailById(overview.id); })
setSelectedReservation({ .catch(handleError);
overview: overview,
reserver: detailData.reserver,
user: detailData.user,
applicationDateTime: detailData.applicationDateTime,
payment: detailData.payment
});
setIsModalOpen(true);
} catch (err) {
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
} finally {
setIsDetailLoading(false);
}
}; };
const handleCloseModal = () => { const getStatusText = (status: ReservationStatus, rank: number) => {
setIsModalOpen(false); if (status === ReservationStatus.CONFIRMED) {
setSelectedReservation(null); return '예약';
};
const handleCancelSubmit = async (reason: string) => {
if (!selectedReservation) return;
if (!window.confirm('정말 취소하시겠어요?')) {
return;
} }
if (status === ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) {
try { return '예약 - 결제 필요';
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.WAITING) {
return `${rank}번째 예약 대기`;
}
return '';
}; };
console.log("reservations=", reservations);
return ( return (
<div className="my-reservation-container-v2"> <div className="content-container">
<h1> </h1> <h2 className="content-container-title"> </h2>
<div className="table-container"></div>
{isLoading && <p> ...</p>} <table className="table">
{error && <p className="error-message-v2">{error}</p>} <thead>
<tr>
{!isLoading && !error && ( <th></th>
<div className="reservation-list-v2"> <th></th>
{reservations.map((res) => { <th></th>
const status = getReservationStatus(res); <th></th>
return ( <th> </th>
<div key={res.id} className={`reservation-summary-card-v2 ${status.className}`}> <th>paymentKey</th>
<div className="card-status-badge">{status.text}</div> <th></th>
<div className="summary-details-v2"> <th></th>
<div><h3 className="summary-theme-name-v2">{res.themeName}</h3></div> </tr>
<div className="summary-subdetails-v2"> </thead>
<p className="summary-store-name-v2">{res.storeName}</p> <tbody>
<p className="summary-datetime-v2">{formatDate(res.date)} {formatTime(res.startFrom)} ~ {formatTime(res.endAt)}</p> {reservations.map(r => (
</div> <tr key={r.id}>
</div> <td>{r.themeName}</td>
<button <td>{r.date}</td>
onClick={() => handleShowDetail(res)} <td>{r.time}</td>
disabled={isDetailLoading} <td>{getStatusText(r.status, r.rank)}</td>
className="detail-button-v2" <td>
> {r.status === ReservationStatus.WAITING &&
{isDetailLoading && selectedReservation?.overview.id === res.id ? '로딩중...' : '상세보기'} <button className="btn btn-danger" onClick={() => _cancelWaiting(r.id.toString())}></button>}
</button> </td>
</div> <td>{r.paymentKey}</td>
); <td>{r.amount}</td>
})} <td></td>
</div> </tr>
)} ))}
</tbody>
{isModalOpen && selectedReservation && ( </table>
<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> </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,37 +0,0 @@
import '@_css/reservation-v2-1.css';
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { formatDate } from 'src/util/DateTimeFormatter';
const ReservationSuccessPage: React.FC = () => {
const location = useLocation();
const { storeName, themeName, date, time, participantCount, totalPrice } = location.state || {};
const formattedDate = date ? formatDate(date) : '';
return (
<div className="reservation-v21-container">
<div className="success-icon"></div>
<h2 className="page-title"> !</h2>
<div className="step-section">
<h3> </h3>
<p><strong>:</strong> {storeName}</p>
<p><strong>:</strong> {themeName}</p>
<p><strong>:</strong> {formattedDate}</p>
<p><strong>:</strong> {time}</p>
<p><strong>:</strong> {participantCount}</p>
<p><strong> :</strong> {totalPrice?.toLocaleString()}</p>
</div>
<div className="success-page-actions">
<Link to="/my-reservation" className="action-button">
</Link>
<Link to="/" className="action-button secondary">
</Link>
</div>
</div>
);
};
export default ReservationSuccessPage;

View File

@ -1,245 +1,42 @@
import { import React, { useState } from 'react';
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 { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { signup } from '@_api/member/memberAPI';
const MIN_PASSWORD_LENGTH = 8; import type { SignupRequest } from '@_api/member/memberTypes';
const SignupPage: React.FC = () => { const SignupPage: React.FC = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [name, setName] = 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(); const navigate = useNavigate();
useEffect(() => { const handleSignup = async () => {
const fetchSido = async () => { const request: SignupRequest = { email, password, name };
try { await signup(request)
const response = await fetchSidoList(); .then((response) => {
setSidoList(response.sidoList); alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`);
} catch (error) { navigate('/login')
console.error('시/도 목록을 불러오는 데 실패했습니다.', error); })
} .catch(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('지역 코드를 가져오는 데 실패했습니다.');
console.error(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 ( return (
<div className="signup-container-v2"> <div className="content-container" style={{ width: '400px' }}>
<h2 className="page-title"></h2> <h2 className="content-container-title">Signup</h2>
<form className="signup-form-v2" onSubmit={handleSignup}> <div className="form-group">
<div className="form-group"> <label>Email address</label>
<label className="form-label"></label> <input type="email" className="form-control" placeholder="Enter email" value={email} onChange={e => setEmail(e.target.value)} />
<input </div>
type="email" <div className="form-group">
className="form-input" <label>Password</label>
placeholder="이메일을 입력하세요" <input type="password" className="form-control" placeholder="Enter password" value={password} onChange={e => setPassword(e.target.value)} />
value={email} </div>
onChange={e => setEmail(e.target.value)} <div className="form-group">
required <label>Name</label>
/> <input type="text" className="form-control" placeholder="Enter name" value={name} onChange={e => setName(e.target.value)} />
{hasSubmitted && errors.email && ( </div>
<p className="error-text">{errors.email}</p> <button className="btn btn-custom" onClick={handleSignup}>Register</button>
)}
</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> </div>
); );
}; };

View File

@ -1,6 +1,4 @@
import { useAdminAuth } from '@_context/AdminAuthContext'; import React, { type ReactNode } from 'react';
import React, { type ReactNode, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import AdminNavbar from './AdminNavbar'; import AdminNavbar from './AdminNavbar';
interface AdminLayoutProps { interface AdminLayoutProps {
@ -8,23 +6,6 @@ interface AdminLayoutProps {
} }
const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => { 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 ( return (
<> <>
<AdminNavbar /> <AdminNavbar />

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