diff --git a/frontend/index.html b/frontend/index.html
index 3f0455e..ecd8815 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,8 +4,16 @@
-
-
Project Base
+
+
+
+ 서린즈
diff --git a/frontend/public/images/events/season-festival.jpg b/frontend/public/images/events/season-festival.jpg
new file mode 100644
index 0000000..7514512
Binary files /dev/null and b/frontend/public/images/events/season-festival.jpg differ
diff --git a/frontend/src/assets/styles/base.css b/frontend/src/assets/styles/base.css
new file mode 100644
index 0000000..6abd1b3
--- /dev/null
+++ b/frontend/src/assets/styles/base.css
@@ -0,0 +1,64 @@
+/* 전역 기본값 — 토큰을 먼저 읽은 뒤 적용한다 */
+@import './tokens.css';
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ /* 모든 rem 값의 기준 (16px = 1rem) */
+ font-size: 100%;
+}
+
+body {
+ margin: 0;
+ background-color: var(--sz-surface);
+ color: var(--sz-text);
+ font-family: 'Pretendard Variable', 'Pretendard', -apple-system, 'Apple SD Gothic Neo',
+ 'Malgun Gothic', sans-serif;
+ font-size: 1rem;
+ /* 숫자 자리가 흔들리지 않도록 고정폭 숫자를 쓴다 (랭킹 표) */
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.015em;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ color: var(--sz-brand-text);
+ text-decoration: none;
+}
+
+a:hover {
+ color: var(--sz-brand-text-hover);
+}
+
+button {
+ font-family: inherit;
+ letter-spacing: inherit;
+}
+
+img {
+ max-width: 100%;
+}
+
+/* 키보드 이동 시에만 초점 테두리를 보인다 */
+:focus-visible {
+ outline: 2px solid var(--sz-brand-text);
+ outline-offset: 2px;
+}
+
+/* 시각적으로 감추되 보조기기에는 읽히게 한다 */
+.sz-sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/frontend/src/assets/styles/tokens.css b/frontend/src/assets/styles/tokens.css
new file mode 100644
index 0000000..db54f6d
--- /dev/null
+++ b/frontend/src/assets/styles/tokens.css
@@ -0,0 +1,67 @@
+/*
+ * 서린즈 디자인 토큰 — .design/surinz 아트보드(Foundations · Brand)의 값을 그대로 옮긴 것.
+ * 화면 코드에서는 여기 없는 색·치수를 새로 만들지 않는다.
+ */
+:root {
+ /* ---------- 브랜드 (탄피 오렌지) ---------- */
+ /* 면 위에는 흰 글자, 흰 바탕 위 글자는 진한 변형을 쓴다 — 값이 서로 다르다 */
+ --sz-brand: #ff6a00;
+ --sz-brand-press: #d95a00;
+ --sz-brand-text: #c24a00;
+ --sz-brand-text-hover: #a03c00;
+
+ /* ---------- 글자 ---------- */
+ --sz-text: #14161a;
+ --sz-text-sub: #5b6169;
+ --sz-text-dim: #7a828c;
+ --sz-text-faint: #9ba1a9;
+ --sz-text-disabled: #c9cdd3;
+ --sz-text-invert: #ffffff;
+
+ /* ---------- 선 ---------- */
+ --sz-line-strong: #e4e8ed;
+ --sz-line: #edeff2;
+ --sz-line-row: #f3f5f7;
+ --sz-line-button: #d6dae0;
+
+ /* ---------- 면 ---------- */
+ --sz-surface: #ffffff;
+ --sz-surface-sub: #fafbfc;
+ --sz-surface-card: #f5f6f8;
+ --sz-surface-button: #f2f4f7;
+ --sz-surface-badge: #f0f2f5;
+ --sz-surface-placeholder: #edeff2;
+ --sz-scrim: rgba(20, 22, 26, 0.5);
+ --sz-scrim-strong: rgba(20, 22, 26, 0.55);
+
+ /* ---------- 의미 ---------- */
+ --sz-up: #12805c;
+ --sz-down: #c4453d;
+
+ /* ---------- 라운드 ---------- */
+ --sz-radius-pill: 999px;
+ --sz-radius-xl: 1.5rem; /* 24px — 큰 배너 */
+ --sz-radius-lg: 1.25rem; /* 20px */
+ --sz-radius-post: 1.125rem; /* 18px — 커뮤니티 카드 */
+ --sz-radius-md: 1rem; /* 16px — 보통 카드 · 상태 블록 */
+ --sz-radius-sm: 0.875rem; /* 14px */
+ --sz-radius-thumb: 0.75rem; /* 12px — 썸네일 */
+ --sz-radius-tile: 0.5rem; /* 8px — 계급 · 클랜 타일 */
+
+ /* ---------- 치수 ---------- */
+ --sz-row-h: 2.875rem; /* 46px — 데스크톱 표 행 */
+ --sz-row-h-mobile: 3.25rem; /* 52px — 모바일 표 행 */
+ --sz-row-head-h: 2rem; /* 32px */
+ --sz-tap-min: 2.75rem; /* 44px — 모바일 최소 터치 영역 */
+ --sz-page-pad: 2.5rem; /* 40px */
+ --sz-page-pad-tablet: 1.5rem; /* 24px */
+ --sz-page-pad-mobile: 1.25rem; /* 20px */
+ --sz-section-gap: 2.5rem; /* 40px */
+ --sz-section-gap-mobile: 2rem; /* 32px */
+
+ /* ---------- 그림자 ---------- */
+ --sz-shadow-hero: 0 1px 2px rgba(20, 22, 26, 0.04), 0 12px 32px rgba(20, 22, 26, 0.07);
+ --sz-shadow-hero-mobile: 0 1px 2px rgba(20, 22, 26, 0.04), 0 8px 22px rgba(20, 22, 26, 0.06);
+ --sz-shadow-header: 0 1px 12px rgba(20, 22, 26, 0.06);
+ --sz-shadow-sheet: 0 8px 20px rgba(20, 22, 26, 0.08), 0 32px 64px rgba(20, 22, 26, 0.16);
+}
diff --git a/frontend/src/components/common/AppIcon.vue b/frontend/src/components/common/AppIcon.vue
new file mode 100644
index 0000000..bad6af5
--- /dev/null
+++ b/frontend/src/components/common/AppIcon.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/AvatarCircle.vue b/frontend/src/components/common/AvatarCircle.vue
new file mode 100644
index 0000000..7bdfccb
--- /dev/null
+++ b/frontend/src/components/common/AvatarCircle.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/BrandLogo.vue b/frontend/src/components/common/BrandLogo.vue
new file mode 100644
index 0000000..9f70cf4
--- /dev/null
+++ b/frontend/src/components/common/BrandLogo.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/PillTabs.vue b/frontend/src/components/common/PillTabs.vue
new file mode 100644
index 0000000..740cae4
--- /dev/null
+++ b/frontend/src/components/common/PillTabs.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
diff --git a/frontend/src/components/common/RankBadge.vue b/frontend/src/components/common/RankBadge.vue
new file mode 100644
index 0000000..56d9141
--- /dev/null
+++ b/frontend/src/components/common/RankBadge.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/RankMovementCell.vue b/frontend/src/components/common/RankMovementCell.vue
new file mode 100644
index 0000000..6383751
--- /dev/null
+++ b/frontend/src/components/common/RankMovementCell.vue
@@ -0,0 +1,82 @@
+
+
+
+
+ {{ description() }}
+
+
+
+
+
+
+ {{ movement.amount }}
+
+
+
+
+
diff --git a/frontend/src/components/common/SectionBoundary.vue b/frontend/src/components/common/SectionBoundary.vue
new file mode 100644
index 0000000..0827ff4
--- /dev/null
+++ b/frontend/src/components/common/SectionBoundary.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/SectionHeader.vue b/frontend/src/components/common/SectionHeader.vue
new file mode 100644
index 0000000..1c4dc4e
--- /dev/null
+++ b/frontend/src/components/common/SectionHeader.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/SectionStateBlock.vue b/frontend/src/components/common/SectionStateBlock.vue
new file mode 100644
index 0000000..78cea19
--- /dev/null
+++ b/frontend/src/components/common/SectionStateBlock.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+ 다시 시도
+
+
+ {{ linkLabel }}
+
+
+
+
+
diff --git a/frontend/src/components/common/SegmentedTabs.vue b/frontend/src/components/common/SegmentedTabs.vue
new file mode 100644
index 0000000..1d86b04
--- /dev/null
+++ b/frontend/src/components/common/SegmentedTabs.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
diff --git a/frontend/src/components/common/SkeletonBox.vue b/frontend/src/components/common/SkeletonBox.vue
new file mode 100644
index 0000000..c963d99
--- /dev/null
+++ b/frontend/src/components/common/SkeletonBox.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/common/app-icon.ts b/frontend/src/components/common/app-icon.ts
new file mode 100644
index 0000000..19dfe22
--- /dev/null
+++ b/frontend/src/components/common/app-icon.ts
@@ -0,0 +1,160 @@
+/**
+ * 아이콘 정의 — 아트보드에 쓰인 선 아이콘·채움 아이콘의 좌표를 한곳에 모았다.
+ * 색은 그리는 쪽에서 currentColor 로 받는다.
+ */
+
+type ShapeTag = 'path' | 'circle' | 'rect'
+
+export interface IconShape {
+ tag: ShapeTag
+ d?: string
+ cx?: number
+ cy?: number
+ r?: number
+ x?: number
+ y?: number
+ width?: number
+ height?: number
+ rx?: number
+}
+
+export interface IconDefinition {
+ viewBox: string
+ /** 선 아이콘인지 채움 아이콘인지 */
+ mode: 'stroke' | 'fill'
+ strokeWidth?: number
+ shapes: IconShape[]
+}
+
+// path 하나를 짧게 적기 위한 도우미
+const p = (d: string): IconShape => ({ tag: 'path', d })
+
+export const icons = {
+ search: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 2.2,
+ shapes: [{ tag: 'circle', cx: 11, cy: 11, r: 6.5 }, p('M19.5 19.5L16 16')],
+ },
+ menu: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 2,
+ shapes: [p('M4 7h16'), p('M4 12h16'), p('M4 17h16')],
+ },
+ close: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 2,
+ shapes: [p('M6 6l12 12'), p('M18 6L6 18')],
+ },
+ chevronLeft: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 2.3,
+ shapes: [p('M14.5 5l-7 7 7 7')],
+ },
+ chevronRight: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 2.3,
+ shapes: [p('M9.5 5l7 7-7 7')],
+ },
+ album: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 2,
+ shapes: [
+ { tag: 'rect', x: 8, y: 3, width: 13, height: 13, rx: 3 },
+ p('M16 19.5A2.5 2.5 0 0113.5 22H6a3 3 0 01-3-3V9.5'),
+ ],
+ },
+ alert: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 1.8,
+ shapes: [{ tag: 'circle', cx: 12, cy: 12, r: 8.5 }, p('M12 7.5v5'), p('M12 16.2h.01')],
+ },
+ message: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 1.8,
+ shapes: [p('M20 14.5a3 3 0 01-3 3H9.5L5 20.5V6a3 3 0 013-3h9a3 3 0 013 3z')],
+ },
+ shield: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 1.8,
+ shapes: [p('M12 3.5l7 2.8v5.2c0 3.8-2.8 7.2-7 9-4.2-1.8-7-5.2-7-9V6.3z')],
+ },
+ chart: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 1.8,
+ shapes: [p('M4 19.5h16'), p('M7.5 16.5v-4'), p('M12 16.5v-8'), p('M16.5 16.5v-5.5')],
+ },
+ video: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 1.8,
+ shapes: [
+ { tag: 'rect', x: 3.5, y: 5, width: 17, height: 12.5, rx: 3 },
+ p('M10.5 9.2l4.8 3-4.8 3z'),
+ ],
+ },
+ document: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 1.8,
+ shapes: [p('M13.5 3H7.5a2 2 0 00-2 2v14a2 2 0 002 2h9a2 2 0 002-2V8z'), p('M13.5 3v5h5')],
+ },
+ flag: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 1.8,
+ shapes: [p('M4.5 4.5v15'), p('M4.5 5.5h12l-2.4 3.4 2.4 3.4h-12')],
+ },
+ // 계급 자리표시 — 실제 계급 이미지 규격을 받으면 교체한다
+ tierBar: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 2.4,
+ shapes: [p('M5 10l7 4 7-4'), p('M5 15l7 4 7-4')],
+ },
+ tierStar: {
+ viewBox: '0 0 24 24',
+ mode: 'fill',
+ shapes: [p('M12 4l2.3 4.9 5.4.7-3.9 3.8 1 5.3L12 16.2 7.2 18.7l1-5.3L4.3 9.6l5.4-.7z')],
+ },
+ tierDiamond: {
+ viewBox: '0 0 24 24',
+ mode: 'fill',
+ shapes: [p('M12 4.5l5.5 7.5L12 19.5 6.5 12z')],
+ },
+ // 랭크전 티어 자리표시 — 한 줄에 계급과 나란히 놓이므로 표장을 갈라 둔다
+ // (계급은 막대·별·마름모, 티어는 육각, 클랜은 방패)
+ tierHex: {
+ viewBox: '0 0 24 24',
+ mode: 'stroke',
+ strokeWidth: 1.8,
+ shapes: [p('M12 3l7.5 4.3v9.4L12 21l-7.5-4.3V7.3z'), p('M9 13.2l3-2.6 3 2.6')],
+ },
+ play: {
+ viewBox: '0 0 24 24',
+ mode: 'fill',
+ shapes: [p('M8 5.5l11 6.5-11 6.5z')],
+ },
+ // 등락 삼각형 — 표에서만 쓰는 작은 아이콘이라 12 격자를 따로 쓴다
+ caretUp: {
+ viewBox: '0 0 12 12',
+ mode: 'fill',
+ shapes: [p('M6 2l4 7H2z')],
+ },
+ caretDown: {
+ viewBox: '0 0 12 12',
+ mode: 'fill',
+ shapes: [p('M6 10L2 3h8z')],
+ },
+} satisfies Record
+
+export type IconName = keyof typeof icons
diff --git a/frontend/src/components/home/CommunitySection.vue b/frontend/src/components/home/CommunitySection.vue
new file mode 100644
index 0000000..1568995
--- /dev/null
+++ b/frontend/src/components/home/CommunitySection.vue
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/home/EventSection.vue b/frontend/src/components/home/EventSection.vue
new file mode 100644
index 0000000..4ccf6e5
--- /dev/null
+++ b/frontend/src/components/home/EventSection.vue
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/home/GameRankingSection.vue b/frontend/src/components/home/GameRankingSection.vue
new file mode 100644
index 0000000..f8355a1
--- /dev/null
+++ b/frontend/src/components/home/GameRankingSection.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ branchTitle[branch] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ branchTitle[branch] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/home/NewsSection.vue b/frontend/src/components/home/NewsSection.vue
new file mode 100644
index 0000000..33a4d11
--- /dev/null
+++ b/frontend/src/components/home/NewsSection.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/home/RankPagination.vue b/frontend/src/components/home/RankPagination.vue
new file mode 100644
index 0000000..e73c8f7
--- /dev/null
+++ b/frontend/src/components/home/RankPagination.vue
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/home/RankingTable.vue b/frontend/src/components/home/RankingTable.vue
new file mode 100644
index 0000000..02ed445
--- /dev/null
+++ b/frontend/src/components/home/RankingTable.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/home/SearchRankingSection.vue b/frontend/src/components/home/SearchRankingSection.vue
new file mode 100644
index 0000000..5094455
--- /dev/null
+++ b/frontend/src/components/home/SearchRankingSection.vue
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/home/StreamingSection.vue b/frontend/src/components/home/StreamingSection.vue
new file mode 100644
index 0000000..9aa09e3
--- /dev/null
+++ b/frontend/src/components/home/StreamingSection.vue
@@ -0,0 +1,258 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/home/ranking-table.spec.ts b/frontend/src/components/home/ranking-table.spec.ts
new file mode 100644
index 0000000..ff8e326
--- /dev/null
+++ b/frontend/src/components/home/ranking-table.spec.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest'
+import { toClanRows, toUserRows } from '@/components/home/ranking-table'
+import type { ClanRankEntry, UserRankEntry } from '@/types/home'
+
+describe('랭킹 표 줄 변환', () => {
+ it('유저 랭킹은 계급 · 티어 자리표시와 RP 를 그대로 옮긴다', () => {
+ const entries: UserRankEntry[] = [
+ {
+ rank: 1,
+ nickname: '헤드샷장인',
+ gradeShape: 'star',
+ tierImageUrl: '/images/tiers/diamond.png',
+ rp: 2486,
+ movement: { direction: 'up', amount: 1 },
+ },
+ ]
+
+ expect(toUserRows(entries)).toEqual([
+ {
+ rank: 1,
+ label: '헤드샷장인',
+ badgeVariant: 'grade',
+ badgeShape: 'star',
+ badgeImageUrl: undefined,
+ tierImageUrl: '/images/tiers/diamond.png',
+ rp: 2486,
+ movement: { direction: 'up', amount: 1 },
+ },
+ ])
+ })
+
+ it('계급 랭킹처럼 티어 이미지가 없으면 그 자리는 비워 둔다', () => {
+ const entries: UserRankEntry[] = [
+ {
+ rank: 1,
+ nickname: '달빛조각사',
+ gradeShape: 'bar',
+ movement: { direction: 'same', amount: 0 },
+ },
+ ]
+
+ const [row] = toUserRows(entries)
+
+ expect(row?.tierImageUrl).toBeUndefined()
+ expect(row?.rp).toBeUndefined()
+ })
+
+ it('클랜 랭킹은 클랜명을 이름 자리에 두고 RP 를 두지 않는다', () => {
+ const entries: ClanRankEntry[] = [
+ { rank: 3, clanName: '야간부대', movement: { direction: 'down', amount: 1 } },
+ ]
+
+ const [row] = toClanRows(entries)
+
+ expect(row?.label).toBe('야간부대')
+ expect(row?.badgeVariant).toBe('clan')
+ expect(row?.rp).toBeUndefined()
+ })
+})
diff --git a/frontend/src/components/home/ranking-table.ts b/frontend/src/components/home/ranking-table.ts
new file mode 100644
index 0000000..1621c90
--- /dev/null
+++ b/frontend/src/components/home/ranking-table.ts
@@ -0,0 +1,47 @@
+/**
+ * 랭킹 표 한 줄의 화면용 모양.
+ *
+ * 유저 랭킹(계급·랭크전)과 클랜 랭킹은 담는 값이 다르지만 표의 생김새는 같다.
+ * 두 도메인 타입을 이 모양으로 옮겨서 표 컴포넌트 하나로 그린다.
+ */
+import type { ClanRankEntry, RankGradeShape, RankMovement, UserRankEntry } from '@/types/home'
+
+export interface RankingRow {
+ rank: number
+ /** 닉네임 또는 클랜명 */
+ label: string
+ /** 이름 앞 타일이 계급 이미지 자리인지 클랜 마크 자리인지 */
+ badgeVariant: 'grade' | 'clan'
+ /** 계급 자리표시 모양 — 유저 랭킹에만 있다 */
+ badgeShape?: RankGradeShape
+ /** 계급 이미지 또는 클랜 마크 */
+ badgeImageUrl?: string
+ /** 랭크전 티어 이미지 — RP 와 한 칸에 붙는다 */
+ tierImageUrl?: string
+ /** 랭크전 랭킹에만 있는 RP 점수 */
+ rp?: number
+ movement: RankMovement
+}
+
+export function toUserRows(entries: UserRankEntry[]): RankingRow[] {
+ return entries.map((entry) => ({
+ rank: entry.rank,
+ label: entry.nickname,
+ badgeVariant: 'grade',
+ badgeShape: entry.gradeShape,
+ badgeImageUrl: entry.gradeImageUrl,
+ tierImageUrl: entry.tierImageUrl,
+ rp: entry.rp,
+ movement: entry.movement,
+ }))
+}
+
+export function toClanRows(entries: ClanRankEntry[]): RankingRow[] {
+ return entries.map((entry) => ({
+ rank: entry.rank,
+ label: entry.clanName,
+ badgeVariant: 'clan',
+ badgeImageUrl: entry.markImageUrl,
+ movement: entry.movement,
+ }))
+}
diff --git a/frontend/src/components/layout/AppFooter.vue b/frontend/src/components/layout/AppFooter.vue
new file mode 100644
index 0000000..4d28990
--- /dev/null
+++ b/frontend/src/components/layout/AppFooter.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue
new file mode 100644
index 0000000..6e56988
--- /dev/null
+++ b/frontend/src/components/layout/AppHeader.vue
@@ -0,0 +1,358 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+ 로그인
+
+
+
+
+
+
+
diff --git a/frontend/src/components/layout/SearchHero.vue b/frontend/src/components/layout/SearchHero.vue
new file mode 100644
index 0000000..f3fba66
--- /dev/null
+++ b/frontend/src/components/layout/SearchHero.vue
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+ 누구의 전적이 궁금하세요?
+
+
+ 닉네임만 알려주시면 최근 경기 흐름부터 자주 쓰는 무기까지 정리해 드릴게요.
+
+
+
+
+
+
최근에 찾아봤어요
+
+
+
+ {{ item.nickname }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/layout/SearchSheet.vue b/frontend/src/components/layout/SearchSheet.vue
new file mode 100644
index 0000000..8c41c29
--- /dev/null
+++ b/frontend/src/components/layout/SearchSheet.vue
@@ -0,0 +1,411 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 최근에 찾아봤어요
+
+
+
+
+ {{ item.nickname }}
+
+
+
+
+
+
+
+ 지금 많이 찾는
+
+
+ {{ entry.rank }}
+ {{ entry.nickname }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useApi.spec.ts b/frontend/src/composables/useApi.spec.ts
new file mode 100644
index 0000000..e94ee40
--- /dev/null
+++ b/frontend/src/composables/useApi.spec.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { resolveErrorMessage } from '@/composables/useApi'
+
+// 에러 코드 ↔ 메시지는 세 플랫폼이 공유하는 계약이라 분기를 직접 검증한다
+describe('resolveErrorMessage', () => {
+ it('서버가 내려준 에러 코드의 문장을 쓴다', () => {
+ const error = { response: { status: 400, data: { success: false, error: { code: 'AUTH_003', message: '' } } } }
+
+ expect(resolveErrorMessage(error)).toBe('해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.')
+ })
+
+ it('에러 코드가 없으면 HTTP 상태로 코드를 정한다', () => {
+ expect(resolveErrorMessage({ response: { status: 401 } })).toBe(
+ '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
+ )
+ expect(resolveErrorMessage({ response: { status: 422 } })).toBe(
+ '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
+ )
+ expect(resolveErrorMessage({ response: { status: 404 } })).toBe(
+ '요청하신 정보나 페이지를 찾을 수 없습니다.',
+ )
+ })
+
+ it('모르는 코드나 응답이 없는 에러는 SYS_001 문장으로 떨어진다', () => {
+ const unknownCode = { response: { status: 500, data: { success: false, error: { code: 'NOPE_999', message: '' } } } }
+ const fallbackMessage = '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'
+
+ expect(resolveErrorMessage(unknownCode)).toBe(fallbackMessage)
+ expect(resolveErrorMessage(new Error('네트워크 끊김'))).toBe(fallbackMessage)
+ expect(resolveErrorMessage(undefined)).toBe(fallbackMessage)
+ })
+})
diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts
index 8a7e69d..99e9391 100644
--- a/frontend/src/composables/useApi.ts
+++ b/frontend/src/composables/useApi.ts
@@ -14,6 +14,14 @@ export interface StandardResponse {
}
}
+// 요청 단위 확장 옵션 — 섹션 안에서 실패를 직접 그리는 화면(홈 등)이 전역 알림을 끄는 데 쓴다
+declare module 'axios' {
+ export interface AxiosRequestConfig {
+ /** true 면 전역 알림을 띄우지 않는다 — 호출한 쪽에서 문구를 직접 보여 줄 때 */
+ silentError?: boolean
+ }
+}
+
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
// SYS_001 은 폴백 메시지로 항상 존재함을 타입으로 보장 (noUncheckedIndexedAccess 대응)
const ErrorCodeLexicon: Record & { SYS_001: string } = {
@@ -31,6 +39,41 @@ const showErrorUI = (message: string) => {
window.alert(message)
}
+// 에러 코드 → 한국어 문장. 모르는 코드는 SYS_001 문장으로 떨어진다
+const messageOf = (code: string) => ErrorCodeLexicon[code] || ErrorCodeLexicon.SYS_001
+
+/**
+ * HTTP 상태 코드를 에러 코드로 옮긴다.
+ * 서버가 error.code 를 내려 주면 그 값이 우선한다.
+ */
+const codeFromStatus = (status: number): string => {
+ if (status === 401) return 'AUTH_001'
+ if (status === 403) return 'AUTH_003'
+ if (status === 400 || status === 422) return 'VAL_001'
+ if (status === 404) return 'RES_001'
+ return 'SYS_001'
+}
+
+/**
+ * resolveErrorMessage — 잡은 에러에서 사용자에게 보여 줄 문장을 꺼낸다.
+ *
+ * 화면에서 실패 문구를 새로 짓지 않기 위한 통로다. 실패 블록·폼 오류 등
+ * 알림 대신 화면 안에 문구를 그리는 자리에서 쓴다.
+ */
+export function resolveErrorMessage(error: unknown): string {
+ const candidate = error as
+ | { code?: string; response?: { status?: number; data?: StandardResponse } }
+ | undefined
+
+ const serverCode = candidate?.response?.data?.error?.code || candidate?.code
+ if (typeof serverCode === 'string' && serverCode in ErrorCodeLexicon) {
+ return messageOf(serverCode)
+ }
+
+ const status = candidate?.response?.status
+ return messageOf(typeof status === 'number' ? codeFromStatus(status) : 'SYS_001')
+}
+
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
@@ -57,27 +100,18 @@ api.interceptors.response.use(
}
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
const errCode = payload.error?.code || 'BIZ_001'
- showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
+ if (!response.config?.silentError) {
+ showErrorUI(messageOf(errCode))
+ }
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'
+ // silentError 요청은 호출한 쪽이 화면 안에서 직접 문구를 그린다
+ if (!error?.config?.silentError) {
+ showErrorUI(resolveErrorMessage(error))
}
-
- showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
return Promise.reject(error)
},
)
diff --git a/frontend/src/composables/useHome.ts b/frontend/src/composables/useHome.ts
new file mode 100644
index 0000000..d45c913
--- /dev/null
+++ b/frontend/src/composables/useHome.ts
@@ -0,0 +1,81 @@
+import { useSectionResource, type SectionResource } from '@/composables/useSectionResource'
+import * as fixtures from '@/mocks/home.fixtures'
+import type {
+ CommunityPost,
+ EventBanner,
+ GameRanking,
+ NewsItem,
+ RecentSearchItem,
+ SearchRankEntry,
+ StreamChannel,
+} from '@/types/home'
+
+/**
+ * 홈 화면 데이터 — 섹션마다 따로 부른다.
+ *
+ * 아직 백엔드에 홈 API 가 없어서 임시 데이터를 돌려준다.
+ * 연동할 때는 각 로더의 본문을 아래 한 줄로 바꾸면 된다 (주석의 예시 참고).
+ * `silentError` 를 켜는 이유는, 실패를 전역 알림 대신 섹션 안의 실패 블록으로 보여 주기 때문이다.
+ */
+
+// 임시 데이터를 비동기로 흉내 낸다 — 로딩 상태(스켈레톤)를 실제로 확인하기 위한 짧은 지연
+const MOCK_DELAY_MS = 400
+
+function mock(value: T): Promise {
+ return new Promise((resolve) => {
+ window.setTimeout(() => resolve(value), MOCK_DELAY_MS)
+ })
+}
+
+/** 이벤트 배너 */
+export function useEventBanners(): SectionResource {
+ // 연동 시: return api.get('/home/events', { silentError: true })
+ return useSectionResource(() => mock(fixtures.eventBanners))
+}
+
+/** 검색 랭킹 */
+export function useSearchRanking(): SectionResource {
+ // 연동 시: return api.get('/home/search-ranking', { silentError: true })
+ return useSectionResource(() => mock(fixtures.searchRanking))
+}
+
+/** 게임 랭킹 — 계급 · 랭크전 · 클랜 세 갈래를 한 번에 받는다 */
+export function useGameRanking(): SectionResource {
+ // 연동 시: return api.get('/home/game-ranking', { silentError: true })
+ return useSectionResource(() => mock(fixtures.gameRanking), {
+ // 세 갈래가 모두 비어 있을 때만 «비어 있음» 으로 본다
+ isEmpty: (data) => data.tier.length === 0 && data.ranked.length === 0 && data.clan.length === 0,
+ })
+}
+
+/** 커뮤니티 인기 글 */
+export function useCommunityPosts(): SectionResource {
+ // 연동 시: return api.get('/home/community', { silentError: true })
+ return useSectionResource(() => mock(fixtures.communityPosts))
+}
+
+/** 서든어택 소식 */
+export function useGameNews(): SectionResource {
+ // 연동 시: return api.get('/home/news/game', { silentError: true })
+ return useSectionResource(() => mock(fixtures.gameNews))
+}
+
+/** 서린즈 소식 */
+export function useServiceNews(): SectionResource {
+ // 연동 시: return api.get('/home/news/service', { silentError: true })
+ return useSectionResource(() => mock(fixtures.serviceNews))
+}
+
+/** 지금 방송 중인 채널 */
+export function useLiveStreams(): SectionResource {
+ // 연동 시: return api.get('/home/streams', { silentError: true })
+ return useSectionResource(() => mock(fixtures.liveStreams))
+}
+
+/**
+ * 최근 검색 기록 — 서버가 아니라 브라우저에 남는 값이라 섹션 상태를 두지 않는다.
+ * 실제 저장소(localStorage) 연결은 검색 기능을 붙일 때 함께 처리한다.
+ */
+export function useRecentSearches(): RecentSearchItem[] {
+ return fixtures.recentSearches
+}
diff --git a/frontend/src/composables/useMediaQuery.ts b/frontend/src/composables/useMediaQuery.ts
new file mode 100644
index 0000000..e243588
--- /dev/null
+++ b/frontend/src/composables/useMediaQuery.ts
@@ -0,0 +1,36 @@
+import { onBeforeUnmount, readonly, ref, type Ref } from 'vue'
+
+/**
+ * useMediaQuery — 미디어 쿼리 일치 여부를 반응형으로 돌려준다.
+ *
+ * CSS 로 해결되는 배치는 CSS 에서 처리하고,
+ * 이 컴포저블은 «구조 자체가 달라지는» 경우(예: 게임 랭킹을 세 개 나란히 vs 하나씩)에만 쓴다.
+ */
+export function useMediaQuery(query: string): Readonly[> {
+ const matches = ref(false)
+
+ // 서버 렌더링·테스트 환경에서는 matchMedia 가 없을 수 있다
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return readonly(matches)
+ }
+
+ const mediaQuery = window.matchMedia(query)
+ matches.value = mediaQuery.matches
+
+ const handleChange = (event: MediaQueryListEvent) => {
+ matches.value = event.matches
+ }
+
+ mediaQuery.addEventListener('change', handleChange)
+ onBeforeUnmount(() => mediaQuery.removeEventListener('change', handleChange))
+
+ return readonly(matches)
+}
+
+/** 서린즈 화면 분기점 — 아트보드 1440 / 768 / 390 에 맞춘 값 */
+export const BREAKPOINT = {
+ /** 태블릿 이하 — 게임 랭킹을 갈래 세그먼트로 하나씩 보여 준다 */
+ tabletDown: '(max-width: 74.9375rem)',
+ /** 모바일 — 한 열 배치 */
+ mobileDown: '(max-width: 47.9375rem)',
+} as const
diff --git a/frontend/src/composables/useSectionResource.spec.ts b/frontend/src/composables/useSectionResource.spec.ts
new file mode 100644
index 0000000..b6f5a48
--- /dev/null
+++ b/frontend/src/composables/useSectionResource.spec.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { useSectionResource } from '@/composables/useSectionResource'
+
+describe('useSectionResource', () => {
+ it('불러오기에 성공하면 ready 상태가 되고 데이터를 담는다', async () => {
+ const resource = useSectionResource(() => Promise.resolve([1, 2, 3]))
+
+ await resource.load()
+
+ expect(resource.status.value).toBe('ready')
+ expect(resource.data.value).toEqual([1, 2, 3])
+ })
+
+ it('빈 배열이면 empty 상태가 된다', async () => {
+ const resource = useSectionResource](() => Promise.resolve([]))
+
+ await resource.load()
+
+ expect(resource.status.value).toBe('empty')
+ })
+
+ it('isEmpty 를 넘기면 그 판정으로 empty 를 정한다', async () => {
+ const resource = useSectionResource(() => Promise.resolve({ items: [] }), {
+ isEmpty: (data) => data.items.length === 0,
+ })
+
+ await resource.load()
+
+ expect(resource.status.value).toBe('empty')
+ })
+
+ it('실패하면 error 상태가 되고 에러 코드 맵의 문장을 담는다', async () => {
+ const resource = useSectionResource(() => Promise.reject({ response: { status: 404 } }))
+
+ await resource.load()
+
+ expect(resource.status.value).toBe('error')
+ expect(resource.errorMessage.value).toBe('요청하신 정보나 페이지를 찾을 수 없습니다.')
+ expect(resource.data.value).toBeNull()
+ })
+
+ it('다시 시도하면 실패했던 섹션이 다시 채워진다', async () => {
+ let shouldFail = true
+ const resource = useSectionResource(() =>
+ shouldFail ? Promise.reject(new Error('일시 실패')) : Promise.resolve(['가']),
+ )
+
+ await resource.load()
+ expect(resource.status.value).toBe('error')
+
+ shouldFail = false
+ await resource.reload()
+
+ expect(resource.status.value).toBe('ready')
+ expect(resource.errorMessage.value).toBe('')
+ })
+})
diff --git a/frontend/src/composables/useSectionResource.ts b/frontend/src/composables/useSectionResource.ts
new file mode 100644
index 0000000..f30ffeb
--- /dev/null
+++ b/frontend/src/composables/useSectionResource.ts
@@ -0,0 +1,64 @@
+import { ref, shallowRef, type Ref, type ShallowRef } from 'vue'
+import { resolveErrorMessage } from '@/composables/useApi'
+
+/**
+ * 섹션 하나의 상태 — 홈은 섹션마다 따로 채우기 때문에
+ * 한 섹션이 실패하거나 비어도 나머지는 그대로 보인다.
+ */
+export type SectionStatus = 'idle' | 'loading' | 'ready' | 'empty' | 'error'
+
+export interface SectionResource {
+ data: ShallowRef
+ status: Ref
+ /** 실패했을 때 화면에 그대로 쓸 문구 — 에러 코드 맵에서 온다 */
+ errorMessage: Ref
+ /** 처음 불러오기 */
+ load: () => Promise
+ /** 「다시 시도」 — 실패 블록의 버튼이 부른다 */
+ reload: () => Promise
+}
+
+interface Options {
+ /** 결과가 «비어 있음» 인지 판단한다. 기본값은 빈 배열 판정 */
+ isEmpty?: (data: T) => boolean
+}
+
+/**
+ * useSectionResource — 홈 섹션 하나를 불러오고 로딩 · 성공 · 비어 있음 · 실패 네 상태를 관리한다.
+ *
+ * 실패 문구는 여기서 새로 짓지 않고 에러 코드 맵(useApi)의 문장을 그대로 쓴다.
+ */
+export function useSectionResource(
+ loader: () => Promise,
+ options: Options = {},
+): SectionResource {
+ const data = shallowRef(null)
+ const status = ref('idle')
+ const errorMessage = ref('')
+
+ const isEmpty = options.isEmpty ?? ((value: T) => Array.isArray(value) && value.length === 0)
+
+ async function load() {
+ status.value = 'loading'
+ errorMessage.value = ''
+
+ try {
+ const result = await loader()
+ data.value = result
+ status.value = isEmpty(result) ? 'empty' : 'ready'
+ } catch (error: unknown) {
+ data.value = null
+ errorMessage.value = resolveErrorMessage(error)
+ status.value = 'error'
+ }
+ }
+
+ return {
+ data,
+ status,
+ errorMessage,
+ load,
+ // 다시 시도도 처음 불러오기와 같은 흐름을 탄다
+ reload: load,
+ }
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index fda1e6e..f062008 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,6 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
+// 전역 스타일(디자인 토큰 + 기본값) 을 앱 코드보다 먼저 읽는다
+import '@/assets/styles/base.css'
+
import App from './App.vue'
import router from './router'
diff --git a/frontend/src/mocks/home.fixtures.ts b/frontend/src/mocks/home.fixtures.ts
new file mode 100644
index 0000000..01ab19a
--- /dev/null
+++ b/frontend/src/mocks/home.fixtures.ts
@@ -0,0 +1,216 @@
+/**
+ * 홈 화면 임시 데이터 — .design/surinz 아트보드에 그려진 값을 그대로 옮긴 것.
+ *
+ * 백엔드에 홈 API 가 아직 없어서 화면을 먼저 세운다.
+ * 실제 연동은 `composables/useHome.ts` 의 로더 한 줄만 바꾸면 되고,
+ * 그때 이 파일은 지우거나 테스트 픽스처로만 남긴다.
+ */
+import type {
+ ClanRankEntry,
+ CommunityPost,
+ EventBanner,
+ GameRanking,
+ NewsItem,
+ RecentSearchItem,
+ SearchRankEntry,
+ StreamChannel,
+ UserRankEntry,
+} from '@/types/home'
+
+// 등락 값을 짧게 만드는 도우미 — 픽스처를 읽기 쉽게 하려는 용도
+const up = (amount: number) => ({ direction: 'up' as const, amount })
+const down = (amount: number) => ({ direction: 'down' as const, amount })
+const same = () => ({ direction: 'same' as const, amount: 0 })
+
+export const recentSearches: RecentSearchItem[] = [
+ { nickname: '달빛조각사', avatarColor: '#DDE4EE' },
+ { nickname: '칼든햄스터', avatarColor: '#E5E0F0' },
+ { nickname: '새벽두시', avatarColor: '#E7E3D8' },
+ { nickname: '무명연대장', avatarColor: '#D8E6E2' },
+]
+
+export const eventBanners: EventBanner[] = [
+ {
+ id: 'ev-1',
+ category: '시즌 이벤트',
+ title: '서든 페스티벌 같이 갈래?',
+ period: '9월 1일 ~ 9월 30일',
+ imageUrl: '/images/events/season-festival.jpg',
+ },
+ {
+ id: 'ev-2',
+ category: '출석 이벤트',
+ title: '매일 접속하고 보급 상자 받아 가기',
+ period: '9월 1일 ~ 9월 14일',
+ placeholderColor: '#E4E8ED',
+ },
+ {
+ id: 'ev-3',
+ category: '클랜 이벤트',
+ title: '클랜전 정기 리그 참가 모집',
+ period: '9월 5일 ~ 9월 21일',
+ placeholderColor: '#EAE6E0',
+ },
+ {
+ id: 'ev-4',
+ category: '신규 유저',
+ title: '처음 오셨나요? 첫 주 지원 꾸러미',
+ period: '상시',
+ placeholderColor: '#E2E9E6',
+ },
+ {
+ id: 'ev-5',
+ category: '업데이트',
+ title: '신규 맵 컨테이너 야드 체험 주간',
+ period: '9월 8일 ~ 9월 15일',
+ placeholderColor: '#E8E4EC',
+ },
+]
+
+export const searchRanking: SearchRankEntry[] = [
+ { rank: 1, nickname: '달빛조각사', movement: up(2) },
+ { rank: 2, nickname: '칼든햄스터', movement: down(1) },
+ { rank: 3, nickname: '새벽두시', movement: up(4) },
+ { rank: 4, nickname: '무명연대장', movement: same() },
+ { rank: 5, nickname: '한밤의저격수', movement: up(3) },
+ { rank: 6, nickname: '조용한총잡이', movement: down(2) },
+ { rank: 7, nickname: '야간부대장', movement: same() },
+ { rank: 8, nickname: '헤드샷장인', movement: up(5) },
+ { rank: 9, nickname: '삼보급단골', movement: same() },
+]
+
+const tierRanking: UserRankEntry[] = [
+ { rank: 1, nickname: '달빛조각사', gradeShape: 'bar', movement: up(2) },
+ { rank: 2, nickname: '칼든햄스터', gradeShape: 'star', movement: down(1) },
+ { rank: 3, nickname: '새벽두시', gradeShape: 'bar', movement: up(4) },
+ { rank: 4, nickname: '무명연대장', gradeShape: 'diamond', movement: same() },
+ { rank: 5, nickname: '한밤의저격수', gradeShape: 'diamond', movement: up(3) },
+ { rank: 6, nickname: '조용한총잡이', gradeShape: 'bar', movement: down(2) },
+ { rank: 7, nickname: '야간부대장', gradeShape: 'diamond', movement: same() },
+ { rank: 8, nickname: '헤드샷장인', gradeShape: 'diamond', movement: up(5) },
+ { rank: 9, nickname: '삼보급단골', gradeShape: 'bar', movement: down(3) },
+ { rank: 10, nickname: '웨어하우스지박령', gradeShape: 'star', movement: up(1) },
+]
+
+const rankedRanking: UserRankEntry[] = [
+ { rank: 1, nickname: '헤드샷장인', gradeShape: 'star', rp: 2486, movement: up(1) },
+ { rank: 2, nickname: '달빛조각사', gradeShape: 'bar', rp: 2451, movement: down(1) },
+ { rank: 3, nickname: '삼보급단골', gradeShape: 'diamond', rp: 2398, movement: up(6) },
+ { rank: 4, nickname: '칼든햄스터', gradeShape: 'star', rp: 2344, movement: same() },
+ { rank: 5, nickname: '웨어하우스지박령', gradeShape: 'diamond', rp: 2301, movement: up(2) },
+ { rank: 6, nickname: '새벽두시', gradeShape: 'bar', rp: 2276, movement: down(3) },
+ { rank: 7, nickname: '조용한총잡이', gradeShape: 'diamond', rp: 2240, movement: same() },
+ { rank: 8, nickname: '한밤의저격수', gradeShape: 'diamond', rp: 2205, movement: up(4) },
+ { rank: 9, nickname: '야간부대장', gradeShape: 'bar', rp: 2181, movement: down(2) },
+ { rank: 10, nickname: '무명연대장', gradeShape: 'diamond', rp: 2154, movement: up(1) },
+]
+
+const clanRanking: ClanRankEntry[] = [
+ { rank: 1, clanName: '무명연대', movement: same() },
+ { rank: 2, clanName: '새벽클랜', movement: up(2) },
+ { rank: 3, clanName: '야간부대', movement: down(1) },
+ { rank: 4, clanName: '정예사격단', movement: up(3) },
+ { rank: 5, clanName: '삼보급수호대', movement: same() },
+ { rank: 6, clanName: '헤드샷연구소', movement: up(4) },
+ { rank: 7, clanName: '크로스카운터', movement: down(2) },
+ { rank: 8, clanName: '웨어하우스단', movement: same() },
+ { rank: 9, clanName: '새벽정찰대', movement: up(1) },
+ { rank: 10, clanName: '삼보급기동대', movement: down(3) },
+]
+
+export const gameRanking: GameRanking = {
+ tier: tierRanking,
+ ranked: rankedRanking,
+ clan: clanRanking,
+ totalCount: 100,
+ pageSize: 10,
+}
+
+export const communityPosts: CommunityPost[] = [
+ {
+ id: 'cp-1',
+ kind: 'photo',
+ placeholderColor: '#E4E8ED',
+ authorNickname: '달빛조각사',
+ authorAvatarColor: '#C9D2DD',
+ reactionCount: 312,
+ },
+ {
+ id: 'cp-2',
+ kind: 'album',
+ placeholderColor: '#EAE6E0',
+ authorNickname: '칼든햄스터',
+ authorAvatarColor: '#D8D2C8',
+ reactionCount: 274,
+ },
+ {
+ id: 'cp-3',
+ kind: 'text',
+ excerpt: '폭파 삼보급 A 롱각, 다들 어디 서세요?',
+ placeholderColor: '#F5F6F8',
+ authorNickname: '새벽두시',
+ authorAvatarColor: '#DDE4EE',
+ reactionCount: 198,
+ },
+ {
+ id: 'cp-4',
+ kind: 'clip',
+ placeholderColor: '#E8E4EC',
+ authorNickname: '무명연대장',
+ authorAvatarColor: '#D5CEDC',
+ reactionCount: 163,
+ },
+]
+
+export const gameNews: NewsItem[] = [
+ { id: 'gn-1', title: '9월 정기점검 안내', date: '09.02', isNew: true },
+ { id: 'gn-2', title: '비매너 이용자 제재 결과', date: '09.01', isNew: true },
+ { id: 'gn-3', title: '가을 시즌 랭크전 일정', date: '08.29', isNew: false },
+ { id: 'gn-4', title: '신규 맵 컨테이너 야드 추가', date: '08.28', isNew: false },
+ { id: 'gn-5', title: 'AK-47 반동 수치 조정', date: '08.26', isNew: false },
+ { id: 'gn-6', title: '클랜전 매칭 로직 개선', date: '08.22', isNew: false },
+]
+
+export const serviceNews: NewsItem[] = [
+ { id: 'sn-1', title: '맵별 승률 통계를 열었어요', date: '09.03', isNew: true },
+ { id: 'sn-2', title: '전적 갱신이 두 배 빨라졌어요', date: '09.01', isNew: true },
+ { id: 'sn-3', title: '즐겨찾기 알림 기능 추가', date: '08.30', isNew: false },
+ { id: 'sn-4', title: '8월 서버 점검 결과 보고', date: '08.27', isNew: false },
+ { id: 'sn-5', title: '커뮤니티 이용 규칙 정리', date: '08.24', isNew: false },
+ { id: 'sn-6', title: '모바일 화면을 다듬었어요', date: '08.20', isNew: false },
+]
+
+export const liveStreams: StreamChannel[] = [
+ {
+ id: 'st-1',
+ title: '삼보급 스나 연습',
+ streamerName: '달빛조각사',
+ viewerCount: 1204,
+ placeholderColor: '#E4E8ED',
+ streamerAvatarColor: '#DDE4EE',
+ },
+ {
+ id: 'st-2',
+ title: '클랜전 정기 리그 중계',
+ streamerName: '무명연대',
+ viewerCount: 862,
+ placeholderColor: '#EAE6E0',
+ streamerAvatarColor: '#D8E6E2',
+ },
+ {
+ id: 'st-3',
+ title: '초보 탈출 폭파미션 강의',
+ streamerName: '새벽두시',
+ viewerCount: 517,
+ placeholderColor: '#E2E9E6',
+ streamerAvatarColor: '#E7E3D8',
+ },
+ {
+ id: 'st-4',
+ title: '시청자 참여 한 판',
+ streamerName: '한밤의저격수',
+ viewerCount: 341,
+ placeholderColor: '#E8E4EC',
+ streamerAvatarColor: '#E5E0F0',
+ },
+]
diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue
index 879e215..953cb96 100644
--- a/frontend/src/pages/HomePage.vue
+++ b/frontend/src/pages/HomePage.vue
@@ -1,27 +1,235 @@
-
- {{ message }}
-
- Visit
- vuejs.org
- to read the documentation
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/shared/utils/format.ts b/frontend/src/shared/utils/format.ts
index 063be41..9fed7a6 100644
--- a/frontend/src/shared/utils/format.ts
+++ b/frontend/src/shared/utils/format.ts
@@ -18,3 +18,21 @@ export function formatDate(input: Date | string): string {
const dd = String(date.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
+
+/**
+ * Date 를 'HH:mm' 형식으로 변환 (예: 랭킹 집계 기준 시각)
+ */
+export function formatClockTime(input: Date | string): string {
+ const date = typeof input === 'string' ? new Date(input) : input
+ if (Number.isNaN(date.getTime())) return ''
+ const hh = String(date.getHours()).padStart(2, '0')
+ const mm = String(date.getMinutes()).padStart(2, '0')
+ return `${hh}:${mm}`
+}
+
+/**
+ * 숫자에 천 단위 구분을 넣는다 (예: 1204 → "1,204")
+ */
+export function formatCount(value: number): string {
+ return value.toLocaleString('ko-KR')
+}
diff --git a/frontend/src/types/home.ts b/frontend/src/types/home.ts
new file mode 100644
index 0000000..c8240d2
--- /dev/null
+++ b/frontend/src/types/home.ts
@@ -0,0 +1,130 @@
+/**
+ * 홈 화면 도메인 타입 — .design/surinz 홈 아트보드(Main · Tablet · Mobile)의 화면 요소를 그대로 옮긴 것.
+ * 백엔드 응답 스키마가 확정되면 이 파일만 맞추면 된다.
+ */
+
+/** 등락 방향 — 상승 / 하락 / 변동 없음 */
+export type MovementDirection = 'up' | 'down' | 'same'
+
+/** 순위 등락 — 변동 없음이면 amount 는 0 */
+export interface RankMovement {
+ direction: MovementDirection
+ amount: number
+}
+
+/** 계급 자리표시 종류 — 실제 계급 이미지가 없을 때 그리는 약식 표장 */
+export type RankGradeShape = 'bar' | 'star' | 'diamond'
+
+/** 이벤트 배너 */
+export interface EventBanner {
+ id: string
+ /** 이벤트 분류 (예: 시즌 이벤트) */
+ category: string
+ title: string
+ /** 노출 기간 문구 */
+ period: string
+ /** 배너 이미지. 없으면 자리표시 색을 쓴다 */
+ imageUrl?: string
+ /** 이미지가 없을 때 채울 자리표시 색 */
+ placeholderColor?: string
+}
+
+/** 검색 랭킹 한 줄 */
+export interface SearchRankEntry {
+ rank: number
+ nickname: string
+ movement: RankMovement
+}
+
+/** 계급 · 랭크전 랭킹 한 줄 */
+export interface UserRankEntry {
+ rank: number
+ nickname: string
+ /** 계급 이미지. 없으면 gradeShape 로 약식 표장을 그린다 */
+ gradeImageUrl?: string
+ gradeShape: RankGradeShape
+ /**
+ * 랭크전 티어 이미지 — 랭크전 랭킹에만 있다 (RP 와 한 칸에 붙는다).
+ * 없으면 육각 자리표시를 그린다.
+ */
+ tierImageUrl?: string
+ /** 랭크전 랭킹에만 있는 RP 점수 */
+ rp?: number
+ movement: RankMovement
+}
+
+/** 클랜 랭킹 한 줄 */
+export interface ClanRankEntry {
+ rank: number
+ clanName: string
+ /** 클랜 마크 이미지. 없으면 방패 자리표시를 그린다 */
+ markImageUrl?: string
+ movement: RankMovement
+}
+
+/** 게임 랭킹 갈래 — 세 갈래를 나란히 두거나(데스크톱) 하나씩 고른다(태블릿·모바일) */
+export type GameRankBranch = 'tier' | 'ranked' | 'clan'
+
+/** 갈래 안의 하위 탭 (예: 통합 / 시즌) */
+export interface RankTabOption {
+ value: string
+ label: string
+}
+
+/** 게임 랭킹 묶음 — 세 갈래가 같은 순위 구간을 함께 본다 */
+export interface GameRanking {
+ tier: UserRankEntry[]
+ ranked: UserRankEntry[]
+ clan: ClanRankEntry[]
+ /** 전체 순위 수 — 구간 이동(페이지네이션) 계산에 쓴다 */
+ totalCount: number
+ /** 한 구간에 보여 주는 순위 수 */
+ pageSize: number
+}
+
+/** 커뮤니티 글 종류 — 사진 / 글 / 클립(영상) / 여러 장 */
+export type CommunityPostKind = 'photo' | 'text' | 'clip' | 'album'
+
+/** 커뮤니티 글 */
+export interface CommunityPost {
+ id: string
+ kind: CommunityPostKind
+ /** 글 종류일 때 카드에 그대로 보이는 본문 */
+ excerpt?: string
+ thumbnailUrl?: string
+ /** 썸네일이 없을 때 채울 자리표시 색 */
+ placeholderColor: string
+ authorNickname: string
+ authorAvatarUrl?: string
+ /** 프로필 이미지가 없을 때 채울 자리표시 색 */
+ authorAvatarColor: string
+ reactionCount: number
+}
+
+/** 소식 한 줄 (서든어택 · 서린즈 공통) */
+export interface NewsItem {
+ id: string
+ title: string
+ /** 노출용 날짜 문구 (MM.DD) */
+ date: string
+ isNew: boolean
+}
+
+/** 방송 중인 채널 */
+export interface StreamChannel {
+ id: string
+ title: string
+ streamerName: string
+ viewerCount: number
+ thumbnailUrl?: string
+ placeholderColor: string
+ streamerAvatarUrl?: string
+ streamerAvatarColor: string
+}
+
+/** 최근 검색 기록 칩 */
+export interface RecentSearchItem {
+ nickname: string
+ avatarUrl?: string
+ avatarColor: string
+}