Merge branch 'main-design' into devs/design-merge

# Conflicts:
#	src/main/resources/logback-spring.xml
This commit is contained in:
Rinjae
2025-10-22 13:45:44 +09:00
56 changed files with 14128 additions and 831 deletions
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.apis.controller; package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSearchData;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto; import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade; import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService; import com.eactive.apim.portal.apps.apis.service.ApiService;
@@ -24,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestParam;
@RequestMapping("/apis") @RequestMapping("/apis")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ApiController { public class ApiController {
private static final String NOT_FOUND_MESSAGE = "찾을 수 없습니다."; private static final String NOT_FOUND_MESSAGE = "찾을 수 없습니다.";
private final ApiService apiService; private final ApiService apiService;
private final ApiServiceService apiServiceService; private final ApiServiceService apiServiceService;
@@ -33,15 +35,20 @@ public class ApiController {
@GetMapping("/detail") @GetMapping("/detail")
public String apidetail(@RequestParam(value = "id", required = false) String id, ModelMap model) { public String apidetail(@RequestParam(value = "id", required = false) String id, ModelMap model) {
if( id ==null) { if (id == null) {
return "redirect:/apis/common"; return "redirect:/apis/common";
} }
ApiSpecInfoDto api = apiService.selectDetail(id); ApiSpecInfoDto api = apiService.selectDetail(id);
if(api==null){ if (api == null) {
throw new NotFoundException(NOT_FOUND_MESSAGE); throw new NotFoundException(NOT_FOUND_MESSAGE);
} }
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
model.addAttribute("apiSpecInfo", api); model.addAttribute("apiSpecInfo", api);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("services", searchResult.get("services"));
return "apps/apis/mainApiDetail"; return "apps/apis/mainApiDetail";
} }
@@ -54,7 +61,7 @@ public class ApiController {
model.addAttribute("apis", searchResult.get("apis")); model.addAttribute("apis", searchResult.get("apis"));
model.addAttribute("totalApiCount", searchResult.get("totalApiCount")); model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount")); model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() >0 ? search.getGroupIds().get(0) : "-1"); model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
return "apps/apis/mainApiList"; return "apps/apis/mainApiList";
} }
@@ -1,7 +1,11 @@
package com.eactive.apim.portal.apps.apis.controller; package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService; import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
import java.io.IOException; import java.io.IOException;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
@@ -10,6 +14,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils; import org.springframework.util.FileCopyUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -21,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
public class TestbedSpecController { public class TestbedSpecController {
@Autowired @Autowired
ApiSpecService apiSpecService; private ApiSpecInfoService apiSpecInfoService;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec"; private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json"; private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@@ -40,12 +45,12 @@ public class TestbedSpecController {
} }
} }
String testbedSpec = apiSpecService.generateApiServiceTestbedSpec(id); Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (testbedSpec.isEmpty()) { if (!spec.isPresent()) {
return ResponseEntity.notFound().build(); StringUtils.hasText(spec.get().getTestbedSpec());
} else {
return ResponseEntity.ok().body(testbedSpec);
} }
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
} }
} }
@@ -44,4 +44,6 @@ public class ApiSpecInfoDto {
private String displayOrg; private String displayOrg;
private String displayRoleCode; private String displayRoleCode;
private String apiGroupName;
} }
@@ -113,7 +113,11 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
filteredApis.forEach(api -> { filteredApis.forEach(api -> {
api.setMainIcon(apiData.getServicesByApiId().get(api.getApiId()).getMainIcon()); ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
if (service != null) {
api.setMainIcon(service.getMainIcon());
api.setApiGroupName(service.getGroupName());
}
}); });
List<ApiServiceDTO> servicesToReturn = apiData.getAllServices().stream() List<ApiServiceDTO> servicesToReturn = apiData.getAllServices().stream()
@@ -143,6 +147,7 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
result.put("services", servicesToReturn); result.put("services", servicesToReturn);
result.put("apis", sortedApis); result.put("apis", sortedApis);
result.put("selectedApiCount", sortedApis.size()); result.put("selectedApiCount", sortedApis.size());
result.put("totalApiCount", apiData.getApiSpecsById().size());
return result; return result;
} }
@@ -12,6 +12,7 @@ import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -51,9 +52,12 @@ public class MainApiFacade {
public List<String> getHashTags() { public List<String> getHashTags() {
Map<String, String> property = portalPropertyService.getPortalPropertiesAsMap(PORTAL_PROPERTY); Map<String, String> property = portalPropertyService.getPortalPropertiesAsMap(PORTAL_PROPERTY);
String hashTags = property.getOrDefault("main.keyword.list", "#인증, #대출, #예금"); String hashTags = property.getOrDefault("main.keyword.list", "#계좌조회, #송금API, #실시간결제, #오픈뱅킹");
//split with , and return as a list //split with , and clean each tag (remove # and trim)
return Arrays.asList(hashTags.split(",")); return Arrays.stream(hashTags.split(","))
.map(tag -> tag.trim().replaceFirst("^#", ""))
.filter(tag -> !tag.isEmpty())
.collect(Collectors.toList());
} }
public List<ApiSpecInfoDto> getOpenApis(List<String> apiIds) { public List<ApiSpecInfoDto> getOpenApis(List<String> apiIds) {
@@ -69,7 +69,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/favicon_16px.png").addResourceLocations("/favicon_16px.png", "classpath:/static/favicon_16px.png").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver()); registry.addResourceHandler("/favicon.ico").addResourceLocations("/favicon.ico", "classpath:/static/favicon.ico").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
} }
+9 -9
View File
@@ -3,11 +3,11 @@
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/> <property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
<!-- <springProfile name="gf63">--> <!-- <springProfile name="gf63">-->
<!-- <property name="LOG_PATH" value="d:/kjb-logs/${inst.Name:-devSvr00}"/>--> <!-- <property name="LOG_PATH" value="d:/kjb-logs/${inst.Name:-devSvr00}"/>-->
<!-- </springProfile>--> <!-- </springProfile>-->
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />--> <!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" /> <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
@@ -102,7 +102,7 @@
</logger> </logger>
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" additivity="false"> <logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" /> <appender-ref ref="HIBERNATE_APPENDER" />
></logger> ></logger>
<logger name="com.eactive.eapim" level="DEBUG" additivity="false"/> <logger name="com.eactive.eapim" level="DEBUG" additivity="false"/>
<logger name="kjb.safedb" level="DEBUG" additivity="false"> <logger name="kjb.safedb" level="DEBUG" additivity="false">
@@ -116,9 +116,9 @@
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
</root> </root>
<!-- <root level="DEBUG">--> <!-- <root level="DEBUG">-->
<!-- <appender-ref ref="ROLLING"/>--> <!-- <appender-ref ref="ROLLING"/>-->
<!-- <appender-ref ref="CONSOLE"/>--> <!-- <appender-ref ref="CONSOLE"/>-->
<!-- </root>--> <!-- </root>-->
</springProfile> </springProfile>
</configuration> </configuration>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,144 @@
// -----------------------------------------------------------------------------
// This file contains all application-wide SASS mixins
// -----------------------------------------------------------------------------
// Media query mixins
@mixin respond-to($breakpoint) {
@if $breakpoint == 'xs' {
@media (max-width: #{$breakpoint-xs}) { @content; }
} @else if $breakpoint == 'sm' {
@media (max-width: #{$breakpoint-sm}) { @content; }
} @else if $breakpoint == 'md' {
@media (max-width: #{$breakpoint-md}) { @content; }
} @else if $breakpoint == 'lg' {
@media (min-width: #{$breakpoint-lg}) { @content; }
} @else if $breakpoint == 'xl' {
@media (min-width: #{$breakpoint-xl}) { @content; }
} @else if $breakpoint == 'mobile' {
@media (max-width: #{$breakpoint-lg}) { @content; }
} @else if $breakpoint == 'tablet' {
@media (max-width: #{$breakpoint-md}) { @content; }
}
}
// Flexbox mixins
@mixin flex-center {
display: flex;
justify-content: center;
align-items: center;
}
@mixin flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
// Button mixin
@mixin button-base {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
padding: $button-padding-y $button-padding-x;
border-radius: $border-radius-lg;
font-weight: $font-weight-semibold;
font-size: $font-size-base;
text-decoration: none;
transition: $transition-base;
cursor: pointer;
border: none;
&:hover {
transform: translateY(-3px);
}
}
// Card mixin
@mixin card {
background: $white;
border-radius: $border-radius-2xl;
padding: $card-padding;
transition: $transition-base;
&:hover {
transform: translateY(-5px);
box-shadow: $shadow-lg;
}
}
// Gradient text
@mixin gradient-text($gradient) {
background: $gradient;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
// Truncate text
@mixin text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Absolute center
@mixin absolute-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
// Backdrop blur
@mixin backdrop-blur($amount: 10px) {
backdrop-filter: blur($amount);
-webkit-backdrop-filter: blur($amount);
}
// Animation mixins
@mixin animate-float {
animation: float 20s ease-in-out infinite;
}
@mixin animate-bounce {
animation: bounce 2s infinite;
}
@mixin animate-slide-in-down {
animation: slideInDown 0.6s ease-out;
}
@mixin animate-slide-in-up {
animation: slideInUp 0.8s ease-out;
}
@mixin animate-fade-in {
animation: fadeIn 1.2s ease-out;
}
// Hover lift effect
@mixin hover-lift {
transition: $transition-base;
&:hover {
transform: translateY(-8px);
box-shadow: $shadow-xl;
}
}
// Glass effect
@mixin glass-effect {
background: rgba($white, 0.95);
@include backdrop-blur(10px);
border: 1px solid rgba($white, 0.18);
}
// Pattern circle
@mixin pattern-circle($size, $color) {
width: $size;
height: $size;
background: $color;
border-radius: 50%;
opacity: 0.1;
position: absolute;
}
@@ -0,0 +1,121 @@
// -----------------------------------------------------------------------------
// This file contains all application-wide SASS variables
// Based on design/styles-v2.css - Modern vibrant design system
// -----------------------------------------------------------------------------
// Color Palette - Vibrant and Modern
// Primary colors
$primary-blue: #4B9BFF; // Bright blue (main brand color)
$secondary-blue: #2E7FF7; // Deep blue
$accent-cyan: #00D4FF; // Sky blue accent
$accent-yellow: #FFD93D; // Yellow accent
$accent-orange: #FF6B6B; // Orange accent
$accent-green: #6BCF7F; // Green accent
$accent-purple: #A78BFA; // Purple accent
// Neutral colors
$text-dark: #1A1A2E; // Main text color
$text-gray: #64748B; // Secondary text
$text-light: #94A3B8; // Light text
$white: #FFFFFF; // Pure white
$gray-bg: #F8FAFC; // Light background
$light-bg: #EFF6FF; // Blue tinted background
$border-gray: #E2E8F0; // Border color
$black: #000000; // Pure black
$gray-800: #1F2937; // Dark gray
$gray-900: #111827; // Darker gray
// Gradients
$gradient-primary: linear-gradient(135deg, $primary-blue 0%, $secondary-blue 100%);
$gradient-accent: linear-gradient(135deg, $accent-cyan 0%, $primary-blue 100%);
$gradient-warm: linear-gradient(135deg, $accent-yellow 0%, $accent-orange 100%);
// Shadows
$shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 12px rgba(75, 155, 255, 0.1);
$shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
$shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
// Typography
$font-family-primary: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR', sans-serif;
$font-family-mono: 'Fira Code', 'Courier New', monospace;
// Font sizes
$font-size-xs: 12px;
$font-size-sm: 14px;
$font-size-base: 16px;
$font-size-md: 18px;
$font-size-lg: 20px;
$font-size-xl: 24px;
$font-size-2xl: 32px;
$font-size-3xl: 40px;
$font-size-4xl: 48px;
$font-size-5xl: 56px;
// Font weights
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
$font-weight-extrabold: 800;
// Line heights
$line-height-tight: 1.2;
$line-height-normal: 1.6;
$line-height-loose: 1.8;
// Spacing
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$spacing-2xl: 40px;
$spacing-3xl: 48px;
$spacing-4xl: 64px;
$spacing-5xl: 80px;
$spacing-6xl: 100px;
// Border radius
$border-radius-sm: 6px;
$border-radius-md: 8px;
$border-radius-lg: 12px;
$border-radius-xl: 16px;
$border-radius-2xl: 20px;
$border-radius-full: 50px;
$border-radius-circle: 50%;
// Layout
$container-max-width: 1280px;
$header-height: 80px;
// Breakpoints
$breakpoint-xs: 480px;
$breakpoint-sm: 768px;
$breakpoint-md: 1024px;
$breakpoint-lg: 1280px;
$breakpoint-xl: 1440px;
// Z-index
$z-index-dropdown: 100;
$z-index-sticky: 200;
$z-index-fixed: 300;
$z-index-header: 350;
$z-index-modal-backdrop: 400;
$z-index-modal: 500;
$z-index-popover: 600;
$z-index-tooltip: 700;
$z-index-notification: 800;
// Animation
$transition-base: all 0.3s ease;
$transition-fast: all 0.15s ease;
$transition-slow: all 0.5s ease;
// Component specific variables
$button-padding-x: 32px;
$button-padding-y: 16px;
$card-padding: 32px;
$input-padding-x: 16px;
$input-padding-y: 12px;
$input-height: 48px;
@@ -0,0 +1,330 @@
// -----------------------------------------------------------------------------
// Animation keyframes and utilities
// -----------------------------------------------------------------------------
// Float animation
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-30px) rotate(10deg);
}
}
// Bounce animation
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// Slide in from top
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Slide in from bottom
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Slide in from left
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// Slide in from right
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// Fade in
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// Fade in and scale
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
// Spin
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Pulse
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
// Shimmer effect
@keyframes shimmer {
0% {
transform: translateX(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) rotate(45deg);
}
}
// Wave
@keyframes wave {
0% {
transform: rotate(0deg);
}
10% {
transform: rotate(14deg);
}
20% {
transform: rotate(-8deg);
}
30% {
transform: rotate(14deg);
}
40% {
transform: rotate(-4deg);
}
50% {
transform: rotate(10deg);
}
60% {
transform: rotate(0deg);
}
100% {
transform: rotate(0deg);
}
}
// Pop in
@keyframes popIn {
0% {
opacity: 0;
transform: scale(0.5);
}
80% {
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
// Popup fade in (for modals)
@keyframes popupFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
// Ripple effect
@keyframes ripple {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(4);
opacity: 0;
}
}
// Gradient animation
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
// Animation utility classes
.animate-float {
animation: float 20s ease-in-out infinite;
}
.animate-bounce {
animation: bounce 2s infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out;
}
.animate-slide-up {
animation: slideInUp 0.8s ease-out;
}
.animate-slide-down {
animation: slideInDown 0.6s ease-out;
}
.animate-pop {
animation: popIn 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
// Animation delays
.delay-1 {
animation-delay: 0.1s;
}
.delay-2 {
animation-delay: 0.2s;
}
.delay-3 {
animation-delay: 0.3s;
}
.delay-4 {
animation-delay: 0.4s;
}
.delay-5 {
animation-delay: 0.5s;
}
// Animation durations
.duration-fast {
animation-duration: 0.3s;
}
.duration-normal {
animation-duration: 0.6s;
}
.duration-slow {
animation-duration: 1s;
}
.duration-slower {
animation-duration: 2s;
}
// Hover animation triggers
.hover-grow {
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
}
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-5px);
box-shadow: $shadow-lg;
}
}
.hover-rotate {
transition: transform 0.3s ease;
&:hover {
transform: rotate(5deg);
}
}
.hover-shine {
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.2) 50%,
transparent 70%
);
transform: rotate(45deg);
transition: transform 0.6s ease;
transform: translateX(-200%);
}
&:hover::before {
transform: translateX(200%) rotate(45deg);
}
}
@@ -0,0 +1,95 @@
// -----------------------------------------------------------------------------
// Reset and normalize styles
// -----------------------------------------------------------------------------
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: $font-family-primary;
line-height: $line-height-normal;
color: $text-dark;
background-color: $white;
overflow-x: hidden;
}
// Lists
ul, ol {
list-style: none;
}
// Links
a {
text-decoration: none;
color: inherit;
transition: $transition-base;
}
// Images
img {
max-width: 100%;
height: auto;
display: block;
}
// Buttons
button {
font-family: inherit;
font-size: inherit;
line-height: inherit;
cursor: pointer;
background: transparent;
border: none;
padding: 0;
}
// Forms
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
border: none;
outline: none;
}
// Tables
table {
border-collapse: collapse;
border-spacing: 0;
}
// Headings
h1, h2, h3, h4, h5, h6 {
font-weight: $font-weight-bold;
line-height: $line-height-tight;
}
// Semantic HTML5 elements
article, aside, details, figcaption, figure,
footer, header, hgroup, main, menu, nav,
section, summary {
display: block;
}
// Selection
::selection {
background-color: rgba($primary-blue, 0.2);
color: $text-dark;
}
::-moz-selection {
background-color: rgba($primary-blue, 0.2);
color: $text-dark;
}
@@ -0,0 +1,219 @@
// -----------------------------------------------------------------------------
// Basic typography styles
// -----------------------------------------------------------------------------
// Body text
body {
font-size: $font-size-base;
font-weight: $font-weight-regular;
color: $text-dark;
}
// Headings
h1, .h1 {
font-size: $font-size-5xl;
font-weight: $font-weight-extrabold;
margin-bottom: $spacing-lg;
@include respond-to('sm') {
font-size: $font-size-3xl;
}
@include respond-to('xs') {
font-size: $font-size-2xl;
}
}
h2, .h2 {
font-size: $font-size-4xl;
font-weight: $font-weight-bold;
margin-bottom: $spacing-md;
@include respond-to('sm') {
font-size: $font-size-2xl;
}
@include respond-to('xs') {
font-size: $font-size-xl;
}
}
h3, .h3 {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
margin-bottom: $spacing-md;
@include respond-to('sm') {
font-size: $font-size-xl;
}
}
h4, .h4 {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-sm;
}
h5, .h5 {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-sm;
}
h6, .h6 {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-sm;
}
// Paragraphs
p {
margin-bottom: $spacing-md;
line-height: $line-height-normal;
&:last-child {
margin-bottom: 0;
}
}
// Text utilities
.text-gradient {
@include gradient-text($gradient-primary);
}
.text-gradient-accent {
@include gradient-text($gradient-accent);
}
.text-gradient-warm {
@include gradient-text($gradient-warm);
}
.text-primary {
color: $primary-blue;
}
.text-secondary {
color: $secondary-blue;
}
.text-dark {
color: $text-dark;
}
.text-gray {
color: $text-gray;
}
.text-light {
color: $text-light;
}
.text-white {
color: $white;
}
// Text alignment
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
// Font weights
.font-regular {
font-weight: $font-weight-regular;
}
.font-medium {
font-weight: $font-weight-medium;
}
.font-semibold {
font-weight: $font-weight-semibold;
}
.font-bold {
font-weight: $font-weight-bold;
}
.font-extrabold {
font-weight: $font-weight-extrabold;
}
// Text sizes
.text-xs {
font-size: $font-size-xs;
}
.text-sm {
font-size: $font-size-sm;
}
.text-base {
font-size: $font-size-base;
}
.text-lg {
font-size: $font-size-lg;
}
.text-xl {
font-size: $font-size-xl;
}
// Special text styles
.lead {
font-size: $font-size-lg;
font-weight: $font-weight-regular;
line-height: $line-height-loose;
color: $text-gray;
}
.caption {
font-size: $font-size-sm;
color: $text-gray;
}
.code {
font-family: $font-family-mono;
font-size: $font-size-sm;
background: $gray-bg;
padding: 2px 6px;
border-radius: $border-radius-sm;
}
// Links
a {
color: $primary-blue;
&:hover {
color: $secondary-blue;
}
}
// Blockquote
blockquote {
padding: $spacing-md;
margin: $spacing-md 0;
border-left: 4px solid $primary-blue;
background: $gray-bg;
font-style: italic;
p {
margin-bottom: 0;
}
}
// Horizontal rule
hr {
border: none;
border-top: 1px solid $border-gray;
margin: $spacing-xl 0;
}
@@ -0,0 +1,497 @@
// -----------------------------------------------------------------------------
// Utility classes
// -----------------------------------------------------------------------------
// Container
.container {
max-width: $container-max-width;
margin: 0 auto;
padding: 0 $spacing-lg;
@include respond-to('sm') {
padding: 0 $spacing-md;
}
}
// Display utilities
.d-none {
display: none !important;
}
.d-block {
display: block !important;
}
.d-inline-block {
display: inline-block !important;
}
.d-flex {
display: flex !important;
}
.d-inline-flex {
display: inline-flex !important;
}
.d-grid {
display: grid !important;
}
// Flexbox utilities
.flex-center {
@include flex-center;
}
.flex-between {
@include flex-between;
}
.flex-column {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.align-center {
align-items: center;
}
.align-start {
align-items: flex-start;
}
.align-end {
align-items: flex-end;
}
.justify-center {
justify-content: center;
}
.justify-start {
justify-content: flex-start;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
// Gap utilities
.gap-xs {
gap: $spacing-xs;
}
.gap-sm {
gap: $spacing-sm;
}
.gap-md {
gap: $spacing-md;
}
.gap-lg {
gap: $spacing-lg;
}
.gap-xl {
gap: $spacing-xl;
}
// Margin utilities
@each $size, $value in (
0: 0,
xs: $spacing-xs,
sm: $spacing-sm,
md: $spacing-md,
lg: $spacing-lg,
xl: $spacing-xl,
2xl: $spacing-2xl,
3xl: $spacing-3xl,
4xl: $spacing-4xl,
auto: auto
) {
.m-#{$size} {
margin: $value !important;
}
.mt-#{$size} {
margin-top: $value !important;
}
.mb-#{$size} {
margin-bottom: $value !important;
}
.ml-#{$size} {
margin-left: $value !important;
}
.mr-#{$size} {
margin-right: $value !important;
}
.mx-#{$size} {
margin-left: $value !important;
margin-right: $value !important;
}
.my-#{$size} {
margin-top: $value !important;
margin-bottom: $value !important;
}
}
// Padding utilities
@each $size, $value in (
0: 0,
xs: $spacing-xs,
sm: $spacing-sm,
md: $spacing-md,
lg: $spacing-lg,
xl: $spacing-xl,
2xl: $spacing-2xl,
3xl: $spacing-3xl,
4xl: $spacing-4xl
) {
.p-#{$size} {
padding: $value !important;
}
.pt-#{$size} {
padding-top: $value !important;
}
.pb-#{$size} {
padding-bottom: $value !important;
}
.pl-#{$size} {
padding-left: $value !important;
}
.pr-#{$size} {
padding-right: $value !important;
}
.px-#{$size} {
padding-left: $value !important;
padding-right: $value !important;
}
.py-#{$size} {
padding-top: $value !important;
padding-bottom: $value !important;
}
}
// Width utilities
.w-25 {
width: 25%;
}
.w-50 {
width: 50%;
}
.w-75 {
width: 75%;
}
.w-100 {
width: 100%;
}
.w-auto {
width: auto;
}
// Height utilities
.h-25 {
height: 25%;
}
.h-50 {
height: 50%;
}
.h-75 {
height: 75%;
}
.h-100 {
height: 100%;
}
.h-auto {
height: auto;
}
.vh-100 {
height: 100vh;
}
// Position utilities
.position-relative {
position: relative;
}
.position-absolute {
position: absolute;
}
.position-fixed {
position: fixed;
}
.position-sticky {
position: sticky;
}
// Background utilities
.bg-primary {
background: $primary-blue;
}
.bg-secondary {
background: $secondary-blue;
}
.bg-white {
background: $white;
}
.bg-gray {
background: $gray-bg;
}
.bg-light {
background: $light-bg;
}
.bg-gradient-primary {
background: $gradient-primary;
}
.bg-gradient-accent {
background: $gradient-accent;
}
.bg-gradient-warm {
background: $gradient-warm;
}
// Border utilities
.border {
border: 1px solid $border-gray;
}
.border-0 {
border: none !important;
}
.border-top {
border-top: 1px solid $border-gray;
}
.border-bottom {
border-bottom: 1px solid $border-gray;
}
.border-left {
border-left: 1px solid $border-gray;
}
.border-right {
border-right: 1px solid $border-gray;
}
.border-primary {
border-color: $primary-blue;
}
// Border radius utilities
.rounded-0 {
border-radius: 0;
}
.rounded-sm {
border-radius: $border-radius-sm;
}
.rounded {
border-radius: $border-radius-md;
}
.rounded-lg {
border-radius: $border-radius-lg;
}
.rounded-xl {
border-radius: $border-radius-xl;
}
.rounded-full {
border-radius: $border-radius-full;
}
.rounded-circle {
border-radius: $border-radius-circle;
}
// Shadow utilities
.shadow-none {
box-shadow: none !important;
}
.shadow-sm {
box-shadow: $shadow-sm;
}
.shadow {
box-shadow: $shadow-md;
}
.shadow-lg {
box-shadow: $shadow-lg;
}
.shadow-xl {
box-shadow: $shadow-xl;
}
// Overflow utilities
.overflow-hidden {
overflow: hidden;
}
.overflow-auto {
overflow: auto;
}
.overflow-x-hidden {
overflow-x: hidden;
}
.overflow-y-hidden {
overflow-y: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-y-auto {
overflow-y: auto;
}
// Z-index utilities
.z-0 {
z-index: 0;
}
.z-10 {
z-index: 10;
}
.z-20 {
z-index: 20;
}
.z-30 {
z-index: 30;
}
.z-40 {
z-index: 40;
}
.z-50 {
z-index: 50;
}
// Responsive utilities
@include respond-to('sm') {
.sm-hide {
display: none !important;
}
}
@include respond-to('md') {
.md-hide {
display: none !important;
}
}
.mobile-only {
@media (min-width: #{$breakpoint-sm + 1px}) {
display: none !important;
}
}
.desktop-only {
@include respond-to('sm') {
display: none !important;
}
}
// Cursor utilities
.cursor-pointer {
cursor: pointer;
}
.cursor-default {
cursor: default;
}
.cursor-not-allowed {
cursor: not-allowed;
}
// Opacity utilities
.opacity-0 {
opacity: 0;
}
.opacity-25 {
opacity: 0.25;
}
.opacity-50 {
opacity: 0.5;
}
.opacity-75 {
opacity: 0.75;
}
.opacity-100 {
opacity: 1;
}
// Transition utilities
.transition {
transition: $transition-base;
}
.transition-fast {
transition: $transition-fast;
}
.transition-slow {
transition: $transition-slow;
}
.transition-none {
transition: none;
}
@@ -0,0 +1,271 @@
// -----------------------------------------------------------------------------
// Button styles - Modern vibrant design
// -----------------------------------------------------------------------------
// Base button
.btn {
@include button-base;
position: relative;
overflow: hidden;
// Button sizes
&-sm {
padding: $spacing-sm $spacing-md;
font-size: $font-size-sm;
}
&-md {
padding: $spacing-md $spacing-lg;
font-size: $font-size-base;
}
&-lg {
padding: $spacing-md $spacing-xl;
font-size: $font-size-md;
}
&-xl {
padding: $spacing-lg $spacing-2xl;
font-size: $font-size-lg;
}
// Button variants
&-primary {
background: $gradient-primary;
color: $white;
box-shadow: $shadow-md;
&:hover {
transform: translateY(-3px);
box-shadow: $shadow-xl;
}
&:active {
transform: translateY(-1px);
}
}
&-secondary {
background: $white;
color: $primary-blue;
border: 2px solid $primary-blue;
&:hover {
background: $light-bg;
transform: translateY(-3px);
}
}
&-accent {
background: $gradient-accent;
color: $white;
box-shadow: $shadow-md;
&:hover {
transform: translateY(-3px);
box-shadow: $shadow-xl;
}
}
&-warm {
background: $gradient-warm;
color: $white;
box-shadow: $shadow-md;
&:hover {
transform: translateY(-3px);
box-shadow: $shadow-xl;
}
}
&-success {
background: $accent-green;
color: $white;
&:hover {
background: darken($accent-green, 10%);
transform: translateY(-3px);
}
}
&-danger {
background: $accent-orange;
color: $white;
&:hover {
background: darken($accent-orange, 10%);
transform: translateY(-3px);
}
}
&-ghost {
background: transparent;
color: $primary-blue;
border: 2px solid transparent;
&:hover {
background: rgba($primary-blue, 0.1);
border-color: $primary-blue;
}
}
&-outline {
background: transparent;
color: $text-dark;
border: 2px solid $border-gray;
&:hover {
border-color: $primary-blue;
color: $primary-blue;
transform: translateY(-3px);
}
}
// Button states
&:disabled,
&.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
&.loading {
color: transparent;
pointer-events: none;
&::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
@include absolute-center;
border: 2px solid $white;
border-radius: 50%;
border-top-color: transparent;
animation: spin 0.6s linear infinite;
}
}
// Button with icon
&-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border-radius: $border-radius-circle;
&.btn-sm {
width: 32px;
height: 32px;
}
&.btn-lg {
width: 56px;
height: 56px;
}
}
// Full width button
&-block {
width: 100%;
justify-content: center;
}
// Button group
&-group {
display: inline-flex;
gap: -1px;
.btn {
border-radius: 0;
&:first-child {
border-top-left-radius: $border-radius-lg;
border-bottom-left-radius: $border-radius-lg;
}
&:last-child {
border-top-right-radius: $border-radius-lg;
border-bottom-right-radius: $border-radius-lg;
}
}
}
}
// Floating action button
.fab {
position: fixed;
bottom: $spacing-xl;
right: $spacing-xl;
width: 60px;
height: 60px;
background: $gradient-primary;
border: none;
border-radius: $border-radius-circle;
color: $white;
font-size: $font-size-xl;
cursor: pointer;
box-shadow: $shadow-lg;
transition: $transition-base;
z-index: $z-index-fixed;
@include flex-center;
&:hover {
transform: scale(1.1);
box-shadow: $shadow-xl;
.fab-label {
opacity: 1;
transform: translateX(-10px);
}
}
.fab-label {
position: absolute;
right: 70px;
top: 50%;
transform: translateY(-50%);
background: $text-dark;
color: $white;
padding: $spacing-sm $spacing-md;
border-radius: $border-radius-md;
font-size: $font-size-sm;
white-space: nowrap;
opacity: 0;
transition: $transition-base;
pointer-events: none;
}
}
// CTA buttons
.btn-cta {
&-primary {
@include button-base;
background: $white;
color: $primary-blue;
padding: $spacing-md $spacing-2xl;
font-size: $font-size-md;
box-shadow: $shadow-lg;
&:hover {
transform: translateY(-3px);
box-shadow: $shadow-xl;
}
}
&-secondary {
@include button-base;
background: transparent;
color: $white;
border: 2px solid $white;
padding: $spacing-md $spacing-2xl;
font-size: $font-size-md;
&:hover {
background: rgba($white, 0.1);
transform: translateY(-3px);
}
}
}
@@ -0,0 +1,386 @@
// -----------------------------------------------------------------------------
// Card styles - Modern vibrant design
// -----------------------------------------------------------------------------
// Base card
.card {
@include card;
position: relative;
overflow: hidden;
// Card hover effect
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba($primary-blue, 0.03) 50%,
transparent 70%
);
transform: rotate(45deg);
transition: $transition-slow;
opacity: 0;
}
&:hover::before {
animation: shimmer 0.6s ease;
opacity: 1;
}
}
// API showcase cards
.api-card {
@include card;
text-align: center;
border: 2px solid $border-gray;
&:hover {
border-color: $primary-blue;
transform: translateY(-8px);
}
// Popular variant
&-popular {
border-color: $accent-yellow;
background: linear-gradient(180deg, rgba($accent-yellow, 0.05) 0%, $white 100%);
}
// New variant
&-new {
border-color: $accent-green;
background: linear-gradient(180deg, rgba($accent-green, 0.05) 0%, $white 100%);
}
// API badge
.api-badge {
position: absolute;
top: -12px;
right: 20px;
padding: $spacing-xs $spacing-md;
background: $accent-yellow;
color: $text-dark;
border-radius: $border-radius-2xl;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
text-transform: uppercase;
&.new {
background: $accent-green;
color: $white;
}
&.beta {
background: $accent-purple;
color: $white;
}
}
// API icon
.api-icon {
width: 60px;
height: 60px;
margin: 0 auto $spacing-lg;
@include flex-center;
font-size: 28px;
background: $light-bg;
color: $primary-blue;
border-radius: $border-radius-xl;
transition: $transition-base;
}
&:hover .api-icon {
transform: scale(1.1);
background: $gradient-primary;
color: $white;
}
// API content
.api-name {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
margin-bottom: $spacing-md;
color: $text-dark;
}
.api-description {
color: $text-gray;
line-height: $line-height-normal;
margin-bottom: $spacing-lg;
font-size: $font-size-sm;
}
// API features
.api-features {
display: flex;
justify-content: center;
gap: $spacing-sm;
margin-bottom: $spacing-lg;
flex-wrap: wrap;
.feature-tag {
padding: $spacing-xs $spacing-sm;
background: $gray-bg;
color: $text-gray;
border-radius: $border-radius-2xl;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
}
}
// API link
.api-link {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
color: $primary-blue;
font-weight: $font-weight-semibold;
font-size: $font-size-sm;
transition: $transition-base;
&:hover {
gap: $spacing-sm;
color: $secondary-blue;
}
}
}
// Experience cards
.experience-card {
@include card;
text-align: center;
.card-icon {
width: 56px;
height: 56px;
margin: 0 auto $spacing-lg;
@include flex-center;
font-size: $font-size-xl;
background: $gradient-primary;
color: $white;
border-radius: $border-radius-xl;
}
h3 {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
margin-bottom: $spacing-md;
color: $text-dark;
}
p {
color: $text-gray;
line-height: $line-height-normal;
margin-bottom: $spacing-lg;
font-size: $font-size-sm;
}
// Card metric
.card-metric {
display: flex;
flex-direction: column;
align-items: center;
padding-top: $spacing-lg;
border-top: 1px solid $border-gray;
.metric-value {
font-size: $font-size-xl;
font-weight: $font-weight-extrabold;
color: $primary-blue;
}
.metric-label {
font-size: $font-size-xs;
color: $text-gray;
font-weight: $font-weight-medium;
}
}
}
// Feature cards
.feature-card {
@include card;
display: flex;
align-items: flex-start;
gap: $spacing-lg;
.feature-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
@include flex-center;
background: $gradient-primary;
color: $white;
border-radius: $border-radius-lg;
font-size: $font-size-xl;
}
.feature-content {
flex: 1;
h4 {
font-size: $font-size-md;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-sm;
color: $text-dark;
}
p {
color: $text-gray;
line-height: $line-height-normal;
font-size: $font-size-sm;
}
}
}
// Testimonial cards
.testimonial-card {
@include card;
position: relative;
&::before {
content: '"';
position: absolute;
top: $spacing-md;
left: $spacing-md;
font-size: 60px;
color: $primary-blue;
opacity: 0.1;
font-weight: $font-weight-bold;
}
.testimonial-content {
position: relative;
z-index: 1;
font-size: $font-size-base;
line-height: $line-height-loose;
color: $text-dark;
margin-bottom: $spacing-lg;
font-style: italic;
}
.testimonial-author {
display: flex;
align-items: center;
gap: $spacing-md;
.author-avatar {
width: 48px;
height: 48px;
border-radius: $border-radius-circle;
object-fit: cover;
}
.author-info {
.author-name {
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: 2px;
}
.author-title {
font-size: $font-size-sm;
color: $text-gray;
}
}
}
}
// Pricing cards
.pricing-card {
@include card;
text-align: center;
position: relative;
&.featured {
border: 2px solid $primary-blue;
transform: scale(1.05);
.pricing-badge {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
padding: $spacing-xs $spacing-md;
background: $gradient-primary;
color: $white;
border-radius: $border-radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
text-transform: uppercase;
}
}
.pricing-header {
padding-bottom: $spacing-lg;
border-bottom: 1px solid $border-gray;
margin-bottom: $spacing-lg;
h3 {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
margin-bottom: $spacing-sm;
color: $text-dark;
}
.price {
display: flex;
align-items: baseline;
justify-content: center;
gap: $spacing-xs;
.currency {
font-size: $font-size-lg;
color: $text-gray;
}
.amount {
font-size: $font-size-4xl;
font-weight: $font-weight-extrabold;
@include gradient-text($gradient-primary);
}
.period {
font-size: $font-size-base;
color: $text-gray;
}
}
}
.pricing-features {
list-style: none;
margin-bottom: $spacing-lg;
li {
padding: $spacing-sm 0;
color: $text-gray;
font-size: $font-size-sm;
display: flex;
align-items: center;
gap: $spacing-sm;
&::before {
content: '';
color: $accent-green;
font-weight: $font-weight-bold;
}
&.disabled {
opacity: 0.5;
&::before {
content: '×';
color: $text-light;
}
}
}
}
.pricing-cta {
margin-top: auto;
}
}
@@ -0,0 +1,10 @@
// -----------------------------------------------------------------------------
// CTA (Call to Action) section styles
// -----------------------------------------------------------------------------
.final-cta {
position: relative;
padding: $spacing-6xl * 1.5 0;
background: $gradient-primary;
overflow: hidden;
}
@@ -0,0 +1,240 @@
// -----------------------------------------------------------------------------
// Form styles
// -----------------------------------------------------------------------------
// Form group
.form-group {
margin-bottom: $spacing-lg;
label {
display: block;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-dark;
margin-bottom: $spacing-sm;
.required {
color: $accent-orange;
margin-left: 2px;
}
}
.form-hint {
font-size: $font-size-xs;
color: $text-gray;
margin-top: $spacing-xs;
}
.form-error {
font-size: $font-size-xs;
color: $accent-orange;
margin-top: $spacing-xs;
display: flex;
align-items: center;
gap: $spacing-xs;
}
}
// Form control base
.form-control {
width: 100%;
padding: $input-padding-y $input-padding-x;
font-size: $font-size-base;
font-family: $font-family-primary;
color: $text-dark;
background: $white;
border: 2px solid $border-gray;
border-radius: $border-radius-md;
transition: $transition-base;
&::placeholder {
color: $text-light;
}
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.1);
}
&:disabled {
background: $gray-bg;
color: $text-gray;
cursor: not-allowed;
}
// Size variations
&-sm {
padding: $spacing-sm $spacing-md;
font-size: $font-size-sm;
}
&-lg {
padding: $spacing-md $spacing-lg;
font-size: $font-size-md;
}
// State variations
&.is-valid {
border-color: $accent-green;
&:focus {
box-shadow: 0 0 0 3px rgba($accent-green, 0.1);
}
}
&.is-invalid {
border-color: $accent-orange;
&:focus {
box-shadow: 0 0 0 3px rgba($accent-orange, 0.1);
}
}
}
// Textarea
textarea.form-control {
min-height: 120px;
resize: vertical;
}
// Select
select.form-control {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M6 8L0 0h12z' fill='%2364748B'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right $spacing-md center;
background-size: 12px;
padding-right: $spacing-2xl;
}
// Checkbox and Radio
.form-check {
display: flex;
align-items: center;
margin-bottom: $spacing-md;
input[type="checkbox"],
input[type="radio"] {
width: 20px;
height: 20px;
margin-right: $spacing-sm;
cursor: pointer;
}
label {
margin-bottom: 0;
cursor: pointer;
user-select: none;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
input,
label {
cursor: not-allowed;
}
}
}
// Switch toggle
.form-switch {
display: flex;
align-items: center;
gap: $spacing-md;
.switch {
position: relative;
width: 48px;
height: 24px;
background: $border-gray;
border-radius: $border-radius-full;
cursor: pointer;
transition: $transition-base;
&::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: $white;
border-radius: $border-radius-circle;
transition: $transition-base;
}
&.active {
background: $primary-blue;
&::after {
transform: translateX(24px);
}
}
}
}
// Input group
.input-group {
display: flex;
width: 100%;
.input-group-text {
padding: $input-padding-y $input-padding-x;
font-size: $font-size-base;
color: $text-gray;
background: $gray-bg;
border: 2px solid $border-gray;
border-radius: $border-radius-md 0 0 $border-radius-md;
border-right: none;
}
.form-control {
border-radius: 0 $border-radius-md $border-radius-md 0;
}
&.input-group-append {
.input-group-text {
border-radius: 0 $border-radius-md $border-radius-md 0;
border-right: 2px solid $border-gray;
border-left: none;
}
.form-control {
border-radius: $border-radius-md 0 0 $border-radius-md;
}
}
}
// Form inline
.form-inline {
display: flex;
align-items: flex-end;
gap: $spacing-md;
.form-group {
margin-bottom: 0;
}
@include respond-to('sm') {
flex-direction: column;
align-items: stretch;
.form-group {
margin-bottom: $spacing-md;
}
}
}
// Form row
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-lg;
@include respond-to('sm') {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,196 @@
// -----------------------------------------------------------------------------
// Modal styles
// -----------------------------------------------------------------------------
// Modal backdrop
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: $z-index-modal-backdrop;
opacity: 0;
visibility: hidden;
transition: $transition-base;
&.show {
opacity: 1;
visibility: visible;
}
}
// Modal wrapper
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: $z-index-modal;
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-lg;
opacity: 0;
visibility: hidden;
transition: $transition-base;
&.show {
opacity: 1;
visibility: visible;
.modal-dialog {
transform: scale(1);
}
}
}
// Modal dialog
.modal-dialog {
position: relative;
background: $white;
border-radius: $border-radius-xl;
box-shadow: $shadow-xl;
max-width: 500px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
transform: scale(0.95);
transition: $transition-base;
// Size variations
&.modal-sm {
max-width: 400px;
}
&.modal-lg {
max-width: 800px;
}
&.modal-xl {
max-width: 1200px;
}
&.modal-fullscreen {
max-width: 100%;
max-height: 100%;
height: 100%;
margin: 0;
border-radius: 0;
}
}
// Modal header
.modal-header {
padding: $spacing-lg;
border-bottom: 1px solid $border-gray;
display: flex;
align-items: center;
justify-content: space-between;
.modal-title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $text-dark;
margin: 0;
}
.modal-close {
width: 32px;
height: 32px;
@include flex-center;
background: transparent;
border: none;
border-radius: $border-radius-md;
color: $text-gray;
font-size: $font-size-xl;
cursor: pointer;
transition: $transition-base;
&:hover {
background: $gray-bg;
color: $text-dark;
}
}
}
// Modal body
.modal-body {
padding: $spacing-lg;
flex: 1;
overflow-y: auto;
color: $text-dark;
line-height: $line-height-normal;
}
// Modal footer
.modal-footer {
padding: $spacing-lg;
border-top: 1px solid $border-gray;
display: flex;
gap: $spacing-md;
justify-content: flex-end;
&.modal-footer-center {
justify-content: center;
}
&.modal-footer-between {
justify-content: space-between;
}
}
// Alert modal
.modal-alert {
.modal-dialog {
max-width: 400px;
}
.modal-body {
text-align: center;
padding: $spacing-xl;
.alert-icon {
width: 64px;
height: 64px;
margin: 0 auto $spacing-lg;
@include flex-center;
font-size: $font-size-2xl;
border-radius: $border-radius-circle;
&.alert-success {
background: rgba($accent-green, 0.1);
color: $accent-green;
}
&.alert-warning {
background: rgba($accent-yellow, 0.1);
color: $accent-yellow;
}
&.alert-danger {
background: rgba($accent-orange, 0.1);
color: $accent-orange;
}
&.alert-info {
background: rgba($primary-blue, 0.1);
color: $primary-blue;
}
}
.alert-title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-sm;
color: $text-dark;
}
.alert-message {
color: $text-gray;
}
}
}
@@ -0,0 +1,239 @@
// -----------------------------------------------------------------------------
// Navigation component styles
// -----------------------------------------------------------------------------
// Breadcrumb navigation
.breadcrumb {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-md 0;
font-size: $font-size-sm;
.breadcrumb-item {
display: flex;
align-items: center;
gap: $spacing-sm;
color: $text-gray;
a {
color: $text-gray;
transition: $transition-base;
&:hover {
color: $primary-blue;
}
}
&.active {
color: $text-dark;
font-weight: $font-weight-medium;
}
&:not(:last-child)::after {
content: '/';
color: $text-light;
margin-left: $spacing-sm;
}
}
}
// Tab navigation
.nav-tabs {
display: flex;
gap: $spacing-xs;
border-bottom: 2px solid $border-gray;
margin-bottom: $spacing-xl;
.nav-item {
position: relative;
}
.nav-link {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-md $spacing-lg;
color: $text-gray;
font-weight: $font-weight-medium;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: $transition-base;
&:hover {
color: $text-dark;
}
&.active {
color: $primary-blue;
border-bottom-color: $primary-blue;
}
.badge {
padding: 2px 6px;
background: $gray-bg;
color: $text-gray;
border-radius: $border-radius-full;
font-size: 11px;
font-weight: $font-weight-semibold;
}
}
}
// Pills navigation
.nav-pills {
display: flex;
gap: $spacing-sm;
padding: $spacing-xs;
background: $gray-bg;
border-radius: $border-radius-lg;
.nav-link {
padding: $spacing-sm $spacing-lg;
color: $text-gray;
font-weight: $font-weight-medium;
border-radius: $border-radius-md;
transition: $transition-base;
&:hover {
color: $text-dark;
background: rgba($white, 0.5);
}
&.active {
background: $white;
color: $primary-blue;
box-shadow: $shadow-sm;
}
}
}
// Sidebar navigation
.sidebar-nav {
.nav-section {
margin-bottom: $spacing-xl;
.nav-title {
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: uppercase;
color: $text-gray;
padding: $spacing-sm $spacing-md;
letter-spacing: 0.05em;
}
}
.nav-menu {
list-style: none;
.nav-item {
margin-bottom: $spacing-xs;
}
.nav-link {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-sm $spacing-md;
color: $text-dark;
border-radius: $border-radius-md;
transition: $transition-base;
.nav-icon {
width: 20px;
height: 20px;
@include flex-center;
font-size: $font-size-md;
color: $text-gray;
}
&:hover {
background: $gray-bg;
color: $primary-blue;
.nav-icon {
color: $primary-blue;
}
}
&.active {
background: $light-bg;
color: $primary-blue;
font-weight: $font-weight-medium;
.nav-icon {
color: $primary-blue;
}
}
}
// Nested menu
.nav-submenu {
margin-left: $spacing-2xl;
margin-top: $spacing-xs;
list-style: none;
.nav-link {
font-size: $font-size-sm;
padding: $spacing-xs $spacing-md;
}
}
}
}
// Pagination
.pagination {
display: flex;
align-items: center;
gap: $spacing-xs;
.page-item {
&.disabled {
.page-link {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
}
&.active {
.page-link {
background: $primary-blue;
color: $white;
border-color: $primary-blue;
}
}
}
.page-link {
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 $spacing-sm;
background: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
color: $text-dark;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
transition: $transition-base;
&:hover {
background: $gray-bg;
border-color: $primary-blue;
color: $primary-blue;
}
&.page-prev,
&.page-next {
font-size: $font-size-md;
}
}
.page-dots {
padding: 0 $spacing-sm;
color: $text-gray;
}
}
@@ -0,0 +1,8 @@
// -----------------------------------------------------------------------------
// Partners section styles
// -----------------------------------------------------------------------------
.partners {
padding: $spacing-5xl 0;
background: $white;
}
@@ -0,0 +1,158 @@
// -----------------------------------------------------------------------------
// Container and section styles
// -----------------------------------------------------------------------------
// Main container
.container {
max-width: $container-max-width;
margin: 0 auto;
padding: 0 $spacing-lg;
@include respond-to('sm') {
padding: 0 $spacing-md;
}
// Container variations
&-fluid {
max-width: 100%;
padding: 0 $spacing-lg;
}
&-narrow {
max-width: 960px;
}
&-wide {
max-width: 1440px;
}
}
// Section spacing
.section {
padding: $spacing-6xl 0;
@include respond-to('sm') {
padding: $spacing-4xl 0;
}
// Section variations
&-sm {
padding: $spacing-3xl 0;
}
&-lg {
padding: $spacing-6xl * 1.5 0;
}
&-no-top {
padding-top: 0;
}
&-no-bottom {
padding-bottom: 0;
}
}
// Section backgrounds
.section-bg {
&-gray {
background: $gray-bg;
}
&-light {
background: $light-bg;
}
&-gradient {
background: linear-gradient(180deg, $white 0%, $light-bg 100%);
}
&-gradient-reverse {
background: linear-gradient(180deg, $light-bg 0%, $white 100%);
}
&-primary {
background: $gradient-primary;
color: $white;
}
&-dark {
background: $text-dark;
color: $white;
}
}
// Section header
.section-header {
text-align: center;
margin-bottom: $spacing-4xl;
@include respond-to('sm') {
margin-bottom: $spacing-3xl;
}
.section-badge {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-xs $spacing-md;
background: $light-bg;
color: $primary-blue;
border-radius: $border-radius-full;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-md;
}
.section-title {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
margin-bottom: $spacing-md;
color: $text-dark;
@include respond-to('sm') {
font-size: $font-size-2xl;
}
.title-highlight {
@include gradient-text($gradient-accent);
}
}
.section-subtitle {
font-size: $font-size-md;
color: $text-gray;
line-height: $line-height-normal;
max-width: 600px;
margin: 0 auto;
@include respond-to('sm') {
font-size: $font-size-base;
}
}
}
// Content wrapper
.content-wrapper {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
.main-content {
flex: 1;
padding-top: $header-height;
}
}
// Page wrapper
.page-wrapper {
position: relative;
overflow: hidden;
}
// Inner content
.inner-content {
position: relative;
z-index: 1;
}
@@ -0,0 +1,282 @@
// -----------------------------------------------------------------------------
// Footer styles - Modern vibrant design
// -----------------------------------------------------------------------------
.global-footer {
background: $text-dark;
color: $white;
padding: $spacing-5xl 0 $spacing-2xl;
@include respond-to('sm') {
padding: $spacing-3xl 0 $spacing-xl;
}
}
.footer-top {
display: grid;
grid-template-columns: 2fr 3fr;
gap: $spacing-5xl;
margin-bottom: $spacing-4xl;
max-width: $container-max-width;
margin-left: auto;
margin-right: auto;
padding: 0 $spacing-lg;
@include respond-to('md') {
grid-template-columns: 1fr;
gap: $spacing-2xl;
}
}
// Footer brand section
.footer-brand {
max-width: 360px;
.footer-logo {
height: 48px;
margin-bottom: $spacing-lg;
}
.footer-desc {
font-size: $font-size-sm;
line-height: $line-height-loose;
color: rgba($white, 0.7);
margin-bottom: $spacing-lg;
}
.footer-newsletter {
margin-bottom: $spacing-xl;
h4 {
font-size: $font-size-md;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-md;
}
.newsletter-form {
display: flex;
gap: $spacing-sm;
input {
flex: 1;
padding: $spacing-sm $spacing-md;
background: rgba($white, 0.1);
border: 1px solid rgba($white, 0.2);
border-radius: $border-radius-md;
color: $white;
font-size: $font-size-sm;
&::placeholder {
color: rgba($white, 0.5);
}
&:focus {
background: rgba($white, 0.15);
border-color: $primary-blue;
outline: none;
}
}
button {
padding: $spacing-sm $spacing-lg;
background: $gradient-primary;
color: $white;
border-radius: $border-radius-md;
font-weight: $font-weight-semibold;
font-size: $font-size-sm;
transition: $transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-md;
}
}
}
}
}
// Social links
.social-links {
display: flex;
gap: $spacing-md;
a {
width: 44px;
height: 44px;
background: rgba($white, 0.1);
border-radius: $border-radius-lg;
@include flex-center;
color: $white;
transition: $transition-base;
font-size: $font-size-lg;
&:hover {
background: $primary-blue;
transform: translateY(-3px);
}
&.facebook:hover {
background: #1877f2;
}
&.twitter:hover {
background: #1da1f2;
}
&.linkedin:hover {
background: #0077b5;
}
&.github:hover {
background: #333;
}
&.youtube:hover {
background: #ff0000;
}
}
}
// Footer links
.footer-links {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $spacing-2xl;
@include respond-to('md') {
grid-template-columns: repeat(2, 1fr);
}
@include respond-to('sm') {
grid-template-columns: 1fr;
text-align: center;
}
}
.footer-column {
h4 {
font-size: $font-size-base;
font-weight: $font-weight-bold;
margin-bottom: $spacing-lg;
color: $white;
}
ul {
list-style: none;
li {
margin-bottom: $spacing-md;
a {
color: rgba($white, 0.7);
font-size: $font-size-sm;
transition: $transition-base;
position: relative;
&::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background: $accent-cyan;
transition: $transition-base;
}
&:hover {
color: $accent-cyan;
&::after {
width: 100%;
}
}
}
}
}
// Special badges
.footer-badge {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-xs $spacing-sm;
background: rgba($accent-green, 0.2);
color: $accent-green;
border-radius: $border-radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
margin-left: $spacing-sm;
}
}
// Footer bottom
.footer-bottom {
padding-top: $spacing-2xl;
border-top: 1px solid rgba($white, 0.1);
max-width: $container-max-width;
margin: 0 auto;
padding-left: $spacing-lg;
padding-right: $spacing-lg;
}
.footer-legal {
display: flex;
justify-content: space-between;
align-items: center;
@include respond-to('sm') {
flex-direction: column;
gap: $spacing-lg;
text-align: center;
}
p {
color: rgba($white, 0.5);
font-size: $font-size-sm;
margin: 0;
}
.legal-links {
display: flex;
gap: $spacing-lg;
a {
color: rgba($white, 0.7);
font-size: $font-size-sm;
transition: $transition-base;
&:hover {
color: $white;
}
}
}
}
// Footer decoration
.footer-decoration {
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100px;
right: -100px;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba($primary-blue, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
&::after {
content: '';
position: absolute;
bottom: -150px;
left: -150px;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba($accent-cyan, 0.05) 0%, transparent 70%);
border-radius: 50%;
}
}
@@ -0,0 +1,298 @@
// -----------------------------------------------------------------------------
// Grid system - Modern responsive layout
// -----------------------------------------------------------------------------
// Grid container
.grid {
display: grid;
gap: $spacing-lg;
// Column templates
&-cols-1 {
grid-template-columns: 1fr;
}
&-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
&-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
&-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
&-cols-5 {
grid-template-columns: repeat(5, 1fr);
}
&-cols-6 {
grid-template-columns: repeat(6, 1fr);
}
&-cols-12 {
grid-template-columns: repeat(12, 1fr);
}
// Auto-fit grid
&-auto {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
&-auto-sm {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
&-auto-lg {
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
}
// Gap variations
&.gap-0 {
gap: 0;
}
&.gap-sm {
gap: $spacing-sm;
}
&.gap-md {
gap: $spacing-md;
}
&.gap-lg {
gap: $spacing-lg;
}
&.gap-xl {
gap: $spacing-xl;
}
// Responsive grids
@include respond-to('md') {
&-md-cols-1 {
grid-template-columns: 1fr;
}
&-md-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
}
@include respond-to('sm') {
&-sm-cols-1 {
grid-template-columns: 1fr;
}
&-sm-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
}
}
// Grid item spans
.col-span-1 {
grid-column: span 1;
}
.col-span-2 {
grid-column: span 2;
}
.col-span-3 {
grid-column: span 3;
}
.col-span-4 {
grid-column: span 4;
}
.col-span-5 {
grid-column: span 5;
}
.col-span-6 {
grid-column: span 6;
}
.col-span-full {
grid-column: 1 / -1;
}
.row-span-1 {
grid-row: span 1;
}
.row-span-2 {
grid-row: span 2;
}
.row-span-3 {
grid-row: span 3;
}
// API Grid
.api-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: $spacing-lg;
@include respond-to('md') {
grid-template-columns: repeat(2, 1fr);
}
@include respond-to('sm') {
grid-template-columns: 1fr;
}
}
// Experience Grid
.experience-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: $spacing-lg;
@include respond-to('sm') {
grid-template-columns: 1fr;
}
}
// Partners Grid
.partners-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-2xl;
align-items: center;
@include respond-to('sm') {
grid-template-columns: repeat(2, 1fr);
}
}
// Feature Grid
.feature-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-xl;
@include respond-to('sm') {
grid-template-columns: 1fr;
}
}
// Demo Grid
.demo-wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-2xl;
align-items: start;
@include respond-to('md') {
grid-template-columns: 1fr;
}
}
// Footer Grid
.footer-grid {
display: grid;
grid-template-columns: 2fr 3fr;
gap: $spacing-5xl;
@include respond-to('md') {
grid-template-columns: 1fr;
gap: $spacing-2xl;
}
.footer-links {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $spacing-2xl;
@include respond-to('md') {
grid-template-columns: repeat(2, 1fr);
}
@include respond-to('sm') {
grid-template-columns: 1fr;
text-align: center;
}
}
}
// Pricing Grid
.pricing-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $spacing-lg;
align-items: center;
@include respond-to('md') {
grid-template-columns: 1fr;
gap: $spacing-xl;
}
}
// Testimonial Grid
.testimonial-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: $spacing-lg;
@include respond-to('sm') {
grid-template-columns: 1fr;
}
}
// Stats Grid
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $spacing-xl;
text-align: center;
@include respond-to('md') {
grid-template-columns: repeat(2, 1fr);
}
@include respond-to('sm') {
grid-template-columns: 1fr;
}
}
// Two Column Layout
.two-column {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-3xl;
align-items: center;
@include respond-to('md') {
grid-template-columns: 1fr;
gap: $spacing-xl;
}
&.reverse {
direction: rtl;
> * {
direction: ltr;
}
@include respond-to('md') {
direction: ltr;
}
}
}
// Three Column Layout
.three-column {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $spacing-xl;
@include respond-to('md') {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,805 @@
// -----------------------------------------------------------------------------
// Header styles - Modern & Responsive Design
// -----------------------------------------------------------------------------
// CSS Variables for modern header styling
:root {
// Enhanced color palette from design
--primary-blue: #4B9BFF;
--secondary-blue: #2E7FF7;
--accent-cyan: #00D4FF;
--accent-yellow: #FFD93D;
--accent-orange: #FF6B6B;
--accent-green: #6BCF7F;
--accent-purple: #A78BFA;
// Base colors
--text-dark: #1A1A2E;
--text-gray: #64748B;
--text-light: #94A3B8;
--white: #FFFFFF;
--gray-bg: #F8FAFC;
--light-bg: #EFF6FF;
--border-gray: #E2E8F0;
// Gradients
--gradient-primary: linear-gradient(135deg, #4B9BFF 0%, #2E7FF7 100%);
--gradient-accent: linear-gradient(135deg, #00D4FF 0%, #4B9BFF 100%);
--gradient-warm: linear-gradient(135deg, #FFD93D 0%, #FF6B6B 100%);
// Shadows
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(75, 155, 255, 0.1);
--shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
}
// Blind text for screen readers
.blind {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// ===========================
// Modern Header Structure
// ===========================
.global-header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: var(--shadow-sm);
z-index: 1000;
height: 80px;
transition: all 0.3s ease;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 80px;
max-width: 1280px;
margin: 0 auto;
padding: 0 20px;
}
// ===========================
// Header Layout Components
// ===========================
// Header Left Section
.header-left {
display: flex;
align-items: center;
.logo {
height: 45px;
width: auto;
}
}
// Logo Wrapper
.logo-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
// Logo with modern styling
.logo {
display: flex;
align-items: center;
gap: 12px;
height: 45px;
z-index: 10;
a {
display: flex;
align-items: center;
gap: 12px;
height: 100%;
text-decoration: none;
}
img {
height: 45px;
width: auto;
}
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: var(--primary-blue);
padding: 4px 12px;
background: var(--light-bg);
border-radius: 20px;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
// When logo-text is a link
&:hover {
background: var(--primary-blue);
color: var(--white);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
&:visited {
color: var(--primary-blue);
}
}
// Logo link styles
.logo-link,
.mobile-logo-link,
.drawer-logo-link {
display: inline-block;
text-decoration: none;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
img {
display: block;
}
}
// Header Right Section
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
// Search Button
.search-btn {
width: 40px;
height: 40px;
background: var(--gray-bg);
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
color: var(--text-gray);
transition: all 0.3s ease;
&:hover {
background: var(--light-bg);
color: var(--primary-blue);
transform: scale(1.1);
}
}
// ===========================
// Desktop/Mobile Layout Control
// ===========================
.desktop-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
@media (max-width: 768px) {
display: none;
}
}
.mobile-header {
display: none;
align-items: center;
justify-content: space-between;
width: 100%;
@media (max-width: 768px) {
display: flex;
}
}
// ===========================
// Mobile Header Components
// ===========================
.mobile-left {
.mobile-logo {
height: 32px;
width: auto;
}
}
.mobile-center {
flex: 1;
text-align: center;
.mobile-title {
font-size: 18px;
font-weight: 700;
color: var(--text-dark);
margin: 0;
}
}
.mobile-right {
.mobile-menu-btn {
width: 40px;
height: 40px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
padding: 8px;
border-radius: 8px;
transition: background-color 0.3s ease;
&:hover {
background: var(--gray-bg);
}
.hamburger-line {
width: 24px;
height: 2px;
background: var(--text-dark);
transition: all 0.3s ease;
border-radius: 1px;
}
&.active {
.hamburger-line {
&:nth-child(1) {
transform: rotate(45deg) translateY(6px);
}
&:nth-child(2) {
opacity: 0;
}
&:nth-child(3) {
transform: rotate(-45deg) translateY(-6px);
}
}
}
}
}
// ===========================
// Mobile Drawer/Modal
// ===========================
.mobile-drawer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
visibility: hidden;
opacity: 0;
transition: all 0.3s ease;
&.active {
visibility: visible;
opacity: 1;
.drawer-content {
transform: translateY(0);
}
}
.drawer-overlay {
display: none; // Not needed for full-screen drawer
}
.drawer-content {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background: var(--white);
transform: translateY(100%);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 2px solid var(--border-gray);
background: var(--white);
box-shadow: var(--shadow-sm);
min-height: 60px;
.drawer-logo {
display: flex;
align-items: center;
gap: 12px;
.logo {
height: 32px;
width: auto;
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: var(--primary-blue);
}
}
.drawer-close {
width: 40px;
height: 40px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 18px;
color: var(--text-gray);
transition: all 0.3s ease;
&:hover {
background: var(--white);
color: var(--text-dark);
}
}
}
.drawer-nav {
flex: 1;
padding: 0;
overflow-y: auto;
background: var(--white);
.drawer-menu {
list-style: none;
margin: 0;
padding: 20px 0;
li {
margin-bottom: 8px;
.drawer-link {
display: block;
padding: 16px 24px;
margin: 0 20px;
color: var(--text-dark);
text-decoration: none;
font-size: 18px;
font-weight: 500;
border-radius: 12px;
transition: all 0.3s ease;
&:hover, &:active {
background: var(--light-bg);
color: var(--primary-blue);
transform: translateX(4px);
box-shadow: var(--shadow-sm);
}
}
}
}
}
.drawer-actions {
padding: 24px;
border-top: 2px solid var(--border-gray);
background: var(--white);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05);
.drawer-login-btn {
display: block;
text-align: center;
padding: 14px 20px;
color: var(--text-dark);
text-decoration: none;
border: 2px solid var(--primary-blue);
border-radius: 12px;
font-weight: 600;
font-size: 16px;
margin-bottom: 12px;
transition: all 0.3s ease;
&:hover {
background: var(--light-bg);
color: var(--primary-blue);
}
}
.drawer-signup-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 20px;
background: var(--gradient-primary);
color: var(--white);
text-decoration: none;
border-radius: 12px;
font-weight: 600;
font-size: 16px;
margin-bottom: 12px;
transition: all 0.3s ease;
box-shadow: var(--shadow-md);
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
}
.drawer-search-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 20px;
background: var(--gray-bg);
color: var(--text-dark);
border: none;
border-radius: 12px;
font-weight: 500;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: var(--light-bg);
color: var(--primary-blue);
}
}
}
}
.btn-mobilemenu {
position: absolute;
right: $spacing-md;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
background: transparent;
border: none;
cursor: pointer;
padding: 8px;
z-index: $z-index-modal + 1; // Above the nav
span {
display: block;
width: 24px;
height: 2px;
background: $text-dark;
position: relative;
transition: all $transition-fast;
text-indent: -9999px;
&::before,
&::after {
content: '';
position: absolute;
left: 0;
width: 24px;
height: 2px;
background: $text-dark;
transition: all $transition-fast;
}
&::before { top: -8px; }
&::after { top: 8px; }
}
&.on span {
background: transparent;
&::before { top: 0; transform: rotate(45deg); }
&::after { top: 0; transform: rotate(-45deg); }
}
}
// ===========================
// Unified Navigation (Mobile-First)
// ===========================
.main-nav.m-nav {
// Mobile: Off-canvas menu
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $white;
z-index: $z-index-modal;
right: -100%; // Initial state for JS animation
transition: none; // JS handles animation
overflow-y: auto;
display: flex;
flex-direction: column;
// The JS will directly manipulate the 'right' property, not toggle a class for this element.
// So, no .active or .on state here for the main nav panel.
.nav-header {
padding: $spacing-lg;
background: $gray-bg;
border-bottom: 1px solid $border-gray;
}
.nav-list {
list-style: none;
margin: 0;
padding: 0;
flex-grow: 1;
.nav-item {
border-bottom: 1px solid $border-gray;
> a {
display: flex;
align-items: center;
padding: $spacing-lg;
color: $text-dark;
font-size: $font-size-md;
font-weight: $font-weight-medium;
text-decoration: none;
position: relative;
}
&.has-submenu > a::after {
content: '';
position: absolute;
right: $spacing-lg;
top: 50%;
width: 8px;
height: 8px;
border-right: 2px solid $text-gray;
border-bottom: 2px solid $text-gray;
transform: translateY(-50%) rotate(45deg);
transition: transform $transition-fast;
}
&.expanded > a::after {
transform: translateY(-25%) rotate(-135deg);
}
.sub-menu {
display: none;
list-style: none;
padding: 0;
margin: 0;
background: $gray-bg;
a {
display: block;
padding: $spacing-md $spacing-lg $spacing-md ($spacing-lg * 2);
color: $text-gray;
font-size: $font-size-sm;
text-decoration: none;
&:hover {
background: $white;
color: $primary-blue;
}
}
}
&.expanded .sub-menu {
display: block;
}
}
}
.info_wrap {
padding: $spacing-xl $spacing-lg;
background: $white;
border-top: 1px solid $border-gray;
margin-top: auto;
// styles for tit1, tit2...
}
}
// ===========================
// Modern Navigation Menu
// ===========================
.nav-menu {
display: flex;
list-style: none;
gap: 8px;
margin: 0;
padding: 0;
}
.nav-link {
display: flex;
align-items: center;
gap: 6px;
text-decoration: none;
color: var(--text-dark);
font-weight: 500;
font-size: 15px;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
background: var(--light-bg);
color: var(--primary-blue);
transform: translateY(-2px);
}
}
.nav-icon {
font-size: 18px;
}
.signup-btn {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
background: var(--gradient-primary);
color: var(--white);
padding: 12px 24px;
border-radius: 50px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: var(--shadow-md);
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
}
// ===========================
// Desktop Navigation Styles
// ===========================
@include respond-to('desktop') {
.m-only { display: none !important; }
.pc-only { display: block !important; }
.btn-mobilemenu { display: none; }
.main-nav.m-nav {
position: static;
right: auto;
transition: none;
flex-direction: row;
align-items: center;
overflow: visible;
background: transparent;
height: 100%;
flex: 1;
.nav-header { display: none; }
.info_wrap { display: none; }
.nav-list {
display: flex;
height: 100%;
flex-grow: 1;
.nav-item {
border-bottom: none;
position: relative;
height: 100%;
> a {
height: 100%;
padding: 0 24px;
color: var(--text-dark);
font-weight: 500;
font-size: 15px;
transition: all 0.3s ease;
&:hover {
color: var(--primary-blue);
&::after {
content: '';
position: absolute;
bottom: 0;
left: 24px;
right: 24px;
height: 3px;
background: var(--primary-blue);
border-radius: 2px;
}
}
}
&.has-submenu > a::after { display: none; }
.sub-menu {
position: absolute;
top: 100%;
left: 0;
background: var(--white);
border: 1px solid var(--border-gray);
border-radius: 8px;
box-shadow: var(--shadow-lg);
min-width: 200px;
padding: 8px 0;
display: none;
z-index: 1001;
a {
display: block;
padding: 8px 16px;
color: var(--text-dark);
font-size: 14px;
text-decoration: none;
transition: all 0.3s ease;
&:hover {
background: var(--light-bg);
color: var(--primary-blue);
}
}
}
&:hover .sub-menu {
display: block;
}
}
}
}
}
// ===========================
// Body Layout Compensation
// ===========================
body {
padding-top: 80px; // Compensate for fixed header
}
// ===========================
// Mobile Responsive Design
// ===========================
@media (max-width: 768px) {
.global-header {
height: 60px;
.container .header-content {
padding: 0 16px;
height: 60px;
}
}
body {
padding-top: 60px; // Smaller header on mobile
}
}
@media (max-width: 480px) {
.global-header {
.header-content {
padding: 0 12px;
}
}
.logo {
.logo-text {
display: none; // Hide logo text on very small screens
}
}
}
+47
View File
@@ -0,0 +1,47 @@
// -----------------------------------------------------------------------------
// Main SASS Entry Point - Version 2 (Modern Vibrant Design)
// Based on design/styles-v2.css
// -----------------------------------------------------------------------------
@charset "utf-8";
// 1. Configuration and helpers (no CSS output)
@import 'abstracts/variables';
@import 'abstracts/mixins';
// 2. Base styles
@import 'base/reset';
@import 'base/typography';
@import 'base/animations';
// 3. Layout
@import 'layout/header';
@import 'layout/footer';
@import 'layout/grid';
@import 'layout/container';
// 4. Components
@import 'components/buttons';
@import 'components/cards';
@import 'components/forms';
@import 'components/modals';
@import 'components/navigation';
@import 'components/partners';
@import 'components/cta';
// 5. Page-specific styles
@import 'pages/index';
@import 'pages/home';
@import 'pages/api-portal';
@import 'pages/api-market';
@import 'pages/documentation';
@import 'pages/login';
// 6. Themes
@import 'themes/dark';
// 7. Utilities (should be last)
@import 'base/utilities';
// 8. Vendor overrides
@import 'vendors/overrides';
@@ -0,0 +1,780 @@
// -----------------------------------------------------------------------------
// API Market Page - Modern Sidebar + Card Grid Layout
// Reference: design/api-market.html
// -----------------------------------------------------------------------------
.api-market-container {
display: flex;
min-height: 100vh;
background-color: $gray-bg;
position: relative;
}
// Sidebar Navigation
.api-market-sidebar {
width: 280px;
background-color: $white;
border-right: 1px solid $border-gray;
padding: $spacing-2xl $spacing-lg;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
flex-shrink: 0;
@media (max-width: $breakpoint-md) {
width: 240px;
}
@media (max-width: $breakpoint-sm) {
position: fixed;
left: 0;
top: 60px; // Below mobile header
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: $shadow-lg;
z-index: $z-index-modal;
height: calc(100vh - 60px); // Full height minus header
&.mobile-open {
transform: translateX(0);
}
}
}
.api-sidebar-nav {
.menu-section {
margin-bottom: $spacing-xs;
}
.menu-title {
padding: 12px $spacing-md;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $secondary-blue;
cursor: pointer;
border-radius: $border-radius-md;
transition: $transition-base;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background-color: $gray-bg;
}
&.active {
background-color: $light-bg;
color: $secondary-blue;
}
.api-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 $spacing-sm;
background-color: rgba($primary-blue, 0.1);
color: $primary-blue;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
border-radius: $border-radius-full;
transition: $transition-base;
}
&.active .api-count {
background-color: $primary-blue;
color: $white;
}
}
// Level 2: API List
.api-list {
padding-left: $spacing-md;
margin-top: $spacing-xs;
margin-bottom: $spacing-sm;
}
.api-item {
padding: 8px $spacing-md;
font-size: $font-size-sm;
color: $text-gray;
cursor: pointer;
border-radius: $border-radius-sm;
transition: $transition-base;
display: flex;
align-items: center;
position: relative;
margin-bottom: 4px;
&::before {
content: '';
margin-right: $spacing-sm;
color: $primary-blue;
opacity: 0.5;
}
&:hover {
background-color: rgba($primary-blue, 0.05);
color: $text-dark;
padding-left: calc($spacing-md + 4px);
&::before {
opacity: 1;
}
}
&.active {
background-color: rgba($primary-blue, 0.1);
color: $primary-blue;
font-weight: $font-weight-medium;
&::before {
opacity: 1;
}
}
.api-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
// Main Content Area
.api-market-content {
flex: 1;
padding: $spacing-2xl $spacing-3xl;
min-height: 100vh;
display: flex;
flex-direction: column;
@media (max-width: $breakpoint-sm) {
padding: $spacing-lg $spacing-md;
}
}
// Mobile Menu Toggle
.api-mobile-toggle {
display: none;
position: fixed;
bottom: $spacing-lg;
right: $spacing-lg;
width: 56px;
height: 56px;
background: $gradient-primary;
border-radius: $border-radius-circle;
border: none;
color: $white;
font-size: 24px;
cursor: pointer;
box-shadow: $shadow-lg;
z-index: $z-index-modal + 1;
transition: $transition-base;
&:hover {
transform: scale(1.05);
box-shadow: $shadow-xl;
}
&:active {
transform: scale(0.95);
}
@media (max-width: $breakpoint-sm) {
display: flex;
align-items: center;
justify-content: center;
}
}
// Header Section
.api-market-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-2xl;
@media (max-width: $breakpoint-sm) {
flex-direction: column;
align-items: flex-start;
gap: $spacing-lg;
}
}
.api-market-title {
h1 {
font-size: $font-size-sm;
color: $text-gray;
font-weight: $font-weight-regular;
margin-bottom: $spacing-xs;
}
h2 {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $text-dark;
}
}
// Search Box
.api-market-search {
position: relative;
width: 300px;
@media (max-width: $breakpoint-sm) {
width: 100%;
}
form {
position: relative;
display: flex;
align-items: center;
}
input {
flex: 1;
padding: 12px 48px 12px $spacing-md;
border: 1px solid $border-gray;
border-radius: $border-radius-lg;
font-size: $font-size-sm;
outline: none;
transition: $transition-base;
&:focus {
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.1);
}
&::placeholder {
color: $text-light;
}
}
.search-submit-btn {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
background: transparent;
border: none;
border-radius: $border-radius-md;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: $transition-base;
color: $text-gray;
&:hover {
background: $light-bg;
color: $primary-blue;
transform: translateY(-50%) scale(1.05);
}
&:active {
transform: translateY(-50%) scale(0.95);
}
.search-icon {
font-size: 18px;
display: block;
}
}
}
// Result Count
.api-result-count {
font-size: $font-size-base;
color: $text-gray;
margin-bottom: $spacing-xl;
strong {
color: $primary-blue;
font-weight: $font-weight-semibold;
}
}
// Card Grid
.api-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: $spacing-lg;
@media (max-width: $breakpoint-sm) {
grid-template-columns: 1fr;
}
}
// API Card
.api-card {
background: $white;
border-radius: $border-radius-lg;
padding: $spacing-xl;
box-shadow: $shadow-sm;
transition: $transition-base;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 220px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: $gradient-primary;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
&:hover {
box-shadow: $shadow-lg;
transform: translateY(-5px);
&::before {
transform: scaleX(1);
}
.api-card-icon {
transform: scale(1.1);
}
}
&:active {
transform: translateY(-3px);
}
}
.api-card-header {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-bottom: $spacing-md;
}
.api-card-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: $gray-bg;
border-radius: $border-radius-sm;
font-size: $font-size-xs;
color: $text-gray;
font-weight: $font-weight-medium;
&::before {
content: "";
color: $primary-blue;
font-size: 10px;
}
}
.api-card-title {
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: 12px;
line-height: $line-height-tight;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.api-card-description {
font-size: $font-size-sm;
color: $text-gray;
line-height: $line-height-normal;
flex-grow: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.api-card-icon {
text-align: center;
margin-top: $spacing-lg;
font-size: 48px;
opacity: 0.8;
transition: transform 0.3s ease;
img {
max-width: 60px;
max-height: 60px;
object-fit: contain;
}
}
// Icon color variations
.api-card:nth-child(3n+1) .api-card-icon {
color: #60a5fa;
}
.api-card:nth-child(3n+2) .api-card-icon {
color: #34d399;
}
.api-card:nth-child(3n+3) .api-card-icon {
color: #818cf8;
}
// Empty State
.api-empty-state {
text-align: center;
padding: $spacing-5xl $spacing-lg;
color: $text-gray;
.empty-icon {
font-size: 80px;
margin-bottom: $spacing-lg;
opacity: 0.5;
}
h3 {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-md;
}
p {
font-size: $font-size-base;
color: $text-gray;
}
}
// Loading State
.api-loading {
display: flex;
justify-content: center;
align-items: center;
padding: $spacing-5xl;
.spinner {
width: 48px;
height: 48px;
border: 4px solid $border-gray;
border-top-color: $primary-blue;
border-radius: $border-radius-circle;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Mobile Overlay
.api-mobile-overlay {
display: none;
position: fixed;
top: 60px; // Below mobile header
left: 0;
right: 0;
bottom: 0;
background-color: rgba($black, 0.5);
z-index: $z-index-modal - 1;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
@media (max-width: $breakpoint-sm) {
display: block;
}
&.active {
opacity: 1;
pointer-events: auto;
}
}
// API Detail Content
.api-detail-content {
display: flex;
flex-direction: column;
gap: $spacing-xl;
}
// Tab Navigation
.api-detail-tabs {
display: flex;
gap: $spacing-sm;
border-bottom: 2px solid $border-gray;
margin-bottom: $spacing-xl;
.tab-button {
padding: $spacing-md $spacing-lg;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $text-gray;
cursor: pointer;
transition: $transition-base;
position: relative;
bottom: -2px;
&:hover {
color: $primary-blue;
background-color: rgba($primary-blue, 0.05);
}
&.active {
color: $primary-blue;
font-weight: $font-weight-semibold;
border-bottom-color: $primary-blue;
}
}
}
// Tab Content
.tab-content {
display: none;
flex-direction: column;
gap: $spacing-xl;
min-height: 200px;
&.active {
display: flex;
}
}
// API Overview Card
.api-overview-card {
background: $white;
border-radius: $border-radius-lg;
padding: $spacing-xl;
box-shadow: $shadow-sm;
display: flex;
flex-direction: column;
gap: $spacing-lg;
.api-overview-header {
display: flex;
flex-direction: row;
align-items: center;
gap: $spacing-md;
@media (max-width: $breakpoint-sm) {
flex-direction: column;
align-items: flex-start;
}
}
.api-method-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 8px 16px;
background: $gradient-primary;
color: $white;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
border-radius: $border-radius-sm;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.api-endpoint {
flex: 1;
code {
display: block;
padding: $spacing-sm $spacing-md;
background-color: $gray-bg;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
font-size: $font-size-md;
font-family: 'Courier New', monospace;
color: $text-dark;
word-break: break-all;
}
}
.api-simple-description {
padding: $spacing-md 0;
border-bottom: 1px solid $border-gray;
p {
font-size: $font-size-base;
color: $text-gray;
line-height: $line-height-normal;
margin: 0;
}
}
.api-overview-info {
h4 {
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-md;
}
}
}
// API Details Grid
.api-details-grid {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
// API Detail Card
.api-detail-card {
background: $white;
border-radius: $border-radius-lg;
padding: $spacing-xl;
box-shadow: $shadow-sm;
transition: $transition-base;
&:hover {
box-shadow: $shadow-md;
}
h3 {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-dark;
margin-bottom: $spacing-md;
padding-bottom: $spacing-sm;
border-bottom: 2px solid $light-bg;
}
.detail-content {
font-size: $font-size-sm;
color: $text-gray;
line-height: $line-height-normal;
pre {
background-color: $gray-bg;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
padding: $spacing-md;
overflow-x: auto;
margin: 0;
code {
font-family: 'Courier New', monospace;
font-size: $font-size-sm;
color: $text-dark;
white-space: pre-wrap;
word-break: break-word;
}
}
// Table styling for HTML tables in specifications
table {
width: 100%;
border-collapse: collapse;
margin: $spacing-md 0;
background-color: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
overflow: hidden;
thead {
background-color: $light-bg;
}
th {
text-align: left;
padding: $spacing-sm $spacing-md;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-dark;
border-bottom: 2px solid $border-gray;
background-color: rgba($primary-blue, 0.05);
&:not(:last-child) {
border-right: 1px solid $border-gray;
}
}
td {
padding: $spacing-sm $spacing-md;
font-size: $font-size-sm;
color: $text-gray;
border-bottom: 1px solid $border-gray;
&:not(:last-child) {
border-right: 1px solid $border-gray;
}
}
tr {
transition: $transition-base;
&:last-child td {
border-bottom: none;
}
&:hover {
background-color: rgba($primary-blue, 0.02);
}
}
tbody tr:nth-child(even) {
background-color: rgba($gray-bg, 0.3);
}
}
}
}
// API Info Table
.api-info-table {
width: 100%;
border-collapse: collapse;
tr {
border-bottom: 1px solid $border-gray;
&:last-child {
border-bottom: none;
}
}
th {
text-align: left;
padding: $spacing-sm $spacing-md;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-dark;
width: 30%;
background-color: $gray-bg;
}
td {
padding: $spacing-sm $spacing-md;
font-size: $font-size-sm;
color: $text-gray;
code {
display: inline-block;
padding: 2px 8px;
background-color: $gray-bg;
border-radius: $border-radius-sm;
font-family: 'Courier New', monospace;
font-size: $font-size-xs;
color: $primary-blue;
}
}
}
@@ -0,0 +1,7 @@
// -----------------------------------------------------------------------------
// API Portal page specific styles
// -----------------------------------------------------------------------------
.api-portal-page {
// Any API portal-specific styles go here
}
@@ -0,0 +1,7 @@
// -----------------------------------------------------------------------------
// Documentation page specific styles
// -----------------------------------------------------------------------------
.documentation-page {
// Any documentation-specific styles go here
}
@@ -0,0 +1,7 @@
// -----------------------------------------------------------------------------
// Home page specific styles
// -----------------------------------------------------------------------------
.home-page {
// Any home-specific styles go here
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,308 @@
// -----------------------------------------------------------------------------
// Login Page Styles - Modern Design
// -----------------------------------------------------------------------------
.login-page {
min-height: calc(100vh - 140px); // Account for header and footer
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--light-bg) 0%, var(--gray-bg) 100%);
padding: 40px 20px;
position: relative;
overflow: hidden;
// Decorative background elements
&::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--accent-cyan) 0%, transparent 70%);
opacity: 0.1;
border-radius: 50%;
}
&::after {
content: '';
position: absolute;
bottom: -30%;
left: -10%;
width: 400px;
height: 400px;
background: radial-gradient(circle, var(--primary-blue) 0%, transparent 70%);
opacity: 0.1;
border-radius: 50%;
}
}
.login-container {
width: 100%;
max-width: 480px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.login-card {
background: var(--white);
border-radius: 24px;
box-shadow: var(--shadow-xl);
padding: 48px 40px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.8);
@media (max-width: 480px) {
padding: 32px 24px;
border-radius: 16px;
}
}
.login-header {
text-align: center;
margin-bottom: 40px;
.login-logo {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
img {
height: 48px;
width: auto;
}
.logo-text {
font-size: 20px;
font-weight: 700;
color: var(--primary-blue);
padding: 6px 16px;
background: var(--light-bg);
border-radius: 24px;
}
}
.login-title {
font-size: 28px;
font-weight: 700;
color: var(--text-dark);
margin-bottom: 8px;
}
.login-subtitle {
font-size: 15px;
color: var(--text-gray);
}
}
.login-form {
margin-bottom: 24px;
.form-group {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 14px 16px;
font-size: 15px;
border: 2px solid var(--border-gray);
border-radius: 12px;
background: var(--white);
transition: all 0.3s ease;
outline: none;
&::placeholder {
color: var(--text-light);
}
&:focus {
border-color: var(--primary-blue);
box-shadow: 0 0 0 4px rgba(75, 155, 255, 0.1);
}
&.error {
border-color: var(--accent-orange);
&:focus {
box-shadow: 0 0 0 4px rgba(255, 107, 107, 0.1);
}
}
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary-blue);
cursor: pointer;
}
label {
font-size: 14px;
color: var(--text-gray);
cursor: pointer;
user-select: none;
}
}
}
.login-button {
width: 100%;
padding: 16px 24px;
font-size: 16px;
font-weight: 600;
color: var(--white);
background: var(--gradient-primary);
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: var(--shadow-md);
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
&:active {
transform: translateY(0);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
}
.login-links {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 32px;
padding-top: 32px;
border-top: 1px solid var(--border-gray);
flex-wrap: wrap;
@media (max-width: 480px) {
gap: 12px;
margin-top: 24px;
padding-top: 24px;
}
a {
font-size: 14px;
color: var(--text-gray);
text-decoration: none;
transition: color 0.3s ease;
display: flex;
align-items: center;
gap: 6px;
&:hover {
color: var(--primary-blue);
}
i {
font-size: 16px;
}
}
.link-separator {
color: var(--border-gray);
font-size: 14px;
}
}
// Alert messages
.login-alert {
margin-bottom: 20px;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
&.alert-error {
background: rgba(255, 107, 107, 0.1);
color: var(--accent-orange);
border: 1px solid rgba(255, 107, 107, 0.2);
}
&.alert-success {
background: rgba(107, 207, 127, 0.1);
color: var(--accent-green);
border: 1px solid rgba(107, 207, 127, 0.2);
}
&.alert-info {
background: rgba(75, 155, 255, 0.1);
color: var(--primary-blue);
border: 1px solid rgba(75, 155, 255, 0.2);
}
i {
font-size: 18px;
}
}
// Loading state
.login-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
border-radius: 24px;
z-index: 10;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
&.active {
opacity: 1;
pointer-events: all;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-gray);
border-top-color: var(--primary-blue);
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,6 @@
// -----------------------------------------------------------------------------
// Dark theme styles (optional)
// -----------------------------------------------------------------------------
// Dark theme variables can be defined here
// This file can be left empty if dark theme is not needed yet
@@ -0,0 +1,6 @@
// -----------------------------------------------------------------------------
// Vendor library overrides
// -----------------------------------------------------------------------------
// Place any third-party library style overrides here
// This file can be left empty if no vendor libraries are used
@@ -1,204 +1,454 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_api_layout}"> xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/kjbank_base_layout}">
<body> <body>
<section layout:fragment="contentFragment" class="content api_con"> <th:block layout:fragment="contentFragment">
<style> <section class="api-market-container">
/* Custom dark theme for CodeMirror */
.cm-s-custom-dark.CodeMirror {
background-color: #252B37;
color: white;
}
.cm-s-custom-dark .CodeMirror-gutters { <!-- Sidebar Navigation -->
background-color: #1a1a1a; <aside class="api-market-sidebar" id="apiSidebar">
border-right: 1px solid #3a3a3a; <nav class="api-sidebar-nav">
} <!-- All APIs -->
<div class="menu-section">
.cm-s-custom-dark .CodeMirror-linenumber { <div class="menu-title" th:classappend="${selected == '-1'} ? 'active'" data-group="">
color: #777; 전체
} <span class="api-count" th:text="${totalApiCount}">0</span>
.cm-s-custom-dark .CodeMirror-cursor {
border-left: 1px solid white;
}
.cm-s-custom-dark .cm-string {
color: #a6e22e;
}
.cm-s-custom-dark .cm-number {
color: #ae81ff;
}
.cm-s-custom-dark .cm-atom {
color: #f92672;
}
.cm-s-custom-dark .cm-keyword {
color: #66d9ef;
}
.cm-s-custom-dark .cm-property {
color: #ccffee;
}
.cm-s-custom-dark .cm-punctuation {
color: #f8f8f2;
}
</style>
<div class="content_wrap w_api">
<div class="sub_title4">
<h2 class="title" th:text="${apiSpecInfo.apiName}"></h2>
<p class="infotxt" th:text="${apiSpecInfo.apiSimpleDescription}"></p>
</div>
<div class="pc-only">
<div class="inner i_cs h_inner2">
<div class="api_stit">
<table class="api_table" cellspacing="0" cellpadding="0" width="100%">
<colgroup>
<col width="200px">
<col width="auto">
</colgroup>
<tbody>
<tr>
<th>API 명 (URI)</th>
<td th:text="${apiSpecInfo.apiUrl}"></td>
</tr>
<tr>
<th>Method</th>
<td>[[${apiSpecInfo.apiMethod}]]</td>
</tr>
<tr>
<th>Content-Type</th>
<td>[[${apiSpecInfo.apiContentType}]]</td>
</tr>
</tbody>
</table>
<div class="api_stit api_dot" th:utext="${apiSpecInfo.description}">
</div> </div>
</div> </div>
</div>
</div> <!-- Service Categories -->
<div class="inner i_cs h_inner2"> <div class="menu-section" th:each="service : ${services}">
<div class="api_stit m-only"> <div class="menu-title"
<table class="api_table" cellspacing="0" cellpadding="0" width="100%"> th:classappend="${selected == service.id} ? 'active'"
<colgroup> th:data-group="${service.id}">
<col width="110px"> [[${service.groupName}]]
<col width="auto"> <span class="api-count" th:text="${service.apiGroupApiList.size()}">0</span>
</colgroup> </div>
<tbody>
<tr> <!-- Level 2: API List -->
<th>API 명 (URI)</th> <div class="api-list" th:if="${service.apiGroupApiList != null and !service.apiGroupApiList.isEmpty()}">
<td th:text="${apiSpecInfo.apiUrl}"></td> <div class="api-item"
</tr> th:each="api : ${service.apiGroupApiList}"
<tr> th:data-api-id="${api.apiId}"
<th>Method</th> th:attr="data-href=@{/apis/detail(apiId=${api.apiId})}">
<td>[[${apiSpecInfo.apiMethod}]]</td> <span class="api-name" th:text="${api.apiDesc}">API Name</span>
</tr> </div>
<tr> </div>
<th>Content-Type</th> </div>
<td>[[${apiSpecInfo.apiContentType}]]</td> </nav>
</tr> </aside>
</tbody>
</table> <!-- Mobile Menu Toggle -->
<div class="api_stit api_dot" th:utext="${apiSpecInfo.description}"> <button class="api-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
</button>
<!-- Mobile Overlay -->
<div class="api-mobile-overlay" id="mobileOverlay"></div>
<!-- Main Content -->
<main class="api-market-content">
<!-- Header -->
<div class="api-market-header">
<div class="api-market-title">
<h1>서비스</h1>
<h2 th:text="${apiSpecInfo.apiName}">API 이름</h2>
</div> </div>
</div> </div>
<!-- API Content -->
<div class="api-detail-content">
<div class="api_stit api_sc api_tb_type api_sc2"> <!-- Tab Navigation -->
<h3>요청 메시지</h3> <div class="api-detail-tabs">
<div class="pc-only" th:utext="${apiSpecInfo.apiRequestSpec}"> <button class="tab-button active" data-tab="api-info">API 정보</button>
<button class="tab-button" data-tab="testbed">테스트베드</button>
</div> </div>
<div class="m-only" th:utext="${apiSpecInfo.apiRequestSpec}">
<!-- Tab Content -->
<div class="tab-content active" id="api-info-tab">
<!-- API Overview Card (Merged with Additional Information) -->
<div class="api-overview-card">
<div class="api-overview-header">
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
<div class="api-endpoint">
<code th:text="${apiSpecInfo.apiUrl}">/api/v1/example</code>
</div>
</div>
<!-- API Information Table -->
<div class="api-overview-info">
<table class="api-info-table">
<tr th:if="${apiSpecInfo.apiContentType != null}">
<th>Content Type</th>
<td th:text="${apiSpecInfo.apiContentType}">application/json</td>
</tr>
</table>
</div>
<div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}">
<p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p>
</div>
<div class="detail-content" th:utext="${apiSpecInfo.description}">
Detailed API description
</div>
</div> </div>
</div>
<div class="api_stit api_sc"> <!-- API Details Grid -->
<h3>요청 메시지 예제</h3> <div class="api-details-grid">
<ul>
<li> <!-- Request Specification -->
<p class="tit"> <div class="api-detail-card" th:if="${apiSpecInfo.apiRequestSpec != null}">
<span>Header</span> <h3>Request Specification</h3>
</p> <div class="detail-content" th:utext="${apiSpecInfo.apiRequestSpec}">
<div class="api_sauce"> Request spec
<p>
Content-Type: application/json
</p>
</div> </div>
</li> </div>
</ul>
</div> <!-- Sample Request -->
<div class="api_stit api_sc api_sc3"> <div class="api-detail-card" th:if="${apiSpecInfo.sampleRequest != null}">
<ul> <h3>샘플 요청</h3>
<li> <div class="detail-content">
<p class="tit"> <pre><code th:text="${apiSpecInfo.sampleRequest}">Sample request</code></pre>
<span>Body</span>
</p>
<div class="api_sauce">
<textarea id="sampleRequest" th:text="${apiSpecInfo.sampleRequest}"></textarea>
</div> </div>
</li> </div>
</ul>
<!-- Response Specification -->
<div class="api-detail-card" th:if="${apiSpecInfo.apiResponseSpec != null}">
<h3>Response Specification</h3>
<div class="detail-content" th:utext="${apiSpecInfo.apiResponseSpec}">
Response spec
</div>
</div>
<!-- Sample Response -->
<div class="api-detail-card" th:if="${apiSpecInfo.sampleResponse != null}">
<h3>샘플 응답</h3>
<div class="detail-content">
<pre><code th:text="${apiSpecInfo.sampleResponse}">Sample response</code></pre>
</div>
</div>
</div>
</div>
<!-- Testbed Tab Content -->
<div class="tab-content" id="testbed-tab">
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
<div class="api-overview-card">
<div id="swagger-ui"></div>
</div>
</div>
</div> </div>
<div class="api_line"></div> </main>
</section>
</th:block>
<div class="api_stit api_sc api_sc2 api_tb_type">
<h3>응답 메시지</h3>
<div class="pc-only" th:utext="${apiSpecInfo.apiResponseSpec}"></div>
<div class="m-only" th:utext="${apiSpecInfo.apiResponseSpec}"></div>
</div>
<div class="api_stit api_sc">
<h3>응답 메시지 예제</h3>
<ul>
<li>
<p class="tit">
<span>Body</span>
</p>
<div class="api_sauce">
<textarea id="sampleResponse" th:text="${apiSpecInfo.sampleResponse}"></textarea>
</div>
</li>
</ul>
</div>
<div class="api_btn">
<a th:href="@{/apis/testbed/api(id=${apiSpecInfo.apiId})}" class="common_btn_type_1 gray">테스트</a>
<div class="btn_gap"></div>
<a href="#none" class="common_btn_type_1" id="requestApiKey">API 이용 신청</a>
</div>
</div>
</div>
</section>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', (event) => { document.addEventListener('DOMContentLoaded', function() {
const commonOptions = { // DOM Elements
lineNumbers: false, const sidebar = document.getElementById('apiSidebar');
mode: {name: 'javascript', json: true}, const mobileToggle = document.getElementById('mobileToggle');
theme: 'custom-dark', const mobileOverlay = document.getElementById('mobileOverlay');
readOnly: true,
autoCloseBrackets: true,
matchBrackets: true,
foldGutter: true
};
const sampleRequestEditor = CodeMirror.fromTextArea(document.getElementById('sampleRequest'), commonOptions);
const sampleResponseEditor = CodeMirror.fromTextArea(document.getElementById('sampleResponse'), commonOptions); // API Card Click Handlers
const apiCards = document.querySelectorAll('.api-card');
apiCards.forEach(function(card) {
// Click handler
card.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
// Refresh CodeMirror instances to ensure proper rendering // Keyboard accessibility
sampleRequestEditor.refresh(); card.addEventListener('keypress', function(e) {
sampleResponseEditor.refresh(); if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
}
});
});
// Level 2 API Item Click Handlers
const apiItems = document.querySelectorAll('.api-item');
apiItems.forEach(function(item) {
// Click handler
item.addEventListener('click', function(e) {
e.stopPropagation(); // Prevent parent menu-title click
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
// Keyboard accessibility
item.addEventListener('keypress', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
}
});
});
// Category/Service Selection - removed as not needed in detail page
// Mobile Menu Toggle
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sidebar.classList.toggle('mobile-open');
mobileOverlay.classList.toggle('active');
// Update button text
if (sidebar.classList.contains('mobile-open')) {
this.innerHTML = '✕';
this.setAttribute('aria-label', '메뉴 닫기');
} else {
this.innerHTML = '☰';
this.setAttribute('aria-label', '메뉴 열기');
}
});
// Add touch event for better mobile support
mobileToggle.addEventListener('touchend', function(e) {
e.preventDefault();
e.stopPropagation();
this.click();
});
// Close menu on overlay click
mobileOverlay.addEventListener('click', function() {
sidebar.classList.remove('mobile-open');
this.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
});
} else {
console.warn('Mobile toggle elements not found:', {
mobileToggle: !!mobileToggle,
sidebar: !!sidebar,
mobileOverlay: !!mobileOverlay
});
}
// Close mobile menu on resize to desktop
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
});
// Tab functionality
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(function(button) {
button.addEventListener('click', function() {
const targetTab = this.getAttribute('data-tab');
// Remove active class from all buttons and contents
tabButtons.forEach(function(btn) {
btn.classList.remove('active');
});
tabContents.forEach(function(content) {
content.classList.remove('active');
});
// Add active class to clicked button and corresponding content
this.classList.add('active');
document.getElementById(targetTab + '-tab').classList.add('active');
});
});
// Swagger UI functionality for testbed tab
let swaggerInitialized = false;
const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default';
// Initialize Swagger UI when testbed tab is clicked
tabButtons.forEach(function(button) {
button.addEventListener('click', function() {
const targetTab = this.getAttribute('data-tab');
if (targetTab === 'testbed' && !swaggerInitialized) {
initializeSwagger(currentApiId);
swaggerInitialized = true;
}
});
});
function initializeSwagger(apiId) {
if (!apiId || apiId === 'default') {
document.getElementById('swagger-ui').innerHTML = '<p>API 정보를 불러올 수 없습니다.</p>';
return;
}
const swaggerUrl = `/api/apis/${apiId}/swagger.json`;
// Load Swagger UI scripts dynamically
const loadScript = (src) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
};
// Load required scripts
Promise.all([
loadScript(/*[[@{/plugins/swaggerUI/swagger-ui-bundle.js}]]*/ ''),
loadScript(/*[[@{/plugins/swaggerUI/swagger-ui-standalone-preset.js}]]*/ '')
]).then(() => {
// Load code template script
return loadScript(/*[[@{/js/code_template.js}]]*/ '');
}).then(() => {
// Initialize Swagger UI
const generateSnippet = (parsedRequest, language) => {
let result;
try {
$.ajax({
url: /*[[@{/api/sample_code/}]]*/ '' + language,
type: 'POST',
async: false,
contentType: 'application/json',
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ ''
},
data: JSON.stringify(parsedRequest),
success: function(data) {
result = data;
},
error: function(jqXHR, textStatus, errorThrown) {
console.error('Error:', errorThrown);
}
});
} catch (error) {
console.error('Error:', error);
}
return result;
};
const SnippetGeneratorPlugin = {
fn: {
requestSnippetGenerator_csharp: (request) => {
var parsedRequest = parseSwaggerRequest(request);
return generateSnippet(parsedRequest, 'csharp');
},
requestSnippetGenerator_ecma5: (request) => {
var parsedRequest = parseSwaggerRequest(request);
return generateSnippet(parsedRequest, 'ecma5');
},
requestSnippetGenerator_java: (request) => {
var parsedRequest = parseSwaggerRequest(request);
return generateSnippet(parsedRequest, 'java');
},
requestSnippetGenerator_python: (request) => {
var parsedRequest = parseSwaggerRequest(request);
return generateSnippet(parsedRequest, 'python');
}
}
};
// Clear existing Swagger UI
document.getElementById('swagger-ui').innerHTML = '';
// Initialize Swagger UI
const ui = SwaggerUIBundle({
url: swaggerUrl,
dom_id: '#swagger-ui',
requestInterceptor: function(req) {
if (!req.loadSpec) {
const urlPath = new URL(req.url).pathname;
const method = req.method.toLowerCase();
const spec = ui.spec();
const specString = JSON.stringify(spec);
const specData = JSON.parse(specString);
if (specData && specData.json && specData.json.paths && specData.json.paths[urlPath]) {
const operation = specData.json.paths[urlPath][method];
if (operation && operation['x-original-api-id']) {
req.headers['original-api-id'] = operation['x-original-api-id'];
}
}
req.headers['original-url'] = req.url;
req.headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ '';
if ($('#apps').val() != null) {
req.headers['client-id'] = $('#apps').val();
}
req.url = window.location.origin + /*[[@{/api/call-api}]]*/ '';
}
return req;
},
showMutatedRequest: false,
validatorUrl: '',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset.slice(1)
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl,
SnippetGeneratorPlugin
],
layout: 'StandaloneLayout',
requestSnippetsEnabled: true,
requestSnippets: {
generators: {
'csharp': {
title: 'C#',
syntax: 'csharp'
},
'ecma5': {
title: 'ECMA5',
syntax: 'javascript'
},
'java': {
title: 'Java',
syntax: 'java'
},
'python': {
title: 'Python',
syntax: 'python'
}
},
defaultExpanded: true,
languages: null
}
});
window.ui = ui;
}).catch(error => {
console.error('Failed to load Swagger UI:', error);
document.getElementById('swagger-ui').innerHTML = '<p>Swagger UI를 불러오는 중 오류가 발생했습니다.</p>';
});
}
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -1,153 +1,250 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}"> xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/kjbank_base_layout}">
<body> <body>
<section layout:fragment="contentFragment" class="content"> <th:block layout:fragment="contentFragment">
<section class="api-market-container">
<div class="content_wrap"> <!-- Sidebar Navigation -->
<div class="api_service_type m-only"> <aside class="api-market-sidebar" id="apiSidebar">
<img th:src="@{/img/bg_api_visual03.png}" alt="Kbank API 서비스"> <nav class="api-sidebar-nav">
<div class="service_txt"> <!-- All APIs -->
<p>Kbank API 서비스</p> <div class="menu-section">
<span> <div class="menu-title" th:classappend="${selected == '-1'} ? 'active'" data-group="">
API를 활용해 당신의 비즈니스 모델을<br/> 전체
혁신하여 창의적이고 차별화된 서비스를<br/> <span class="api-count" th:text="${totalApiCount}">0</span>
제작하세요.
</span>
</div>
</div>
<div class="api_service_type pc-only">
<img th:src="@{/img/bg_api_visual02.png}" alt="Kbank API 서비스">
<div class="service_txt">
<p>Kbank API 서비스</p>
<span>
Kbank가 제공하는 다양한 API를 소개합니다.<br/>
API를 활용해 당신의 비즈니스 모델을 혁신하여 창의적이고<br/>
차별화된 서비스를 제작하세요.
</span>
</div>
</div>
<div class="api_inner api_pd">
<div class="main_keyword">
<div class="search_type2">
<form id="searchForm" th:action="@{/apis}" method="get">
<div class="search-container2">
<input type="hidden" name="groupIds" th:value="${#strings.replace(search.groupIds, '[', '').replace(']', '')}"/>
<input type="text" class="search-box2" name="keyword" th:value="${search.keyword}" placeholder="어떤 API를 찾고 계신가요?">
<button class="search-button2" type="submit"></button>
</div> </div>
</form> </div>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${services}">
<div class="menu-title"
th:classappend="${selected == service.id} ? 'active'"
th:data-group="${service.id}">
[[${service.groupName}]]
<span class="api-count" th:text="${service.apiGroupApiList.size()}">0</span>
</div>
</div>
</nav>
</aside>
<!-- Mobile Menu Toggle -->
<button class="api-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
</button>
<!-- Mobile Overlay -->
<div class="api-mobile-overlay" id="mobileOverlay"></div>
<!-- Main Content -->
<main class="api-market-content">
<!-- Header -->
<div class="api-market-header">
<div class="api-market-title">
<h1>오픈API</h1>
<h2>API 마켓</h2>
</div>
<div class="api-market-search">
<form id="searchForm" th:action="@{/apis}" method="get">
<input type="hidden" name="groupIds" th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}"/>
<input type="text"
class="search-input"
name="keyword"
th:value="${search.keyword}"
placeholder="어떤 API를 찾고 계신가요?">
<button type="submit" class="search-submit-btn" aria-label="검색">
<span class="search-icon">🔍</span>
</button>
</form>
</div>
</div> </div>
<div class="api_keyword">
<ul class="keyword_list tab_list grt"> <!-- Result Count -->
<li> <div class="api-result-count">
<a href="#">전체<span class="number" th:text="${selectedApiCount}" th:classppend="${selected == '-1'}?'active'"></span></a> <strong th:text="${selectedApiCount}">0</strong>
</li>
<li th:each="item : ${services}">
<a href="#" th:data-group="${item.id}" th:classappend="${selected == item.id}?'active'">
[[${item.groupName}]]<span class="number" th:text="${item.apiGroupApiList.size()}"></span>
</a>
</li>
</ul>
</div> </div>
<div class="m-only">
<div class="api_info_container card_top"> <!-- API Cards Grid -->
<div class="api_box" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}"> <div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}">
<div class="api_info_box"> <div class="api-card"
<div class="api_list" th:data-href="@{/apis/detail(id=${api.apiId})}"> th:each="api : ${apis}"
<p class="txt1" th:text="${api.apiName}"></p> th:data-href="@{/apis/detail(id=${api.apiId})}"
<p class="txt2" th:text="${api.apiSimpleDescription}"></p> role="button"
</div> tabindex="0">
<div class="api_img" style="height: 48px;"> <div>
<img th:if="${#strings.isEmpty(api.mainIcon) == false}" th:src="${api.mainIcon}" alt=""> <!-- Card Header with Badge -->
</div> <div class="api-card-header">
<span class="api-card-badge" th:if="${api.apiGroupName}" th:text="${api.apiGroupName}">
카테고리
</span>
</div> </div>
<!-- Card Title -->
<h3 class="api-card-title" th:text="${api.apiName}">
API 이름
</h3>
<!-- Card Description -->
<p class="api-card-description" th:text="${api.apiSimpleDescription ?: 'API 설명이 없습니다.'}">
API 설명
</p>
</div>
<!-- Card Icon -->
<div class="api-card-icon">
<img th:if="${api.mainIcon}"
th:src="${api.mainIcon}"
th:alt="${api.apiName}"
onerror="this.style.display='none'; this.parentElement.innerHTML='📄'">
<span th:if="${#strings.isEmpty(api.mainIcon)}">📄</span>
</div> </div>
</div> </div>
</div> </div>
<div class="pc-only">
<div class="ser_result" ><span>[[${selectedApiCount}]]건</span> 검색</div> <!-- Empty State -->
<div class="api_info_container box_list mt_0"> <div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}">
<div class="api_box" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}"> <div class="empty-icon">🔍</div>
<div class="api_info_box pc-only"> <h3>검색 결과가 없습니다</h3>
<div class="api_list" th:data-href="@{/apis/detail(id=${api.apiId})}"> <p>다른 키워드로 검색하거나 카테고리를 변경해 보세요.</p>
<p class="txt1" th:text="${api.apiName}"></p>
<p class="txt2" th:text="${api.apiSimpleDescription}"></p>
</div>
<div class="api_img">
<a th:href="@{/apis/detail(id=${api.apiId})}" class="btn btn-primary"><img th:if="${#strings.isEmpty(api.mainIcon) == false}" th:src="${api.mainIcon}" alt=""></a>
</div>
</div>
</div>
</div>
</div> </div>
</div>
</div> </main>
</div> </section>
</section> </th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', (event) => { document.addEventListener('DOMContentLoaded', function() {
const apiListItems = document.querySelectorAll('.api_list'); // DOM Elements
apiListItems.forEach(function(item) { const sidebar = document.getElementById('apiSidebar');
item.addEventListener('click', function() { const mobileToggle = document.getElementById('mobileToggle');
var href = this.getAttribute('data-href'); const mobileOverlay = document.getElementById('mobileOverlay');
if (href) { const searchForm = document.getElementById('searchForm');
window.location.href = href; const groupIdsInput = searchForm.querySelector('input[name="groupIds"]');
} const searchInput = searchForm.querySelector('input[name="keyword"]');
});
});
const apiItems = document.querySelectorAll('.api_box'); // API Card Click Handlers
apiItems.forEach(function(item) { const apiCards = document.querySelectorAll('.api-card');
item.removeEventListener('click',null); apiCards.forEach(function(card) {
item.addEventListener('click', function() { // Click handler
card.addEventListener('click', function() {
const href = this.getAttribute('data-href'); const href = this.getAttribute('data-href');
if (href) { if (href) {
window.location.href = href; window.location.href = href;
} }
}); });
// Keyboard accessibility
card.addEventListener('keypress', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
}
});
}); });
var keywordListItems = document.querySelectorAll('.keyword_list li'); // Category/Service Selection
var form = document.getElementById('searchForm'); const menuTitles = document.querySelectorAll('.menu-title');
var groupIdsInput = form.querySelector('input[name="groupIds"]'); menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
keywordListItems.forEach(function(item) { const groupId = this.getAttribute('data-group');
item.addEventListener('click', function(e) {
e.preventDefault(); // Prevent default anchor behavior // Update groupIds input
var anchor = this.querySelector('a');
var groupId = anchor.getAttribute('data-group');
if (groupId) { if (groupId) {
groupId = groupId.replace(/[\[\]]/g, '');
}
if (groupId) {
// If specific group is selected, create/set input value
if (!groupIdsInput) {
groupIdsInput = document.createElement('input');
groupIdsInput.type = 'hidden';
groupIdsInput.name = 'groupIds';
form.appendChild(groupIdsInput);
}
groupIdsInput.value = groupId; groupIdsInput.value = groupId;
} else { } else {
if (groupIdsInput) { groupIdsInput.value = '';
groupIdsInput.remove();
}
} }
form.submit(); // Submit form
searchForm.submit();
}); });
}); });
// Search on Enter key
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
searchForm.submit();
}
});
// Mobile Menu Toggle
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sidebar.classList.toggle('mobile-open');
mobileOverlay.classList.toggle('active');
// Update button text
if (sidebar.classList.contains('mobile-open')) {
this.innerHTML = '✕';
this.setAttribute('aria-label', '메뉴 닫기');
} else {
this.innerHTML = '☰';
this.setAttribute('aria-label', '메뉴 열기');
}
});
// Add touch event for better mobile support
mobileToggle.addEventListener('touchend', function(e) {
e.preventDefault();
e.stopPropagation();
this.click();
});
// Close menu on overlay click
mobileOverlay.addEventListener('click', function() {
sidebar.classList.remove('mobile-open');
this.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
});
// Close menu on category selection (mobile)
menuTitles.forEach(function(title) {
title.addEventListener('click', function() {
if (window.innerWidth <= 768) {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
});
});
} else {
console.warn('Mobile toggle elements not found:', {
mobileToggle: !!mobileToggle,
sidebar: !!sidebar,
mobileOverlay: !!mobileOverlay
});
}
// Close mobile menu on resize to desktop
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
});
}); });
</script> </script>
</th:block> </th:block>
</html> </html>
@@ -1,104 +1,90 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body> <body>
<section layout:fragment="contentFragment" class="content"> <th:block layout:fragment="contentFragment">
<div class="content_wrap"> <div class="login-page">
<div class="sub_title2"> <div class="login-container">
<h2 class="title add7">로그인</h2> <div class="login-card">
</div> <!-- Login Header with Logo -->
<div class="inner i_cs9 h_inner9"> <div class="login-header">
<div class="form_type"> <h1 class="login-title">로그인</h1>
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"> <p class="login-subtitle">광주은행 개발자 포털에 오신 것을 환영합니다</p>
</div>
<!-- Alert Messages -->
<div th:if="${param.logout}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>로그아웃되었습니다.</span>
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
<input type="hidden" id="message" name="message" th:value="${loginMessage}"> <input type="hidden" id="message" name="message" th:value="${loginMessage}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="form_type_box"> <input type="hidden" id="loginId" th:value="${loginId}">
<div class="info1 form_top">
<p class="title">
<span>아이디</span>
</p>
<div class="info_line info_add">
<div class="info_box1 add add2">
<input type="text" id="id" name="id" th:value="${loginId}"
class="common_input_type_1 h_inp"
placeholder="이메일 아이디를 입력해 주세요." required>
<input type="hidden" id="loginId" th:value="${loginId}">
</div>
</div>
</div>
<div class="info1"> <!-- Email Field -->
<p class="title"> <div class="form-group">
<span>비밀번호</span> <label for="id" class="form-label">이메일</label>
</p> <input type="text"
<div class="info_line info_add"> id="id"
<div class="info_box1 add add2"> name="id"
<input type="password" id="password" name="password" th:value="${loginId}"
class="common_input_type_1 h_inp" class="form-input"
placeholder="비밀번호를 입력해 주세요." required> placeholder="이메일 아이디를 입력해 주세요"
</div> required>
</div>
</div>
</div> </div>
<div class="box_inp">
<span class="inp_check f_size"> <!-- Password Field -->
<input id="checkId" name="checkId" type="checkbox"> <div class="form-group">
<label for="checkId">아이디 저장</label> <label for="password" class="form-label">비밀번호</label>
</span> <input type="password"
id="password"
name="password"
class="form-input"
placeholder="비밀번호를 입력해 주세요"
required>
</div>
<!-- Remember Me Checkbox -->
<div class="form-checkbox">
<input id="checkId" name="checkId" type="checkbox">
<label for="checkId">아이디 저장</label>
</div> </div>
</form> </form>
<div class="btn_login">
<a href="#" class="common_btn_type_1 h_btn" id="actionLogin"><span>로그인</span></a> <!-- Login Button -->
<button type="button" class="login-button" id="actionLogin">
로그인
</button>
<!-- Additional Links -->
<div class="login-links">
<a th:href="@{/account_recovery(tab='findId')}">
<i class="fas fa-search"></i>
<span>아이디 찾기</span>
</a>
<span class="link-separator">|</span>
<a th:href="@{/account_recovery(tab='resetPassword')}">
<i class="fas fa-lock"></i>
<span>비밀번호 초기화</span>
</a>
<span class="link-separator">|</span>
<a th:href="@{/signup}">
<i class="fas fa-user-plus"></i>
<span>회원가입</span>
</a>
</div> </div>
<div class="login_list pc-only">
<ul> <!-- Loading State -->
<li class="icon_search"> <div class="login-loading" id="loginLoading">
<img th:src="@{/img/icon/icon_login_search.png}" alt=""> <div class="spinner"></div>
<a th:href="@{/account_recovery(tab='findId')}">아이디 찾기</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_lock">
<img th:src="@{/img/icon/icon_login_lock.png}" alt="">
<a th:href="@{/account_recovery(tab='resetPassword')}">비밀번호 초기화</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_join">
<img th:src="@{/img/icon/icon_login_join.png}" alt="">
<a th:href="@{/signup}">회원가입</a>
</li>
</ul>
</div>
<div class="login_list m-only">
<ul>
<li class="icon_search">
<a th:href="@{/account_recovery(tab='findId')}">아이디 찾기</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_lock">
<a th:href="@{/account_recovery(tab='resetPassword')}">비밀번호 초기화</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_join">
<a th:href="@{/signup}">회원가입</a>
</li>
</ul>
</div>
<div th:if="${param.logout}" class="alert alert-info mt-3">
<p>로그아웃되었습니다.</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</th:block>
</section>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
@@ -179,6 +165,10 @@
let actionLogin = function (event) { let actionLogin = function (event) {
let form = document.loginForm; let form = document.loginForm;
// Show loading state
$('#loginLoading').addClass('active');
if ($('#checkId').is(':checked')) { if ($('#checkId').is(':checked')) {
setCookie('saveid', $('#id').val(), new Date(new Date().getTime() + 1000 * 3600 * 24 * 30)); setCookie('saveid', $('#id').val(), new Date(new Date().getTime() + 1000 * 3600 * 24 * 30));
} else { } else {
@@ -188,9 +178,11 @@
if (!form.checkValidity()) { if (!form.checkValidity()) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
$('#loginLoading').removeClass('active');
customPopups.showAlert('입력 정보 확인'); customPopups.showAlert('입력 정보 확인');
} else { } else {
if (form.password.value.length < 8) { if (form.password.value.length < 8) {
$('#loginLoading').removeClass('active');
customPopups.showAlert('[[#{login.passLengthShort}]]'); customPopups.showAlert('[[#{login.passLengthShort}]]');
} else { } else {
const originalPassword = form.password.value; const originalPassword = form.password.value;
@@ -201,7 +193,10 @@
form.classList.add('was-validated'); form.classList.add('was-validated');
}; };
$('#actionLogin').on('click', actionLogin); $('#actionLogin').on('click', function(event) {
event.preventDefault();
actionLogin(event);
});
$('#password').on('keydown', function (event) { $('#password').on('keydown', function (event) {
if (event.key === 'Enter' || event.keyCode === 13 || event.code === 'Enter') { if (event.key === 'Enter' || event.keyCode === 13 || event.code === 'Enter') {
event.preventDefault(); event.preventDefault();
@@ -1,459 +1,372 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_index_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_index_layout}">
<body> <body>
<section layout:fragment="contentFragment" class="content main"> <th:block layout:fragment="contentFragment">
<script th:src="@{/js/htmx.min.js}"></script> <section class="hero-section">
<!-- main slick --> <div class="hero-patterns">
<div class="slider-container"> <div class="pattern-circle pattern-1"></div>
<div class="slider m-only"> <div class="pattern-circle pattern-2"></div>
<div class="slider_box"> <div class="pattern-dots"></div>
</div>
<img th:src="@{/img/img_mainslick_m.png}" alt="슬라이드 1"> <div class="container">
<div class="slick_txt"> <div class="hero-content">
<p>디지털 금융 리더 <span>케이뱅크</span><br/>새로운 비즈니스 기회를<br/>제공합니다.</p> <div class="hero-badge">
<span class="badge-icon">🚀</span>
<span>Wa bank 생태계에 오신 것을 환영합니다!</span>
</div> </div>
</div> <h1 class="main-headline">
<div class="slider_box"> <span class="headline-wa">Wa한</span> 금융 혁신,<br>
<img th:src="@{/img/img_mainslick2_m.png}" alt="슬라이드 2"> 여러분의 아이디어로 시작됩니다
<div class="slick_txt"> </h1>
<p>차별화된 <span>API와<br> 편리한 테스트 환경</span><br> 지원합니다</p> <h2 class="sub-headline">
</div> 광주은행 오픈 API로 새로운 금융 서비스를 만들어보세요.<br>
</div> 안전하고 빠르게, 그리고 쉽게 연동할 수 있습니다.
<div class="slider_box"> </h2>
<img th:src="@{/img/img_mainslick3_m.png}" alt="슬라이드 3"> <div class="hero-buttons">
<div class="slick_txt"> <a href="/apis" class="btn btn-primary">
<p><span>디지털 금융 혁신</span><br> 케이뱅크가<br> 함께합니다</p> <i class="fas fa-code"></i> API 둘러보기
</a>
<a href="#get-started" class="btn btn-secondary">
<i class="fas fa-play"></i> 5분 만에 시작하기
</a>
</div> </div>
</div> </div>
</div> </div>
<div class="slider pc-only"> </section>
<div class="slider_box">
<img th:src="@{/img/img_mainslick_pc.png}" alt="슬라이드 1">
<div class="slick_txt">
<p><span>케이뱅크</span>가 새로운 비즈니스<br> 기회를 제공합니다</p>
</div>
</div>
<div class="slider_box">
<img th:src="@{/img/img_mainslick2_pc.png}" alt="슬라이드 2">
<div class="slick_txt">
<p>차별화된 <span>API와</span><br/>
<span>편리한 테스트 환경</span><br/>지원합니다</p>
</div>
</div>
<div class="slider_box">
<img th:src="@{/img/img_mainslick3_pc.png}" alt="슬라이드 3">
<div class="slick_txt">
<p><span>디지털 금융 혁신</span><br>케이뱅크가<br>함께합니다</p>
</div>
</div>
</div>
<button class="slick-prev" aria-label="Previous" type="button">이전</button>
<button class="slick-next" aria-label="Next" type="button">다음</button>
<ul class="slick-dots">
<li>
<button type="button">1</button>
</li>
<li>
<button type="button">2</button>
</li>
<li>
<button type="button">3</button>
</li>
</ul>
</div>
<!-- // main slick -->
<div class="main-container"> <!-- API 검색 섹션 -->
<div class="search_type"> <section class="api-search-section">
<form th:action="@{/apis}" id="searchForm" method="get"> <div class="container">
<div class="search-container"> <div class="search-container">
<input type="text" name="keyword" class="search-box" id="search-box" placeholder="어떤 API를 찾고 계신가요?"> <form th:action="@{/apis}" method="get" class="search-form">
<button class="search-button" type="submit"></button> <div class="search-wrapper">
</div> <input type="text" class="search-input" placeholder="원하는 API를 검색해보세요" id="apiSearchInput" name="keyword">
</form> <button class="search-button" type="submit">
</div> <i class="fas fa-search"></i>
<div class="hashtag"> <span>검색하기</span>
<ul> </button>
<li th:each="tag: ${hashtags}"><a href="#" th:text="${tag}">#인증</a></li>
</ul>
</div>
<div class="main_keyword">
<div class="api_tit">
<h2>가장 많이 찾는 API</h2>
<p class="total_view">
<a href="/apis">전체보기</a>
</p>
</div>
<div class="api_keyword">
<ul class="keyword_list tab_list">
<li>
<a href="#"
hx-get="/api_fragments"
hx-target="#api_spec_list"
hx-trigger="click"
onclick="setActive(this)">전체<span class="number" th:text="${totalApiCount}"></span></a>
</li>
<li th:each="item : ${services}">
<a href="#"
th:hx-get="@{/api_fragments(id=${item.id})}"
hx-target="#api_spec_list"
hx-trigger="click"
onclick="setActive(this)">
[[${item.groupName}]]<span class="number" th:text="${item.apiGroupApiList.size()}"></span>
</a>
</li>
</ul>
<div class="more_type m-only">
<button class="showmore" type="button">
<img th:src="@{/img/icon/icon_more_btn.png}" alt="더보기 버튼">
</button>
</div>
<div class="more_type pc-only">
<button class="showmore" type="button">
<img th:src="@{/img/icon/icon_more_btn2.png}" alt="더보기 버튼">
</button>
</div>
</div>
<div class="api_info_container box_list" id="api_spec_list">
<div class="api_info_box" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}">
<div class="api_list" th:data-href="@{/apis/detail(id=${api.apiId})}">
<p class="txt1" th:text="${api.apiName}"></p>
<p class="txt2" th:text="${api.apiSimpleDescription}"></p>
</div> </div>
<div class="api_img" style="height: 48px;"> </form>
<a th:href="@{/apis/detail(id=${api.apiId})}" class="btn btn-primary"> <div class="hashtag-suggestions" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">
<img th:if="${#strings.isEmpty(api.mainIcon) == false}" th:src="${api.mainIcon}" alt=""> <span class="hashtag-label">추천 검색어:</span>
</a> <a href="#" class="hashtag" th:each="tag : ${hashtags}"
th:data-search="${tag}"
th:text="${'#' + tag}">
</a>
</div>
<!-- Fallback hashtags if no hashtags from controller -->
<div class="hashtag-suggestions" th:unless="${hashtags != null and not #lists.isEmpty(hashtags)}">
<span class="hashtag-label">추천 검색어:</span>
<a href="#" class="hashtag" data-search="계좌조회">#계좌조회</a>
<a href="#" class="hashtag" data-search="송금API">#송금API</a>
<a href="#" class="hashtag" data-search="실시간결제">#실시간결제</a>
<a href="#" class="hashtag" data-search="오픈뱅킹">#오픈뱅킹</a>
</div>
</div>
</div>
</section>
<section class="api-showcase">
<div class="container">
<div class="section-header">
<h2 class="section-title">
<span class="title-highlight">인기 API</span>로 바로 시작하세요
</h2>
<p class="section-subtitle">가장 많이 사용되는 API를 확인하고 바로 테스트해보세요</p>
</div>
<div class="api-grid">
<!-- Dynamic API Cards from Controller (max 4) -->
<div th:each="api, iterStat : ${apis}"
th:if="${iterStat.index < 4}"
class="api-card"
th:classappend="${iterStat.index == 0} ? 'api-card-popular' : (${iterStat.index == 3} ? 'api-card-new' : '')">
<!-- Show badge for first (인기) and last (NEW) items -->
<div th:if="${iterStat.index == 0}" class="api-badge">인기</div>
<div th:if="${iterStat.index == 3}" class="api-badge new">NEW</div>
<!-- API Icon - using different icons based on index or API type -->
<div class="api-icon">
<!-- Icon selection based on API name or service type -->
<i th:if="${api.apiName != null}"
th:class="${#strings.containsIgnoreCase(api.apiName, '계좌') or #strings.containsIgnoreCase(api.apiName, '조회')} ? 'fas fa-wallet' :
(${#strings.containsIgnoreCase(api.apiName, '송금') or #strings.containsIgnoreCase(api.apiName, '이체')} ? 'fas fa-exchange-alt' :
(${#strings.containsIgnoreCase(api.apiName, '카드') or #strings.containsIgnoreCase(api.apiName, '결제')} ? 'fas fa-credit-card' :
(${#strings.containsIgnoreCase(api.apiName, 'AI') or #strings.containsIgnoreCase(api.apiName, '분석')} ? 'fas fa-chart-line' :
(${iterStat.index == 0} ? 'fas fa-wallet' :
(${iterStat.index == 1} ? 'fas fa-exchange-alt' :
(${iterStat.index == 2} ? 'fas fa-credit-card' : 'fas fa-chart-line'))))))"></i>
</div>
<!-- API Name -->
<h3 class="api-name" th:text="${api.apiName}">API 이름</h3>
<!-- API Description -->
<p class="api-description" th:text="${api.apiSimpleDescription != null ? api.apiSimpleDescription : api.description}">API 설명</p>
<!-- API Features/Method Info -->
<div class="api-features">
<span class="feature-tag" th:if="${api.apiMethod != null}" th:text="${api.apiMethod}">GET</span>
<span class="feature-tag" th:if="${api.service != null}" th:text="${api.service}">서비스</span>
</div>
<!-- Test Link -->
<a th:href="@{/apis/detail(id=${api.apiId})}" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
<!-- Fallback: Show static cards if no APIs from controller -->
<div th:if="${apis == null or #lists.isEmpty(apis)}" class="api-card api-card-popular">
<div class="api-badge">인기</div>
<div class="api-icon">
<i class="fas fa-wallet"></i>
</div>
<h3 class="api-name">계좌 조회 API</h3>
<p class="api-description">실시간 잔액 조회와 거래내역을 안전하게 확인</p>
<div class="api-features">
<span class="feature-tag">실시간</span>
<span class="feature-tag">OAuth 2.0</span>
</div>
<a href="#" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
<div th:if="${apis == null or #lists.isEmpty(apis)}" class="api-card">
<div class="api-icon">
<i class="fas fa-exchange-alt"></i>
</div>
<h3 class="api-name">송금 API</h3>
<p class="api-description">간편하고 안전한 송금 서비스 구현</p>
<div class="api-features">
<span class="feature-tag">간편송금</span>
<span class="feature-tag">실명확인</span>
</div>
<a href="#" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
<div th:if="${apis == null or #lists.isEmpty(apis)}" class="api-card">
<div class="api-icon">
<i class="fas fa-credit-card"></i>
</div>
<h3 class="api-name">카드 결제 API</h3>
<p class="api-description">온/오프라인 결제 솔루션 통합</p>
<div class="api-features">
<span class="feature-tag">PG연동</span>
<span class="feature-tag">간편결제</span>
</div>
<a href="#" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
<div th:if="${apis == null or #lists.isEmpty(apis)}" class="api-card api-card-new">
<div class="api-badge new">NEW</div>
<div class="api-icon">
<i class="fas fa-chart-line"></i>
</div>
<h3 class="api-name">AI 신용평가 API</h3>
<p class="api-description">머신러닝 기반 실시간 신용 분석</p>
<div class="api-features">
<span class="feature-tag">AI/ML</span>
<span class="feature-tag">실시간</span>
</div>
<a href="#" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
</div>
</div>
</section>
<!-- 정보 섹션 -->
<section class="info-section">
<div class="container">
<div class="info-wrapper">
<div class="image-container">
<div class="image-placeholder">
<i class="fas fa-chart-line"></i>
<span>금융 혁신</span>
</div> </div>
</div> </div>
</div>
</div>
<div class="main_con"> <div class="info-content">
<p class="con_tit m-only"><img th:src="@{/img/img_con_tit.png}" alt="Kbank API PORTAL"></p> <p class="highlight-text">
<p class="con_tit pc-only"><img th:src="@{/img/img_con_tit2.png}" alt="Kbank API PORTAL"></p> 광주은행 <span class="text-accent">API Portal</span>은 기업이 금융서비스를 편리하게 개발할 수 있도록<br>
<p class="con_img m-only"><img th:src="@{/img/img_mask_group.png}" alt="Kbank API PORTAL"></p> 차별화된 API와 테스트환경을 제공합니다.
<p class="con_img pc-only"><img th:src="@{/img/img_mask_group2.png}" alt="Kbank API PORTAL"></p>
<p class="con_txt m-only">
<span>Kbank API Portal</span>은 기업이 금융서비스를<br>
편리하게 개발할 수 있도록 차별화된<br>
API와 테스트환경을 제공합니다.<br>
</p>
<p class="con_txt pc-only">
<span>Kbank API Portal</span>은 기업이 금융서비스를 편리하게 개발할 수 있도록 차별화된 API와 테스트환경을 제공합니다.
</p>
<div class="main_btn">
<a th:href="@{/intro}" class="common_btn_type_5"><span>처음 만나는 케이뱅크 API</span></a>
<a th:href="@{/partnership}" class="common_btn_type_5 m_btn"><span>사업 제휴 문의</span></a>
</div>
</div>
</div>
<div class="tiding_box">
<div class="api_type">
<div class="api_tiding">
<div class="api_tit">
<h2>새로운 케이뱅크 API 소식을<br> 만나보세요</h2>
<p class="total_view">
<a href="/portalnotice">전체보기</a>
</p> </p>
</div>
<div class="api_tiding_list"> <div class="action-buttons">
<ul> <a href="#" class="action-btn">
<li th:each="notice : ${portalNotices}"> <span class="btn-number">1</span>
<a th:href="@{/portalnotice/detail(id=${notice.id})}"> <span class="btn-text">처음 만나는 광주뱅크 API</span>
<p th:text="${notice.noticeSubject}"></p> <i class="fas fa-arrow-right"></i>
</a> </a>
<span th:text="${#temporals.format(notice.createdDate, 'yyyy.MM.dd')}"></span> <a href="#" class="action-btn">
</li> <span class="btn-number">2</span>
</ul> <span class="btn-text">사업 제휴 문의</span>
<i class="fas fa-arrow-right"></i>
</a>
</div>
</div> </div>
</div> </div>
<div class="api_info"> </div>
<ul> </section>
<li class="img1">
<a th:href="@{/faq_list}"> <!-- 지원 센터 섹션 -->
<p>FAQ</p> <section class="support-center">
<span>자주 묻는 질문과 답변을<br> 확인해보세요</span> <div class="container">
<img th:src="@{/img/icon/icon_petals01.png}" alt="faq 이미지"> <div class="section-header">
</a> <h2 class="section-title">
</li> 도움이 <span class="title-highlight">필요하신가요?</span>
<li class="img2"> </h2>
<a th:href="@{/inquiry}"> <p class="section-subtitle">다양한 방법으로 필요한 정보를 찾아보세요</p>
<p>Q&amp;A</p> </div>
<span>문의사항에<br> 친절하게 답변 드리겠습니다</span> <div class="support-grid">
<img th:src="@{/img/icon/icon_petals02.png}" alt="Q&A 이미지"> <a th:href="@{/portalnotice}" class="support-card">
</a> <div class="card-icon">
</li> <i class="fas fa-bullhorn"></i>
<li class="img3"> </div>
<a th:href="@{/partnership}"> <h3>공지사항</h3>
<p>사업제휴</p> <p>최신 업데이트와 중요한 소식을 확인하세요</p>
<span>온라인 비즈니스 혁신을 위한<br> 사업 제안을 환영합니다</span> <div class="card-arrow">
<img th:src="@{/img/icon/icon_petals03.png}" alt="사업제휴 이미지"> <i class="fas fa-arrow-right"></i>
</a> </div>
</li> </a>
</ul> <a th:href="@{/faq_list}" class="support-card">
<div class="card-icon">
<i class="fas fa-question-circle"></i>
</div>
<h3>자주 묻는 질문</h3>
<p>자주 묻는 질문과 답변을 빠르게 찾아보세요</p>
<div class="card-arrow">
<i class="fas fa-arrow-right"></i>
</div>
</a>
<a th:href="@{/inquiry}" class="support-card">
<div class="card-icon">
<i class="fas fa-comments"></i>
</div>
<h3>문의하기</h3>
<p>추가 도움이 필요하시면 직접 문의해주세요</p>
<div class="card-arrow">
<i class="fas fa-arrow-right"></i>
</div>
</a>
</div> </div>
</div> </div>
</div> </section>
<div class="api_count"> <!-- API 활용 현황 섹션 -->
<div class="api_total_box"> <section class="api-stats-section">
<p class="txt m-only">우리곁의 수많은 서비스들이<br> <em>케이뱅크API</em><br> 함께하고 있습니다</p> <div class="container">
<p class="txt pc-only">우리곁의 수많은 서비스들이<br> <em>케이뱅크API와 함께</em>하고 있습니다</p> <div class="section-header">
<div class="api_total"> <h2 class="section-title">
<ul> 우리곁의 수많은 서비스들이<br>
<li> <span class="title-highlight">광주은행 API</span>와 함께하고 있습니다
<img th:src="@{/img/icon/icon_api01.png}" alt="서비스 이용 수"> </h2>
<div class="api_num"> </div>
<p th:text="${serviceCount}"></p> <div class="stats-cards">
<span>서비스 이용</span> <div class="stat-card">
</div> <div class="stat-icon">
</li> <i class="fas fa-store"></i>
<li> </div>
<img th:src="@{/img/icon/icon_api02.png}" alt="API 이용건수"> <div class="stat-content">
<div class="api_num"> <div class="stat-number" th:attr="data-target=${serviceCount != null ? serviceCount : 0}" th:text="${serviceCount != null ? serviceCount : 0}">0</div>
<p th:text="${selectedApiCount}"></p> <div class="stat-label">서비스 이용 수</div>
<span>API 이용</span> <div class="stat-description">다양한 분야의 서비스가 활용중</div>
</div> </div>
</li> </div>
<li> <div class="stat-card">
<img th:src="@{/img/icon/icon_api03.png}" alt="API 활용기업"> <div class="stat-icon">
<div class="api_num"> <i class="fas fa-exchange-alt"></i>
<p th:text="${approvedOrgCount}"></p> </div>
<span>API 활용 기업</span> <div class="stat-content">
</div> <div class="stat-number" th:attr="data-target=${totalApiCount != null ? totalApiCount : 0}" th:text="${totalApiCount != null ? totalApiCount : 0}">0</div>
</li> <div class="stat-label">API 이용 건수</div>
</ul> <div class="stat-description">일일 평균 API 호출 수</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-building"></i>
</div>
<div class="stat-content">
<div class="stat-number" th:attr="data-target=${approvedOrgCount != null ? approvedOrgCount : 0}" th:text="${approvedOrgCount != null ? approvedOrgCount : 0}">0</div>
<div class="stat-label">API 활용 기업</div>
<div class="stat-description">혁신적인 파트너사와 함께</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </section>
</th:block>
<div id="main_bg"></div>
</section>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript">
$(document).ready(function() {
// 기존 success 메시지 처리
var successMsg = [[${success}]];
console.log("Success message:", successMsg);
if (successMsg) {
console.log("Showing alert");
customPopups.showAlert(successMsg);
}
// 장기미사용 계정 체크
const dormantAccount = [[${session.dormantAccount}]];
if (dormantAccount) {
const dormantMsg = /*[[${session.success}]]*/ '';
const redirectUrl = /*[[${session.redirectUrl}]]*/ '';
console.log('dormantMsg:', dormantMsg);
console.log('redirectUrl:', redirectUrl);
customPopups.showAlert(dormantMsg);
$('#customAlertOkButton').off('click.custom').on('click.custom', function () {
customPopups.hideAlert('#customAlert');
window.location.href = redirectUrl;
});
return; // 다른 체크 중단
}
// 비밀번호 만료 체크 추가
const passwordExpired = [[${session.passwordExpired}]];
if (passwordExpired) {
const pwdExpiredMsg = /*[[${session.success}]]*/ '';
const formattedMsg = pwdExpiredMsg.replace(/\n/g, '<br>');
const redirectUrl = /*[[${session.redirectUrl}]]*/ '';
console.log('pwdExpiredMsg:', pwdExpiredMsg);
console.log('redirectUrl:', redirectUrl);
$('#customAlertMessage').html(formattedMsg);
$('#customAlert').css('display','flex');
$('#customAlertOkButton').off('click.custom').on('click.custom', function () {
customPopups.hideAlert('#customAlert');
window.location.href = redirectUrl;
});
}
});
</script>
<script> <script>
function setActive(element) { // 숫자 카운트업 애니메이션
// Remove 'active' class from all links document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.keyword_list a').forEach(el => { const observerOptions = {
el.classList.remove('active'); threshold: 0.5,
}); rootMargin: '0px 0px -100px 0px'
// Add 'active' class to clicked link };
element.classList.add('active');
}
document.addEventListener('DOMContentLoaded', (event) => { const animateNumbers = (entries, observer) => {
const ITEMS_PER_PAGE = 4; entries.forEach(entry => {
let isExpanded = false; if (entry.isIntersecting) {
const numbers = entry.target.querySelectorAll('.stat-number');
// 더보기 버튼 초기화 numbers.forEach(number => {
function initMoreButton() { const target = parseInt(number.getAttribute('data-target'));
const mobileMoreBtn = document.querySelector('.more_type.m-only .showmore'); const duration = 2000; // 2초
const pcMoreBtn = document.querySelector('.more_type.pc-only .showmore'); const increment = target / (duration / 16);
const apiInfoBoxes = document.querySelectorAll('.api_info_box'); let current = 0;
// 5개 미만이면 더보기 버튼 숨기기 const updateNumber = () => {
const mobileMoreContainer = document.querySelector('.more_type.m-only'); current += increment;
const pcMoreContainer = document.querySelector('.more_type.pc-only'); if (current < target) {
number.textContent = Math.floor(current).toLocaleString('ko-KR');
requestAnimationFrame(updateNumber);
} else {
number.textContent = target.toLocaleString('ko-KR');
}
};
console.log('더보기 버튼 초기화:', { updateNumber();
항목개수: apiInfoBoxes.length, });
보여질상태: apiInfoBoxes.length >= 5 ? 'block' : 'none'
});
// 더보기 버튼 표시 여부 설정 observer.unobserve(entry.target);
if (apiInfoBoxes.length < 5) {
mobileMoreContainer.style.setProperty('display', 'none', 'important');
pcMoreContainer.style.setProperty('display', 'none', 'important');
} else {
mobileMoreContainer.style.setProperty('display', 'block', 'important');
pcMoreContainer.style.setProperty('display', 'block', 'important');
}
// 초기 상태 설정
updateApiListVisibility();
// 더보기 버튼 이벤트 리스너 설정
[mobileMoreBtn, pcMoreBtn].forEach(btn => {
if (btn) {
btn.removeEventListener('click', handleMoreClick);
btn.addEventListener('click', handleMoreClick);
} }
}); });
};
const observer = new IntersectionObserver(animateNumbers, observerOptions);
const statsSection = document.querySelector('.api-stats-section');
if (statsSection) {
observer.observe(statsSection);
} }
function handleMoreClick(e) { // API 검색 기능
e.preventDefault(); const searchInput = document.getElementById('apiSearchInput');
console.log('더보기 버튼 클릭'); const searchForm = document.querySelector('.search-form');
isExpanded = !isExpanded; // 상태 토글 const hashtags = document.querySelectorAll('.hashtag');
// 버튼 이미지/텍스트 변경 // 해시태그 클릭 이벤트
const mobileMoreBtn = document.querySelector('.more_type.m-only .showmore img'); hashtags.forEach(tag => {
const pcMoreBtn = document.querySelector('.more_type.pc-only .showmore img'); tag.addEventListener('click', function(e) {
if (mobileMoreBtn) {
mobileMoreBtn.alt = isExpanded ? "접기 버튼" : "더보기 버튼";
}
if (pcMoreBtn) {
pcMoreBtn.alt = isExpanded ? "접기 버튼" : "더보기 버튼";
}
updateApiListVisibility();
}
// API 리스트 표시 상태 업데이트
function updateApiListVisibility() {
const apiInfoBoxes = document.querySelectorAll('.api_info_box');
const endIndex = isExpanded ? apiInfoBoxes.length : ITEMS_PER_PAGE;
console.log('업데이트 - API 목록:', {
총개수: apiInfoBoxes.length,
표시상태: isExpanded ? '전체표시' : '일부표시',
표시할개수: endIndex
});
apiInfoBoxes.forEach((box, index) => {
box.style.display = index < endIndex ? 'block' : 'none';
});
}
// HTMX 로드 완료 이벤트 리스너
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'api_spec_list') {
console.log('HTMX 컨텐츠 로드됨 - 페이지 초기화');
isExpanded = false;
initMoreButton();
}
});
document.body.addEventListener('htmx:afterSettle', function(evt) {
const apiListItems = document.querySelectorAll('.api_list');
apiListItems.forEach(function(item) {
item.removeEventListener('click',null);
item.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
});
const apiItems = document.querySelectorAll('.api_info_box');
apiItems.forEach(function(item) {
item.removeEventListener('click',null);
item.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
});
});
// 초기 실행
console.log('초기 실행');
initMoreButton();
// All 카테고리를 기본으로 active 설정
document.querySelector('.keyword_list li:first-child a').classList.add('active');
const apiListItems = document.querySelectorAll('.api_list');
apiListItems.forEach(function(item) {
item.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
});
const apiItems = document.querySelectorAll('.api_info_box');
apiItems.forEach(function(item) {
item.removeEventListener('click',null);
item.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
});
const hashtagItems = document.querySelectorAll('.hashtag ul li a');
const searchBox = document.getElementById('search-box');
hashtagItems.forEach(function(item) {
item.addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
// Remove the # symbol if present and set the text in search box const searchTerm = this.getAttribute('data-search');
const searchText = this.textContent.replace('#', '').trim(); searchInput.value = searchTerm;
searchBox.value = searchText; // 폼 제출
if (searchForm) {
document.getElementById("searchForm").submit(); searchForm.submit();
});
});
const infoItems = document.querySelectorAll('.api_info ul li');
infoItems.forEach(function (item) {
item.style.cursor = 'pointer';
item.addEventListener('click', function() {
const link = this.querySelector('a');
if (link) {
location.href = link.getAttribute('href');
} }
}); });
}); });
}); });
</script> </script>
</th:block> </th:block>
@@ -15,9 +15,8 @@
<meta content="max-age=0, public" http-equiv="Cache-Control"/> <meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/> <meta content="index, follow" name="robots"/>
<meta content="https://www.eactive.co.kr/" property="og:url"/> <meta content="https://www.eactive.co.kr/" property="og:url"/>
<link rel="icon" type="image/ico" th:href="@{/favicon_16px.png}"> <link rel="icon" type="image/ico" th:href="@{/favicon.ico}">
<link rel="stylesheet" type="text/css" th:href="@{/css/style2.css}"> <link rel="stylesheet" type="text/css" th:href="@{/css/main.css}">
<link rel="stylesheet" type="text/css" th:href="@{/css/common2.css}">
</head> </head>
<body> <body>
<div id="wrap" class="wrap_top"> <div id="wrap" class="wrap_top">
@@ -15,9 +15,8 @@
<meta content="max-age=0, public" http-equiv="Cache-Control"/> <meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/> <meta content="index, follow" name="robots"/>
<meta content="https://www.eactive.co.kr/" property="og:url"/> <meta content="https://www.eactive.co.kr/" property="og:url"/>
<link rel="icon" type="image/ico" th:href="@{/favicon_16px.png}"> <link rel="icon" type="image/ico" th:href="@{/favicon.ico}">
<link rel="stylesheet" type="text/css" th:href="@{/css/style2.css}"> <link rel="stylesheet" type="text/css" th:href="@{/css/main.css}">
<link rel="stylesheet" type="text/css" th:href="@{/css/common2.css}">
</head> </head>
<body> <body>
<div id="wrap" class="wrap_top"> <div id="wrap" class="wrap_top">
@@ -15,9 +15,8 @@
<meta content="max-age=0, public" http-equiv="Cache-Control"/> <meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/> <meta content="index, follow" name="robots"/>
<meta content="https://www.eactive.co.kr/" property="og:url"/> <meta content="https://www.eactive.co.kr/" property="og:url"/>
<link rel="icon" type="image/ico" th:href="@{/favicon_16px.png}"> <link rel="icon" type="image/ico" th:href="@{/favicon.ico}">
<link rel="stylesheet" type="text/css" th:href="@{/css/style2.css}"> <link rel="stylesheet" type="text/css" th:href="@{/css/main.css}">
<link rel="stylesheet" type="text/css" th:href="@{/css/common2.css}">
</head> </head>
<body> <body>
<div id="wrap" class="wrap_top"> <div id="wrap" class="wrap_top">
@@ -0,0 +1,32 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<aside layout:fragment="apiAside" class="lnb">
<nav th:with="services=${@apiServiceService.searchApiGroupsForLnb()}">
<ul class="lnb_list_type">
<li>
<a href="#none">공통안내</a>
<ul class="lnb_list_nav">
<li> <a th:href="@{/apis/common}">API 개발 공통</a></li>
<li> <a th:href="@{/apis/KAPAP004U3}">가상계좌 응답 코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U4}">가상계좌 배치 설계</a></li>
<li> <a th:href="@{/apis/KAPAP004U5}">가상계좌 VAN사 코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U6}">펌뱅킹 응답코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U7}">펌뱅킹 배치 설계</a></li>
<li> <a th:href="@{/apis/KAPAP004U8}">대출금리 응답코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U9}">케이뱅크 페이 응답코드</a></li>
<li> <a th:href="@{/apis/KAPAP004U10}">케이뱅크 페이 복합과세 예제</a></li>
<li> <a th:href="@{/apis/token-spec}">케이뱅크 OAuth 2.0 토큰 발급</a></li>
</ul>
</li>
<li th:each="apiService : ${services}">
<a href="#none" th:text="${apiService.groupName}">API Group Name</a>
<ul class="lnb_list_nav">
<li th:each="api : ${apiService.apiGroupApiList}">
<a th:href="@{/apis/detail(id=${api.apiId})}" th:text="${api.apiDesc}">API Description</a>
</li>
</ul>
</li>
</ul>
</nav>
</aside>
@@ -0,0 +1,69 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
>
<body>
<footer th:fragment="footerFragment" class="global-footer">
<div class="container">
<div class="footer-top">
<div class="footer-brand">
<img src="/img/logo/logo.png" alt="광주은행" class="footer-logo">
<p class="footer-desc">
Wa bank와 함께 만드는 새로운 금융의 미래<br>
개발자 여러분과 함께 성장합니다.
</p>
</div>
<div class="footer-links">
<div class="footer-column">
<h4>제품</h4>
<ul>
<li><a href="#">API 카탈로그</a></li>
<li><a href="#">SDK 다운로드</a></li>
<li><a href="#">Playground</a></li>
<li><a href="#">요금제</a></li>
</ul>
</div>
<div class="footer-column">
<h4>개발자</h4>
<ul>
<li><a href="#">시작 가이드</a></li>
<li><a href="#">API 문서</a></li>
<li><a href="#">튜토리얼</a></li>
<li><a href="#">샘플 코드</a></li>
</ul>
</div>
<div class="footer-column">
<h4>지원</h4>
<ul>
<li><a href="#">도움말 센터</a></li>
<li><a href="#">커뮤니티</a></li>
<li><a href="#">기술 지원</a></li>
<li><a href="#">시스템 상태</a></li>
</ul>
</div>
<div class="footer-column">
<h4>회사</h4>
<ul>
<li><a href="#">광주은행 소개</a></li>
<li><a href="#">뉴스룸</a></li>
<li><a href="#">채용</a></li>
<li><a href="#">제휴 문의</a></li>
</ul>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="footer-legal">
<p>© 2025 광주은행. All rights reserved.</p>
<div class="legal-links">
<a href="#">이용약관</a>
<a href="#">개인정보처리방침</a>
<a href="#">쿠키 정책</a>
</div>
</div>
</div>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,52 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:fragment="headFragment">
<title th:text="#{title.html}">개발자 포털</title>
<meta content="https://www.eactive.co.kr/" property="og:url"/>
<meta charset="UTF-8"/>
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=Edge">
<meta http-equiv="Cache-Control" content="no-cache">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<link rel="stylesheet" type="text/css" th:href="@{/css/all.min.css}">
<link rel="stylesheet" type="text/css" th:href="@{/css/main.css}">
<link rel="stylesheet" type="text/css" th:href="@{/css/slick.css}">
<link rel="stylesheet" type="text/css" th:href="@{/css/daterangepicker.css}">
<link rel="stylesheet" th:href="@{/plugins/codemirror/codemirror.css}" type="text/css"/>
<link rel="stylesheet" th:href="@{/plugins/codemirror/theme/monokai.css}" type="text/css"/>
<link rel="stylesheet" th:href="@{/plugins/summernote/summernote-lite.css}" type="text/css" />
<link rel="stylesheet" th:href="@{/plugins/jquery-ui/jquery-ui.min.css}" />
<link rel="icon" type="image/ico" th:href="@{/favicon.ico}">
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
<script th:src="@{/plugins/jquery/jquery-3.7.1.min.js}"></script>
<script th:src="@{/js/lodash.js}"></script>
<script th:src="@{/js/common.js}"></script>
<script th:src="@{/js/moment.min.js}"></script>
<script th:src="@{/js/daterangepicker.js}"></script>
<script th:src="@{/plugins/codemirror/codemirror.js}"></script>
<script th:src="@{/plugins/codemirror/mode/clike.js}"></script>
<script th:src="@{/plugins/codemirror/mode/javascript.js}"></script>
<script th:src="@{/plugins/codemirror/addon/display/fullscreen.js}"></script>
<script th:src="@{/plugins/codemirror/addon/display/placeholder.js}"></script>
<script th:src="@{/plugins/summernote/summernote-lite.min.js}"></script>
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
<script th:src="@{/js/htmx.min.js}"></script>
</head>
</html>
@@ -0,0 +1,135 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<body>
<th:block th:fragment="headerFragment">
<!-- Global Header Container -->
<header class="global-header">
<div class="container">
<div class="header-content">
<!-- Desktop Header Layout -->
<div class="desktop-header">
<div class="header-left">
<div class="logo-wrapper">
<a th:href="@{/}" class="logo-link">
<img src="/img/logo/logo.png" alt="광주은행" class="logo">
</a>
<a th:href="@{/}" class="logo-text">개발자 포털</a>
</div>
</div>
<nav class="header-center">
<ul class="nav-menu">
<li><a href="#docs" class="nav-link">서비스 소개</a></li>
<li><a href="/apis" class="nav-link">오픈 API</a></li>
<li><a href="#playground" class="nav-link">고객지원</a></li>
<li><a href="#community" class="nav-link">MY</a></li>
</ul>
</nav>
<div class="header-right">
<button class="search-btn"><i class="fas fa-search"></i></button>
<a th:href="@{/login}" class="login-btn">로그인</a>
<a href="#signup" class="signup-btn">
<span>회원 가입</span>
<i class="fas fa-arrow-right"></i>
</a>
</div>
</div>
<!-- Mobile Header Layout -->
<div class="mobile-header">
<div class="mobile-left">
<a th:href="@{/}" class="mobile-logo-link">
<img src="/img/logo/logo.png" alt="광주은행" class="mobile-logo">
</a>
</div>
<div class="mobile-center">
<h1 class="mobile-title">개발자 포털</h1>
</div>
<div class="mobile-right">
<button class="mobile-menu-btn" id="mobileMenuToggle">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
</div>
</div>
</div>
</div>
</header>
<!-- Mobile Drawer/Modal -->
<div class="mobile-drawer" id="mobileDrawer">
<div class="drawer-overlay" id="drawerOverlay"></div>
<div class="drawer-content">
<div class="drawer-header">
<div class="drawer-logo">
<a th:href="@{/}" class="drawer-logo-link">
<img src="/img/logo/logo.png" alt="광주은행" class="logo">
</a>
<a th:href="@{/}" class="logo-text">개발자 포털</a>
</div>
<button class="drawer-close" id="drawerClose">
<i class="fas fa-times"></i>
</button>
</div>
<nav class="drawer-nav">
<ul class="drawer-menu">
<li><a href="#docs" class="drawer-link">서비스 소개</a></li>
<li><a href="/apis" class="drawer-link">오픈 API</a></li>
<li><a href="#playground" class="drawer-link">고객지원</a></li>
<li><a href="#community" class="drawer-link">MY</a></li>
</ul>
</nav>
<div class="drawer-actions">
<a th:href="@{/login}" class="drawer-login-btn">로그인</a>
<a href="#signup" class="drawer-signup-btn">
<span>회원 가입</span>
<i class="fas fa-arrow-right"></i>
</a>
</div>
</div>
</div>
</th:block>
<!-- Mobile Menu JavaScript -->
<th:block th:fragment="headerScript">
<script>
document.addEventListener('DOMContentLoaded', function() {
const menuToggle = document.getElementById('mobileMenuToggle');
const drawer = document.getElementById('mobileDrawer');
const drawerClose = document.getElementById('drawerClose');
const drawerOverlay = document.getElementById('drawerOverlay');
function openDrawer() {
drawer.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeDrawer() {
drawer.classList.remove('active');
document.body.style.overflow = '';
}
menuToggle?.addEventListener('click', openDrawer);
drawerClose?.addEventListener('click', closeDrawer);
drawerOverlay?.addEventListener('click', closeDrawer);
// Close drawer when clicking on nav links
document.querySelectorAll('.drawer-link').forEach(link => {
link.addEventListener('click', closeDrawer);
});
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,19 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="utf-8">
</head>
<body>
<div th:fragment="headerFragment" class="navigation" id="navigation">
<ul class="navigation-area">
<li th:each="crumb : ${breadcrumb}">
<a th:href="${crumb.path}" class="navigation-button" th:text="${crumb.name}">Unknown Page</a>
</li>
</ul>
</div>
</body>
</html>
@@ -0,0 +1,24 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="utf-8">
</head>
<body>
<aside class="sidebar" th:fragment="menuFragment">
</aside>
<!-- menu script -->
<th:block th:fragment="menuScript">
<script th:inline="javascript">
$(function () {
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/kjbank_base_layout}">
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<div class="sub_title2">
<h2 class="title" th:classappend="${isTermsOfUse ? 'add3' : 'add4'}"
th:text="${agreementTitle}">이용약관</h2>
</div>
<div class="inner cs2">
<div class="tab-wrap">
<div class="tabs">
<ul class="tab_nav tab_bt">
<li th:class="${isTermsOfUse ? 'active' : ''}">
<a th:href="@{/agreements/terms(tab='terms')}">이용약관</a>
</li>
<li th:class="${isPrivacyPolicy ? 'active' : ''}">
<a th:href="@{/agreements/terms(tab='privacy')}">개인정보처리방침</a>
</li>
</ul>
<div class="tab active" th:id="${isTermsOfUse ? 'tab1' : 'tab2'}">
<div class="form_type">
<div class="info1 ts_top">
<div class="info_line ts_left">
<div class="info_box1 info_add3 info_top">
<span class="input">
<form id="agreementForm" th:action="@{/agreements/terms}" method="get">
<input type="hidden" name="tab"
th:value="${currentTab}">
<div class="custom_select" id="agreement_select">
<div class="select_selected">
시행일자 : <span th:text="${#temporals.format(selectedAgreement.publishedOn, 'yyyy.MM.dd')}"></span>
</div>
<ul class="common_selecttype select_items select_hide">
<li th:each="agreement : ${agreementsList}"
th:data-id="${#temporals.format(agreement.publishedOn, 'yyyy-MM-dd')}"
th:text="'시행일자 : ' + ${#temporals.format(agreement.publishedOn, 'yyyy.MM.dd')}"
th:class="${#temporals.format(agreement.publishedOn, 'yyyy-MM-dd') == selectedDate ? 'selected' : ''}">
</li>
</ul>
</div>
<input type="hidden" name="publishedOn" id="publishedOnInput">
</form>
</span>
</div>
</div>
</div>
</div>
<div class="terms_contents m_termst" th:utext="${selectedAgreement.contents}"
style="white-space: pre-wrap; word-wrap: break-word;">
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<script layout:fragment="contentScript">
document.addEventListener('DOMContentLoaded', function () {
const selects = document.getElementById('agreement_select');
const form = document.getElementById('agreementForm');
const publishedOnInput = document.getElementById('publishedOnInput');
createCustomSelect(selects, {
onSelect: (item) => {
publishedOnInput.value = item;
form.submit();
}
});
});
</script>
</body>
</html>
@@ -0,0 +1,26 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/kjbank/head :: headFragment">
<meta charset="utf-8">
</head>
<body>
<th:block th:replace="fragment/kjbank/header_container :: headerFragment"></th:block>
<th:block th:replace="fragment/kjbank/header_container :: headerScript"></th:block>
<div class="loading-overlay" style="display: none;">
<div class="loading-spinner"></div>
</div>
<th:block layout:fragment="contentFragment">
</th:block>
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/kjbank/footer :: footerFragment"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
</body>
</html>
@@ -0,0 +1,26 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/kjbank/head :: headFragment">
<meta charset="utf-8">
</head>
<body>
<!-- header -->
<th:block th:replace="fragment/kjbank/header_container :: headerFragment"></th:block>
<th:block th:replace="fragment/kjbank/header_container :: headerScript"></th:block>
<th:block layout:fragment="contentFragment">
</th:block>
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/kjbank/footer :: footerFragment"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
</body>
</html>