first commit
@@ -0,0 +1,41 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
*.local
|
||||
|
||||
# Nuxt
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*.local
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"nuxt.mdc",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"oxc.oxc-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine AS build-stage
|
||||
WORKDIR /app
|
||||
|
||||
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
|
||||
RUN npm install -g npm@11
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# BUILD_MODE 는 docker-compose 호환을 위해 유지 (nuxt generate 는 항상 production 빌드)
|
||||
ARG BUILD_MODE=production
|
||||
ARG VITE_API_BASE_URL
|
||||
# Nuxt runtimeConfig.public.apiBaseUrl 로 주입되는 환경변수
|
||||
ENV NUXT_PUBLIC_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
# SSG(정적 생성) — 산출물은 .output/public 에 생성됨
|
||||
RUN npm run generate
|
||||
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
|
||||
# 보안 헤더 포함된 nginx 설정 적용
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build-stage /app/.output/public /usr/share/nginx/html
|
||||
|
||||
# 비-root 실행은 nginx-unprivileged 이미지 도입 시 활성화 (현재는 80 포트 바인드 필요)
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,64 @@
|
||||
# frontend
|
||||
|
||||
Nuxt 3 기반 프론트엔드 (SSG · 정적 생성 → nginx 정적 호스팅).
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **Nuxt 3** (Vue 3 + Vite) — 파일 기반 라우팅, 자동 임포트
|
||||
- **Pinia** (`@pinia/nuxt`) — 상태 관리
|
||||
- **Axios** — 중앙 인스턴스 + 응답 인터셉터 (에러코드 매핑)
|
||||
- **ESLint** (`@nuxt/eslint`) · **oxfmt** — 린트/포맷
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app.vue # 최상위 진입점 (NuxtLayout + NuxtPage)
|
||||
├── error.vue # 전역 에러 페이지 (404 등)
|
||||
├── nuxt.config.ts # Nuxt 설정 (SSG, runtimeConfig, 모듈)
|
||||
├── pages/ # 파일 기반 라우팅 (index.vue = '/')
|
||||
├── composables/ # useApi(axios), useUser 등 — 자동 임포트
|
||||
├── stores/ # Pinia 스토어 (user.store.ts)
|
||||
├── shared/utils/ # 순수 유틸 함수 (format.ts) — 자동 임포트
|
||||
└── public/ # 정적 자원 (favicon 등)
|
||||
```
|
||||
|
||||
## 환경변수
|
||||
|
||||
- `NUXT_PUBLIC_API_BASE_URL` — API 베이스 URL (`runtimeConfig.public.apiBaseUrl`).
|
||||
- 도커 빌드 시 `docker-compose.yml` 의 `VITE_API_BASE_URL`(=`FRONT_API_URL`) 인자가
|
||||
Dockerfile 에서 `NUXT_PUBLIC_API_BASE_URL` 로 매핑됩니다.
|
||||
|
||||
## 프로젝트 실행
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### 개발 서버 (HMR)
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 정적 생성 (운영 빌드)
|
||||
|
||||
```sh
|
||||
npm run generate # 산출물: .output/public
|
||||
```
|
||||
|
||||
`npm run preview` 로 생성 결과를 로컬에서 확인할 수 있습니다.
|
||||
|
||||
### 타입 검사 / 린트 / 포맷
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
- 렌더링 방식은 SSG 입니다. 동적 SSR(Node 서버)이 필요해지면 `nuxt.config.ts` 에서
|
||||
`ssr: true` 유지 + `npm run build` 산출물(`.output/server`)을 Node 로 구동하고
|
||||
Dockerfile/nginx 구성을 서버 모드로 전환하면 됩니다.
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 최상위 진입점 — 파일 기반 라우팅(pages/)을 NuxtPage 로 마운트
|
||||
// 도메인별 레이아웃은 layouts/ 디렉터리로 분리하여 확장
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,127 @@
|
||||
/* ============================================================
|
||||
전역 디자인 토큰 · 리셋 · 공통 유틸 클래스
|
||||
- 디자인 원본(px)을 16px = 1rem 기준으로 rem 변환
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-alt: #f4f5f7;
|
||||
--ink: #17181f;
|
||||
--ink-soft: #6c6e78;
|
||||
--line: #e6e7ec;
|
||||
--brand: #202a44;
|
||||
--label: #5b687c;
|
||||
--accent: #202a44;
|
||||
--accent-soft: #dde1ec;
|
||||
--danger: #c2473d;
|
||||
--white: #ffffff;
|
||||
--maxw: 90rem; /* 1440px */
|
||||
--pad: clamp(1.25rem, 5vw, 4rem); /* 20px ~ 64px */
|
||||
--r: 1.125rem; /* 18px */
|
||||
--font-display: 'BebasNeue', 'Bebas Neue', 'Apple SD Gothic Neo', 'Malgun Gothic', 'Nanum Gothic', 'Meiryo', sans-serif;
|
||||
--font-body: 'Pretendard', 'Apple SD Gothic Neo', 'Malgun Gothic', 'Nanum Gothic', 'Noto Sans KR', 'Noto Sans', sans-serif;
|
||||
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ---------- 공통 컨테이너 ---------- */
|
||||
.wrap {
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto;
|
||||
padding-inline: var(--pad);
|
||||
}
|
||||
|
||||
/* ---------- 공통 버튼 ---------- */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
padding: 1.0625rem 3rem; /* 17px 48px */
|
||||
border-radius: 6.25rem; /* 100px */
|
||||
font-size: 1rem; /* 16px */
|
||||
font-weight: 700;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-0.125rem); /* -2px */
|
||||
background: #161e36;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
스크롤 등장 애니메이션
|
||||
- .reveal 클래스는 클라이언트 v-reveal 디렉티브가 부여
|
||||
- JS 미동작(no-JS/SEO) 시에는 클래스가 없어 콘텐츠 그대로 노출
|
||||
- --reveal-delay 로 스태거(순차 등장) 제어
|
||||
============================================================ */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.reveal-up {
|
||||
transform: translateY(1.75rem); /* 아래에서 위로 */
|
||||
}
|
||||
|
||||
.reveal-scale {
|
||||
transform: scale(0.94); /* 살짝 확대되며 등장 */
|
||||
}
|
||||
|
||||
.reveal-fade {
|
||||
transform: none; /* 투명도만 */
|
||||
}
|
||||
|
||||
.reveal.is-revealed {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 모션 최소화 선호 시 모든 등장 애니메이션 비활성화 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
// 공통 푸터 — 모든 페이지에서 동일하게 사용
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<NuxtLink to="/" class="foot-logo" aria-label="이너탭 홈">
|
||||
<img src="/images/img-itab.png" alt="innertab" >
|
||||
</NuxtLink>
|
||||
|
||||
<div class="foot-company">
|
||||
<p class="foot-name"><span class="dot" /> 이너탭</p>
|
||||
<p>사업자등록번호: 000-00-00000 | 이메일: people@innertab.co.kr</p>
|
||||
<p>주소: 서울특별시 강남구 테헤란로 000길 00, 0~0층</p>
|
||||
</div>
|
||||
|
||||
<div class="foot-divider" />
|
||||
|
||||
<div class="foot-bottom">
|
||||
<NuxtLink to="/privacy" class="foot-policy">개인정보처리방침</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
footer {
|
||||
border-top: 0.0625rem solid var(--line);
|
||||
padding: clamp(3rem, 6vw, 4.75rem) 0 clamp(2.5rem, 5vw, 4rem); /* 48~76px / 40~64px */
|
||||
}
|
||||
|
||||
.foot-logo {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.foot-logo img {
|
||||
height: 1.875rem; /* 30px */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.foot-company {
|
||||
margin-top: 1.625rem; /* 26px */
|
||||
}
|
||||
|
||||
.foot-company .foot-name {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem; /* 7px */
|
||||
}
|
||||
|
||||
.foot-company .foot-name .dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem; /* 8px */
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.foot-company p {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.foot-company p + p {
|
||||
margin-top: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.foot-company .foot-name + p {
|
||||
margin-top: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.foot-divider {
|
||||
height: 0.0625rem; /* 1px */
|
||||
background: var(--line);
|
||||
margin: clamp(1.75rem, 3.5vw, 2.75rem) 0 clamp(1.5rem, 3vw, 2.25rem);
|
||||
}
|
||||
|
||||
.foot-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem; /* 20px */
|
||||
}
|
||||
|
||||
.foot-policy {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foot-policy:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
// 공통 헤더 — 메인('home')은 커피챗 CTA, 서브 페이지('back')는 채용 홈 복귀 링크
|
||||
interface Props {
|
||||
variant?: 'home' | 'back'
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'home',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<NuxtLink to="/" class="logo" aria-label="이너탭 홈">
|
||||
<img src="/images/img-itab.png" alt="innertab" >
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink v-if="variant === 'home'" to="/coffee-chat" class="nav-cta">
|
||||
커피챗 신청
|
||||
</NuxtLink>
|
||||
<NuxtLink v-else to="/" class="back">← 채용 홈으로</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(250, 248, 244, 0.82);
|
||||
backdrop-filter: blur(0.875rem); /* 14px */
|
||||
border-bottom: 0.0625rem solid var(--line);
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 4.5rem; /* 72px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 1.75rem; /* 28px */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
padding: 0.625rem 1.375rem; /* 10px 22px */
|
||||
border-radius: 6.25rem; /* 100px */
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-cta:hover {
|
||||
background: #161e36;
|
||||
transform: translateY(-0.0625rem);
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem; /* 7px */
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// 숫자 카운트업 — 요소가 보이면 0부터 목표값까지 증가 애니메이션
|
||||
// SSR/no-JS 환경에서는 최종값을 그대로 렌더(SEO·접근성), 클라이언트에서만 애니메이션
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
interface Props {
|
||||
value: number
|
||||
duration?: number // ms
|
||||
locale?: boolean // 천 단위 구분기호(ko-KR)
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 1600,
|
||||
locale: true,
|
||||
})
|
||||
|
||||
const display = ref<number>(props.value) // 초기값 = 최종값 (SSR 노출)
|
||||
const elRef = ref<HTMLElement | null>(null)
|
||||
let io: IntersectionObserver | null = null
|
||||
let rafId = 0
|
||||
|
||||
const formatted = computed(() =>
|
||||
props.locale ? display.value.toLocaleString('ko-KR') : String(display.value),
|
||||
)
|
||||
|
||||
function animate() {
|
||||
const start = performance.now()
|
||||
const from = 0
|
||||
const to = props.value
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / props.duration)
|
||||
// easeOutCubic — 빠르게 시작해 부드럽게 감속
|
||||
const eased = 1 - Math.pow(1 - t, 3)
|
||||
display.value = Math.round(from + (to - from) * eased)
|
||||
if (t < 1) rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (reduceMotion || !elRef.value) return
|
||||
|
||||
display.value = 0 // 클라이언트에서 0부터 시작
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
animate()
|
||||
io?.disconnect()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.4 },
|
||||
)
|
||||
io.observe(elRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
io?.disconnect()
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="elRef">{{ formatted }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// 이미지 자리표시자 — 실제 사진이 준비되기 전 영역을 채우는 플레이스홀더
|
||||
// (원본 디자인의 <image-slot> 대체. 실제 이미지가 생기면 <img> 로 교체)
|
||||
interface Props {
|
||||
label?: string
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
label: '이미지',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ph" role="img" :aria-label="label">
|
||||
<span class="ph-cap">{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ph {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #eef0f3 0%, #dfe2e7 100%);
|
||||
}
|
||||
|
||||
.ph-cap {
|
||||
font-size: 0.75rem; /* 12px */
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(27, 26, 23, 0.38);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
|
||||
export interface StandardResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
|
||||
const ErrorCodeLexicon: Record<string, string> = {
|
||||
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
|
||||
// SSG 프리렌더(서버) 단계에서는 window 가 없으므로 클라이언트에서만 동작
|
||||
const showErrorUI = (message: string) => {
|
||||
if (import.meta.client) {
|
||||
window.alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글턴 인스턴스 — 최초 호출 시 1회만 생성
|
||||
let apiInstance: AxiosInstance | null = null
|
||||
|
||||
const createApi = (): AxiosInstance => {
|
||||
// 런타임 환경변수에서 API 베이스 URL 주입 (NUXT_PUBLIC_API_BASE_URL)
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: (config.public.apiBaseUrl as string) || '/api',
|
||||
timeout: 10000,
|
||||
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// 요청 인터셉터 — CSRF, 추가 헤더 등 필요 시 확장
|
||||
api.interceptors.request.use(
|
||||
(cfg: InternalAxiosRequestConfig) => cfg,
|
||||
(error: unknown) => Promise.reject(error),
|
||||
)
|
||||
|
||||
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 에러코드 → 메시지 매핑
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse<StandardResponse>) => {
|
||||
const payload = response.data
|
||||
|
||||
if (payload && typeof payload.success === 'boolean') {
|
||||
if (payload.success) {
|
||||
// 성공 시 data 만 언래핑하여 반환
|
||||
return payload.data as never
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(payload.error)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
(error) => {
|
||||
let errCode = 'SYS_001'
|
||||
|
||||
if (error?.response) {
|
||||
const status: number = error.response.status
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
}
|
||||
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
/**
|
||||
* useApi — 컴포넌트/스토어에서 axios 인스턴스를 가져올 때 사용하는 composable
|
||||
* 직접 import 대신 useApi() 사용을 권장 (테스트 시 모킹 용이)
|
||||
*/
|
||||
export function useApi(): AxiosInstance {
|
||||
if (!apiInstance) {
|
||||
apiInstance = createApi()
|
||||
}
|
||||
return apiInstance
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 도메인별 API Composable 샘플 — 실제 도메인이 추가되면 이 패턴을 복제
|
||||
// (ref, useApi 는 Nuxt 자동 임포트로 별도 import 불필요)
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
const api = useApi()
|
||||
const loading = ref<boolean>(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function getUser(id: number): Promise<User | null> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// 인터셉터에서 success/data 언래핑 후 데이터만 반환
|
||||
return (await api.get<User>(`/users/${id}`)) as unknown as User
|
||||
} catch (e) {
|
||||
error.value = (e as Error)?.message ?? 'unknown error'
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, getUser }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app'
|
||||
|
||||
// 전역 에러 페이지 — 404 등 라우팅/렌더 실패 시 노출 (기존 NotFoundPage 대체)
|
||||
defineProps<{ error: NuxtError }>()
|
||||
|
||||
// 홈으로 복귀하며 에러 상태 초기화
|
||||
function handleHome() {
|
||||
clearError({ redirect: '/' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="error-page" role="alert">
|
||||
<h1>{{ error.statusCode }}</h1>
|
||||
<p v-if="error.statusCode === 404">요청하신 정보나 페이지를 찾을 수 없습니다.</p>
|
||||
<p v-else>일시적인 시스템 오류가 발생했습니다.</p>
|
||||
<button type="button" @click="handleHome">홈으로 돌아가기</button>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
// Nuxt 통합 Flat config — @nuxt/eslint 모듈이 .nuxt/eslint.config.mjs 를 생성
|
||||
// (Vue 3 + TypeScript + Nuxt 자동 임포트 인식 룰 포함)
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt({
|
||||
rules: {
|
||||
// 컴포넌트 단어 수 강제 해제 (index 등 단일 단어 페이지 허용)
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# ----------------------------------------
|
||||
# 보안 헤더
|
||||
# ----------------------------------------
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# ----------------------------------------
|
||||
# 압축 (Gzip)
|
||||
# ----------------------------------------
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
|
||||
|
||||
# ----------------------------------------
|
||||
# Nuxt SSG 라우팅
|
||||
# - 프리렌더된 정적 페이지 우선 제공
|
||||
# - 미생성 경로는 SPA 폴백(200.html)으로 클라이언트 라우팅 처리
|
||||
# ----------------------------------------
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /200.html;
|
||||
|
||||
# 정적 자원 캐싱 (Nuxt 빌드 산출물은 해시 파일명이라 장기 캐싱 가능)
|
||||
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# Nuxt 가 생성한 404 페이지 사용
|
||||
error_page 404 /404.html;
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
# 숨김 파일 접근 차단
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Nuxt 설정 — SSG(정적 생성) 기반, nginx 정적 호스팅용
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-05-30',
|
||||
|
||||
// 개발 도구 활성화 (운영 빌드에는 포함되지 않음)
|
||||
devtools: { enabled: true },
|
||||
|
||||
// 모듈 — 상태 관리(Pinia), ESLint 통합
|
||||
modules: ['@pinia/nuxt', '@nuxt/eslint'],
|
||||
|
||||
// 자동 임포트 디렉터리 확장 — CLAUDE.md 규칙(shared/utils 순수 함수)
|
||||
imports: {
|
||||
dirs: ['shared/utils'],
|
||||
},
|
||||
|
||||
// 전역 CSS — 디자인 토큰/리셋/공통 유틸 클래스
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
// 런타임 환경변수 — public 값만 클라이언트 번들에 포함됨
|
||||
// SSG 빌드 시 process.env 값이 정적으로 주입됨 (Dockerfile ARG → ENV)
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '/api',
|
||||
},
|
||||
},
|
||||
|
||||
// 전역 <head> 기본값 — 한국어 로케일 + 웹폰트(Pretendard / Bebas Neue / Noto Sans KR)
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'ko' },
|
||||
title: '이너탭 (innertab)',
|
||||
meta: [
|
||||
{ charset: 'UTF-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
|
||||
{ name: 'description', content: '이너탭(innertab) 채용 — 사람의 매일이 닿는 모든 곳에서 가장 앞선 답을 증명합니다.' },
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', href: '/favicon.ico' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fastly.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Noto+Sans+KR:wght@400;500;700;900&display=swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 개발 서버 설정 — 도커/윈도우 핫 리로딩 및 외부 도메인(ngrok) 허용
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
typescript: {
|
||||
typeCheck: false, // CI/별도 명령(npm run typecheck)에서 vue-tsc 로 검사
|
||||
strict: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "oxfmt ."
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"nuxt": "^3.15.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.9.0",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"eslint": "^9.18.0",
|
||||
"oxfmt": "^0.42.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
<script setup lang="ts">
|
||||
// 커피챗 신청 — 클라이언트 검증 + 완료 화면 (원본 바닐라 JS → Vue 반응형 이식)
|
||||
import { reactive, ref, nextTick } from 'vue'
|
||||
|
||||
useHead({ title: '커피챗 신청 | innertab' })
|
||||
|
||||
// 폼 상태
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
interest: '',
|
||||
when: '',
|
||||
current: '',
|
||||
message: '',
|
||||
})
|
||||
const agreePrivacy = ref(false)
|
||||
const agreePool = ref(false)
|
||||
|
||||
// 검증 에러 상태
|
||||
const errors = reactive({
|
||||
name: false,
|
||||
email: false,
|
||||
interest: false,
|
||||
})
|
||||
const consentInvalid = ref(false)
|
||||
|
||||
// 완료 화면 상태
|
||||
const submitted = ref(false)
|
||||
const summary = ref<Array<{ k: string; v: string }>>([])
|
||||
const formCard = ref<HTMLElement | null>(null)
|
||||
|
||||
const interestOptions = [
|
||||
'브랜드 · 마케팅',
|
||||
'콘텐츠 · 디자인',
|
||||
'커머스 · 세일즈',
|
||||
'글로벌 사업',
|
||||
'프로덕트 · 개발',
|
||||
'R&D · 상품기획',
|
||||
'경영지원 · People',
|
||||
'기타',
|
||||
]
|
||||
const whenOptions = ['평일 오전', '평일 오후', '평일 저녁', '주말', '조율 가능']
|
||||
|
||||
// 입력 시 해당 필드 에러 해제
|
||||
function clearError(field: keyof typeof errors) {
|
||||
errors[field] = false
|
||||
}
|
||||
function clearConsent() {
|
||||
if (agreePrivacy.value) consentInvalid.value = false
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
async function handleSubmit() {
|
||||
errors.name = !form.name.trim()
|
||||
errors.email = !EMAIL_RE.test(form.email.trim())
|
||||
errors.interest = !form.interest
|
||||
consentInvalid.value = !agreePrivacy.value
|
||||
|
||||
const ok = !errors.name && !errors.email && !errors.interest && !consentInvalid.value
|
||||
if (!ok) {
|
||||
// 첫 번째 오류 위치로 스크롤
|
||||
await nextTick()
|
||||
const firstErr = formCard.value?.querySelector('.invalid') as HTMLElement | null
|
||||
if (firstErr) {
|
||||
const y = firstErr.getBoundingClientRect().top + window.scrollY - 120
|
||||
window.scrollTo({ top: y, behavior: 'smooth' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 완료 요약 구성
|
||||
summary.value = [
|
||||
{ k: '성명', v: form.name.trim() },
|
||||
{ k: '이메일', v: form.email.trim() },
|
||||
{ k: '관심 분야', v: form.interest },
|
||||
{ k: '희망 시간대', v: form.when || '조율 가능' },
|
||||
]
|
||||
|
||||
// TODO: 백엔드 연동 시 useApi() 기반 composable 로 POST 처리
|
||||
submitted.value = true
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader variant="back" />
|
||||
|
||||
<main class="cc">
|
||||
<div class="wrap cc-grid">
|
||||
<!-- LEFT: 소개 -->
|
||||
<section class="intro">
|
||||
<p v-reveal class="cc-eyebrow">Coffee Chat</p>
|
||||
<h1 v-reveal="{ delay: 80 }">지원 전에,<br >가볍게 먼저 이야기 나눠요.</h1>
|
||||
<p v-reveal="{ delay: 160 }" class="lead">
|
||||
이너탭이 어떤 곳인지, 어떤 동료들과 일하게 될지 궁금하신가요? 정식 지원이 아니어도
|
||||
좋습니다. 편하게 신청해 주시면, 담당자가 일정을 조율해 연락드립니다.
|
||||
</p>
|
||||
|
||||
<div class="facts">
|
||||
<div v-reveal="{ delay: 60 }" class="fact">
|
||||
<span class="ico">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
|
||||
</span>
|
||||
<div><h3>약 30분 내외</h3><p>부담 없는 짧은 대화로 진행됩니다.</p></div>
|
||||
</div>
|
||||
<div v-reveal="{ delay: 140 }" class="fact">
|
||||
<span class="ico">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M3 9h18M8 4v16" /></svg>
|
||||
</span>
|
||||
<div><h3>온라인 · 오프라인</h3><p>편하신 방식으로 진행할 수 있어요.</p></div>
|
||||
</div>
|
||||
<div v-reveal="{ delay: 220 }" class="fact">
|
||||
<span class="ico">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v12H5.2L4 17.5z" /></svg>
|
||||
</span>
|
||||
<div><h3>일정 조율 후 안내</h3><p>신청 내용 확인 후 이메일로 연락드립니다.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT: 폼 -->
|
||||
<section>
|
||||
<div ref="formCard" v-reveal="{ delay: 120 }" class="form-card">
|
||||
<!-- 입력 폼 -->
|
||||
<div v-show="!submitted" class="form-live">
|
||||
<h2 class="fc-title">커피챗 신청</h2>
|
||||
<p class="fc-sub">아래 정보를 남겨주시면 담당자가 확인 후 연락드립니다.</p>
|
||||
|
||||
<form novalidate @submit.prevent="handleSubmit">
|
||||
<div class="two">
|
||||
<div class="field" :class="{ invalid: errors.name }">
|
||||
<label for="name">성명<span class="req">*</span></label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
class="ctrl"
|
||||
type="text"
|
||||
placeholder="홍길동"
|
||||
autocomplete="name"
|
||||
@input="clearError('name')"
|
||||
>
|
||||
<p class="err-msg">성명을 입력해 주세요.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="phone">연락처<span class="opt">선택</span></label>
|
||||
<input
|
||||
id="phone"
|
||||
v-model="form.phone"
|
||||
class="ctrl"
|
||||
type="tel"
|
||||
placeholder="010-0000-0000"
|
||||
autocomplete="tel"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" :class="{ invalid: errors.email }">
|
||||
<label for="email">이메일<span class="req">*</span></label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
class="ctrl"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
@input="clearError('email')"
|
||||
>
|
||||
<p class="err-msg">올바른 이메일 주소를 입력해 주세요.</p>
|
||||
</div>
|
||||
|
||||
<div class="two">
|
||||
<div class="field" :class="{ invalid: errors.interest }">
|
||||
<label for="interest">관심 직무·분야<span class="req">*</span></label>
|
||||
<select id="interest" v-model="form.interest" class="ctrl" @change="clearError('interest')">
|
||||
<option value="" disabled>선택해 주세요</option>
|
||||
<option v-for="opt in interestOptions" :key="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<p class="err-msg">관심 분야를 선택해 주세요.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="when">희망 시간대<span class="opt">선택</span></label>
|
||||
<select id="when" v-model="form.when" class="ctrl">
|
||||
<option value="" disabled>선택해 주세요</option>
|
||||
<option v-for="opt in whenOptions" :key="opt">{{ opt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="current">현재 소속 · 직무<span class="opt">선택</span></label>
|
||||
<input
|
||||
id="current"
|
||||
v-model="form.current"
|
||||
class="ctrl"
|
||||
type="text"
|
||||
placeholder="예) ○○회사 마케터 / 취업준비생 / 학생"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="message">사전에 나누고 싶은 이야기<span class="opt">선택</span></label>
|
||||
<textarea
|
||||
id="message"
|
||||
v-model="form.message"
|
||||
class="ctrl"
|
||||
placeholder="궁금한 점이나 듣고 싶은 이야기를 자유롭게 남겨주세요."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="consent" :class="{ invalid: consentInvalid }">
|
||||
<label class="check">
|
||||
<input v-model="agreePrivacy" type="checkbox" @change="clearConsent" >
|
||||
<span class="box">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L20 7" /></svg>
|
||||
</span>
|
||||
<span class="ctext">
|
||||
<b>(필수)</b> 개인정보 수집·이용에 동의합니다. 성명·이메일 등은 커피챗 진행
|
||||
목적에 한해 이용되며 면담 종료 후 파기됩니다.
|
||||
<NuxtLink to="/privacy" target="_blank">자세히 보기</NuxtLink>
|
||||
</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="agreePool" type="checkbox" >
|
||||
<span class="box">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L20 7" /></svg>
|
||||
</span>
|
||||
<span class="ctext">
|
||||
<b>(선택)</b> 향후 채용 기회 제공을 위한 인재풀 보관에 동의합니다. (동의일로부터
|
||||
1년, 언제든 철회 가능)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit">커피챗 신청하기</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 완료 화면 -->
|
||||
<div v-show="submitted" class="success">
|
||||
<div class="badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||
</div>
|
||||
<h2>신청이 완료되었습니다!</h2>
|
||||
<p>
|
||||
소중한 신청 감사합니다.<br >담당자가 신청 내용을 확인한 뒤 입력하신 이메일로 일정을
|
||||
안내드릴게요.
|
||||
</p>
|
||||
<div class="summary">
|
||||
<div v-for="row in summary" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span><span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/" class="home-link">채용 홈으로 돌아가기 →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cc {
|
||||
padding: clamp(2.75rem, 6vw, 5.25rem) 0 clamp(4.5rem, 10vw, 7.5rem); /* 44~84px / 72~120px */
|
||||
}
|
||||
|
||||
.cc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 0.92fr 1.08fr;
|
||||
gap: clamp(2.5rem, 6vw, 5.5rem); /* 40~88px */
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* LEFT 소개 */
|
||||
.intro {
|
||||
position: sticky;
|
||||
top: 6.5rem; /* 104px */
|
||||
}
|
||||
|
||||
.cc-eyebrow {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.125rem; /* 18px */
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--label);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: clamp(1.875rem, 4vw, 2.875rem); /* 30~46px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.22;
|
||||
margin-top: 1rem; /* 16px */
|
||||
}
|
||||
|
||||
.intro .lead {
|
||||
font-size: clamp(0.9375rem, 1.5vw, 1.0625rem); /* 15~17px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.8;
|
||||
margin-top: 1.375rem; /* 22px */
|
||||
}
|
||||
|
||||
.facts {
|
||||
margin-top: clamp(2rem, 4vw, 3rem); /* 32~48px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem; /* 2px */
|
||||
border-top: 0.0625rem solid var(--line);
|
||||
}
|
||||
|
||||
.fact {
|
||||
display: flex;
|
||||
gap: 1.125rem; /* 18px */
|
||||
padding: 1.125rem 0; /* 18px */
|
||||
border-bottom: 0.0625rem solid var(--line);
|
||||
}
|
||||
|
||||
.fact .ico {
|
||||
width: 2.375rem; /* 38px */
|
||||
height: 2.375rem;
|
||||
border-radius: 0.625rem; /* 10px */
|
||||
background: var(--bg-alt);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.fact .ico svg {
|
||||
width: 1.1875rem; /* 19px */
|
||||
height: 1.1875rem;
|
||||
}
|
||||
|
||||
.fact h3 {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fact p {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: var(--ink-soft);
|
||||
margin-top: 0.1875rem; /* 3px */
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* RIGHT 폼 */
|
||||
.form-card {
|
||||
background: var(--white);
|
||||
border: 0.0625rem solid var(--line);
|
||||
border-radius: 1.375rem; /* 22px */
|
||||
padding: clamp(1.75rem, 3.4vw, 2.875rem); /* 28~46px */
|
||||
box-shadow: 0 1.875rem 4.375rem -3.125rem rgba(30, 40, 60, 0.5);
|
||||
}
|
||||
|
||||
.fc-title {
|
||||
font-size: clamp(1.25rem, 2vw, 1.5625rem); /* 20~25px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.fc-sub {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: var(--ink-soft);
|
||||
margin-top: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
form {
|
||||
margin-top: clamp(1.5rem, 3vw, 2.125rem); /* 24~34px */
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1.375rem; /* 22px */
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin-bottom: 0.5625rem; /* 9px */
|
||||
}
|
||||
|
||||
.field label .req {
|
||||
color: var(--danger);
|
||||
margin-left: 0.1875rem; /* 3px */
|
||||
}
|
||||
|
||||
.field label .opt {
|
||||
color: var(--ink-soft);
|
||||
font-weight: 500;
|
||||
margin-left: 0.375rem; /* 6px */
|
||||
font-size: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.ctrl {
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
color: var(--ink);
|
||||
background: var(--bg-alt);
|
||||
border: 0.09375rem solid transparent; /* 1.5px */
|
||||
border-radius: 0.75rem; /* 12px */
|
||||
padding: 0.875rem 1rem; /* 14px 16px */
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.ctrl::placeholder {
|
||||
color: #a2a5ae;
|
||||
}
|
||||
|
||||
.ctrl:focus {
|
||||
outline: none;
|
||||
background: #fff;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
textarea.ctrl {
|
||||
resize: vertical;
|
||||
min-height: 6.875rem; /* 110px */
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
select.ctrl {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236C6E78' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
padding-right: 2.5rem; /* 40px */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field.invalid .ctrl {
|
||||
border-color: var(--danger);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.err-msg {
|
||||
display: none;
|
||||
font-size: 0.78125rem; /* 12.5px */
|
||||
color: var(--danger);
|
||||
margin-top: 0.4375rem; /* 7px */
|
||||
}
|
||||
|
||||
.field.invalid .err-msg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.two {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem; /* 16px */
|
||||
}
|
||||
|
||||
/* 동의 */
|
||||
.consent {
|
||||
margin-top: 0.25rem; /* 4px */
|
||||
padding-top: 1.375rem; /* 22px */
|
||||
border-top: 0.0625rem solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
gap: 0.75rem; /* 12px */
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.check .box {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem; /* 20px */
|
||||
height: 1.25rem;
|
||||
border-radius: 0.375rem; /* 6px */
|
||||
border: 0.09375rem solid #c4c7cf; /* 1.5px */
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-top: 0.0625rem; /* 1px */
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.check .box svg {
|
||||
width: 0.75rem; /* 12px */
|
||||
height: 0.75rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.check input:checked + .box {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.check input:checked + .box svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.check input:focus-visible + .box {
|
||||
box-shadow: 0 0 0 0.1875rem rgba(32, 42, 68, 0.2); /* 3px */
|
||||
}
|
||||
|
||||
.check .ctext {
|
||||
font-size: 0.84375rem; /* 13.5px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.check .ctext b {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.check .ctext a {
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.125rem; /* 2px */
|
||||
}
|
||||
|
||||
.consent.invalid .check:first-child .box {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
margin-top: 1.75rem; /* 28px */
|
||||
padding: 1.0625rem; /* 17px */
|
||||
border: 0;
|
||||
border-radius: 6.25rem; /* 100px */
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 1rem; /* 16px */
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.submit:hover {
|
||||
background: #161e36;
|
||||
transform: translateY(-0.125rem); /* -2px */
|
||||
}
|
||||
|
||||
/* 완료 화면 */
|
||||
.success {
|
||||
text-align: center;
|
||||
padding: clamp(1.25rem, 3vw, 2.25rem) 0; /* 20~36px */
|
||||
}
|
||||
|
||||
.success .badge {
|
||||
width: 4.75rem; /* 76px */
|
||||
height: 4.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto clamp(1.25rem, 2.5vw, 1.75rem); /* 20~28px */
|
||||
}
|
||||
|
||||
.success .badge svg {
|
||||
width: 2.125rem; /* 34px */
|
||||
height: 2.125rem;
|
||||
}
|
||||
|
||||
.success h2 {
|
||||
font-size: clamp(1.375rem, 2.4vw, 1.875rem); /* 22~30px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.success p {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.7;
|
||||
margin-top: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.success .summary {
|
||||
text-align: left;
|
||||
background: var(--bg-alt);
|
||||
border-radius: 0.875rem; /* 14px */
|
||||
padding: 1.375rem 1.5rem; /* 22px 24px */
|
||||
margin-top: 1.75rem; /* 28px */
|
||||
}
|
||||
|
||||
.success .summary .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem; /* 16px */
|
||||
padding: 0.5625rem 0; /* 9px */
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.success .summary .row .k {
|
||||
color: var(--ink-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success .summary .row .v {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.success .home-link {
|
||||
display: inline-block;
|
||||
margin-top: 1.75rem; /* 28px */
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
font-weight: 700;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.success .home-link:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.1875rem; /* 3px */
|
||||
}
|
||||
|
||||
@media (max-width: 56.25rem) {
|
||||
/* 900px */
|
||||
.cc-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(2.25rem, 6vw, 3.5rem);
|
||||
}
|
||||
|
||||
.intro {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 32.5rem) {
|
||||
/* 520px */
|
||||
.two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,870 @@
|
||||
<script setup lang="ts">
|
||||
// 메인(채용 홈) — Hero / About / 브랜드 / Growth / People / CTA
|
||||
useHead({ title: '이너탭 (innertab)' })
|
||||
|
||||
// 브랜드 타일 데이터
|
||||
interface BrandTile {
|
||||
name: string
|
||||
img: string
|
||||
feature?: boolean
|
||||
}
|
||||
const brands: BrandTile[] = [
|
||||
{ name: 'selite', img: '/images/img-selite.png', feature: true },
|
||||
{ name: 'INNERDYSET', img: '/images/img-innerdyset.png' },
|
||||
{ name: 'recurely', img: '/images/img-recurely.png' },
|
||||
{ name: 'innerzen', img: '/images/img-innerzen.png' },
|
||||
{ name: 'retriq', img: '/images/img-retriq.png' },
|
||||
]
|
||||
|
||||
// 성장 지표 데이터
|
||||
interface Stat {
|
||||
title: string
|
||||
value: number
|
||||
unit: string
|
||||
desc: string
|
||||
}
|
||||
const stats: Stat[] = [
|
||||
{ title: '운영 브랜드', value: 5, unit: '개', desc: '서로 다른 문제를 해결하기 위해\n각기 다른 브랜드를 정교하게 설계했습니다.' },
|
||||
{ title: '누적 매출', value: 150, unit: '억', desc: '고객의 믿음과 선택으로\n이너탭은 꾸준히 성장해왔습니다.' },
|
||||
{ title: '연 평균 성장률', value: 320, unit: '%', desc: '끊임없는 도전과 실행으로\n이너탭은 더 빠르게 확장하고 있습니다.' },
|
||||
{ title: '더 나은 일상을 연구한 시간', value: 1200, unit: '일', desc: '작은 변화가 만드는 큰 차이를\n매일 관찰하고 개선해왔습니다.' },
|
||||
]
|
||||
|
||||
// 주요 연혁
|
||||
const history: string[] = [
|
||||
'일 최대 매출 4,500만원 달성',
|
||||
'미국 법인 설립',
|
||||
'홍콩 법인 설립',
|
||||
'한국 소비자 평가 대상 2회',
|
||||
]
|
||||
|
||||
// 구성원 카드 (실제 사진 준비 전 — 플레이스홀더)
|
||||
interface PeopleCard {
|
||||
label: string
|
||||
shape: 'tall' | 'wide'
|
||||
title: string
|
||||
desc: string
|
||||
}
|
||||
const peopleColA: PeopleCard[] = [
|
||||
{ label: '회사 사진 — 종무식', shape: 'tall', title: '2025 이너탭 종무식 : “Together, We Rise”', desc: '함께 만든 시간, 더 높이 올라가기 위한 시작' },
|
||||
{ label: '회사 사진 — 인터뷰', shape: 'wide', title: '꿈은 크게, 실행은 집요하게!', desc: '브랜드사업본부장, 조이' },
|
||||
]
|
||||
const peopleColB: PeopleCard[] = [
|
||||
{ label: '회사 사진 — 구성원', shape: 'wide', title: '2025 Innertab Superookie', desc: '최고의 순간은 함께 만든 성장 속에서 — 슈퍼루키 5인의 성장 스토리' },
|
||||
{ label: '회사 사진 — 인터뷰', shape: 'tall', title: '실험·회고·학습으로 실제로 팔리는 브랜드를 만듭니다', desc: '글로벌사업본부 영미권실장, Gaby' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader variant="home" />
|
||||
|
||||
<!-- ===================== HERO ===================== -->
|
||||
<section class="hero">
|
||||
<div class="hero-video"><span class="ph-label">▶ Hero Video</span></div>
|
||||
<div class="scroll-hint"><span>SCROLL</span><i /></div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== ABOUT ===================== -->
|
||||
<section id="mission" class="about">
|
||||
<div class="wrap">
|
||||
<div class="inner">
|
||||
<p v-reveal class="m-eyebrow">ABOUT ITAB</p>
|
||||
<p v-reveal="{ delay: 80 }" class="m-lead">누군가의 매일을 더 낫게 만드는 일,</p>
|
||||
<h2 v-reveal="{ delay: 160 }" class="m-title">이너탭은 <mark>그 답</mark>을 제시합니다.</h2>
|
||||
|
||||
<p v-reveal class="about-intro">매일은 당연한 의문들로 시작됩니다.</p>
|
||||
<ul class="q-list">
|
||||
<li v-reveal="{ delay: 60 }">더 가뿐한 아침은 불가능할까?</li>
|
||||
<li v-reveal="{ delay: 160 }">더 단단한 나를 만드는 방법은 무엇일까?</li>
|
||||
</ul>
|
||||
|
||||
<div v-reveal class="about-statement">
|
||||
<p>이너탭은 질문에 답하는 무게가 다릅니다.</p>
|
||||
<p>타협 없는 설계로 본질을 짚고, 데이터와 결과로 증명합니다.</p>
|
||||
<p>우리는 안주하지 않습니다.</p>
|
||||
<p class="closing">사람의 매일이 닿는 모든 곳에서,<br >우리는 가장 앞선 답을 증명합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 브랜드 벤토 그리드 -->
|
||||
<div id="brands" class="brand-bento">
|
||||
<article
|
||||
v-for="(brand, i) in brands"
|
||||
:key="brand.name"
|
||||
v-reveal:scale="{ delay: i * 70 }"
|
||||
class="btile"
|
||||
:class="{ feature: brand.feature }"
|
||||
>
|
||||
<img :src="brand.img" :alt="`${brand.name} 브랜드 이미지`" >
|
||||
<div class="tile-overlay" />
|
||||
<span class="tile-name">{{ brand.name }}</span>
|
||||
<span class="tile-arrow" aria-hidden="true">↗</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== GROWTH ===================== -->
|
||||
<section id="growth" class="growth">
|
||||
<div class="growth-bg"><span class="bg-label">BACKGROUND VIDEO</span></div>
|
||||
<div class="wrap">
|
||||
<div class="growth-head">
|
||||
<p v-reveal class="m-eyebrow">Growth</p>
|
||||
<h2 v-reveal="{ delay: 100 }">우리가 만든 변화를 증명하며,<br >끊임없이 성장하고 있습니다.</h2>
|
||||
</div>
|
||||
|
||||
<div class="growth-layout">
|
||||
<div class="stat-grid">
|
||||
<div
|
||||
v-for="(stat, i) in stats"
|
||||
:key="stat.title"
|
||||
v-reveal="{ delay: i * 90 }"
|
||||
class="gcard stat-card"
|
||||
>
|
||||
<div class="stat-orb" />
|
||||
<div class="gcard-title">{{ stat.title }}</div>
|
||||
<div class="stat-big">
|
||||
<CountUp :value="stat.value" /><small>{{ stat.unit }}</small><span class="plus">+</span>
|
||||
</div>
|
||||
<p class="stat-desc">
|
||||
<template v-for="(line, li) in stat.desc.split('\n')" :key="li">
|
||||
{{ line }}<br v-if="li === 0" >
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-reveal="{ delay: 120 }" class="gcard history-card">
|
||||
<div class="gcard-title">주요 연혁 내역</div>
|
||||
<ul class="timeline">
|
||||
<li v-for="(item, i) in history" :key="i" v-reveal="{ delay: 200 + i * 90 }">
|
||||
<span class="t-text">{{ item }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== PEOPLE ===================== -->
|
||||
<section id="people" class="people">
|
||||
<div class="wrap">
|
||||
<div class="people-head">
|
||||
<p v-reveal class="people-lead">이너탭은</p>
|
||||
<h2 v-reveal="{ delay: 100 }">구성원들과 함께 성장합니다.</h2>
|
||||
</div>
|
||||
<div class="people-cols">
|
||||
<div class="pcol pcol-a">
|
||||
<article v-for="(card, i) in peopleColA" :key="i" v-reveal="{ delay: i * 120 }" class="pcard">
|
||||
<div class="pshot" :class="card.shape">
|
||||
<ImagePlaceholder :label="card.label" />
|
||||
</div>
|
||||
<h3>{{ card.title }}</h3>
|
||||
<p>{{ card.desc }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="pcol pcol-b">
|
||||
<article v-for="(card, i) in peopleColB" :key="i" v-reveal="{ delay: 80 + i * 120 }" class="pcard">
|
||||
<div class="pshot" :class="card.shape">
|
||||
<ImagePlaceholder :label="card.label" />
|
||||
</div>
|
||||
<h3>{{ card.title }}</h3>
|
||||
<p>{{ card.desc }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== CTA ===================== -->
|
||||
<section id="cta" class="cta">
|
||||
<div class="cta-bg" />
|
||||
<div class="cta-overlay" />
|
||||
<div class="wrap">
|
||||
<div class="cta-inner">
|
||||
<h2 v-reveal>바로 지금,<br >이너탭의 여정에 함께 하세요!</h2>
|
||||
<p v-reveal="{ delay: 120 }">저희와 함께 더 큰 성장을 만들어 나갈 구성원을 기다리고 있습니다.</p>
|
||||
<div v-reveal="{ delay: 240 }" class="cta-btns">
|
||||
<NuxtLink to="/coffee-chat" class="btn btn-primary">커피챗 신청하기</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ===================== HERO ===================== */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: clamp(35rem, 86vh, 51.25rem); /* 560~820px */
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(160deg, #161d33 0%, #202a44 55%, #33415f 100%);
|
||||
/* 로드 시 살짝 확대된 상태에서 천천히 안착 (Ken Burns) */
|
||||
animation: hero-settle 2.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes hero-settle {
|
||||
from {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-video .ph-label {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
animation: hero-fade-in 1.2s ease 0.4s both;
|
||||
}
|
||||
|
||||
@keyframes hero-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-hint {
|
||||
position: absolute;
|
||||
bottom: 1.75rem; /* 28px */
|
||||
right: var(--pad);
|
||||
z-index: 2;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.75rem; /* 12px */
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem; /* 10px */
|
||||
animation: hero-fade-in 1.2s ease 0.7s both;
|
||||
}
|
||||
|
||||
.scroll-hint span {
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.scroll-hint i {
|
||||
position: relative;
|
||||
width: 0.0625rem; /* 1px */
|
||||
height: 2.875rem; /* 46px */
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 라인을 따라 아래로 흐르는 펄스 — 스크롤 유도 */
|
||||
.scroll-hint i::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
animation: scroll-pulse 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes scroll-pulse {
|
||||
0% {
|
||||
top: -100%;
|
||||
}
|
||||
60%,
|
||||
100% {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== ABOUT ===================== */
|
||||
.about {
|
||||
padding: clamp(4.5rem, 11vw, 9.375rem) 0; /* 72~150px */
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.about .inner {
|
||||
max-width: 100%; /* 사이트 폭(.wrap)에 맞춰 브랜드 벤토와 동일하게 */
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.m-eyebrow {
|
||||
font-size: 1.4375rem; /* 23px */
|
||||
font-weight: 700;
|
||||
color: var(--label);
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.m-lead {
|
||||
font-size: clamp(1rem, 1.7vw, 1.25rem); /* 16~20px */
|
||||
color: var(--ink-soft);
|
||||
font-weight: 500;
|
||||
margin-top: 1.875rem; /* 30px */
|
||||
}
|
||||
|
||||
.m-title {
|
||||
font-size: clamp(1.875rem, 4.4vw, 3.375rem); /* 30~54px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.3;
|
||||
margin-top: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.m-title mark {
|
||||
background: var(--brand);
|
||||
color: var(--white);
|
||||
padding: 0.04em 0.14em;
|
||||
border-radius: 0.125rem; /* 2px */
|
||||
}
|
||||
|
||||
.about-intro {
|
||||
font-size: clamp(1rem, 1.5vw, 1.1875rem); /* 16~19px */
|
||||
color: var(--ink-soft);
|
||||
margin-top: clamp(2.5rem, 5vw, 4rem); /* 40~64px */
|
||||
}
|
||||
|
||||
.q-list {
|
||||
list-style: none;
|
||||
margin-top: 1.375rem; /* 22px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.q-list li {
|
||||
font-size: clamp(1.25rem, 2.4vw, 1.875rem); /* 20~30px */
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
line-height: 1.35;
|
||||
padding-left: 1.625rem; /* 26px */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.q-list li::before {
|
||||
content: 'Q.';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.08em;
|
||||
color: var(--label);
|
||||
font-weight: 800;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.about-statement {
|
||||
margin-top: clamp(3.5rem, 7vw, 6rem); /* 56~96px */
|
||||
padding-top: clamp(2.5rem, 5vw, 3.75rem); /* 40~60px */
|
||||
border-top: 0.0625rem solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.about-statement p {
|
||||
font-size: clamp(1rem, 1.6vw, 1.25rem); /* 16~20px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.about-statement .closing {
|
||||
font-size: clamp(1.375rem, 2.6vw, 2.125rem); /* 22~34px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
/* ===================== 브랜드 벤토 ===================== */
|
||||
.brand-bento {
|
||||
margin-top: clamp(3.5rem, 7vw, 6rem); /* 56~96px */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
gap: clamp(0.75rem, 1.4vw, 1.125rem); /* 12~18px */
|
||||
}
|
||||
|
||||
.btile {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1.25rem; /* 20px */
|
||||
aspect-ratio: 1;
|
||||
background: #11151f;
|
||||
cursor: pointer; /* 이미지 위에서 포인터 커서 */
|
||||
}
|
||||
|
||||
.btile.feature {
|
||||
/* 가로로 2칸 차지하는 와이드 타일 — 가로(landscape) 이미지가 잘리지 않도록 */
|
||||
grid-column: span 2;
|
||||
aspect-ratio: 2 / 1;
|
||||
}
|
||||
|
||||
.btile img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.btile:hover img {
|
||||
transform: scale(1.05); /* 호버 시 이미지 확대 */
|
||||
}
|
||||
|
||||
.btile .tile-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to top, rgba(8, 11, 18, 0.78) 0%, rgba(8, 11, 18, 0.25) 34%, transparent 60%);
|
||||
}
|
||||
|
||||
.btile .tile-name {
|
||||
position: absolute;
|
||||
left: clamp(1.25rem, 2vw, 1.875rem); /* 20~30px */
|
||||
bottom: clamp(1.25rem, 2vw, 1.75rem); /* 20~28px */
|
||||
z-index: 3;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
font-size: clamp(1.125rem, 1.6vw, 1.5rem); /* 18~24px */
|
||||
pointer-events: none;
|
||||
text-shadow: 0 0.125rem 1rem rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.btile.feature .tile-name {
|
||||
font-size: clamp(1.75rem, 2.8vw, 2.625rem); /* 28~42px */
|
||||
}
|
||||
|
||||
.btile .tile-arrow {
|
||||
position: absolute;
|
||||
right: clamp(1rem, 1.6vw, 1.375rem); /* 16~22px */
|
||||
bottom: clamp(1rem, 1.6vw, 1.375rem);
|
||||
z-index: 3;
|
||||
width: 2.875rem; /* 46px */
|
||||
height: 2.875rem;
|
||||
border-radius: 50%;
|
||||
border: 0.09375rem solid rgba(255, 255, 255, 0.7); /* 1.5px */
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.0625rem; /* 17px */
|
||||
pointer-events: none;
|
||||
transition: background 0.25s ease, color 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.btile:hover .tile-arrow {
|
||||
background: #fff;
|
||||
color: var(--brand);
|
||||
transform: translateY(-0.125rem);
|
||||
}
|
||||
|
||||
/* ===================== GROWTH ===================== */
|
||||
.growth {
|
||||
position: relative;
|
||||
padding: clamp(4.5rem, 11vw, 9.375rem) 0; /* 72~150px */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.growth-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(circle at 16% 24%, rgba(255, 255, 255, 0.55), transparent 20%),
|
||||
radial-gradient(circle at 70% 62%, rgba(255, 255, 255, 0.4), transparent 16%),
|
||||
radial-gradient(circle at 88% 16%, rgba(255, 255, 255, 0.45), transparent 13%),
|
||||
radial-gradient(circle at 38% 82%, rgba(255, 255, 255, 0.4), transparent 16%),
|
||||
radial-gradient(circle at 58% 38%, rgba(120, 128, 136, 0.45), transparent 32%),
|
||||
linear-gradient(125deg, #aeb6bc 0%, #cbd1d6 46%, #a6aeb4 100%);
|
||||
}
|
||||
|
||||
.bg-label {
|
||||
position: absolute;
|
||||
right: 1.125rem; /* 18px */
|
||||
bottom: 0.875rem; /* 14px */
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.75rem; /* 12px */
|
||||
letter-spacing: 0.18em;
|
||||
color: rgba(40, 50, 70, 0.32);
|
||||
}
|
||||
|
||||
.growth .wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.growth-head {
|
||||
text-align: center;
|
||||
margin-bottom: clamp(2.5rem, 5vw, 4.375rem); /* 40~70px */
|
||||
}
|
||||
|
||||
.growth-head .m-eyebrow {
|
||||
margin-bottom: 1.125rem; /* 18px */
|
||||
}
|
||||
|
||||
.growth-head h2 {
|
||||
font-size: clamp(1.75rem, 4.2vw, 3.125rem); /* 28~50px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.32;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.growth-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.45fr 1fr;
|
||||
gap: clamp(1rem, 1.8vw, 1.5rem); /* 16~24px */
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: clamp(1rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
|
||||
.gcard {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
backdrop-filter: blur(0.375rem) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(0.375rem) saturate(1.1);
|
||||
border: 0.0625rem solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 1.5rem; /* 24px */
|
||||
padding: clamp(1.625rem, 2.6vw, 2.375rem); /* 26~38px */
|
||||
box-shadow: 0 1.25rem 3.125rem -2.125rem rgba(30, 40, 60, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gcard-title,
|
||||
.stat-big,
|
||||
.stat-desc,
|
||||
.timeline {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-card .stat-orb {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
width: clamp(9.375rem, 17vw, 12.5rem); /* 150~200px */
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
left: 1rem; /* 16px — 카드 안쪽으로 이동 */
|
||||
top: 3.75rem; /* 60px */
|
||||
/* 가장자리를 투명하게 → 하드한 동그라미 테두리 제거 */
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
rgba(255, 255, 255, 0.95),
|
||||
rgba(255, 255, 255, 0.55) 54%,
|
||||
rgba(255, 255, 255, 0) 78%
|
||||
);
|
||||
}
|
||||
|
||||
.gcard-title {
|
||||
font-size: 1.0625rem; /* 17px */
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.stat-card .stat-big {
|
||||
font-size: clamp(2.5rem, 5vw, 3.75rem); /* 40~60px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--ink);
|
||||
line-height: 1;
|
||||
margin: clamp(1.75rem, 4vw, 3rem) 0 clamp(1.25rem, 3vw, 1.875rem);
|
||||
}
|
||||
|
||||
.stat-card .stat-big small {
|
||||
font-size: 0.36em;
|
||||
font-weight: 700;
|
||||
margin-left: 0.1875rem; /* 3px */
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.plus {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.125rem; /* 18px */
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
font-weight: 700;
|
||||
vertical-align: top;
|
||||
margin-left: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.stat-card .stat-desc {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.65;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin-top: clamp(1.25rem, 2.5vw, 1.875rem); /* 20~30px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timeline li {
|
||||
display: flex;
|
||||
gap: 1.125rem; /* 18px */
|
||||
align-items: baseline;
|
||||
padding: clamp(0.9375rem, 1.6vw, 1.1875rem) 0; /* 15~19px */
|
||||
border-top: 0.0625rem solid rgba(40, 50, 70, 0.1);
|
||||
}
|
||||
|
||||
.timeline li:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.timeline .t-text {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ===================== PEOPLE ===================== */
|
||||
.people {
|
||||
padding: clamp(4.5rem, 11vw, 9.375rem) 0; /* 72~150px */
|
||||
}
|
||||
|
||||
.people-lead {
|
||||
font-size: clamp(1.125rem, 1.9vw, 1.5rem); /* 18~24px */
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.people-head h2 {
|
||||
font-size: clamp(1.875rem, 4.4vw, 3.5rem); /* 30~56px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.18;
|
||||
color: var(--ink);
|
||||
margin-top: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.people-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: clamp(1.5rem, 2.6vw, 2.75rem); /* 24~44px */
|
||||
margin-top: clamp(2.25rem, 4.5vw, 3.75rem); /* 36~60px */
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pcol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(2.5rem, 5vw, 4.5rem); /* 40~72px */
|
||||
}
|
||||
|
||||
.pcol-b {
|
||||
margin-top: clamp(3rem, 7vw, 6.5rem); /* 48~104px */
|
||||
}
|
||||
|
||||
.pcard {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pcard .pshot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 1rem; /* 16px */
|
||||
overflow: hidden;
|
||||
background: #edeff2;
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.pcard .pshot.tall {
|
||||
aspect-ratio: 4 / 5;
|
||||
}
|
||||
|
||||
.pcard .pshot.wide {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.pcard:hover .pshot {
|
||||
box-shadow: 0 1.625rem 3.5rem -2rem rgba(30, 40, 60, 0.5);
|
||||
transform: translateY(-0.25rem); /* -4px */
|
||||
}
|
||||
|
||||
.pcard h3 {
|
||||
font-size: clamp(1.125rem, 1.7vw, 1.375rem); /* 18~22px */
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
margin-top: 1.25rem; /* 20px */
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pcard p {
|
||||
font-size: clamp(0.8125rem, 1.2vw, 0.9375rem); /* 13~15px */
|
||||
color: var(--ink-soft);
|
||||
margin-top: 0.5rem; /* 8px */
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ===================== CTA ===================== */
|
||||
.cta {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: clamp(4.5rem, 10vw, 8rem) 0; /* 72~128px — 높이 축소 */
|
||||
}
|
||||
|
||||
.cta-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
/* 배너 이미지 배경 */
|
||||
background: url('/images/img-banner.png') center / cover no-repeat;
|
||||
}
|
||||
|
||||
.cta-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
/* 더 어둡게 */
|
||||
background: linear-gradient(90deg, rgba(16, 13, 9, 0.82) 0%, rgba(16, 13, 9, 0.7) 60%, rgba(16, 13, 9, 0.6) 100%);
|
||||
}
|
||||
|
||||
.cta .wrap {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.cta-inner {
|
||||
color: var(--white);
|
||||
max-width: 45rem; /* 720px */
|
||||
}
|
||||
|
||||
.cta-inner h2 {
|
||||
font-size: clamp(1.75rem, 4.2vw, 3.25rem); /* 28~52px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cta-inner p {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: clamp(0.9375rem, 1.5vw, 1.125rem); /* 15~18px */
|
||||
margin-top: 1.125rem; /* 18px */
|
||||
}
|
||||
|
||||
.cta-btns {
|
||||
display: flex;
|
||||
gap: 0.875rem; /* 14px */
|
||||
margin-top: clamp(2rem, 4vw, 3rem); /* 32~48px */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ===================== 반응형 ===================== */
|
||||
@media (max-width: 56.25rem) {
|
||||
/* 900px */
|
||||
.growth-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brand-bento {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.btile.feature {
|
||||
grid-row: auto;
|
||||
grid-column: span 2;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 32.5rem) {
|
||||
/* 520px */
|
||||
.brand-bento {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btile.feature {
|
||||
grid-column: auto;
|
||||
aspect-ratio: 1; /* 1열에서는 다른 타일과 동일한 정사각 비율 */
|
||||
}
|
||||
|
||||
/* Growth 스탯: 가장 작은 화면에서는 2x2 → 1열 나열 */
|
||||
.stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.people-cols {
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(2.5rem, 8vw, 4rem);
|
||||
}
|
||||
|
||||
.pcol {
|
||||
gap: clamp(2.5rem, 8vw, 4rem);
|
||||
}
|
||||
|
||||
.pcol-b {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.pcard .pshot.tall,
|
||||
.pcard .pshot.wide {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 모션 최소화 선호 시 히어로/호버 애니메이션 비활성화 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-video,
|
||||
.hero-video .ph-label,
|
||||
.scroll-hint,
|
||||
.scroll-hint i::after {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.btile img {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.btile:hover img {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
// 개인정보처리방침 — 채용·커피챗 과정의 개인정보 처리 안내 문서
|
||||
useHead({ title: '개인정보처리방침 | innertab' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader variant="back" />
|
||||
|
||||
<div class="doc-wrap">
|
||||
<div class="doc-head">
|
||||
<p v-reveal class="doc-eyebrow">Privacy Policy</p>
|
||||
<h1 v-reveal="{ delay: 80 }">개인정보처리방침</h1>
|
||||
<p v-reveal="{ delay: 160 }" class="meta">
|
||||
본 방침은 이너탭(innertab) 채용 홈페이지를 통한 입사지원·채용 전형 및 커피챗(비공식 면담)
|
||||
과정에서의 개인정보 처리에 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-reveal class="intro">
|
||||
<p>
|
||||
이너탭(이하 ‘회사’)은 「개인정보 보호법」 등 관련 법령을 준수하며, 채용 과정 및
|
||||
커피챗(채용 전 비공식 면담)에서 수집하는 지원자·신청자의 개인정보를 안전하게 보호하기 위해
|
||||
다음과 같은 개인정보처리방침을 수립·공개합니다. 본 방침은 채용 및 커피챗 목적에 한정하여
|
||||
적용되며, 그 외 서비스 이용에 대한 처리방침과는 별도로 운영됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="doc">
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">01</span><span>수집하는 개인정보의 항목</span></h2>
|
||||
<div class="body">
|
||||
<p>회사는 채용 전형 진행을 위하여 지원자가 직접 제출하거나 전형 과정에서 생성되는 다음의 개인정보를 수집합니다.</p>
|
||||
<p class="sub">필수 항목</p>
|
||||
<ul>
|
||||
<li><b>인적사항</b> — 성명, 생년월일, 연락처(휴대전화번호), 이메일 주소</li>
|
||||
<li><b>학력 및 경력사항</b> — 최종 학력, 전공, 경력, 자격증, 어학 능력</li>
|
||||
<li><b>지원서류</b> — 이력서, 자기소개서, 포트폴리오 등 지원자가 제출한 자료</li>
|
||||
</ul>
|
||||
<p class="sub">선택 항목</p>
|
||||
<ul>
|
||||
<li>증명사진, 희망 근무지 등 지원자가 자발적으로 기재한 정보</li>
|
||||
</ul>
|
||||
<p class="sub">자동 생성·수집 항목</p>
|
||||
<ul>
|
||||
<li>지원 일시, 전형 진행 기록, 접속 로그 등 채용 관리에 필요한 정보</li>
|
||||
</ul>
|
||||
<p class="sub">커피챗 신청 시 수집 항목</p>
|
||||
<ul>
|
||||
<li><b>신청자 정보</b> — 성명, 이메일(필수), 연락처(선택)</li>
|
||||
<li><b>면담 관련 정보</b> — 관심 직무·분야, 현재 소속·직무(선택), 희망 일정, 사전 질문 등 신청자가 기재한 내용</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">02</span><span>개인정보의 수집 및 이용 목적</span></h2>
|
||||
<div class="body">
|
||||
<ul>
|
||||
<li>지원자 본인 확인 및 채용 전형(서류·면접 등) 진행</li>
|
||||
<li>전형 결과 안내 및 합격자에 대한 입사 절차 진행</li>
|
||||
<li>커피챗(비공식 면담) 신청 접수, 일정 조율 및 면담 진행</li>
|
||||
<li>채용·커피챗 관련 문의 응대 및 원활한 의사소통</li>
|
||||
<li>신청자 동의 시, 향후 채용 기회 제공을 위한 인재풀(Talent Pool) 운영</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">03</span><span>개인정보의 보유 및 이용 기간</span></h2>
|
||||
<div class="body">
|
||||
<p>회사는 원칙적으로 개인정보의 수집·이용 목적이 달성되면 지체 없이 해당 정보를 파기합니다. 다만 다음의 경우에는 명시한 기간 동안 보관합니다.</p>
|
||||
<div class="info-table">
|
||||
<div class="row"><div class="k">합격자</div><div class="v">입사 후 인사기록으로 전환되며, 별도의 인사 관련 규정 및 법령에 따라 보관</div></div>
|
||||
<div class="row"><div class="k">불합격자</div><div class="v">전형 종료 후 즉시 파기 (단, 분쟁 대응 등을 위해 필요한 경우 관련 법령이 정한 기간 동안 보관)</div></div>
|
||||
<div class="row"><div class="k">인재풀 동의자</div><div class="v">동의일로부터 1년간 보관 후 파기 (동의 철회 시 즉시 파기)</div></div>
|
||||
<div class="row"><div class="k">커피챗 신청자</div><div class="v">면담 종료 후 즉시 파기 (인재풀 보관에 동의한 경우 동의 기간 동안 보관 후 파기)</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">04</span><span>개인정보의 제3자 제공</span></h2>
|
||||
<div class="body">
|
||||
<p>회사는 지원자의 개인정보를 본 방침에서 명시한 범위를 넘어 외부에 제공하지 않습니다. 다만 지원자가 사전에 동의하였거나, 법령에 의해 요구되는 경우에 한하여 제공할 수 있습니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">05</span><span>개인정보 처리의 위탁</span></h2>
|
||||
<div class="body">
|
||||
<p>회사는 원활한 채용 업무 처리를 위해 채용 플랫폼·클라우드 인프라 등 일부 업무를 외부 전문업체에 위탁할 수 있습니다. 위탁 시에는 관련 법령에 따라 개인정보가 안전하게 관리될 수 있도록 필요한 사항을 계약을 통해 규정합니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">06</span><span>정보주체의 권리·의무 및 행사 방법</span></h2>
|
||||
<div class="body">
|
||||
<p>지원자는 언제든지 자신의 개인정보에 대하여 다음의 권리를 행사할 수 있습니다.</p>
|
||||
<ul>
|
||||
<li>개인정보 열람·정정·삭제 요구</li>
|
||||
<li>개인정보 처리 정지 요구</li>
|
||||
<li>개인정보 수집·이용 동의의 철회</li>
|
||||
</ul>
|
||||
<p>권리 행사는 아래 개인정보 보호책임자에게 서면, 이메일 등을 통해 요청할 수 있으며, 회사는 지체 없이 필요한 조치를 취합니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">07</span><span>개인정보의 파기 절차 및 방법</span></h2>
|
||||
<div class="body">
|
||||
<ul>
|
||||
<li><b>파기 절차</b> — 보유 기간이 경과하거나 처리 목적이 달성된 개인정보는 내부 방침에 따라 안전하게 파기합니다.</li>
|
||||
<li><b>파기 방법</b> — 전자적 파일은 복구·재생이 불가능한 방법으로 영구 삭제하며, 출력물 등은 분쇄 또는 소각합니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">08</span><span>개인정보의 안전성 확보 조치</span></h2>
|
||||
<div class="body">
|
||||
<ul>
|
||||
<li>개인정보 접근 권한의 최소화 및 접근 통제</li>
|
||||
<li>개인정보가 포함된 자료의 암호화 및 안전한 저장</li>
|
||||
<li>개인정보 처리 담당자에 대한 정기적인 교육 시행</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">09</span><span>개인정보 보호책임자</span></h2>
|
||||
<div class="body">
|
||||
<p>회사는 개인정보 처리에 관한 업무를 총괄하고, 지원자의 문의·불만 처리 및 피해 구제를 위하여 개인정보 보호책임자를 지정하고 있습니다.</p>
|
||||
<div class="contact-card">
|
||||
<p class="role">개인정보 보호책임자</p>
|
||||
<p>이메일 <span>people@innertab.co.kr</span></p>
|
||||
<p>담당 부서 <span>People 팀</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">10</span><span>정보주체의 권익침해 구제 방법</span></h2>
|
||||
<div class="body">
|
||||
<p>정보주체는 개인정보 침해로 인한 상담 및 피해 구제를 위하여 아래 기관에 분쟁 해결, 상담 등을 신청할 수 있습니다. 회사의 자체적인 처리에 만족하지 못하시는 경우 아래 기관에 도움을 요청하실 수 있습니다.</p>
|
||||
<ul>
|
||||
<li><b>개인정보분쟁조정위원회</b> — 1833-6972 (www.kopico.go.kr)</li>
|
||||
<li><b>개인정보침해신고센터</b> — (국번 없이) 118 (privacy.kisa.or.kr)</li>
|
||||
<li><b>대검찰청 사이버수사과</b> — (국번 없이) 1301 (www.spo.go.kr)</li>
|
||||
<li><b>경찰청 사이버수사국</b> — (국번 없이) 182 (ecrm.cyber.go.kr)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-reveal class="article">
|
||||
<h2><span class="no">11</span><span>방침의 변경</span></h2>
|
||||
<div class="body">
|
||||
<p>본 개인정보처리방침은 관련 법령 및 회사 내부 정책의 변경에 따라 수정될 수 있으며, 변경 시 본 페이지를 통해 그 내용을 공지합니다.</p>
|
||||
<p>· 공고일자 : 2026년 1월 1일<br >· 시행일자 : 2026년 1월 1일</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-wrap {
|
||||
max-width: var(--maxw); /* 사이트 공통 폭(1200px)과 동일 */
|
||||
margin: 0 auto;
|
||||
padding-inline: var(--pad);
|
||||
}
|
||||
|
||||
.doc-head {
|
||||
padding: clamp(3.5rem, 8vw, 6.25rem) 0 clamp(2rem, 4vw, 3rem); /* 56~100px / 32~48px */
|
||||
border-bottom: 0.0625rem solid var(--line);
|
||||
}
|
||||
|
||||
.doc-eyebrow {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.0625rem; /* 17px */
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--label);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doc-head h1 {
|
||||
font-size: clamp(1.875rem, 5vw, 2.875rem); /* 30~46px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
margin-top: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.doc-head .meta {
|
||||
font-size: 0.875rem; /* 14px */
|
||||
color: var(--ink-soft);
|
||||
margin-top: 1.125rem; /* 18px */
|
||||
}
|
||||
|
||||
.intro {
|
||||
padding: clamp(2.25rem, 4.5vw, 3.5rem) 0 0.5rem; /* 36~56px / 8px */
|
||||
}
|
||||
|
||||
.intro p {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.doc {
|
||||
padding: clamp(1.25rem, 3vw, 2rem) 0 clamp(4.5rem, 10vw, 7.5rem); /* 20~32px / 72~120px */
|
||||
}
|
||||
|
||||
.article {
|
||||
padding: clamp(1.75rem, 3.4vw, 2.5rem) 0; /* 28~40px */
|
||||
border-top: 0.0625rem solid var(--line);
|
||||
}
|
||||
|
||||
.article:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.article > h2 {
|
||||
font-size: clamp(1.125rem, 1.9vw, 1.4375rem); /* 18~23px */
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
gap: 0.75rem; /* 12px */
|
||||
align-items: baseline;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.article > h2 .no {
|
||||
font-family: var(--font-display);
|
||||
color: var(--label);
|
||||
font-weight: 700;
|
||||
font-size: 0.92em;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article .body {
|
||||
margin-top: 1.125rem; /* 18px */
|
||||
padding-left: clamp(0rem, 3.4vw, 2.5rem); /* 0~40px */
|
||||
}
|
||||
|
||||
.article p {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.article p + p {
|
||||
margin-top: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
.article ul {
|
||||
list-style: none;
|
||||
margin-top: 0.75rem; /* 12px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem; /* 10px */
|
||||
}
|
||||
|
||||
.article ul li {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.7;
|
||||
padding-left: 1.125rem; /* 18px */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.article ul li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.62em;
|
||||
width: 0.3125rem; /* 5px */
|
||||
height: 0.3125rem;
|
||||
border-radius: 50%;
|
||||
background: var(--label);
|
||||
}
|
||||
|
||||
.article ul li b {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.article .sub {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin-top: 1.375rem; /* 22px */
|
||||
}
|
||||
|
||||
/* 정보 테이블 */
|
||||
.info-table {
|
||||
margin-top: 1rem; /* 16px */
|
||||
border: 0.0625rem solid var(--line);
|
||||
border-radius: 0.75rem; /* 12px */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-table .row {
|
||||
display: grid;
|
||||
grid-template-columns: 12.5rem 1fr; /* 200px */
|
||||
border-top: 0.0625rem solid var(--line);
|
||||
}
|
||||
|
||||
.info-table .row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.info-table .k {
|
||||
background: #f7f8fa;
|
||||
padding: 1rem 1.25rem; /* 16px 20px */
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.info-table .v {
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
margin-top: 1rem; /* 16px */
|
||||
background: #f7f8fa;
|
||||
border-radius: 0.875rem; /* 14px */
|
||||
padding: clamp(1.375rem, 2.6vw, 1.875rem); /* 22~30px */
|
||||
}
|
||||
|
||||
.contact-card .role {
|
||||
font-size: 0.8125rem; /* 13px */
|
||||
font-weight: 700;
|
||||
color: var(--label);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.contact-card p {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
color: var(--ink);
|
||||
line-height: 1.9;
|
||||
margin-top: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.contact-card p span {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 38.75rem) {
|
||||
/* 620px */
|
||||
.info-table .row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.info-table .k {
|
||||
border-bottom: 0.0625rem solid var(--line);
|
||||
}
|
||||
|
||||
.article .body {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
// v-reveal 디렉티브 — 요소가 뷰포트에 들어오면 등장 애니메이션 트리거
|
||||
// 사용법:
|
||||
// v-reveal → 기본(아래→위 fade-up)
|
||||
// v-reveal:scale → 확대 등장
|
||||
// v-reveal:fade → 투명도만
|
||||
// v-reveal="{ delay: 120 }" → 등장 지연(ms) — 스태거용
|
||||
// v-reveal:up="{ delay: i*90 }" → 타입 + 지연 조합
|
||||
//
|
||||
// 디렉티브는 서버/클라이언트 양쪽에 등록(미등록 시 SSR 렌더 크래시)하되,
|
||||
// IntersectionObserver 등 실제 동작은 클라이언트에서만 수행한다.
|
||||
// getSSRProps 를 제공해 SSR 단계에서 안전하게 통과시킨다.
|
||||
type RevealType = 'up' | 'scale' | 'fade'
|
||||
interface RevealValue {
|
||||
type?: RevealType
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
let observer: IntersectionObserver | null = null
|
||||
let reduceMotion = false
|
||||
|
||||
if (import.meta.client) {
|
||||
reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-revealed')
|
||||
observer?.unobserve(entry.target)
|
||||
}
|
||||
}
|
||||
},
|
||||
// 요소가 화면 하단에서 약간 올라온 시점에 트리거
|
||||
{ threshold: 0.15, rootMargin: '0px 0px -8% 0px' },
|
||||
)
|
||||
}
|
||||
|
||||
nuxtApp.vueApp.directive<HTMLElement, RevealValue | number | undefined>('reveal', {
|
||||
// SSR: 추가 속성 없이 통과 (디렉티브 미등록으로 인한 크래시 방지)
|
||||
getSSRProps: () => ({}),
|
||||
mounted(el, binding) {
|
||||
// 서버에서는 호출되지 않음. 모션 최소화 선호 시 애니메이션 없이 그대로 노출.
|
||||
if (reduceMotion || !observer) return
|
||||
|
||||
const value = binding.value
|
||||
const type: RevealType =
|
||||
(binding.arg as RevealType) ||
|
||||
(typeof value === 'object' && value?.type) ||
|
||||
'up'
|
||||
const delay = typeof value === 'number' ? value : value?.delay ?? 0
|
||||
|
||||
el.classList.add('reveal', `reveal-${type}`)
|
||||
if (delay) el.style.setProperty('--reveal-delay', `${delay}ms`)
|
||||
|
||||
observer.observe(el)
|
||||
},
|
||||
unmounted(el) {
|
||||
observer?.unobserve(el)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,20 @@
|
||||
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성 (nuxt.config imports.dirs 로 자동 임포트)
|
||||
|
||||
/**
|
||||
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
|
||||
*/
|
||||
export function formatCurrency(value: number): string {
|
||||
return `${value.toLocaleString('ko-KR')}원`
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
|
||||
*/
|
||||
export function formatDate(input: Date | string): string {
|
||||
const date = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { User } from '~/composables/useUser'
|
||||
|
||||
// 사용자 도메인 Pinia 스토어 샘플 — 도메인 단위로 파일을 분리하여 관리
|
||||
// (computed, ref 는 Nuxt 자동 임포트)
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const isLoggedIn = computed<boolean>(() => user.value !== null)
|
||||
|
||||
function setUser(payload: User | null) {
|
||||
user.value = payload
|
||||
}
|
||||
|
||||
function clear() {
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return { user, isLoggedIn, setUser, clear }
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
// Nuxt 가 .nuxt/tsconfig.json 을 생성 (별칭/자동 임포트 타입 포함)
|
||||
// 실제 타입 검사는 `nuxt typecheck` (vue-tsc) 로 수행
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||