Compare commits

..

14 Commits

Author SHA1 Message Date
dbfb88f216 test: 로직 변경으로 인해 실패하는 테스트 수정 2025-07-29 15:48:10 +09:00
7131d11fad test: RoomescapeLogMaskingConverter 테스트 추가 및 잘못 작성된 로직 수정 2025-07-29 15:47:57 +09:00
e2f77b982b test: ApiLogMessageConverter 테스트 추가 및 잘못 작성된 로직 수정 2025-07-29 15:47:42 +09:00
c6cabc07d6 feat: actuator 설정 추가 2025-07-29 14:27:06 +09:00
0cab9c133a feat: 로그 관련 설정 클래스 추가 및 이전의 로깅 필터 제거 2025-07-29 14:06:21 +09:00
660ac5ebc9 feat: 컨트롤러 요청 / 응답을 기록하는 AOP 2025-07-29 14:05:59 +09:00
a2fea10f50 feat: HTTP 요청을 받아 기록하는 필터 2025-07-29 14:04:50 +09:00
5e0a52cb47 refactor: ExceptionHandler에서 API 에러 응답을 로깅하도록 수정 2025-07-29 14:04:35 +09:00
7e0bfd9f92 feat: API 로그 메시지를 만드는 유틸 클래스 2025-07-29 14:03:36 +09:00
3157489399 feat: Interceptor / Resolver 에 실패 로깅 및 성공시 member_id MDC 등록 추가 2025-07-29 14:03:02 +09:00
4db2b11970 refactor: 인증 단계에서 회원 조회 실패 오류 코드 수정(-> MEMBER_NOT_FOUND) 2025-07-29 14:02:16 +09:00
fd639001e9 fix: 일부 오타 수정 2025-07-29 14:00:18 +09:00
81d68482bc refactor: 로그 마스킹에서 맨 앞, 맨 뒤 글자는 표시하도록 수정 2025-07-29 13:59:01 +09:00
00f560a411 feat: Observability 의존성 추가 및 로그 형식에 traceId, spanId 추가 2025-07-28 15:47:12 +09:00
467 changed files with 8974 additions and 25667 deletions

6
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,41 +0,0 @@
package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.ExecutionInfo
import net.ttddyy.dsproxy.QueryInfo
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.listener.logging.SLF4JQueryLoggingListener
import java.util.function.Predicate
class MDCAwareSlowQueryListenerWithoutParams : SLF4JQueryLoggingListener {
private val slowQueryPredicate: SlowQueryPredicate
private val sqlLogFormatter: SqlLogFormatter
constructor(logLevel: SLF4JLogLevel, thresholdMs: Long) {
this.logLevel = logLevel
this.slowQueryPredicate = SlowQueryPredicate(thresholdMs)
this.sqlLogFormatter = SqlLogFormatter()
}
override fun afterQuery(
execInfo: ExecutionInfo,
queryInfoList: List<QueryInfo>
) {
if (slowQueryPredicate.test(execInfo.elapsedTime)) {
super.afterQuery(execInfo, queryInfoList)
}
}
override fun writeLog(message: String) {
super.writeLog(sqlLogFormatter.maskParams(message))
}
}
class SqlLogFormatter {
fun maskParams(message: String) = message.replace(Regex("""(,?\s*)Params:\[.*?]"""), "")
}
class SlowQueryPredicate(
private val thresholdMs: Long
) : Predicate<Long> {
override fun test(t: Long): Boolean = (t >= thresholdMs)
}

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,28 +0,0 @@
package com.sangdol.common.log
import com.sangdol.common.log.sql.SlowQueryPredicate
import com.sangdol.common.log.sql.SqlLogFormatter
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class MDCAwareSlowQueryListenerWithoutParamsTest : StringSpec({
"SQL 메시지에서 Params 항목은 가린다." {
val message = """Query:["select * from members m where m.email=?"], Params:[(a@a.a)]"""
val expected = """Query:["select * from members m where m.email=?"]"""
val result = SqlLogFormatter().maskParams(message)
result shouldBe expected
}
"입력된 thresholdMs 보다 소요시간이 긴 쿼리를 기록한다." {
val slowQueryThreshold = 10L
val slowQueryPredicate = SlowQueryPredicate(thresholdMs = slowQueryThreshold)
assertSoftly(slowQueryPredicate) {
this.test(slowQueryThreshold) shouldBe true
this.test(slowQueryThreshold + 1) shouldBe true
this.test(slowQueryThreshold - 1) shouldBe false
}
}
})

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 +0,0 @@
.git
.gitignore
# Node.js
node_modules
npm-debug.log
# Build output
build
dist
# Editor/OS specific
.vscode
.idea
.DS_Store
# Environment variables
.env*

