Skip to content

Commit c907837

Browse files
authored
Merge pull request #240 from DDD-Community/feat/home-banner
feat: Home 에 친밀도 별 영상배너 기능 추가
2 parents 5f4f789 + c64eb46 commit c907837

File tree

17 files changed

+217
-59
lines changed

17 files changed

+217
-59
lines changed

eslint.config.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ const compat = new FlatCompat({
1414

1515
const eslintConfig = [
1616
...compat.extends("next/core-web-vitals", "next/typescript"),
17-
...storybook.configs["flat/recommended"]
17+
...storybook.configs["flat/recommended"],
18+
{
19+
rules: {
20+
"@typescript-eslint/no-empty-object-type": "off",
21+
},
22+
},
1823
];
1924

2025
export default eslintConfig;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client';
2+
3+
import { useEffect, useMemo, useState } from 'react';
4+
import { useQuery } from '@tanstack/react-query';
5+
import { AdviceQuery } from '@/model/advice/queries';
6+
import { GrorongMood } from '@/model/advice/types';
7+
import { BANNER_MESSAGES } from './constants';
8+
import { getDailyRandomIndex, getVideoUrl } from './utils';
9+
10+
export const HomeBanner = () => {
11+
const { data: advice } = useQuery(AdviceQuery.getGrorongAdvice());
12+
const [bannerIndex, setBannerIndex] = useState<number>(0);
13+
const [isClient, setIsClient] = useState(false);
14+
15+
const mood: GrorongMood = advice?.mood ?? 'NORMAL';
16+
17+
useEffect(() => {
18+
setIsClient(true);
19+
setBannerIndex(getDailyRandomIndex(mood));
20+
}, [mood]);
21+
22+
const videoUrl = useMemo(() => getVideoUrl(mood, bannerIndex), [mood, bannerIndex]);
23+
const bannerMessage = useMemo(() => BANNER_MESSAGES[mood][bannerIndex]?.message || '', [mood, bannerIndex]);
24+
25+
// NEXT 하이드레이션 미스매칭 방지를 위한 코드
26+
// 서버와 클라이언트가 getDailyRandomIndex() 실행 시 다른 값을 반환하면 하이드레이션 에러 발생
27+
if (!isClient) {
28+
return (
29+
<div className="relative w-full h-[168px] bg-[#0F0F10]">
30+
<div className="absolute inset-0 animate-pulse bg-gray-800" />
31+
</div>
32+
);
33+
}
34+
35+
return (
36+
<div className="relative w-full h-[168px] overflow-hidden">
37+
<video className="absolute inset-0 w-full h-full object-cover" src={videoUrl} autoPlay loop muted playsInline />
38+
{bannerMessage && (
39+
<div className="absolute top-1/4 left-4 z-10 max-w-[70%]">
40+
<p className="text-[18px] font-bold text-white leading-tight whitespace-pre-line">{bannerMessage}</p>
41+
</div>
42+
)}
43+
</div>
44+
);
45+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { GrorongMood } from '@/model/advice/types';
2+
3+
export const MOOD_TO_VIDEO_PREFIX: Record<GrorongMood, string> = {
4+
HAPPY: 'high',
5+
NORMAL: 'med',
6+
SAD: 'low',
7+
};
8+
9+
export const VIDEO_COUNT = 6;
10+
11+
export const BANNER_STORAGE_KEY = 'home-banner-daily-index';
12+
13+
export type BannerContent = {
14+
message: string;
15+
};
16+
17+
export type BannerData = Record<GrorongMood, BannerContent[]>;
18+
19+
/**
20+
* 영상은 CLOUDFRONT_VIDEO_URL + `/home-banner/Closeness%20{prefix}-{01~06}.mp4` 형식으로 자동 생성됨
21+
*/
22+
export const BANNER_MESSAGES: BannerData = {
23+
HAPPY: [
24+
{ message: '성실한 너를 위해\n꽃다발을 준비했어..' },
25+
{ message: '안녕!\n내 하트를 받아줄래?' },
26+
{ message: '너 덕분에\n그로롱 성실도 +100' },
27+
{ message: '내 친구왔다!\n모두 소리질러~!' },
28+
{ message: '너가 열심히 하니까\n내 맘이 평화로워' },
29+
{ message: '매일매일이 생일 같아\n너무 고마워!' },
30+
],
31+
NORMAL: [
32+
{ message: '지금처럼 와주면\n그로롱 몸짱될 예정!' },
33+
{ message: '나도 너를 본받아\n열심히 공부하고 있어' },
34+
{ message: '안녕\n너를 봐서 설레!' },
35+
{ message: '너가 와서 신나는\n이 맘을 춤으로 표현할게' },
36+
{ message: '요즘 너의 목표 현황\n완전 꿀잼이야' },
37+
{ message: '너가 온 날은\n나에게 봄날같아' },
38+
],
39+
SAD: [
40+
{ message: '너가 없는 시간은\n365일 겨울같아' },
41+
{ message: '빨리 와서 구해줘\n돌이 되기 직전이야' },
42+
{ message: '500만년만에 와서\n그로롱 해골 됨' },
43+
{ message: '기다렸는데,,\n외로웠는데,,' },
44+
{ message: '안 오는거야?\n나 흑화했어' },
45+
{ message: '네가 없는 거리에는\n내가 할일이 없어서' },
46+
],
47+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { HomeBanner } from './HomeBanner';
2+
export { BANNER_MESSAGES } from './constants';
3+
export type { BannerContent, BannerData } from './constants';
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { GrorongMood } from '@/model/advice/types';
2+
import { BANNER_STORAGE_KEY, MOOD_TO_VIDEO_PREFIX, VIDEO_COUNT } from './constants';
3+
4+
type StoredBannerIndex = {
5+
date: string;
6+
indices: Record<GrorongMood, number>;
7+
};
8+
9+
/**
10+
* 오늘 날짜를 YYYY-MM-DD 형식으로 반환
11+
*/
12+
const getTodayString = (): string => {
13+
return new Date().toISOString().split('T')[0];
14+
};
15+
16+
/**
17+
* 0부터 max-1까지의 랜덤 정수 반환
18+
*/
19+
const getRandomIndex = (max: number): number => {
20+
return Math.floor(Math.random() * max);
21+
};
22+
23+
/**
24+
* 하루에 한 번 랜덤으로 선택된 인덱스를 반환
25+
* localStorage에 저장하여 같은 날에는 동일한 인덱스 유지
26+
*/
27+
export const getDailyRandomIndex = (mood: GrorongMood): number => {
28+
if (typeof window === 'undefined') {
29+
return 0;
30+
}
31+
32+
const today = getTodayString();
33+
const stored = localStorage.getItem(BANNER_STORAGE_KEY);
34+
35+
if (stored) {
36+
try {
37+
const parsed: StoredBannerIndex = JSON.parse(stored);
38+
39+
if (parsed.date === today && parsed.indices[mood] !== undefined) {
40+
return parsed.indices[mood];
41+
}
42+
} catch {
43+
// 파싱 실패 시 새로 생성
44+
}
45+
}
46+
47+
// 새로운 날이거나 저장된 값이 없는 경우
48+
const newIndices: Record<GrorongMood, number> = {
49+
HAPPY: getRandomIndex(VIDEO_COUNT),
50+
NORMAL: getRandomIndex(VIDEO_COUNT),
51+
SAD: getRandomIndex(VIDEO_COUNT),
52+
};
53+
54+
const newStored: StoredBannerIndex = {
55+
date: today,
56+
indices: newIndices,
57+
};
58+
59+
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(newStored));
60+
61+
return newIndices[mood];
62+
};
63+
64+
/**
65+
* mood와 인덱스를 기반으로 비디오 URL 생성
66+
* 형식: {CLOUDFRONT_VIDEO_URL}/home-banner/Closeness%20{high|low|mad}-{01~06}.mp4
67+
*/
68+
export const getVideoUrl = (mood: GrorongMood, index: number): string => {
69+
const baseUrl = process.env.NEXT_PUBLIC_CLOUDFRONT_VIDEO_URL;
70+
const prefix = MOOD_TO_VIDEO_PREFIX[mood];
71+
const paddedIndex = String(index + 1).padStart(2, '0');
72+
73+
return `${baseUrl}/home-banner/Closeness%20${prefix}-${paddedIndex}.mp4`;
74+
};

src/composite/home/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { GoalRoadMap } from './goalRoadMap/component';
22
export { ContributionGraph } from './contributionGraph/component';
33
export { GoalBanner } from './goalBanner/component';
44
export { TodoListContainer } from './todoListContainer';
5+
export { HomeBanner } from './homeBanner';

src/composite/home/todoListContainer/TodoListContainer.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import { Z_INDEX } from '@/shared/lib/z-index';
77
import { TodoList } from '@/feature/todo/todoList';
88
import { TodoBottomSheet } from '@/feature/todo/todoBottomSheet';
99
import { Calendar } from '@/feature/todo/calendar';
10-
import { CheerMessageCard } from './components/cheerMessageCard';
1110
import { AddGoalButton } from './components/addGoalButton';
1211
import { TodoListContainerFormProvider } from './form';
1312
import { convertToFormData, getEditingTodoDefault } from './helper';
13+
import { HomeBanner } from '../homeBanner';
1414

1515
export const TodoListContainer = () => {
1616
const addSheet = useBottomSheet();
@@ -33,8 +33,7 @@ export const TodoListContainer = () => {
3333

3434
return (
3535
<div className="relative w-full">
36-
{!isMonthlyView && <CheerMessageCard type="grorong" />}
37-
36+
<HomeBanner />
3837
<div
3938
className={`absolute left-0 right-0 mx-auto bg-[#0F0F10] shadow-xl transition-all duration-300 ease-in-out ${Z_INDEX.CONTENT} ${
4039
isMonthlyView ? 'top-0 rounded-none' : 'top-[140px] rounded-t-3xl'

src/composite/home/todoListContainer/components/cheerMessageCard/CheerMessageCard.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
'use client';
22

3+
import { useQuery } from '@tanstack/react-query';
34
import { GrorongCard } from '@/feature/home/GrorongCard';
45
import { AIMentorCard } from '@/feature/home/AIMentorCard';
56
import { useAIMentorAdvice } from '@/model/aiMentor/context';
6-
import { useGrorongAdvice } from './hooks';
7+
import { AdviceQuery } from '@/model/advice/queries';
78
import { AttendanceStreakPopup } from '@/feature/todo/weeklyTodoList/components/AttendanceStreakPopup';
89

10+
/**
11+
* @deprecated HomeBanner 컴포넌트를 사용해주세요.
12+
* @see {@link @/composite/home/homeBanner/HomeBanner}
13+
*/
914
export const CheerMessageCard = ({ type }: { type: 'grorong' | 'aiMentor' }) => {
10-
const { advice } = useGrorongAdvice();
15+
const { data: advice } = useQuery(AdviceQuery.getGrorongAdvice());
1116
const { aiMentorAdvice } = useAIMentorAdvice();
1217

1318
const renderCard = () => {

src/composite/home/todoListContainer/components/cheerMessageCard/api.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/composite/home/todoListContainer/components/cheerMessageCard/hooks.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.

0 commit comments

Comments
 (0)