generated from pricelees/issue-pr-template
Compare commits
No commits in common. "main" and "refactor/#20-1" have entirely different histories.
main
...
refactor/#
4
.gitignore
vendored
4
.gitignore
vendored
@ -37,7 +37,3 @@ out/
|
|||||||
.vscode/
|
.vscode/
|
||||||
logs
|
logs
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
### sql
|
|
||||||
data/*.sql
|
|
||||||
data/*.txt
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
FROM amazoncorretto:17
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY service/build/libs/service.jar app.jar
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
|
||||||
@ -1,58 +1,83 @@
|
|||||||
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
|
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
val springBootVersion = "3.5.3"
|
val springBootVersion = "3.5.3"
|
||||||
val kotlinVersion = "2.2.0"
|
val kotlinVersion = "2.2.0"
|
||||||
|
|
||||||
id("io.spring.dependency-management") version "1.1.7" apply false
|
id("org.springframework.boot") version springBootVersion
|
||||||
id("org.springframework.boot") version springBootVersion apply false
|
id("io.spring.dependency-management") version "1.1.7"
|
||||||
kotlin("jvm") version kotlinVersion apply false
|
kotlin("jvm") version kotlinVersion
|
||||||
kotlin("kapt") version kotlinVersion apply false
|
kotlin("plugin.spring") version kotlinVersion
|
||||||
kotlin("plugin.spring") version kotlinVersion apply false
|
kotlin("plugin.jpa") version kotlinVersion
|
||||||
kotlin("plugin.jpa") version kotlinVersion apply false
|
kotlin("kapt") version kotlinVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.sangdol"
|
group = "com.sangdol"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
|
||||||
allprojects {
|
java {
|
||||||
repositories {
|
toolchain {
|
||||||
mavenCentral()
|
languageVersion = JavaLanguageVersion.of(17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subprojects {
|
kapt {
|
||||||
apply(plugin = "org.jetbrains.kotlin.jvm")
|
keepJavacAnnotationProcessors = true
|
||||||
apply(plugin = "org.jetbrains.kotlin.kapt")
|
}
|
||||||
apply(plugin = "io.spring.dependency-management")
|
|
||||||
|
|
||||||
extensions.configure<JavaPluginExtension> {
|
repositories {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
mavenCentral()
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extensions.configure<KaptExtension> {
|
dependencies {
|
||||||
keepJavacAnnotationProcessors = true
|
// Spring
|
||||||
}
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
|
|
||||||
dependencies {
|
// API docs
|
||||||
add("implementation", "io.github.oshai:kotlin-logging-jvm:7.0.3")
|
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
|
||||||
add("implementation", "io.kotest:kotest-runner-junit5:5.9.1")
|
|
||||||
add("implementation", "ch.qos.logback:logback-classic:1.5.18")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Test> {
|
// DB
|
||||||
useJUnitPlatform()
|
runtimeOnly("com.h2database:h2")
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<KotlinCompile> {
|
// Jwt
|
||||||
compilerOptions {
|
implementation("io.jsonwebtoken:jjwt:0.12.6")
|
||||||
freeCompilerArgs.addAll(
|
|
||||||
"-Xjsr305=strict",
|
// Logging
|
||||||
"-Xannotation-default-target=param-property"
|
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||||
)
|
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
|
||||||
}
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
build.sh
6
build.sh
@ -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
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package com.sangdol.common.log.constant
|
|
||||||
|
|
||||||
enum class LogType {
|
|
||||||
INCOMING_HTTP_REQUEST,
|
|
||||||
CONTROLLER_INVOKED,
|
|
||||||
SUCCEED,
|
|
||||||
APPLICATION_FAILURE,
|
|
||||||
UNHANDLED_EXCEPTION
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package com.sangdol.common.persistence
|
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
class TestApplication
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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:
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
tasks.named<Jar>("jar") {
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
dependencies {
|
|
||||||
implementation("org.slf4j:slf4j-api:2.0.17")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.named<Jar>("jar") {
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
package com.sangdol.common.web.asepct
|
|
||||||
|
|
||||||
import com.sangdol.common.log.constant.LogType
|
|
||||||
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.aspectj.lang.JoinPoint
|
|
||||||
import org.aspectj.lang.ProceedingJoinPoint
|
|
||||||
import org.aspectj.lang.annotation.Around
|
|
||||||
import org.aspectj.lang.annotation.Aspect
|
|
||||||
import org.aspectj.lang.annotation.Pointcut
|
|
||||||
import org.aspectj.lang.reflect.MethodSignature
|
|
||||||
import org.springframework.http.ResponseEntity
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.context.request.RequestContextHolder
|
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
@Aspect
|
|
||||||
class ControllerLoggingAspect(
|
|
||||||
private val messageConverter: WebLogMessageConverter,
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Pointcut("execution(* com.sangdol..web..*Controller*.*(..))")
|
|
||||||
fun allController() {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Around("allController()")
|
|
||||||
fun logAPICalls(joinPoint: ProceedingJoinPoint): Any? {
|
|
||||||
val servletRequest: HttpServletRequest = servletRequest()
|
|
||||||
val controllerPayload: Map<String, Any> = parseControllerPayload(joinPoint)
|
|
||||||
|
|
||||||
log.info {
|
|
||||||
messageConverter.convertToControllerInvokedMessage(servletRequest, controllerPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return joinPoint.proceed()
|
|
||||||
.also { logSuccess(servletRequest, it as ResponseEntity<*>) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logSuccess(servletRequest: HttpServletRequest, result: ResponseEntity<*>) {
|
|
||||||
val body: Any? = if (log.isDebugEnabled()) result.body else null
|
|
||||||
|
|
||||||
val logMessage = messageConverter.convertToResponseMessage(
|
|
||||||
type = LogType.SUCCEED,
|
|
||||||
servletRequest = servletRequest,
|
|
||||||
httpStatusCode = result.statusCode.value(),
|
|
||||||
responseBody = body,
|
|
||||||
)
|
|
||||||
|
|
||||||
log.info { logMessage }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun servletRequest(): HttpServletRequest {
|
|
||||||
return (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseControllerPayload(joinPoint: JoinPoint): Map<String, Any> {
|
|
||||||
val signature = joinPoint.signature as MethodSignature
|
|
||||||
val args = joinPoint.args
|
|
||||||
val payload = mutableMapOf<String, Any>(
|
|
||||||
"controller_method" to joinPoint.signature.toShortString()
|
|
||||||
)
|
|
||||||
|
|
||||||
val requestParams: MutableMap<String, Any> = mutableMapOf()
|
|
||||||
val pathVariables: MutableMap<String, Any> = mutableMapOf()
|
|
||||||
|
|
||||||
signature.method.parameters.forEachIndexed { index, parameter ->
|
|
||||||
val arg = args[index]
|
|
||||||
|
|
||||||
parameter.getAnnotation(RequestBody::class.java)?.let {
|
|
||||||
payload["request_body"] = arg
|
|
||||||
}
|
|
||||||
|
|
||||||
parameter.getAnnotation(PathVariable::class.java)?.let {
|
|
||||||
pathVariables[parameter.name] = arg
|
|
||||||
}
|
|
||||||
|
|
||||||
parameter.getAnnotation(RequestParam::class.java)?.let {
|
|
||||||
requestParams[parameter.name] = arg
|
|
||||||
}
|
|
||||||
}.also {
|
|
||||||
if (pathVariables.isNotEmpty()) payload["path_variable"] = pathVariables
|
|
||||||
if (requestParams.isNotEmpty()) payload["request_param"] = requestParams
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
package com.sangdol.common.web.servlet
|
|
||||||
|
|
||||||
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
|
||||||
import com.sangdol.common.utils.MdcStartTimeUtil
|
|
||||||
import com.sangdol.common.web.support.log.WebLogMessageConverter
|
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import io.micrometer.tracing.CurrentTraceContext
|
|
||||||
import jakarta.servlet.FilterChain
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
|
||||||
import org.springframework.web.filter.OncePerRequestFilter
|
|
||||||
import org.springframework.web.util.ContentCachingRequestWrapper
|
|
||||||
import org.springframework.web.util.ContentCachingResponseWrapper
|
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
class HttpRequestLoggingFilter(
|
|
||||||
private val messageConverter: WebLogMessageConverter,
|
|
||||||
private val currentTraceContext: CurrentTraceContext
|
|
||||||
) : OncePerRequestFilter() {
|
|
||||||
override fun doFilterInternal(
|
|
||||||
request: HttpServletRequest,
|
|
||||||
response: HttpServletResponse,
|
|
||||||
filterChain: FilterChain
|
|
||||||
) {
|
|
||||||
log.info { messageConverter.convertToHttpRequestMessage(request) }
|
|
||||||
|
|
||||||
val cachedRequest = ContentCachingRequestWrapper(request)
|
|
||||||
val cachedResponse = ContentCachingResponseWrapper(response)
|
|
||||||
|
|
||||||
MdcStartTimeUtil.setCurrentTime()
|
|
||||||
|
|
||||||
try {
|
|
||||||
filterChain.doFilter(cachedRequest, cachedResponse)
|
|
||||||
cachedResponse.copyBodyToResponse()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
MdcStartTimeUtil.clear()
|
|
||||||
MdcPrincipalIdUtil.clear()
|
|
||||||
currentTraceContext.maybeScope(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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.
@ -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
|
|
||||||
@ -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*
|
|
||||||
@ -1 +1 @@
|
|||||||
VITE_API_BASE_URL = '/api'
|
VITE_API_BASE_URL = "http://localhost:8080"
|
||||||
@ -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;"]
|
|
||||||
@ -3,7 +3,7 @@ import globals from 'globals'
|
|||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
import {globalIgnores} from 'eslint/config'
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default tseslint.config([
|
export default tseslint.config([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
@ -11,7 +11,6 @@
|
|||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
"json-bigint": "^1.0.0",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-flatpickr": "^3.10.13",
|
"react-flatpickr": "^3.10.13",
|
||||||
@ -19,7 +18,6 @@
|
|||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/json-bigint": "^1.0.4",
|
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@types/react-flatpickr": "^3.8.11",
|
"@types/react-flatpickr": "^3.8.11",
|
||||||
@ -1325,13 +1323,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-bigint": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@ -1699,15 +1690,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bignumber.js": {
|
|
||||||
"version": "9.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
|
||||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.7",
|
"version": "5.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
|
||||||
@ -2851,15 +2833,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-bigint": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bignumber.js": "^9.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/json-buffer": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
"json-bigint": "^1.0.0",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-flatpickr": "^3.10.13",
|
"react-flatpickr": "^3.10.13",
|
||||||
@ -21,7 +20,6 @@
|
|||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/json-bigint": "^1.0.4",
|
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@types/react-flatpickr": "^3.8.11",
|
"@types/react-flatpickr": "^3.8.11",
|
||||||
|
|||||||
@ -1,2 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88"
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.5 KiB |
@ -33,6 +33,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,64 +1,56 @@
|
|||||||
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import { AdminAuthProvider } from './context/AdminAuthContext';
|
import HomePage from './pages/HomePage';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import SignupPage from './pages/SignupPage';
|
||||||
|
import ReservationPage from './pages/ReservationPage';
|
||||||
|
import MyReservationPage from './pages/MyReservationPage';
|
||||||
import AdminLayout from './pages/admin/AdminLayout';
|
import AdminLayout from './pages/admin/AdminLayout';
|
||||||
import AdminLoginPage from './pages/admin/AdminLoginPage';
|
|
||||||
import AdminPage from './pages/admin/AdminPage';
|
import AdminPage from './pages/admin/AdminPage';
|
||||||
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
|
import AdminReservationPage from './pages/admin/ReservationPage';
|
||||||
import AdminStorePage from './pages/admin/AdminStorePage';
|
import AdminTimePage from './pages/admin/TimePage';
|
||||||
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
|
import AdminThemePage from './pages/admin/ThemePage';
|
||||||
import AdminThemePage from './pages/admin/AdminThemePage';
|
import AdminWaitingPage from './pages/admin/WaitingPage';
|
||||||
import HomePage from '@_pages/HomePage';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import LoginPage from '@_pages/LoginPage';
|
import AdminRoute from './components/AdminRoute';
|
||||||
import MyReservationPage from '@_pages/MyReservationPage';
|
|
||||||
import ReservationFormPage from '@_pages/ReservationFormPage';
|
const AdminRoutes = () => (
|
||||||
import ReservationStep1Page from '@_pages/ReservationStep1Page';
|
<AdminLayout>
|
||||||
import ReservationStep2Page from '@_pages/ReservationStep2Page';
|
<Routes>
|
||||||
import ReservationSuccessPage from '@_pages/ReservationSuccessPage';
|
<Route path="/" element={<AdminPage />} />
|
||||||
import SignupPage from '@_pages/SignupPage';
|
<Route path="/reservation" element={<AdminReservationPage />} />
|
||||||
|
<Route path="/time" element={<AdminTimePage />} />
|
||||||
|
<Route path="/theme" element={<AdminThemePage />} />
|
||||||
|
<Route path="/waiting" element={<AdminWaitingPage />} />
|
||||||
|
</Routes>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/admin/*" element={
|
<Route path="/admin/*" element={
|
||||||
<AdminAuthProvider>
|
<AdminRoute>
|
||||||
<Routes>
|
<AdminRoutes />
|
||||||
<Route path="/login" element={<AdminLoginPage />} />
|
</AdminRoute>
|
||||||
<Route path="/*" element={
|
} />
|
||||||
<AdminLayout>
|
<Route path="/*" element={
|
||||||
<Routes>
|
<Layout>
|
||||||
<Route path="/" element={<AdminPage />} />
|
<Routes>
|
||||||
<Route path="/theme" element={<AdminThemePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/store" element={<AdminStorePage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
<Route path="/schedule" element={<AdminSchedulePage />} />
|
<Route path="/reservation" element={<ReservationPage />} />
|
||||||
</Routes>
|
<Route path="/reservation-mine" element={<MyReservationPage />} />
|
||||||
</AdminLayout>
|
</Routes>
|
||||||
} />
|
</Layout>
|
||||||
</Routes>
|
} />
|
||||||
</AdminAuthProvider>
|
</Routes>
|
||||||
} />
|
</Router>
|
||||||
<Route path="/*" element={
|
</AuthProvider>
|
||||||
<Layout>
|
);
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<HomePage/>} />
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
|
||||||
<Route path="/reservation" element={<ReservationStep1Page />} />
|
|
||||||
<Route path="/reservation/form" element={<ReservationFormPage />} />
|
|
||||||
<Route path="/reservation/payment" element={<ReservationStep2Page />} />
|
|
||||||
<Route path="/reservation/success" element={<ReservationSuccessPage />} />
|
|
||||||
<Route path="/my-reservation" element={<MyReservationPage />} />
|
|
||||||
</Routes>
|
|
||||||
</Layout>
|
|
||||||
} />
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -1,29 +1,8 @@
|
|||||||
import axios, {type AxiosError, type AxiosRequestConfig, type Method} from 'axios';
|
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
|
||||||
import JSONbig from 'json-bigint';
|
|
||||||
import { PrincipalType } from './auth/authTypes';
|
|
||||||
|
|
||||||
// Create a JSONbig instance that stores big integers as strings
|
|
||||||
const JSONbigString = JSONbig({ storeAsString: true });
|
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
// transformResponse is used to parse JSON with big integers (Long type from backend) as strings.
|
|
||||||
// This prevents precision loss in JavaScript.
|
|
||||||
transformResponse: [(data) => {
|
|
||||||
// Do not transform if data is not a string or is empty
|
|
||||||
if (!data || typeof data !== 'string') {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Use the configured JSONbig instance to parse the data
|
|
||||||
return JSONbigString.parse(data);
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, it might not be JSON, so return original data
|
|
||||||
// This is the default behavior of axios if parsing fails
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const isLoginRequiredError = (error: any): boolean => {
|
export const isLoginRequiredError = (error: any): boolean => {
|
||||||
@ -39,7 +18,7 @@ async function request<T>(
|
|||||||
method: Method,
|
method: Method,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data: object = {},
|
data: object = {},
|
||||||
type: PrincipalType,
|
isRequiredAuth: boolean = false
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
method,
|
method,
|
||||||
@ -49,14 +28,14 @@ async function request<T>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessTokenKey = type === PrincipalType.ADMIN ? 'adminAccessToken' : 'accessToken';
|
if (isRequiredAuth) {
|
||||||
const accessToken = localStorage.getItem(accessTokenKey);
|
const accessToken = localStorage.getItem('accessToken');
|
||||||
|
if (accessToken) {
|
||||||
if (accessToken) {
|
if (!config.headers) {
|
||||||
if (!config.headers) {
|
config.headers = {};
|
||||||
config.headers = {};
|
}
|
||||||
|
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method.toUpperCase() !== 'GET') {
|
if (method.toUpperCase() !== 'GET') {
|
||||||
@ -73,50 +52,30 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get<T>(endpoint: string): Promise<T> {
|
async function get<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('GET', endpoint, {}, PrincipalType.USER);
|
return request<T>('GET', endpoint, {}, isRequiredAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminGet<T>(endpoint: string): Promise<T> {
|
async function post<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('GET', endpoint, {}, PrincipalType.ADMIN);
|
return request<T>('POST', endpoint, data, isRequiredAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post<T>(endpoint: string, data: object = {}): Promise<T> {
|
async function put<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('POST', endpoint, data, PrincipalType.USER);
|
return request<T>('PUT', endpoint, data, isRequiredAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminPost<T>(endpoint: string, data: object = {}): Promise<T> {
|
async function patch<T>(endpoint: string, data: object = {}, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('POST', endpoint, data, PrincipalType.ADMIN);
|
return request<T>('PATCH', endpoint, data, isRequiredAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function put<T>(endpoint: string, data: object = {}): Promise<T> {
|
async function del<T>(endpoint: string, isRequiredAuth: boolean = false): Promise<T> {
|
||||||
return request<T>('PUT', endpoint, data, PrincipalType.USER);
|
return request<T>('DELETE', endpoint, {}, isRequiredAuth);
|
||||||
}
|
|
||||||
|
|
||||||
async function adminPut<T>(endpoint: string, data: object = {}): Promise<T> {
|
|
||||||
return request<T>('PUT', endpoint, data, PrincipalType.ADMIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function patch<T>(endpoint: string, data: object = {}): Promise<T> {
|
|
||||||
return request<T>('PATCH', endpoint, data, PrincipalType.USER);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adminPatch<T>(endpoint: string, data: object = {}): Promise<T> {
|
|
||||||
return request<T>('PATCH', endpoint, data, PrincipalType.ADMIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function del<T>(endpoint: string): Promise<T> {
|
|
||||||
return request<T>('DELETE', endpoint, {}, PrincipalType.USER);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adminDel<T>(endpoint: string): Promise<T> {
|
|
||||||
return request<T>('DELETE', endpoint, {}, PrincipalType.ADMIN);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
get, adminGet,
|
get,
|
||||||
post, adminPost,
|
post,
|
||||||
put, adminPut,
|
put,
|
||||||
patch, adminPatch,
|
patch,
|
||||||
del, adminDel,
|
del
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,33 +1,19 @@
|
|||||||
import apiClient from '@_api/apiClient';
|
import apiClient from '@_api/apiClient';
|
||||||
import {
|
import type { LoginRequest, LoginResponse, LoginCheckResponse } from './authTypes';
|
||||||
type AdminLoginSuccessResponse,
|
|
||||||
type LoginRequest,
|
|
||||||
PrincipalType,
|
|
||||||
type UserLoginSuccessResponse,
|
|
||||||
} from './authTypes';
|
|
||||||
|
|
||||||
export const userLogin = async (
|
|
||||||
data: Omit<LoginRequest, 'principalType'>,
|
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||||
): Promise<UserLoginSuccessResponse> => {
|
const response = await apiClient.post<LoginResponse>('/login', data, false);
|
||||||
return await apiClient.post<UserLoginSuccessResponse>(
|
localStorage.setItem('accessToken', response.accessToken);
|
||||||
'/auth/login',
|
|
||||||
{ ...data, principalType: PrincipalType.USER },
|
return response;
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminLogin = async (
|
export const checkLogin = async (): Promise<LoginCheckResponse> => {
|
||||||
data: Omit<LoginRequest, 'principalType'>,
|
return await apiClient.get<LoginCheckResponse>('/login/check', true);
|
||||||
): Promise<AdminLoginSuccessResponse> => {
|
|
||||||
return await apiClient.adminPost<AdminLoginSuccessResponse>(
|
|
||||||
'/auth/login',
|
|
||||||
{ ...data, principalType: PrincipalType.ADMIN },
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logout = async (): Promise<void> => {
|
export const logout = async (): Promise<void> => {
|
||||||
await apiClient.post('/auth/logout', {});
|
await apiClient.post('/logout', {}, true);
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminLogout = async (): Promise<void> => {
|
|
||||||
await apiClient.adminPost('/auth/logout', {});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,38 +1,14 @@
|
|||||||
export const PrincipalType = {
|
|
||||||
ADMIN: 'ADMIN',
|
|
||||||
USER: 'USER',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType];
|
|
||||||
|
|
||||||
export const AdminType = {
|
|
||||||
HQ: 'HQ',
|
|
||||||
STORE: 'STORE',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AdminType = typeof AdminType[keyof typeof AdminType];
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
account: string,
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
principalType: PrincipalType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginSuccessResponse {
|
export interface LoginResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginCheckResponse {
|
||||||
name: string;
|
name: string;
|
||||||
|
role: 'ADMIN' | 'MEMBER';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserLoginSuccessResponse extends LoginSuccessResponse {
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminLoginSuccessResponse extends LoginSuccessResponse {
|
|
||||||
type: AdminType;
|
|
||||||
storeId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrentUserContext {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: PrincipalType;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
export interface OperatorInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditInfo {
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
createdBy: OperatorInfo;
|
|
||||||
updatedBy: OperatorInfo;
|
|
||||||
}
|
|
||||||
10
frontend/src/api/member/memberAPI.ts
Normal file
10
frontend/src/api/member/memberAPI.ts
Normal 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);
|
||||||
|
};
|
||||||
19
frontend/src/api/member/memberTypes.ts
Normal file
19
frontend/src/api/member/memberTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface OrderErrorResponse {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
trial: number;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
};
|
|
||||||
@ -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}`);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -1,33 +1,70 @@
|
|||||||
import apiClient from '../apiClient';
|
import apiClient from "@_api/apiClient";
|
||||||
import type {
|
import type {
|
||||||
MostReservedThemeIdListResponse,
|
AdminReservationCreateRequest,
|
||||||
PendingReservationCreateRequest,
|
MyReservationRetrieveListResponse,
|
||||||
PendingReservationCreateResponse,
|
ReservationCreateWithPaymentRequest,
|
||||||
ReservationDetailRetrieveResponse,
|
ReservationRetrieveListResponse,
|
||||||
ReservationOverviewListResponse
|
ReservationRetrieveResponse,
|
||||||
} from './reservationTypes';
|
ReservationSearchQuery,
|
||||||
|
WaitingCreateRequest
|
||||||
|
} from "./reservationTypes";
|
||||||
|
|
||||||
export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise<PendingReservationCreateResponse> => {
|
// GET /reservations
|
||||||
return await apiClient.post<PendingReservationCreateResponse>('/reservations/pending', request);
|
export const fetchReservations = async (): Promise<ReservationRetrieveListResponse> => {
|
||||||
|
return await apiClient.get<ReservationRetrieveListResponse>('/reservations', true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const confirmReservation = async (reservationId: string): Promise<void> => {
|
// GET /reservations-mine
|
||||||
await apiClient.post(`/reservations/${reservationId}/confirm`, {});
|
export const fetchMyReservations = async (): Promise<MyReservationRetrieveListResponse> => {
|
||||||
|
return await apiClient.get<MyReservationRetrieveListResponse>('/reservations-mine', true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GET /reservations/search
|
||||||
export const cancelReservation = async (id: string, cancelReason: string): Promise<void> => {
|
export const searchReservations = async (params: ReservationSearchQuery): Promise<ReservationRetrieveListResponse> => {
|
||||||
return await apiClient.post(`/reservations/${id}/cancel`, { cancelReason });
|
const query = new URLSearchParams();
|
||||||
|
if (params.themeId) query.append('themeId', params.themeId.toString());
|
||||||
|
if (params.memberId) query.append('memberId', params.memberId.toString());
|
||||||
|
if (params.dateFrom) query.append('dateFrom', params.dateFrom);
|
||||||
|
if (params.dateTo) query.append('dateTo', params.dateTo);
|
||||||
|
return await apiClient.get<ReservationRetrieveListResponse>(`/reservations/search?${query.toString()}`, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAllOverviewByUser = async (): Promise<ReservationOverviewListResponse> => {
|
// DELETE /reservations/{id}
|
||||||
return await apiClient.get<ReservationOverviewListResponse>('/reservations/overview');
|
export const cancelReservationByAdmin = async (id: number): Promise<void> => {
|
||||||
}
|
return await apiClient.del(`/reservations/${id}`, true);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchDetailById = async (reservationId: string): Promise<ReservationDetailRetrieveResponse> => {
|
// POST /reservations
|
||||||
return await apiClient.get<ReservationDetailRetrieveResponse>(`/reservations/${reservationId}/detail`);
|
export const createReservationWithPayment = async (data: ReservationCreateWithPaymentRequest): Promise<ReservationRetrieveResponse> => {
|
||||||
}
|
return await apiClient.post<ReservationRetrieveResponse>('/reservations', data, true);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchMostReservedThemeIds = async (count: number = 10): Promise<MostReservedThemeIdListResponse> => {
|
// POST /reservations/admin
|
||||||
return await apiClient.get<MostReservedThemeIdListResponse>(`/reservations/popular-themes?count=${count}`);
|
export const createReservationByAdmin = async (data: AdminReservationCreateRequest): Promise<ReservationRetrieveResponse> => {
|
||||||
}
|
return await apiClient.post<ReservationRetrieveResponse>('/reservations/admin', data, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /reservations/waiting
|
||||||
|
export const fetchWaitingReservations = async (): Promise<ReservationRetrieveListResponse> => {
|
||||||
|
return await apiClient.get<ReservationRetrieveListResponse>('/reservations/waiting', true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /reservations/waiting
|
||||||
|
export const createWaiting = async (data: WaitingCreateRequest): Promise<ReservationRetrieveResponse> => {
|
||||||
|
return await apiClient.post<ReservationRetrieveResponse>('/reservations/waiting', data, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /reservations/waiting/{id}
|
||||||
|
export const cancelWaiting = async (id: number): Promise<void> => {
|
||||||
|
return await apiClient.del(`/reservations/waiting/${id}`, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /reservations/waiting/{id}/confirm
|
||||||
|
export const confirmWaiting = async (id: number): Promise<void> => {
|
||||||
|
return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /reservations/waiting/{id}/reject
|
||||||
|
export const rejectWaiting = async (id: number): Promise<void> => {
|
||||||
|
return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,88 +1,72 @@
|
|||||||
import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes";
|
import type { MemberRetrieveResponse } from '@_api/member/memberTypes';
|
||||||
import type {UserContactRetrieveResponse} from "@_api/user/userTypes";
|
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
|
||||||
|
import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
|
||||||
export interface ReservationData {
|
|
||||||
scheduleId: string;
|
|
||||||
store: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
theme: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
minParticipants: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
}
|
|
||||||
date: string; // "yyyy-MM-dd"
|
|
||||||
startFrom: string; // "HH:mm ~ HH:mm"
|
|
||||||
endAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReservationStatus = {
|
export const ReservationStatus = {
|
||||||
PENDING: 'PENDING',
|
|
||||||
CONFIRMED: 'CONFIRMED',
|
CONFIRMED: 'CONFIRMED',
|
||||||
CANCELED: 'CANCELED',
|
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
|
||||||
FAILED: 'FAILED',
|
WAITING: 'WAITING',
|
||||||
EXPIRED: 'EXPIRED'
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ReservationStatus =
|
export type ReservationStatus =
|
||||||
| typeof ReservationStatus.PENDING
|
|
||||||
| typeof ReservationStatus.CONFIRMED
|
| typeof ReservationStatus.CONFIRMED
|
||||||
| typeof ReservationStatus.CANCELED
|
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
|
||||||
| typeof ReservationStatus.FAILED
|
| typeof ReservationStatus.WAITING;
|
||||||
| typeof ReservationStatus.EXPIRED;
|
|
||||||
|
|
||||||
export interface PendingReservationCreateRequest {
|
export interface MyReservationRetrieveResponse {
|
||||||
scheduleId: string,
|
id: number;
|
||||||
reserverName: string,
|
themeName: string;
|
||||||
reserverContact: string,
|
date: string;
|
||||||
participantCount: number,
|
time: string;
|
||||||
requirement: string
|
status: ReservationStatus;
|
||||||
|
rank: number;
|
||||||
|
paymentKey: string | null;
|
||||||
|
amount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingReservationCreateResponse {
|
export interface MyReservationRetrieveListResponse {
|
||||||
id: string
|
reservations: MyReservationRetrieveResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationOverviewResponse {
|
export interface ReservationRetrieveResponse {
|
||||||
id: string;
|
id: number;
|
||||||
storeName: string;
|
date: string;
|
||||||
themeName: string;
|
member: MemberRetrieveResponse;
|
||||||
date: string;
|
time: TimeRetrieveResponse;
|
||||||
startFrom: string;
|
theme: ThemeRetrieveResponse;
|
||||||
endAt: string;
|
status: ReservationStatus;
|
||||||
status: ReservationStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationOverviewListResponse {
|
export interface ReservationRetrieveListResponse {
|
||||||
reservations: ReservationOverviewResponse[];
|
reservations: ReservationRetrieveResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReserverInfo {
|
export interface AdminReservationCreateRequest {
|
||||||
name: string;
|
date: string;
|
||||||
contact: string;
|
timeId: number;
|
||||||
participantCount: number;
|
themeId: number;
|
||||||
requirement: string;
|
memberId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationDetailRetrieveResponse {
|
export interface ReservationCreateWithPaymentRequest {
|
||||||
id: string;
|
date: string;
|
||||||
reserver: ReserverInfo;
|
timeId: number;
|
||||||
user: UserContactRetrieveResponse;
|
themeId: number;
|
||||||
applicationDateTime: string;
|
paymentKey: string;
|
||||||
payment: PaymentRetrieveResponse;
|
orderId: string;
|
||||||
|
amount: number;
|
||||||
|
paymentType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationDetail {
|
export interface WaitingCreateRequest {
|
||||||
overview: ReservationOverviewResponse;
|
date: string;
|
||||||
reserver: ReserverInfo;
|
timeId: number;
|
||||||
user: UserContactRetrieveResponse;
|
themeId: number;
|
||||||
applicationDateTime: string;
|
|
||||||
payment?: PaymentRetrieveResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MostReservedThemeIdListResponse {
|
export interface ReservationSearchQuery {
|
||||||
themeIds: string[];
|
themeId?: number;
|
||||||
|
memberId?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`);
|
|
||||||
};
|
|
||||||
@ -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[];
|
|
||||||
}
|
|
||||||
@ -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`, {});
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,48 +1,18 @@
|
|||||||
import apiClient from '@_api/apiClient';
|
import apiClient from "@_api/apiClient";
|
||||||
import type {
|
import type { ThemeCreateRequest, ThemeCreateResponse, ThemeRetrieveListResponse } from "./themeTypes";
|
||||||
AdminThemeDetailResponse,
|
|
||||||
AdminThemeSummaryListResponse,
|
|
||||||
SimpleActiveThemeListResponse,
|
|
||||||
ThemeCreateRequest,
|
|
||||||
ThemeCreateResponse,
|
|
||||||
ThemeIdListResponse,
|
|
||||||
ThemeInfoListResponse,
|
|
||||||
ThemeInfoResponse,
|
|
||||||
ThemeUpdateRequest
|
|
||||||
} from './themeTypes';
|
|
||||||
|
|
||||||
export const fetchAdminThemes = async (): Promise<AdminThemeSummaryListResponse> => {
|
export const createTheme = async (data: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
||||||
return await apiClient.adminGet<AdminThemeSummaryListResponse>('/admin/themes');
|
return await apiClient.post<ThemeCreateResponse>('/themes', data, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAdminThemeDetail = async (id: string): Promise<AdminThemeDetailResponse> => {
|
export const fetchThemes = async (): Promise<ThemeRetrieveListResponse> => {
|
||||||
return await apiClient.adminGet<AdminThemeDetailResponse>(`/admin/themes/${id}`);
|
return await apiClient.get<ThemeRetrieveListResponse>('/themes', true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTheme = async (themeData: ThemeCreateRequest): Promise<ThemeCreateResponse> => {
|
export const mostReservedThemes = async (count: number = 10): Promise<ThemeRetrieveListResponse> => {
|
||||||
return await apiClient.adminPost<ThemeCreateResponse>('/admin/themes', themeData);
|
return await apiClient.get<ThemeRetrieveListResponse>(`/themes/most-reserved-last-week?count=${count}`, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateTheme = async (id: string, themeData: ThemeUpdateRequest): Promise<void> => {
|
export const delTheme = async (id: number): Promise<void> => {
|
||||||
await apiClient.adminPatch<any>(`/admin/themes/${id}`, themeData);
|
return await apiClient.del(`/themes/${id}`, true);
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteTheme = async (id: string): Promise<void> => {
|
|
||||||
await apiClient.adminDel<any>(`/admin/themes/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchActiveThemes = async (): Promise<SimpleActiveThemeListResponse> => {
|
|
||||||
return await apiClient.adminGet<SimpleActiveThemeListResponse>('/admin/themes/active');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<ThemeInfoListResponse> => {
|
|
||||||
return await apiClient.post<ThemeInfoListResponse>('/themes/batch', request);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
|
|
||||||
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchMostReservedThemes = async (count: number): Promise<ThemeInfoListResponse> => {
|
|
||||||
return await apiClient.get<ThemeInfoListResponse>(`/themes/most-reserved?count=${count}`);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,105 +1,23 @@
|
|||||||
import type { AuditInfo } from '@_api/common/commonTypes';
|
|
||||||
|
|
||||||
export interface AdminThemeDetailResponse {
|
|
||||||
theme: ThemeInfoResponse;
|
|
||||||
isActive: boolean;
|
|
||||||
audit: AuditInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemeCreateRequest {
|
export interface ThemeCreateRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
thumbnailUrl: string;
|
thumbnail: string;
|
||||||
difficulty: Difficulty;
|
|
||||||
price: number;
|
|
||||||
minParticipants: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
availableMinutes: number;
|
|
||||||
expectedMinutesFrom: number;
|
|
||||||
expectedMinutesTo: number;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeCreateResponse {
|
export interface ThemeCreateResponse {
|
||||||
id: string;
|
id: number;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemeUpdateRequest {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
thumbnailUrl?: string;
|
|
||||||
difficulty?: Difficulty;
|
|
||||||
price?: number;
|
|
||||||
minParticipants?: number;
|
|
||||||
maxParticipants?: number;
|
|
||||||
availableMinutes?: number;
|
|
||||||
expectedMinutesFrom?: number;
|
|
||||||
expectedMinutesTo?: number;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminThemeSummaryResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
difficulty: Difficulty;
|
|
||||||
price: number;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminThemeSummaryListResponse {
|
|
||||||
themes: AdminThemeSummaryResponse[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemeInfoResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
difficulty: Difficulty;
|
thumbnail: string;
|
||||||
price: number;
|
|
||||||
minParticipants: number;
|
|
||||||
maxParticipants: number;
|
|
||||||
availableMinutes: number;
|
|
||||||
expectedMinutesFrom: number;
|
|
||||||
expectedMinutesTo: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeInfoListResponse {
|
export interface ThemeRetrieveResponse {
|
||||||
themes: ThemeInfoResponse[];
|
id: number;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemeIdListResponse {
|
|
||||||
themeIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Difficulty {
|
|
||||||
VERY_EASY = 'VERY_EASY',
|
|
||||||
EASY = 'EASY',
|
|
||||||
NORMAL = 'NORMAL',
|
|
||||||
HARD = 'HARD',
|
|
||||||
VERY_HARD = 'VERY_HARD',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DifficultyKoreanMap: Record<Difficulty, string> = {
|
|
||||||
[Difficulty.VERY_EASY]: '매우 쉬움',
|
|
||||||
[Difficulty.EASY]: '쉬움',
|
|
||||||
[Difficulty.NORMAL]: '보통',
|
|
||||||
[Difficulty.HARD]: '어려움',
|
|
||||||
[Difficulty.VERY_HARD]: '매우 어려움',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mapThemeResponse(res: any): ThemeInfoResponse {
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
difficulty: Difficulty[res.difficulty as keyof typeof Difficulty],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SimpleActiveThemeResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimpleActiveThemeListResponse {
|
export interface ThemeRetrieveListResponse {
|
||||||
themes: SimpleActiveThemeResponse[];
|
themes: ThemeRetrieveResponse[];
|
||||||
}
|
}
|
||||||
|
|||||||
18
frontend/src/api/time/timeAPI.ts
Normal file
18
frontend/src/api/time/timeAPI.ts
Normal 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);
|
||||||
|
};
|
||||||
27
frontend/src/api/time/timeTypes.ts
Normal file
27
frontend/src/api/time/timeTypes.ts
Normal 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[];
|
||||||
|
}
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,2 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93"
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
28
frontend/src/components/AdminRoute.tsx
Normal file
28
frontend/src/components/AdminRoute.tsx
Normal 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;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React, {type ReactNode} from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import Navbar from './Navbar';
|
import Navbar from './Navbar';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import { checkLogin } from '@_api/auth/authAPI';
|
||||||
import {Link, useNavigate} from 'react-router-dom';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {useAuth} from 'src/context/AuthContext';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import 'src/css/navbar.css';
|
import { useAuth } from 'src/context/AuthContext';
|
||||||
|
|
||||||
const Navbar: React.FC = () => {
|
const Navbar: React.FC = () => {
|
||||||
const { loggedIn, userName, logout } = useAuth();
|
const { loggedIn, userName, logout } = useAuth();
|
||||||
@ -15,31 +15,39 @@ const Navbar: React.FC = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error);
|
console.error('Logout failed:', error);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar-container">
|
<nav className="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
<div className="nav-links">
|
<Link className="navbar-brand" to="/">
|
||||||
<Link className="nav-link" to="/">홈</Link>
|
<img src="/image/service-logo.png" alt="LOGO" style={{ height: '40px' }} />
|
||||||
<Link className="nav-link" to="/reservation">예약하기</Link>
|
</Link>
|
||||||
</div>
|
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<div className="nav-actions">
|
<span className="navbar-toggler-icon"></span>
|
||||||
{!loggedIn ? (
|
</button>
|
||||||
<>
|
<div className="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
<button className="btn btn-secondary" onClick={() => navigate('/login')}>로그인</button>
|
<ul className="navbar-nav ms-auto">
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/signup')}>회원가입</button>
|
<li className="nav-item">
|
||||||
</>
|
<Link className="nav-link" to="/reservation">Reservation</Link>
|
||||||
) : (
|
</li>
|
||||||
<div className="profile-info">
|
{!loggedIn ? (
|
||||||
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
<li className="nav-item">
|
||||||
<span>{userName}</span>
|
<Link className="nav-link" to="/login">Login</Link>
|
||||||
<div className="dropdown-menu">
|
</li>
|
||||||
<Link className="dropdown-item" to="/my-reservation">내 예약</Link>
|
) : (
|
||||||
<div className="dropdown-divider" />
|
<li className="nav-item dropdown">
|
||||||
<a className="dropdown-item" href="#" onClick={handleLogout}>로그아웃</a>
|
<a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
</div>
|
<img className="profile-image" src="/image/default-profile.png" alt="Profile" />
|
||||||
</div>
|
<span id="profile-name">{userName}</span>
|
||||||
)}
|
</a>
|
||||||
|
<ul className="dropdown-menu" aria-labelledby="navbarDropdown">
|
||||||
|
<li><Link className="dropdown-item" to="/reservation-mine">My Reservation</Link></li>
|
||||||
|
<li><hr className="dropdown-divider" /></li>
|
||||||
|
<li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -1,13 +1,15 @@
|
|||||||
import { logout as apiLogout, userLogin as apiLogin } from '@_api/auth/authAPI';
|
import React, { createContext, useState, useEffect, ReactNode, useContext } from 'react';
|
||||||
import { type LoginRequest, type UserLoginSuccessResponse } from '@_api/auth/authTypes';
|
import { checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout } from '@_api/auth/authAPI';
|
||||||
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
|
import type { LoginRequest, LoginCheckResponse } from '@_api/auth/authTypes';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
userName: string | null;
|
userName: string | null;
|
||||||
loading: boolean;
|
role: 'ADMIN' | 'MEMBER' | null;
|
||||||
login: (data: Omit<LoginRequest, 'principalType'>) => Promise<UserLoginSuccessResponse>;
|
loading: boolean; // Add loading state to type
|
||||||
|
login: (data: LoginRequest) => Promise<LoginCheckResponse>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
checkLogin: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
@ -15,33 +17,32 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [loggedIn, setLoggedIn] = useState(false);
|
const [loggedIn, setLoggedIn] = useState(false);
|
||||||
const [userName, setUserName] = useState<string | null>(null);
|
const [userName, setUserName] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [role, setRole] = useState<'ADMIN' | 'MEMBER' | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true); // Add loading state
|
||||||
|
|
||||||
|
const checkLogin = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiCheckLogin();
|
||||||
|
setLoggedIn(true);
|
||||||
|
setUserName(response.name);
|
||||||
|
setRole(response.role);
|
||||||
|
} catch (error) {
|
||||||
|
setLoggedIn(false);
|
||||||
|
setUserName(null);
|
||||||
|
setRole(null);
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
} finally {
|
||||||
|
setLoading(false); // Set loading to false after check is complete
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
checkLogin();
|
||||||
const token = localStorage.getItem('accessToken');
|
|
||||||
const storedUserName = localStorage.getItem('userName');
|
|
||||||
|
|
||||||
if (token && storedUserName) {
|
|
||||||
setLoggedIn(true);
|
|
||||||
setUserName(storedUserName);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load user auth state from storage", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (data: Omit<LoginRequest, 'principalType'>) => {
|
const login = async (data: LoginRequest) => {
|
||||||
const response = await apiLogin(data);
|
const response = await apiLogin(data);
|
||||||
|
await checkLogin();
|
||||||
localStorage.setItem('accessToken', response.accessToken);
|
|
||||||
localStorage.setItem('userName', response.name);
|
|
||||||
|
|
||||||
setLoggedIn(true);
|
|
||||||
setUserName(response.name);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,15 +50,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
try {
|
try {
|
||||||
await apiLogout();
|
await apiLogout();
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem('accessToken');
|
|
||||||
localStorage.removeItem('userName');
|
|
||||||
setLoggedIn(false);
|
setLoggedIn(false);
|
||||||
setUserName(null);
|
setUserName(null);
|
||||||
|
setRole(null);
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ loggedIn, userName, loading, login, logout }}>
|
<AuthContext.Provider value={{ loggedIn, userName, role, loading, login, logout, checkLogin }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
15
frontend/src/css/reservation.css
Normal file
15
frontend/src/css/reservation.css
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -60,20 +60,3 @@
|
|||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =================================== */
|
|
||||||
/* Button Group */
|
|
||||||
/* =================================== */
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end; /* Aligns buttons to the right by default */
|
|
||||||
gap: 0.75rem; /* 12px */
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group.full-width {
|
|
||||||
justify-content: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group.full-width .btn {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
@ -81,15 +81,11 @@ a {
|
|||||||
margin-top: 72px;
|
margin-top: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-select {
|
|
||||||
margin: 10px 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
.button-group {
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
justify-content: center !important;
|
justify-content: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user