View File

@ -1 +1 @@
VITE_API_BASE_URL = '/api'
VITE_API_BASE_URL = "http://localhost:8080"

View File

@ -1,17 +0,0 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

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

View File

@ -1,15 +0,0 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@ -11,7 +11,6 @@
"axios": "^1.7.2",
"bootstrap": "^5.3.3",
"flatpickr": "^4.6.13",
"json-bigint": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-flatpickr": "^3.10.13",
@ -19,7 +18,6 @@
"vite-tsconfig-paths": "^5.1.4"
},
"devDependencies": {
"@types/json-bigint": "^1.0.4",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/react-flatpickr": "^3.8.11",
@ -1325,13 +1323,6 @@
"devOptional": true,
"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": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -1699,15 +1690,6 @@
"dev": true,
"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": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
@ -2851,15 +2833,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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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 Layout from './components/Layout';
import { AdminAuthProvider } from './context/AdminAuthContext';
import { AuthProvider } from './context/AuthContext';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import SignupPage from './pages/SignupPage';
import ReservationPage from './pages/ReservationPage';
import MyReservationPage from './pages/MyReservationPage';
import AdminLayout from './pages/admin/AdminLayout';
import AdminLoginPage from './pages/admin/AdminLoginPage';
import AdminPage from './pages/admin/AdminPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
import AdminStorePage from './pages/admin/AdminStorePage';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminThemePage from './pages/admin/AdminThemePage';
import HomePage from '@_pages/HomePage';
import LoginPage from '@_pages/LoginPage';
import MyReservationPage from '@_pages/MyReservationPage';
import ReservationFormPage from '@_pages/ReservationFormPage';
import ReservationStep1Page from '@_pages/ReservationStep1Page';
import ReservationStep2Page from '@_pages/ReservationStep2Page';
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
import SignupPage from '@_pages/SignupPage';
import AdminReservationPage from './pages/admin/ReservationPage';
import AdminTimePage from './pages/admin/TimePage';
import AdminThemePage from './pages/admin/ThemePage';
import AdminWaitingPage from './pages/admin/WaitingPage';
import { AuthProvider } from './context/AuthContext';
import AdminRoute from './components/AdminRoute';
const AdminRoutes = () => (
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/reservation" element={<AdminReservationPage />} />
<Route path="/time" element={<AdminTimePage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/waiting" element={<AdminWaitingPage />} />
</Routes>
</AdminLayout>
);
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/admin/*" element={
<AdminAuthProvider>
<Routes>
<Route path="/login" element={<AdminLoginPage />} />
<Route path="/*" element={
<AdminLayout>
<Routes>
<Route path="/" element={<AdminPage />} />
<Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/store" element={<AdminStorePage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes>
</AdminLayout>
} />
</Routes>
</AdminAuthProvider>
} />
<Route path="/*" element={
<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>
);
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/admin/*" element={
<AdminRoute>
<AdminRoutes />
</AdminRoute>
} />
<Route path="/*" element={
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/reservation" element={<ReservationPage />} />
<Route path="/reservation-mine" element={<MyReservationPage />} />
</Routes>
</Layout>
} />
</Routes>
</Router>
</AuthProvider>
);
}
export default App;

View File

@ -1,29 +1,8 @@
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 });
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
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 => {
@ -39,7 +18,7 @@ async function request<T>(
method: Method,
endpoint: string,
data: object = {},
type: PrincipalType,
isRequiredAuth: boolean = false
): Promise<T> {
const config: AxiosRequestConfig = {
method,
@ -49,14 +28,14 @@ async function request<T>(
},
};
const accessTokenKey = type === PrincipalType.ADMIN ? 'adminAccessToken' : 'accessToken';
const accessToken = localStorage.getItem(accessTokenKey);
if (accessToken) {
if (!config.headers) {
config.headers = {};
if (isRequiredAuth) {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
if (method.toUpperCase() !== 'GET') {
@ -73,50 +52,30 @@ async function request<T>(
}
}
async function get<T>(endpoint: string): Promise<T> {
return request<T>('GET', endpoint, {}, PrincipalType.USER);
async function get<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('GET', endpoint, {}, isRequiredAuth);
}
async function adminGet<T>(endpoint: string): Promise<T> {
return request<T>('GET', endpoint, {}, PrincipalType.ADMIN);
async function post<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('POST', endpoint, data, isRequiredAuth);
}
async function post<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('POST', endpoint, data, PrincipalType.USER);
async function put<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('PUT', endpoint, data, isRequiredAuth);
}
async function adminPost<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('POST', endpoint, data, PrincipalType.ADMIN);
async function patch<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('PATCH', endpoint, data, isRequiredAuth);
}
async function put<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PUT', endpoint, data, PrincipalType.USER);
}
async function adminPut<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PUT', endpoint, data, PrincipalType.ADMIN);
}
async function patch<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PATCH', endpoint, data, PrincipalType.USER);
}
async function adminPatch<T>(endpoint: string, data: object = {}): Promise<T> {
return request<T>('PATCH', endpoint, data, PrincipalType.ADMIN);
}
async function del<T>(endpoint: string): Promise<T> {
return request<T>('DELETE', endpoint, {}, PrincipalType.USER);
}
async function adminDel<T>(endpoint: string): Promise<T> {
return request<T>('DELETE', endpoint, {}, PrincipalType.ADMIN);
async function del<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
return request<T>('DELETE', endpoint, {}, isRequiredAuth);
}
export default {
get, adminGet,
post, adminPost,
put, adminPut,
patch, adminPatch,
del, adminDel,
get,
post,
put,
patch,
del
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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 {
MostReservedThemeIdListResponse,
PendingReservationCreateRequest,
PendingReservationCreateResponse,
ReservationDetailRetrieveResponse,
ReservationOverviewListResponse
} from './reservationTypes';
AdminReservationCreateRequest,
MyReservationRetrieveListResponse,
ReservationCreateWithPaymentRequest,
ReservationRetrieveListResponse,
ReservationRetrieveResponse,
ReservationSearchQuery,
WaitingCreateRequest
} from "./reservationTypes";
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request);
// GET /reservations
export const fetchReservations = async (): Promise<ReservationRetrieveListResponse> => {
return await apiClient.get<ReservationRetrieveListResponse>('/reservations', true);
};
export const confirmReservation = async (reservationId: string): Promise<void> => {
await apiClient.post(`/reservations/${reservationId}/confirm`, {});
// GET /reservations-mine
export const fetchMyReservations = async (): Promise<MyReservationRetrieveListResponse> => {
return await apiClient.get<MyReservationRetrieveListResponse>('/reservations-mine', true);
};
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason });
// GET /reservations/search
export const searchReservations = async (params: ReservationSearchQuery): Promise<ReservationRetrieveListResponse> => {
const query = new URLSearchParams();
if (params.themeId) query.append('themeId', params.themeId.toString());
if (params.memberId) query.append('memberId', params.memberId.toString());
if (params.dateFrom) query.append('dateFrom', params.dateFrom);
if (params.dateTo) query.append('dateTo', params.dateTo);
return await apiClient.get<ReservationRetrieveListResponse>(`/reservations/search?${query.toString()}`, true);
};
export const fetchAllOverviewByUser = async (): Promise<ReservationOverviewListResponse> => {
return await apiClient.get<ReservationOverviewListResponse>('/reservations/overview');
}
// DELETE /reservations/{id}
export const cancelReservationByAdmin = async (id: number): Promise<void> => {
return await apiClient.del(`/reservations/${id}`, true);
};
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
return await apiClient.get<ReservationDetailRetrieveResponse>(`/reservations/${reservationId}/detail`);
}
// POST /reservations
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> => {
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`);
}
// POST /reservations/admin
export const createReservationByAdmin = async (data: AdminReservationCreateRequest): Promise<ReservationRetrieveResponse> => {
return await apiClient.post<ReservationRetrieveResponse>('/reservations/admin', data, true);
};
// GET /reservations/waiting
export const fetchWaitingReservations = async (): Promise<ReservationRetrieveListResponse> => {
return await apiClient.get<ReservationRetrieveListResponse>('/reservations/waiting', true);
};
// POST /reservations/waiting
export const createWaiting = async (data: WaitingCreateRequest): Promise<ReservationRetrieveResponse> => {
return await apiClient.post<ReservationRetrieveResponse>('/reservations/waiting', data, true);
};
// DELETE /reservations/waiting/{id}
export const cancelWaiting = async (id: 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 {UserContactRetrieveResponse} from "@_api/user/userTypes";
export interface ReservationData {
scheduleId: string;
store: {
id: string;
name: string;
}
theme: {
id: string;
name: string;
price: number;
minParticipants: number;
maxParticipants: number;
}
date: string; // "yyyy-MM-dd"
startFrom: string; // "HH:mm ~ HH:mm"
endAt: string;
}
import type { MemberRetrieveResponse } from '@_api/member/memberTypes';
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
export const ReservationStatus = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
CANCELED: 'CANCELED',
FAILED: 'FAILED',
EXPIRED: 'EXPIRED'
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
WAITING: 'WAITING',
} as const;
export type ReservationStatus =
| typeof ReservationStatus.PENDING
| typeof ReservationStatus.CONFIRMED
| typeof ReservationStatus.CANCELED
| typeof ReservationStatus.FAILED
| typeof ReservationStatus.EXPIRED;
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
| typeof ReservationStatus.WAITING;
export interface PendingReservationCreateRequest {
scheduleId: string,
reserverName: string,
reserverContact: string,
participantCount: number,
requirement: string
export interface MyReservationRetrieveResponse {
id: number;
themeName: string;
date: string;
time: string;
status: ReservationStatus;
rank: number;
paymentKey: string | null;
amount: number | null;
}
export interface PendingReservationCreateResponse {
id: string
export interface MyReservationRetrieveListResponse {
reservations: MyReservationRetrieveResponse[];
}
export interface ReservationOverviewResponse {
id: string;
storeName: string;
themeName: string;
date: string;
startFrom: string;
endAt: string;
status: ReservationStatus;
export interface ReservationRetrieveResponse {
id: number;
date: string;
member: MemberRetrieveResponse;
time: TimeRetrieveResponse;
theme: ThemeRetrieveResponse;
status: ReservationStatus;
}
export interface ReservationOverviewListResponse {
reservations: ReservationOverviewResponse[];
export interface ReservationRetrieveListResponse {
reservations: ReservationRetrieveResponse[];
}
export interface ReserverInfo {
name: string;
contact: string;
participantCount: number;
requirement: string;
export interface AdminReservationCreateRequest {
date: string;
timeId: number;
themeId: number;
memberId: number;
}
export interface ReservationDetailRetrieveResponse {
id: string;
reserver: ReserverInfo;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment: PaymentRetrieveResponse;
export interface ReservationCreateWithPaymentRequest {
date: string;
timeId: number;
themeId: number;
paymentKey: string;
orderId: string;
amount: number;
paymentType: string;
}
export interface ReservationDetail {
overview: ReservationOverviewResponse;
reserver: ReserverInfo;
user: UserContactRetrieveResponse;
applicationDateTime: string;
payment?: PaymentRetrieveResponse;
export interface WaitingCreateRequest {
date: string;
timeId: number;
themeId: number;
}
export interface MostReservedThemeIdListResponse {
themeIds: string[];
export interface ReservationSearchQuery {
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 type {
AdminThemeDetailResponse,
AdminThemeSummaryListResponse,
SimpleActiveThemeListResponse,
ThemeCreateRequest,
ThemeCreateResponse,
ThemeIdListResponse,
ThemeInfoListResponse,
ThemeInfoResponse,
ThemeUpdateRequest
} from './themeTypes';
import apiClient from "@_api/apiClient";
import type { ThemeCreateRequest, ThemeCreateResponse, ThemeRetrieveListResponse } from "./themeTypes";
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryListResponse> => {
return await apiClient.adminGet<AdminThemeSummaryListResponse>('/admin/themes');
export const createTheme = async (data: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
return await apiClient.post<ThemeCreateResponse>('/themes', data, true);
};
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailResponse> => {
return await apiClient.adminGet<AdminThemeDetailResponse>(`/admin/themes/${id}`);
export const fetchThemes = async (): Promise<ThemeRetrieveListResponse> => {
return await apiClient.get<ThemeRetrieveListResponse>('/themes', true);
};
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
return await apiClient.adminPost<ThemeCreateResponse>('/admin/themes', themeData);
export const mostReservedThemes = async (count: number = 10): Promise<ThemeRetrieveListResponse> => {
return await apiClient.get<ThemeRetrieveListResponse>(`/themes/most-reserved-last-week?count=${count}`, false);
};
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
await apiClient.adminPatch<any>(`/admin/themes/${id}`, themeData);
};
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}`);
export const delTheme = async (id: number): Promise<void> => {
return await apiClient.del(`/themes/${id}`, true);
};

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import React from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useAuth} from 'src/context/AuthContext';
import 'src/css/navbar.css';
import { checkLogin } from '@_api/auth/authAPI';
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from 'src/context/AuthContext';
const Navbar: React.FC = () => {
const { loggedIn, userName, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async (e: React.MouseEvent) => {
e.preventDefault();
try {
@ -15,34 +15,42 @@ const Navbar: React.FC = () => {
} catch (error) {
console.error('Logout failed:', error);
}
};
}
return (
<nav className="navbar-container">
<div className="nav-links">
<Link className="nav-link" to="/"></Link>
<Link className="nav-link" to="/reservation"></Link>
</div>
<div className="nav-actions">
{!loggedIn ? (
<>
<button className="btn btn-secondary" onClick={() => navigate('/login')}></button>
<button className="btn btn-primary" onClick={() => navigate('/signup')}></button>
</>
) : (
<div className="profile-info">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<span>{userName}</span>
<div className="dropdown-menu">
<Link className="dropdown-item" to="/my-reservation"> </Link>
<div className="dropdown-divider" />
<a className="dropdown-item" href="#" onClick={handleLogout}></a>
</div>
</div>
)}
<nav className="navbar navbar-expand-lg navbar-light bg-light">
<Link className="navbar-brand" to="/">
<img src="/image/service-logo.png" alt="LOGO" style={{ height: '40px' }} />
</Link>
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<Link className="nav-link" to="/reservation">Reservation</Link>
</li>
{!loggedIn ? (
<li className="nav-item">
<Link className="nav-link" to="/login">Login</Link>
</li>
) : (
<li className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
<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>
</nav>
);
};
export default Navbar;
export default Navbar;

View File

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

View File

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

View File

@ -1,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;
}
/* =================================== */
/* 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;
}
.form-select {
margin: 10px 0px !important;
}
.button-group {
margin-top: 32px;
display: flex;
flex-direction: row;
justify-content: center !important;
flex-direction: column;
justify-content: center;
gap: 16px;
}

View File

@ -3,8 +3,9 @@
line-height: 1.5;
font-weight: 400;
color: #213547;
background-color: #ffffff;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
@ -52,4 +53,15 @@ button:focus-visible {
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 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import '@_css/style.css';
import '@_css/toss-style.css';
import './css/style.css';
import './css/reservation.css';
import './css/toss-style.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

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