Compare commits

..

37 Commits

Author SHA1 Message Date
b0b0eb6fcf refactor: PaymentClient를 사용하는 Request 객체 도입 및 Writer에서의 Client 로직 제거 2025-08-18 16:58:00 +09:00
9aaa988f76 refactor: Tosspay DTO 필드 타입 추가 및 엔티티 변환 확장함수 추가 2025-08-18 16:40:13 +09:00
86a8451bc3 chore: bootjar 생성시 plain jar 생성 방지 코드 추가 2025-08-18 15:57:23 +09:00
110d91a31e feat: 새로운 회원의 예약 조회 API 구현 2025-08-18 15:56:58 +09:00
ba324464e9 feat: 변경된 회원의 예약 조회 기능 추가 2025-08-18 15:56:22 +09:00
fb3be3bcaf refactor: 새로운 결제, 결제 상세, 취소된 결제 정보를 조회하는 PaymentFinderV2 추가 2025-08-18 15:55:52 +09:00
6bbb35e55f refactor: CanceledPaymentEntity에 스키마 변경사항 적용 2025-08-18 15:54:58 +09:00
1f3f57ba1c refactor: 결제 취소 시 취소시간을 애플리케이션에서 지정하도록 수정 2025-08-18 15:54:42 +09:00
b38a5242ce refactor: PaymentTypes내 private 제거 및 변수명 통일 2025-08-18 15:53:30 +09:00
0cb7f3e234 fix: 스키마 오류 수정 2025-08-18 15:46:47 +09:00
477415b3ba feat: LocalDateTime / OffsetDateTime을 ISO 8601 형식으로 직렬화하는 ObjectMapper 모듈 추가 2025-08-18 15:40:49 +09:00
2e2b71743f feat: 새로운 회원의 예약 조회 페이지 적용 2025-08-18 15:35:32 +09:00
fb459c4c5b refactor: INFO 로그레벨에서는 컨트롤러 응답 바디를 기록하지 않도록 수정 2025-08-18 15:34:39 +09:00
817dc9f761 feat: 새로운 결제 및 나의 예약 조회 페이지 추가 2025-08-17 21:23:37 +09:00
4c82ad80c0 feat: 새로운 스펙에 맞춘 결제 및 취소 로직 구현 2025-08-17 21:22:58 +09:00
4d98b18016 feat: 새로운 예약-결제 API 및 요청/응답 스펙 정의 2025-08-17 21:20:06 +09:00
5fdb69ac70 feat: 트랜잭션 분리를 위한 별도 유틸 클래스 생성 2025-08-17 21:18:42 +09:00
d19973978f refactor: 엔티티에서의 수정사항 스키마 반영 2025-08-17 21:18:26 +09:00
0fc537da93 feat: TosspaymentClient 내 성공 로깅 추가 2025-08-17 21:17:22 +09:00
515853c20b feat: PaymentErrorCode 내 '미지원 결제 수단' 코드 정의 2025-08-17 21:16:32 +09:00
349e0372f6 refactor: PaymentEntityV2에 orderId 필드 추가 및 취소 상태 변경 메서드 추가 2025-08-17 21:16:10 +09:00
4108dcd01a rename: PaymentDeatil 내 '공급가액' 필드명 수정(->suppliedAmount) 2025-08-17 21:15:41 +09:00
79527cb708 rename: CanceledPaymentV2 -> CanceledPaymentEntityV2 클래스명 수정 2025-08-17 21:15:17 +09:00
0fc19530d1 refactor: 개별 \@JsonIgnoreProperties 제거 및 ObjectMapper 에서 전역 설정 2025-08-15 18:50:40 +09:00
112836c51c fix: PK 타입 전환에 의해 실패하는 테스트 수정 2025-08-15 18:43:48 +09:00
63d4f93d31 feat: 결제 상세 정보를 저장하는 PaymentDetailEntity 정의 2025-08-15 18:43:28 +09:00
c2906ee430 feat: 새로운 스키마를 사용하는 PaymentEntity & CanceledPaymentEntity 정의 2025-08-15 18:43:21 +09:00
0dd50e2d99 feat: 새로운 DTO 스펙에 맞춘 TosspaymentClient 추가 2025-08-15 18:41:50 +09:00
457cc0947f feat: Tosspay 요청 / 응답 DTO 재정의 2025-08-15 18:41:15 +09:00
6095813891 feat: PaymentErrorCode에 새로운 코드 정의 2025-08-15 18:40:58 +09:00
e4611c76b3 feat: 페이먼츠 API에서 제공하는 타입 정보를 담은 Enum 2025-08-15 18:37:03 +09:00
af81260355 feat: 프론트엔드와의 PK 호환을 위한 Long <-> String ObjectMapper 모듈 추가 2025-08-15 18:23:59 +09:00
d0f6e0fe0c feat: Auditing 없이 Persistable만 정의된 BaseEntity 추가 2025-08-15 18:22:08 +09:00
ed0b81ff45 feat: 결제 관련 새로운 스키마 정의 2025-08-15 18:21:24 +09:00
6cc7eb680c fix: 테스트에서의 충돌 해결을 위해 인덱스 생성 쿼리에 IF NOT EXISTS 추가 2025-08-15 18:02:57 +09:00
56ada7730c fix: 예약 가능 시간 조회 로직 오류 수정 및 테스트 케이스 추가 2025-08-15 17:49:09 +09:00
b05c61a65a refactor: 64자리 PK 도입에 따른 프론트엔드에서의 타입 수정(number -> string) 2025-08-15 17:19:37 +09:00
521 changed files with 17117 additions and 24673 deletions

