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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ 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 +}