Compare commits

..

35 Commits

Author SHA1 Message Date
1a3f6d1350 refactor: deploy 프로파일에서의 sql.init.mode never 지정 2025-08-10 14:35:00 +09:00
78c4b6adf8 refactor: schema에서 index 생성 코드 제거 및 region의 초기 데이터가 정의된 region-data.sql 추가 2025-08-10 14:30:09 +09:00
c9a43491b8 style: optimize imports & reformat code 2025-08-10 14:06:26 +09:00
4de21d0b63 feat: Region Schema / Entity 정의 2025-08-10 14:04:30 +09:00
cf23122e91 chore: 클래스명 수정(Query -> Find / Command -> Write) 2025-08-06 19:09:47 +09:00
2c4ee5fecd fix: theme 패키지에 잘못 들어가 있던 도메인 이동 2025-08-06 19:02:00 +09:00
881a4fcf59 refactor: Service 에서 시작 로깅은 DEBUG 레벨로 격하 2025-08-06 19:01:34 +09:00
f36504e220 test: reservation에 도입된 Finder, Writer, Validator 테스트 및 Query / Command Service 테스트 추가 2025-08-06 18:53:26 +09:00
7b059fc92f fix: 오타 수정 2025-08-06 18:52:51 +09:00
d1772dfcc5 refactor: ReservationService를 Query / Command Service로 분리 후 추가된 Finder, Writer, Validator 적용 2025-08-06 17:05:12 +09:00
7708a15aab feat: reservation 도메인에 Finder,Writer,Validator 추가 2025-08-06 17:04:00 +09:00
703004a7c7 feat: ReservationStatus enum 내 확정 예약 타입들을 반환하는 메서드 추가 2025-08-06 17:00:16 +09:00
cf61c0de43 feat: ReservationSearchSpecification 내 예약 상태를 지정하는 조건 추가 및 테스트 2025-08-06 16:58:59 +09:00
9be9b4919c feat: ReservationResponse 내 확장함수 추가 2025-08-06 16:58:18 +09:00
c396e21b44 fix: PaymentService 내 로그 오타 수정 및 메서드 순서 재배치 2025-08-06 16:53:02 +09:00
393dceb355 test: payment에서의 Finder, Writer 도입으로 인한 테스트 수정 2025-08-05 23:00:58 +09:00
b865dc01f6 feat: payment 도메인에 Finder, Writer 추가 및 서비스 로직 수정 2025-08-05 21:46:50 +09:00
52a6abef79 refactor: 예약 조회와 같은 payload를 가진 예약 성공 응답 DTO 추가 2025-08-05 21:45:21 +09:00
4cd0436c84 refactor: TimeWithAvailability 도메인 필드 접근 제한자 public 변경 2025-08-05 17:32:49 +09:00
853d1a0c4e test: 새로 추가된 ThemeFinder,Writer,Validator 테스트 추가 및 기존 테스트에 반영 2025-08-05 17:32:11 +09:00
18f3a1aebc refactor: 테스트용 테마 데이터 삽입 클래스명 수정(->TestThemeDataHelper) 및 entityManager 클래스 주입 2025-08-05 17:31:43 +09:00
8a56dc7841 feat: theme 도메인에 Finder, Writer, Validator 추가 및 서비스 로직 수정 2025-08-05 17:30:56 +09:00
c5664e8aef test: \@DataJpaTest 기반 MemberRepositoryTest 추가 2025-08-04 21:05:02 +09:00
4b568915ae chore: given-when-then 주석 제거 2025-08-04 20:28:21 +09:00
e1c3943b85 feat: MemberServiceTest 추가 및 TimeServiceTest 일부 보완 2025-08-04 20:26:53 +09:00
ed82426a6f remove: MemberService 내 미사용 메서드 제거 2025-08-04 20:26:38 +09:00
866c9a368f refactor: WebMvcTest에서 Service를 \@SpykBean -> \@MockkBean 으로 수정 2025-08-04 20:16:36 +09:00
ea047d38bb feat: 새로 추가된 TimeFinder,Writer,Validator 테스트 추가 및 기존 테스트 수정 2025-08-04 19:52:15 +09:00
6d56f035c3 feat: time 도메인 Finder, Writer, Validator 도입 및 Service 코드 수정 2025-08-04 18:34:48 +09:00
6cac32ffb2 feat: MemberValidator, Writer에 debug 로그 추가 2025-08-04 18:29:47 +09:00
51671f2803 feat: e92878a8에 누락된 코드 추가 2025-08-04 17:34:54 +09:00
2a9484e5dd refactor: 기존 테스트에 새로 추가된 MemberFinder, MemberWriter 반영 2025-08-04 17:26:52 +09:00
35b7f06c2d refactor: auth 도메인에 새로 추가된 MemberFinder, MemberWriter 반영 2025-08-04 17:26:41 +09:00
76c5524c90 feat: JwtHandler에 debug 로그 추가 2025-08-04 17:25:06 +09:00
e92878a84a feat: member 도메인에 Finder, Writer, Validator 추가 및 서비스 로직 수정 2025-08-04 16:52:59 +09:00
499 changed files with 14487 additions and 25292 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,58 +1,92 @@
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 { kapt {
apply(plugin = "org.jetbrains.kotlin.jvm") keepJavacAnnotationProcessors = true
apply(plugin = "org.jetbrains.kotlin.kapt") }
apply(plugin = "io.spring.dependency-management")
extensions.configure<JavaPluginExtension> { repositories {
sourceCompatibility = JavaVersion.VERSION_17 mavenCentral()
targetCompatibility = JavaVersion.VERSION_17 }
}
extensions.configure<KaptExtension> { dependencies {
keepJavacAnnotationProcessors = true // 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")
dependencies { // API docs
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
}
tasks.withType<Test> { // DB
useJUnitPlatform() implementation("com.github.f4b6a3:tsid-creator:5.2.6")
} runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
tasks.withType<KotlinCompile> { // Jwt
compilerOptions { implementation("io.jsonwebtoken:jjwt:0.12.6")
freeCompilerArgs.addAll(
"-Xjsr305=strict", // Logging
"-Xannotation-default-target=param-property" implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
) implementation("net.logstash.logback:logstash-logback-encoder:8.1")
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) implementation("com.github.loki4j:loki-logback-appender:2.0.0")
} implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.14.4")
testImplementation("com.ninja-squad:springmockk:4.0.2")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// RestAssured
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.withType<KotlinCompile> {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-Xannotation-default-target=param-property"
)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +0,0 @@
package com.sangdol.common.persistence
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.TransactionTemplate
class TransactionExecutionUtil(
private val transactionManager: PlatformTransactionManager
) {
fun <T> withNewTransaction(isReadOnly: Boolean, action: () -> T?): T? {
val transactionTemplate = TransactionTemplate(transactionManager).apply {
this.isReadOnly = isReadOnly
this.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
return transactionTemplate.execute { action() }
}
}

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

@ -33,6 +33,10 @@
} }
} }
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,64 +1,56 @@
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 ReservationFormPage from '@_pages/ReservationFormPage'; const AdminRoutes = () => (
import ReservationStep1Page from '@_pages/ReservationStep1Page'; <AdminLayout>
import ReservationStep2Page from '@_pages/ReservationStep2Page'; <Routes>
import ReservationSuccessPage from '@_pages/ReservationSuccessPage'; <Route path="/" element={<AdminPage />} />
import SignupPage from '@_pages/SignupPage'; <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 (
<AuthProvider> <AuthProvider>
<Router> <Router>
<Routes> <Routes>
<Route path="/admin/*" element={ <Route path="/admin/*" element={
<AdminAuthProvider> <AdminRoute>
<Routes> <AdminRoutes />
<Route path="/login" element={<AdminLoginPage />} /> </AdminRoute>
<Route path="/*" element={ } />
<AdminLayout> <Route path="/*" element={
<Routes> <Layout>
<Route path="/" element={<AdminPage />} /> <Routes>
<Route path="/theme" element={<AdminThemePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/store" element={<AdminStorePage />} /> <Route path="/signup" element={<SignupPage />} />
<Route path="/schedule" element={<AdminSchedulePage />} /> <Route path="/reservation" element={<ReservationPage />} />
</Routes> <Route path="/reservation-mine" element={<MyReservationPage />} />
</AdminLayout> </Routes>
} /> </Layout>
</Routes> } />
</AdminAuthProvider> </Routes>
} /> </Router>
<Route path="/*" element={ </AuthProvider>
<Layout> );
<Routes>
<Route path="/" element={<HomePage/>} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/reservation" element={<ReservationStep1Page />} />
<Route path="/reservation/form" element={<ReservationFormPage />} />
<Route path="/reservation/payment" element={<ReservationStep2Page />} />
<Route path="/reservation/success" element={<ReservationSuccessPage />} />
<Route path="/my-reservation" element={<MyReservationPage />} />
</Routes>
</Layout>
} />
</Routes>
</Router>
</AuthProvider>
);
} }
export default App; export default App;