4
.gitignore vendored
View File

@ -37,7 +37,3 @@ out/
.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,52 +1,91 @@
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
targetCompatibility = JavaVersion.VERSION_17
}
extensions.configure<KaptExtension> {
keepJavacAnnotationProcessors = true keepJavacAnnotationProcessors = true
} }
dependencies { repositories {
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3") mavenCentral()
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1") }
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
}
tasks.withType<Test> { dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
// DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
}
tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
compilerOptions { compilerOptions {
freeCompilerArgs.addAll( freeCompilerArgs.addAll(
"-Xjsr305=strict", "-Xjsr305=strict",
@ -54,5 +93,4 @@ subprojects {
) )
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
} }
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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,22 +1,34 @@
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 AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminThemePage from './pages/admin/ThemePage';
import AdminThemePage from './pages/admin/AdminThemePage'; import AdminWaitingPage from './pages/admin/WaitingPage';
import HomePage from '@_pages/HomePage'; import { AuthProvider } from './context/AuthContext';
import LoginPage from '@_pages/LoginPage'; import AdminRoute from './components/AdminRoute';
import MyReservationPage from '@_pages/MyReservationPage'; import ReservationStep1Page from './pages/v2/ReservationStep1Page';
import ReservationFormPage from '@_pages/ReservationFormPage'; import ReservationStep2Page from './pages/v2/ReservationStep2Page';
import ReservationStep1Page from '@_pages/ReservationStep1Page'; import ReservationSuccessPage from './pages/v2/ReservationSuccessPage';
import ReservationStep2Page from '@_pages/ReservationStep2Page'; import MyReservationPageV2 from './pages/v2/MyReservationPageV2';
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
import SignupPage from '@_pages/SignupPage'; const AdminRoutes = () => (
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/reservation" element={<AdminReservationPage />} />
<Route path="/time" element={<AdminTimePage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/waiting" element={<AdminWaitingPage />} />
</Routes>
</AdminLayout>
);
function App() { function App() {
return ( return (
@ -24,34 +36,24 @@ function App() {
<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>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/store" element={<AdminStorePage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>
} />
</Routes>
</AdminAuthProvider>
} /> } />
<Route path="/*" element={ <Route path="/*" element={
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<HomePage/>} /> <Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} /> <Route path="/signup" element={<SignupPage />} />
<Route path="/reservation" element={<ReservationStep1Page />} /> <Route path="/reservation" element={<ReservationPage />} />
<Route path="/reservation/form" element={<ReservationFormPage />} />
<Route path="/reservation/payment" element={<ReservationStep2Page />} />
<Route path="/reservation/success" element={<ReservationSuccessPage />} />
<Route path="/my-reservation" element={<MyReservationPage />} /> <Route path="/my-reservation" element={<MyReservationPage />} />
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
{/* V2 Reservation Flow */}
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
</Routes> </Routes>
</Layout> </Layout>
} /> } />

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,15 +28,15 @@ async function request<T>(
}, },
}; };
const accessTokenKey = type === PrincipalType.ADMIN ? 'adminAccessToken' : 'accessToken'; if (isRequiredAuth) {
const accessToken = localStorage.getItem(accessTokenKey); const accessToken = localStorage.getItem('accessToken');
if (accessToken) { if (accessToken) {
if (!config.headers) { if (!config.headers) {
config.headers = {}; config.headers = {};
} }
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,
reserverName: string,
reserverContact: string,
participantCount: number,
requirement: string
}
export interface PendingReservationCreateResponse {
id: string
}
export interface ReservationOverviewResponse {
id: string; id: string;
storeName: string;
themeName: string; themeName: string;
date: string; date: string;
startFrom: string; time: string;
endAt: string; status: ReservationStatus;
rank: number;
paymentKey: string | null;
amount: number | null;
}
export interface MyReservationRetrieveListResponse {
reservations: MyReservationRetrieveResponse[];
}
export interface ReservationRetrieveResponse {
id: string;
date: string;
member: MemberRetrieveResponse;
time: TimeRetrieveResponse;
theme: ThemeRetrieveResponse;
status: ReservationStatus; status: ReservationStatus;
} }
export interface ReservationOverviewListResponse { export interface ReservationRetrieveListResponse {
reservations: ReservationOverviewResponse[]; reservations: ReservationRetrieveResponse[];
} }
export interface ReserverInfo { export interface AdminReservationCreateRequest {
name: string; date: string;
contact: string; timeId: string;
participantCount: number; themeId: string;
requirement: string; memberId: string;
} }
export interface ReservationDetailRetrieveResponse { 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;
reserver: ReserverInfo; themeName: string;
user: UserContactRetrieveResponse; date: string;
applicationDateTime: string; startAt: string;
payment: PaymentRetrieveResponse; status: string; // 'CONFIRMED', 'CANCELED_BY_USER', etc.
} }
export interface ReservationDetail { export interface ReservationSummaryListV2 {
overview: ReservationOverviewResponse; reservations: ReservationSummaryV2[];
reserver: ReserverInfo;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment?: PaymentRetrieveResponse;
} }
export interface MostReservedThemeIdListResponse { export interface ReservationDetailV2 {
themeIds: string[]; id: string;
user: UserDetailV2;
themeName: string;
date: string;
startAt: string;
applicationDateTime: string;
payment: PaymentV2;
cancellation: CancellationV2 | null;
}
export interface UserDetailV2 {
id: string;
name: string;
email: string;
}
export interface PaymentV2 {
orderId: string;
totalAmount: number;
method: string;
status: 'DONE' | 'CANCELED';
requestedAt: string;
approvedAt: string;
detail: CardPaymentDetailV2 | BankTransferPaymentDetailV2 | EasyPayPrepaidPaymentDetailV2;
}
export interface CardPaymentDetailV2 {
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,18 @@
import apiClient from '@_api/apiClient'; import apiClient from "@_api/apiClient";
import type { import type { ThemeCreateRequest, ThemeCreateResponse, ThemeRetrieveListResponse } from "./themeTypes";
AdminThemeDetailResponse,
AdminThemeSummaryListResponse,
SimpleActiveThemeListResponse,
ThemeCreateRequest,
ThemeCreateResponse,
ThemeIdListResponse,
ThemeInfoListResponse,
ThemeInfoResponse,
ThemeUpdateRequest
} 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 updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => { export const delTheme = async (id: string): Promise<void> => {
await apiClient.adminPatch<any>(`/admin/themes/${id}`, themeData); return await apiClient.del(`/themes/${id}`, true);
};
export const deleteTheme = async (id: string): Promise<void> => {
await apiClient.adminDel<any>(`/admin/themes/${id}`);
};
export const fetchActiveThemes = async (): Promise<SimpleActiveThemeListResponse> => {
return await apiClient.adminGet<SimpleActiveThemeListResponse>('/admin/themes/active');
};
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,105 +1,23 @@
import type { AuditInfo } from '@_api/common/commonTypes';
export interface AdminThemeDetailResponse {
theme: ThemeInfoResponse;
isActive: boolean;
audit: AuditInfo
}
export interface ThemeCreateRequest { export interface ThemeCreateRequest {
name: string; name: string;
description: string; description: string;
thumbnailUrl: string; thumbnail: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
isActive: boolean;
} }
export interface ThemeCreateResponse { export interface ThemeCreateResponse {
id: string; id: string;
}
export interface ThemeUpdateRequest {
name?: string;
description?: string;
thumbnailUrl?: string;
difficulty?: Difficulty;
price?: number;
minParticipants?: number;
maxParticipants?: number;
availableMinutes?: number;
expectedMinutesFrom?: number;
expectedMinutesTo?: number;
isActive?: boolean;
}
export interface AdminThemeSummaryResponse {
id: string;
name: string; name: string;
difficulty: Difficulty;
price: number;
isActive: boolean;
}
export interface AdminThemeSummaryListResponse {
themes: AdminThemeSummaryResponse[];
}
export interface ThemeInfoResponse {
id: string;
name: string;
thumbnailUrl: string;
description: string; description: string;
difficulty: Difficulty; thumbnail: string;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
} }
export interface ThemeInfoListResponse { export interface ThemeRetrieveResponse {
themes: ThemeInfoResponse[];
}
export interface ThemeIdListResponse {
themeIds: string[];
}
export enum Difficulty {
VERY_EASY = 'VERY_EASY',
EASY = 'EASY',
NORMAL = 'NORMAL',
HARD = 'HARD',
VERY_HARD = 'VERY_HARD',
}
export const DifficultyKoreanMap: Record<Difficulty, string> = {
[Difficulty.VERY_EASY]: '매우 쉬움',
[Difficulty.EASY]: '쉬움',
[Difficulty.NORMAL]: '보통',
[Difficulty.HARD]: '어려움',
[Difficulty.VERY_HARD]: '매우 어려움',
};
export function mapThemeResponse(res: any): ThemeInfoResponse {
return {
...res,
difficulty: Difficulty[res.difficulty as keyof typeof Difficulty],
}
}
export interface SimpleActiveThemeResponse {
id: string; id: string;
name: string; name: string;
description: string;
thumbnail: string;
} }
export interface SimpleActiveThemeListResponse { export interface ThemeRetrieveListResponse {
themes: SimpleActiveThemeResponse[]; themes: ThemeRetrieveResponse[];
} }

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,7 +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';
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
const { loggedIn, userName, logout } = useAuth(); const { loggedIn, userName, logout } = useAuth();
@ -15,31 +14,39 @@ const Navbar: React.FC = () => {
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
} }
}; }
return ( return (
<nav className="navbar-container"> <nav className="navbar navbar-expand-lg navbar-light bg-light">
<div className="nav-links"> <Link className="navbar-brand" to="/">
<Link className="nav-link" to="/"></Link> <img src="/image/service-logo.png" alt="LOGO" style={{ height: '40px' }} />
<Link className="nav-link" to="/reservation"></Link> </Link>
</div> <button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<div className="nav-actions"> <span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<Link className="nav-link" to="/v2/reservation">Reservation</Link>
</li>
{!loggedIn ? ( {!loggedIn ? (
<> <li className="nav-item">
<button className="btn btn-secondary" onClick={() => navigate('/login')}></button> <Link className="nav-link" to="/login">Login</Link>
<button className="btn btn-primary" onClick={() => navigate('/signup')}></button> </li>
</>
) : ( ) : (
<div className="profile-info"> <li className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<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 id="profile-name">{userName}</span>
<div className="dropdown-menu"> </a>
<Link className="dropdown-item" to="/my-reservation"> </Link> <ul className="dropdown-menu" aria-labelledby="navbarDropdown">
<div className="dropdown-divider" /> <li><Link className="dropdown-item" to="/my-reservation/v2">My Reservation</Link></li>
<a className="dropdown-item" href="#" onClick={handleLogout}></a> <li><hr className="dropdown-divider" /></li>
</div> <li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
</div> </ul>
</li>
)} )}
</ul>
</div> </div>
</nav> </nav>
); );

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
useEffect(() => { const checkLogin = async () => {
try { try {
const token = localStorage.getItem('accessToken'); const response = await apiCheckLogin();
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 response = await apiLogin(data);
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('userName', response.name);
setLoggedIn(true); setLoggedIn(true);
setUserName(response.name); 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(() => {
checkLogin();
}, []);
const login = async (data: LoginRequest) => {
const response = await apiLogin(data);
await checkLogin();
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,17 +0,0 @@
/* /src/css/admin-page.css */
.admin-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-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}

View File

@ -1,160 +0,0 @@
/* /src/css/admin-reservation-page.css */
.admin-reservation-container {
max-width: 1400px;
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-reservation-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.admin-reservation-content {
display: flex;
gap: 40px;
}
.reservations-main {
flex-grow: 1;
}
.filter-section {
width: 300px;
flex-shrink: 0;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.section-card .card-title {
font-size: 20px;
font-weight: 600;
color: #333d4b;
margin-bottom: 20px;
}
.table-header {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.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;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #4E5968;
}
.form-input,
.form-select {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.btn-secondary:hover {
background-color: #E5E8EB;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}
.filter-section .btn-primary {
width: 100%;
margin-top: 10px;
}
.editing-row td {
padding-top: 20px;
padding-bottom: 20px;
}
.editing-row .btn {
margin-right: 8px;
}

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,207 +0,0 @@
/* /src/css/admin-store-page.css */
.admin-store-container {
max-width: 1400px;
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-store-container .page-title {
font-size: 32px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.filter-controls {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
}
.filter-controls .form-group {
flex: 1;
}
.section-card {
background-color: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-header {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.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;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.btn-secondary:hover {
background-color: #E5E8EB;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.details-row td {
padding: 0;
background-color: #f8f9fa;
}
.details-container {
padding: 1.5rem;
}
.details-form-card {
background-color: #fff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.form-row {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.form-group {
flex: 1;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #4E5968;
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.audit-info {
padding: 1.5rem;
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: #fff;
margin-bottom: 1.5rem;
}
.audit-title {
font-size: 1.1rem;
font-weight: 600;
color: #343a40;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #dee2e6;
}
.audit-body p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: #495057;
}
.audit-body p strong {
color: #212529;
margin-right: 0.5rem;
}
.add-store-form {
padding: 1.5rem;
background-color: #fdfdff;
border: 1px solid #e5e8eb;
border-radius: 8px;
margin-bottom: 2rem;
}

View File

@ -1,236 +0,0 @@
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--danger-color: #dc3545;
--light-gray-color: #f8f9fa;
--dark-gray-color: #343a40;
--border-color: #dee2e6;
--input-bg-color: #fff;
--text-color: #212529;
--label-color: #495057;
--white-color: #ffffff;
--box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
--border-radius: 8px;
}
*, *::before, *::after {
box-sizing: border-box;
}
.admin-theme-edit-container {
padding: 2rem 0;
background-color: var(--light-gray-color);
min-height: 100vh;
}
.centered-layout {
max-width: 1024px;
margin: 0 auto;
padding: 0 2rem;
}
@media (max-width: 768px) {
.centered-layout {
padding: 0 1rem;
}
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--dark-gray-color);
}
.form-card {
background-color: var(--white-color);
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
margin-bottom: 2rem;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.form-section:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.form-row {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
}
.form-group.full-width {
flex-basis: 100%;
}
.form-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--label-color);
margin-bottom: 0.5rem;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
line-height: 1.5;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--input-bg-color);
color: var(--text-color);
transition: border-color 0.2s, box-shadow 0.2s;
height: 3rem; /* 48px */
}
.form-input:disabled,
.form-textarea:disabled,
.form-select:disabled {
background-color: #e9ecef;
opacity: 1;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-textarea {
height: auto;
min-height: 120px;
resize: vertical;
}
.button-group {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 2rem;
}
.main-actions {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
text-align: center;
}
.main-actions .btn {
padding: 0.85rem 2.8rem;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background-color: var(--primary-color);
color: var(--white-color);
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: var(--secondary-color);
color: var(--white-color);
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-danger {
background-color: var(--danger-color);
color: var(--white-color);
}
.btn-danger:hover {
background-color: #c82333;
}
.audit-info {
background-color: var(--white-color);
padding: 1.5rem;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
margin-top: 2rem;
}
.audit-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--dark-gray-color);
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.audit-body p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: var(--label-color);
}
.audit-body p strong {
color: var(--dark-gray-color);
margin-right: 0.5rem;
}
.delete-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
text-align: center;
}
.btn-delete-text {
background: none;
border: none;
color: var(--danger-color);
text-decoration: underline;
cursor: pointer;
font-size: 0.9rem;
padding: 0.5rem;
}
.btn-delete-text:hover {
color: #a71d2a;
}

View File

@ -1,121 +0,0 @@
/* /src/css/admin-theme-page.css */
.admin-theme-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-theme-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-header {
display: flex;
justify-content: flex-end;
margin-bottom: 20px;
}
.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;
word-break: break-all;
}
.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;
}
.form-input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #1B64DA;
}
.btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.btn-secondary:hover {
background-color: #E5E8EB;
}
.btn-danger {
background-color: #e53e3e;
color: white;
}
.btn-danger:hover {
background-color: #c53030;
}
.editing-row td {
padding-top: 20px;
padding-bottom: 20px;
}
.editing-row .btn {
margin-right: 8px;
}

View File

@ -1,180 +0,0 @@
/* /src/css/home-page-v2.css */
.home-container-v2 {
max-width: 800px;
margin: 40px auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.home-container-v2 .page-title {
font-size: 28px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
text-align: center;
}
.theme-ranking-list-v2 {
display: flex;
flex-direction: column;
gap: 20px;
}
.theme-ranking-item-v2 {
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
cursor: pointer;
}
.theme-ranking-item-v2:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.theme-ranking-item-v2 .thumbnail {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e5e8eb;
}
.theme-ranking-item-v2 .theme-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.theme-ranking-item-v2 .theme-name {
font-size: 20px;
font-weight: 700;
color: #333d4b;
margin: 0;
}
.theme-ranking-item-v2 .theme-description {
font-size: 16px;
color: #505a67;
margin: 0;
}
/* Modal Styles */
.theme-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.theme-modal-content {
background-color: #ffffff !important;
padding: 30px !important;
border-radius: 16px !important;
width: 90% !important;
max-width: 600px !important;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2) !important;
display: flex !important;
flex-direction: column !important;
gap: 20px !important;
}
.modal-thumbnail {
width: 100%;
height: 250px;
object-fit: cover;
border-radius: 12px;
}
.modal-theme-info h2 {
font-size: 26px;
font-weight: 700;
color: #333d4b;
margin: 0 0 10px;
}
.modal-theme-info p {
font-size: 16px;
color: #505a67;
line-height: 1.6;
margin: 0 0 15px;
}
.theme-details {
background-color: #f8f9fa;
border-radius: 8px;
padding: 15px;
}
.theme-details p {
margin: 5px 0;
font-size: 15px;
color: #333d4b;
}
.theme-details strong {
color: #191919;
}
.modal-buttons {
display: flex;
gap: 15px;
margin-top: 15px;
}
.modal-button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.modal-button.reserve {
background-color: #007bff;
color: white;
}
.modal-button.reserve:hover {
background-color: #0056b3;
}
.modal-button.close {
background-color: #6c757d;
color: white;
}
.modal-button.close:hover {
background-color: #5a6268;
}
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
}

View File

@ -1,74 +0,0 @@
/* /src/css/login-page-v2.css */
.login-container-v2 {
width: 100%;
max-width: 400px;
margin: 80px auto;
padding: 40px;
background-color: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.login-container-v2 .page-title {
font-size: 28px;
font-weight: 700;
color: #191F28;
margin-bottom: 30px;
text-align: center;
}
.login-form-v2 .form-group {
margin-bottom: 20px;
}
.login-form-v2 .form-input {
width: 100%;
padding: 14px;
font-size: 16px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.login-form-v2 .form-input:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.login-form-v2 .button-group {
margin-top: 30px;
display: flex;
gap: 10px;
}
.login-form-v2 .btn {
flex-grow: 1;
padding: 14px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.login-form-v2 .btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.login-form-v2 .btn-primary:hover {
background-color: #1B64DA;
}
.login-form-v2 .btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.login-form-v2 .btn-secondary:hover {
background-color: #E5E8EB;
}

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,117 +0,0 @@
/* /src/css/navbar.css */
.navbar-container {
background-color: #ffffff;
height: 60px;
padding: 0 40px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e8eb;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.navbar-container .nav-links {
display: flex;
align-items: center;
gap: 30px;
}
.navbar-container .nav-link {
text-decoration: none;
color: #4E5968;
font-size: 16px;
font-weight: 600;
transition: color 0.2s;
}
.navbar-container .nav-link:hover {
color: #191F28;
}
.navbar-container .nav-actions {
display: flex;
align-items: center;
gap: 12px;
}
.navbar-container .btn {
padding: 8px 16px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.navbar-container .btn-primary {
background-color: #3182F6;
color: #ffffff;
}
.navbar-container .btn-primary:hover {
background-color: #1B64DA;
}
.navbar-container .btn-secondary {
background-color: #F2F4F6;
color: #4E5968;
}
.navbar-container .btn-secondary:hover {
background-color: #E5E8EB;
}
.navbar-container .profile-info {
display: flex;
align-items: center;
gap: 8px;
color: #333d4b;
font-weight: 600;
cursor: pointer;
position: relative;
height: 100%;
padding: 15px 0;
}
.navbar-container .profile-image {
width: 32px;
height: 32px;
border-radius: 50%;
}
.navbar-container .dropdown-menu {
display: none;
position: absolute;
top: 100%;
right: 0;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
min-width: 160px;
z-index: 100;
padding: 8px 0;
}
.navbar-container .profile-info:hover .dropdown-menu {
display: block;
}
.navbar-container .dropdown-item {
display: block;
padding: 10px 16px;
color: #333d4b;
text-decoration: none;
font-size: 15px;
}
.navbar-container .dropdown-item:hover {
background-color: #f4f6f8;
}
.navbar-container .dropdown-divider {
height: 1px;
margin: 8px 0;
overflow: hidden;
background-color: #e5e8eb;
}

View File

@ -1,452 +0,0 @@
/* General Container */
.reservation-v21-container {
width: 100%;
max-width: 900px;
margin: 2rem auto;
padding: 2rem;
font-family: 'Pretendard', sans-serif;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.page-title {
text-align: center;
font-size: 2rem;
font-weight: 700;
margin-bottom: 2.5rem;
color: #212529;
}
/* Step Section */
.step-section {
margin-bottom: 3rem;
padding: 1.5rem;
border: 1px solid #f1f3f5;
border-radius: 8px;
background-color: #f8f9fa;
}
.step-section.disabled {
opacity: 0.5;
pointer-events: none;
}
.step-section h3 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 0;
margin-bottom: 1.5rem;
color: #343a40;
}
/* Date Carousel */
.date-carousel {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.carousel-arrow {
background: none;
border: none;
font-size: 2rem;
color: #868e96;
cursor: pointer;
padding: 0 1rem;
}
.date-options-container {
display: flex;
gap: 10px;
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
.date-options-container::-webkit-scrollbar {
display: none;
}
.date-option {
text-align: center;
cursor: pointer;
padding: 10px;
border-radius: 50%;
width: 60px;
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transition: background-color 0.3s, color 0.3s;
}
.date-option .day-of-week {
font-size: 0.8rem;
margin-bottom: 4px;
}
.date-option .day-circle {
font-weight: 600;
}
.date-option.active {
background-color: #0064FF;
color: white;
}
.date-option:not(.active):hover {
background-color: #f1f3f5;
}
.date-option.disabled {
color: #ced4da;
cursor: not-allowed;
}
.today-button {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 20px;
padding: 0.5rem 1rem;
cursor: pointer;
margin-left: 1rem;
font-weight: 500;
}
/* --- Region & Store Selectors --- */
.region-store-selectors {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.region-store-selectors select {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fff;
font-size: 1rem;
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;
flex-direction: column;
gap: 1.5rem;
}
.theme-schedule-group {
background-color: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
}
.theme-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #f1f3f5;
}
.theme-header h4 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #343a40;
}
.theme-detail-button {
padding: 0.5rem 1rem;
font-size: 0.9rem;
background-color: transparent;
color: #0064FF;
border: 1px solid #0064FF;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s, color 0.2s;
}
.theme-detail-button:hover {
background-color: #0064FF;
color: #fff;
}
/* Time Slots */
.time-slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.time-slot {
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 6px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
}
.time-slot:hover:not(.disabled) {
border-color: #0064FF;
color: #0064FF;
}
.time-slot.active {
background-color: #0064FF;
color: white;
border-color: #0064FF;
font-weight: 600;
}
.time-slot.disabled {
background-color: #f8f9fa;
color: #adb5bd;
cursor: not-allowed;
text-decoration: line-through;
}
.time-availability {
display: block;
font-size: 0.8rem;
margin-top: 4px;
}
.no-times {
color: #868e96;
padding: 2rem;
text-align: center;
background-color: #fff;
border-radius: 8px;
}
/* --- Next Step Button --- */
.next-step-button-container {
margin-top: 2rem;
text-align: center;
}
.next-step-button {
width: 100%;
max-width: 400px;
padding: 1rem;
font-size: 1.2rem;
font-weight: 700;
color: #fff;
background-color: #0064FF;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.next-step-button:hover:not(:disabled) {
background-color: #0053d1;
}
.next-step-button:disabled {
background-color: #a0a0a0;
cursor: not-allowed;
}
/* --- 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: 12px !important;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3) !important;
width: 90% !important;
max-width: 500px !important;
position: relative !important;
max-height: 90vh !important;
overflow-y: auto !important;
}
.modal-close-button {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
color: #868e96;
cursor: pointer;
}
.modal-theme-thumbnail {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 2rem;
text-align: center;
}
.modal-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f1f3f5;
}
.modal-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.modal-section h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.1rem;
color: #495057;
}
.modal-section p {
margin: 0.5rem 0;
color: #495057;
line-height: 1.6;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
.modal-actions .cancel-button,
.modal-actions .confirm-button {
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.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;
}
.success-page-actions .action-button.secondary {
background-color: #f1f3f5;
color: #495057;
}
.success-page-actions .action-button:not(.secondary) {
background-color: #0064FF;
color: #fff;
}
/* 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

@ -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

@ -1,80 +0,0 @@
/* /src/css/signup-page-v2.css */
.signup-container-v2 {
width: 100%;
max-width: 400px;
margin: 80px auto;
padding: 40px;
background-color: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.07);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.signup-container-v2 .page-title {
font-size: 28px;
font-weight: 700;
color: #191F28;
margin-bottom: 30px;
text-align: center;
}
.signup-form-v2 .form-group {
margin-bottom: 20px;
}
.signup-form-v2 .form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #4E5968;
}
.signup-form-v2 .form-input {
width: 100%;
padding: 14px;
font-size: 16px;
border: 1px solid #E5E8EB;
border-radius: 8px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.signup-form-v2 .form-input:focus {
outline: none;
border-color: #3182F6;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.2);
}
.signup-form-v2 .btn-primary {
width: 100%;
margin-top: 10px;
padding: 14px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
background-color: #3182F6;
color: #ffffff;
transition: background-color 0.2s;
}
.signup-form-v2 .btn-primary:hover {
background-color: #1B64DA;
}
.error-text {
color: #E53E3E;
font-size: 12px;
margin-top: 4px;
}
.region-select-group {
display: flex;
gap: 10px;
}
.region-select-group select {
flex: 1;
}

View File

@ -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>
</div> {theme.description}
</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,7 @@
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 type { LoginRequest } from '@_api/auth/authTypes';
import '@_css/login-page-v2.css'; import { useAuth } from '../context/AuthContext';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -12,51 +12,35 @@ 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 }); const request: LoginRequest = { email, password };
await login(request);
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 <input type="email" className="form-control" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
type="email"
className="form-input"
placeholder="이메일"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<input <input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
type="password"
className="form-input"
placeholder="비밀번호"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div> </div>
<div className="button-group"> <div className="d-flex justify-content-between">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/signup')}></button> <button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
<button type="submit" className="btn btn-primary"></button> <button className="btn btn-custom" onClick={handleLogin}>Login</button>
</div> </div>
</form>
</div> </div>
); );
}; };

View File

@ -1,316 +1,88 @@
import { cancelPayment } from '@_api/payment/paymentAPI';
import type { PaymentRetrieveResponse } from '@_api/payment/PaymentTypes';
import { cancelReservation, fetchDetailById, fetchAllOverviewByUser } from '@_api/reservation/reservationAPI';
import {
ReservationStatus,
type ReservationDetail,
type ReservationOverviewResponse
} from '@_api/reservation/reservationTypes';
import '@_css/my-reservation-v2.css';
import { formatDate, formatDisplayDateTime, formatTime } from '@_util/DateTimeFormatter';
import React, { useEffect, useState } from 'react'; import 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;

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