first commit

This commit is contained in:
2026-05-30 15:02:45 +09:00
commit 005719edee
212 changed files with 38830 additions and 0 deletions
+41
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true
}
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"nuxt.mdc",
"dbaeumer.vscode-eslint",
"oxc.oxc-vscode"
]
}
+29
View File
@@ -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;"]
+64
View File
@@ -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 구성을 서버 모드로 전환하면 됩니다.
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
// 최상위 진입점 — 파일 기반 라우팅(pages/)을 NuxtPage 로 마운트
// 도메인별 레이아웃은 layouts/ 디렉터리로 분리하여 확장
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<style scoped></style>
+127
View File
@@ -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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+99
View File
@@ -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 &nbsp;|&nbsp; 이메일: 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>
+81
View File
@@ -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>
+66
View File
@@ -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>
+37
View File
@@ -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>
+105
View File
@@ -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
}
+29
View File
@@ -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 }
}
+33
View File
@@ -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>
+10
View File
@@ -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',
},
})
+53
View File
@@ -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;
}
}
+68
View File
@@ -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,
},
})
+15056
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
+650
View File
@@ -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>
+870
View File
@@ -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>
+371
View File
@@ -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>
+61
View File
@@ -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)
},
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+20
View File
@@ -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}`
}
+19
View File
@@ -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 }
})
+5
View File
@@ -0,0 +1,5 @@
{
// Nuxt 가 .nuxt/tsconfig.json 을 생성 (별칭/자동 임포트 타입 포함)
// 실제 타입 검사는 `nuxt typecheck` (vue-tsc) 로 수행
"extends": "./.nuxt/tsconfig.json"
}