View File

@ -1,29 +1,8 @@
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios'; import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
import JSONbig from 'json-bigint';
import { PrincipalType } from './auth/authTypes';
// Create a JSONbig instance that stores big integers as strings
const JSONbigString = JSONbig({ storeAsString: true });
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000, timeout: 10000,
// transformResponse is used to parse JSON with big integers (Long type from backend) as strings.
// This prevents precision loss in JavaScript.
transformResponse: [(data) => {
// Do not transform if data is not a string or is empty
if (!data || typeof data !== 'string') {
return data;
}
try {
// Use the configured JSONbig instance to parse the data
return JSONbigString.parse(data);
} catch (e) {
// If parsing fails, it might not be JSON, so return original data
// This is the default behavior of axios if parsing fails
return data;
}
}],
}); });
export const isLoginRequiredError = (error: any): boolean => { export const isLoginRequiredError = (error: any): boolean => {
@ -39,7 +18,7 @@ async function request<T>(
method: Method, method: Method,
endpoint: string, endpoint: string,
data: object = {}, data: object = {},
type: PrincipalType, isRequiredAuth: boolean = false
): Promise<T> { ): Promise<T> {
const config: AxiosRequestConfig = { const config: AxiosRequestConfig = {
method, method,
@ -49,14 +28,14 @@ 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') {
@ -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: number;
name: string;
}
export interface MemberRetrieveListResponse {
members: MemberRetrieveResponse[];
}
export interface SignupRequest {
email: string;
password: string;
name: string;
}
export interface SignupResponse {
id: number;
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,70 @@
import apiClient from '../apiClient'; import apiClient from "@_api/apiClient";
import type { import type {
MostReservedThemeIdListResponse, AdminReservationCreateRequest,
PendingReservationCreateRequest, MyReservationRetrieveListResponse,
PendingReservationCreateResponse, ReservationCreateWithPaymentRequest,
ReservationDetailRetrieveResponse, ReservationRetrieveListResponse,
ReservationOverviewListResponse ReservationRetrieveResponse,
} from './reservationTypes'; ReservationSearchQuery,
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: number): 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: number): Promise<void> => {
return await apiClient.del(`/reservations/waiting/${id}`, true);
};
// POST /reservations/waiting/{id}/confirm
export const confirmWaiting = async (id: number): Promise<void> => {
return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true);
};
// POST /reservations/waiting/{id}/reject
export const rejectWaiting = async (id: number): Promise<void> => {
return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true);
};

View File

@ -1,88 +1,72 @@
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',
CONFIRMED: 'CONFIRMED', CONFIRMED: 'CONFIRMED',
CANCELED: 'CANCELED', CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
FAILED: 'FAILED', WAITING: 'WAITING',
EXPIRED: 'EXPIRED'
} as const; } as const;
export type ReservationStatus = export type ReservationStatus =
| 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;
export interface PendingReservationCreateRequest { export interface MyReservationRetrieveResponse {
scheduleId: string, id: number;
reserverName: string, themeName: string;
reserverContact: string, date: string;
participantCount: number, time: string;
requirement: string status: ReservationStatus;
rank: number;
paymentKey: string | null;
amount: number | null;
} }
export interface PendingReservationCreateResponse { export interface MyReservationRetrieveListResponse {
id: string reservations: MyReservationRetrieveResponse[];
} }
export interface ReservationOverviewResponse { export interface ReservationRetrieveResponse {
id: string; id: number;
storeName: string; date: string;
themeName: string; member: MemberRetrieveResponse;
date: string; time: TimeRetrieveResponse;
startFrom: string; theme: ThemeRetrieveResponse;
endAt: string; 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: number;
participantCount: number; themeId: number;
requirement: string; memberId: number;
} }
export interface ReservationDetailRetrieveResponse { export interface ReservationCreateWithPaymentRequest {
id: string; date: string;
reserver: ReserverInfo; timeId: number;
user: UserContactRetrieveResponse; themeId: number;
applicationDateTime: string; paymentKey: string;
payment: PaymentRetrieveResponse; orderId: string;
amount: number;
paymentType: string;
} }
export interface ReservationDetail { export interface WaitingCreateRequest {
overview: ReservationOverviewResponse; date: string;
reserver: ReserverInfo; timeId: number;
user: UserContactRetrieveResponse; themeId: number;
applicationDateTime: string;
payment?: PaymentRetrieveResponse;
} }
export interface MostReservedThemeIdListResponse { export interface ReservationSearchQuery {
themeIds: string[]; themeId?: number;
memberId?: number;
dateFrom?: string;
dateTo?: 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: number): 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: number;
}
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[]; id: number;
}
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;
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: number): Promise<void> => {
return await apiClient.del(`/times/${id}`, true);
};
export const fetchTimesWithAvailability = async (date: string, themeId: number): 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: number;
startAt: string;
}
export interface TimeRetrieveResponse {
id: number;
startAt: string;
}
export interface TimeRetrieveListResponse {
times: TimeCreateResponse[];
}
export interface TimeWithAvailabilityResponse {
id: number;
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>
{!loggedIn ? ( </button>
<> <div className="collapse navbar-collapse" id="navbarSupportedContent">
<button className="btn btn-secondary" onClick={() => navigate('/login')}></button> <ul className="navbar-nav ms-auto">
<button className="btn btn-primary" onClick={() => navigate('/signup')}></button> <li className="nav-item">
</> <Link className="nav-link" to="/reservation">Reservation</Link>
) : ( </li>
<div className="profile-info"> {!loggedIn ? (
<img className="profile-image" src="/image/default-profile.png" alt="Profile" /> <li className="nav-item">
<span>{userName}</span> <Link className="nav-link" to="/login">Login</Link>
<div className="dropdown-menu"> </li>
<Link className="dropdown-item" to="/my-reservation"> </Link> ) : (
<div className="dropdown-divider" /> <li className="nav-item dropdown">
<a className="dropdown-item" href="#" onClick={handleLogout}></a> <a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
</div> <img className="profile-image" src="/image/default-profile.png" alt="Profile" />
</div> <span id="profile-name">{userName}</span>
)} </a>
<ul className="dropdown-menu" aria-labelledby="navbarDropdown">
<li><Link className="dropdown-item" to="/reservation-mine">My Reservation</Link></li>
<li><hr className="dropdown-divider" /></li>
<li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
</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
const checkLogin = async () => {
try {
const response = await apiCheckLogin();
setLoggedIn(true);
setUserName(response.name);
setRole(response.role);
} catch (error) {
setLoggedIn(false);
setUserName(null);
setRole(null);
localStorage.removeItem('accessToken');
} finally {
setLoading(false); // Set loading to false after check is complete
}
};
useEffect(() => { useEffect(() => {
try { checkLogin();
const token = localStorage.getItem('accessToken');
const storedUserName = localStorage.getItem('userName');
if (token && storedUserName) {
setLoggedIn(true);
setUserName(storedUserName);
}
} catch (error) {
console.error("Failed to load user auth state from storage", error);
} finally {
setLoading(false);
}
}, []); }, []);
const login = async (data: Omit<LoginRequest, 'principalType'>) => { const login = async (data: LoginRequest) => {
const response = await apiLogin(data); const response = await apiLogin(data);
await checkLogin();
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('userName', response.name);
setLoggedIn(true);
setUserName(response.name);
return response; return response;
}; };
@ -49,15 +50,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try { try {
await apiLogout(); await apiLogout();
} finally { } finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('userName');
setLoggedIn(false); setLoggedIn(false);
setUserName(null); setUserName(null);
setRole(null);
localStorage.removeItem('accessToken');
} }
}; };
return ( return (
<AuthContext.Provider value={{ loggedIn, userName, loading, login, logout }}> <AuthContext.Provider value={{ loggedIn, userName, role, loading, login, logout, checkLogin }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

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

@ -1,370 +0,0 @@
/* General Container */
.my-reservation-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;
}
.my-reservation-container-v2 h1 {
font-size: 28px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
}
/* Error Message */
.error-message-v2 {
color: #d9534f;
background-color: #f2dede;
border: 1px solid #ebccd1;
padding: 15px;
border-radius: 8px;
text-align: center;
}
/* Reservation List */
.reservation-list-v2 {
display: flex;
flex-direction: column;
gap: 15px;
}
/* Reservation Summary Card */
.reservation-summary-card-v2 {
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
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;
}
.reservation-summary-card-v2:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.summary-subdetails-v2 {
display: flex;
flex-direction: column;
margin: 0px;
gap: 0px;
}
.summary-store-name-v2 {
font-size: 16px;
font-weight: bold;
color: #505a67;
margin: 0 0 5px 0;
}
.summary-details-v2 {
display: flex;
flex-direction: column;
gap: 10px;
}
.summary-theme-name-v2 {
font-size: 20px;
font-weight: 700;
color: #333d4b;
margin: 0;
}
.summary-datetime-v2 {
font-size: 16px;
color: #505a67;
margin-bottom: 5px;
}
/* --- Status Badge --- */
.card-status-badge {
position: absolute;
top: 30px;
right: 10px;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* --- Card Status Styles --- */
.reservation-summary-card-v2 {
position: relative; /* For badge positioning */
}
/* Confirmed (Upcoming) */
.reservation-summary-card-v2.status-confirmed {
border-left: 5px solid #28a745; /* Green accent */
}
.status-confirmed .card-status-badge {
background-color: #28a745;
color: white;
}
/* Completed (Past) */
.reservation-summary-card-v2.status-completed {
background-color: #f8f9fa;
border-left: 5px solid #6c757d; /* Gray accent */
}
.reservation-summary-card-v2.status-completed .summary-theme-name-v2,
.reservation-summary-card-v2.status-completed .summary-datetime-v2 {
color: #6c757d;
}
.reservation-summary-card-v2.status-completed .detail-button-v2 {
background-color: #6c757d;
}
.status-completed .card-status-badge {
background-color: #6c757d;
color: white;
}
/* Canceled */
.reservation-summary-card-v2.status-canceled {
background-color: #f8f9fa;
border-left: 5px solid #dc3545; /* Red accent */
opacity: 0.7;
}
.reservation-summary-card-v2.status-canceled .summary-theme-name-v2,
.reservation-summary-card-v2.status-canceled .summary-datetime-v2 {
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-v2 {
background-color: #007bff;
color: white;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.detail-button-v2:hover {
background-color: #0056b3;
}
.detail-button-v2:disabled {
background-color: #cdd3d8;
cursor: not-allowed;
}
/* Modal Styles */
.modal-overlay-v2 {
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-v2 {
background: #ffffff !important;
padding: 30px !important;
border-radius: 16px !important;
width: 90% !important;
max-width: 500px !important;
position: relative !important;
box-shadow: 0 5px 15px rgba(0,0,0,0.3) !important;
animation: slide-up 0.3s ease-out !important;
max-height: 90vh !important; /* Prevent modal from being too tall */
overflow-y: auto !important; /* Allow scrolling for long content */
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-close-button-v2 {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #8492a6;
}
.modal-content-v2 h2 {
margin-top: 0;
margin-bottom: 25px;
color: #333d4b;
font-size: 24px;
}
.modal-section-v2 {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e8eb;
}
.modal-section-v2:last-child {
border-bottom: none;
}
.modal-section-v2 h3 {
font-size: 18px;
color: #333d4b;
margin-top: 0;
margin-bottom: 15px;
}
.modal-section-v2 p {
margin: 0 0 10px;
color: #505a67;
}
.cancellation-section-v2 {
background-color: #fcf2f2;
padding: 15px;
border-radius: 8px;
border: 1px solid #f0d1d1;
}
.cancellation-section-v2 h3 {
color: #c9302c;
}
/* Modal Actions & Cancellation View */
.modal-actions-v2 {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
}
.modal-actions-v2 button {
border: none;
border-radius: 8px;
padding: 12px 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.cancel-button-v2 {
background-color: #e53e3e;
color: white;
width: 100%;
}
.cancel-button-v2:hover {
background-color: #c53030;
}
.back-button-v2 {
background-color: #e9ecef;
color: #495057;
}
.back-button-v2:hover {
background-color: #dee2e6;
}
.cancel-submit-button-v2 {
background-color: #007bff;
color: white;
}
.cancel-submit-button-v2:hover {
background-color: #0056b3;
}
.cancel-submit-button-v2:disabled,
.back-button-v2:disabled {
background-color: #cdd3d8;
cursor: not-allowed;
}
.cancellation-view-v2 h3 {
font-size: 18px;
color: #333d4b;
margin-bottom: 15px;
}
.cancellation-summary-v2 {
background-color: #f8f9fa;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.cancellation-summary-v2 p {
margin: 0 0 8px;
}
.cancellation-summary-v2 p:last-child {
margin: 0;
}
.cancellation-reason-textarea-v2 {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 8px;
font-size: 16px;
line-height: 1.5;
resize: vertical;
box-sizing: border-box; /* Ensures padding doesn't add to width */
}
.cancellation-reason-textarea-v2:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Added for modal info alignment */
.modal-info-grid p {
display: flex;
align-items: flex-start;
margin: 0.6rem 0;
line-height: 1.5;
}
.modal-info-grid p strong {
flex: 0 0 130px; /* fixed width for labels */
font-weight: 600;
}
.modal-info-grid p span {
flex: 1;
}

View File

@ -1,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,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 @@
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color: #213547; color-scheme: light dark;
background-color: #ffffff; color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@ -52,4 +53,15 @@ button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 4px auto -webkit-focus-ring-color;
} }
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

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

View File

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

View File

@ -1,7 +1,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 type="email" className="form-control" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
<input </div>
type="email" <div className="form-group">
className="form-input" <input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
placeholder="이메일" </div>
value={email} <div className="d-flex justify-content-between">
onChange={e => setEmail(e.target.value)} <button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
required <button className="btn btn-custom" onClick={handleLogin}>Login</button>
/> </div>
</div>
<div className="form-group">
<input
type="password"
className="form-input"
placeholder="비밀번호"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="button-group">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/signup')}></button>
<button type="submit" className="btn btn-primary"></button>
</div>
</form>
</div> </div>
); );
}; };

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: number) => {
try { cancelWaiting(id)
setIsDetailLoading(true); .then(() => {
setDetailError(null); alert('예약 대기가 취소되었습니다.');
setModalView('detail'); setReservations(reservations.filter(r => r.id !== 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)}></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