From d1ec5d648e5bf62d3830ccc966f006366f080fb6 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 10 May 2026 01:57:59 +0900 Subject: [PATCH 01/18] =?UTF-8?q?refactor(festival):=20PerformanceList=20p?= =?UTF-8?q?rops=20=EA=B8=B0=EB=B0=98=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit performances, festivalDate를 optional props로 추가해 다른 축제 페이지에서도 재사용 가능하도록 개선. 기존 IntroductionPage는 기본값으로 동작이 유지된다. --- .../PerformanceList/PerformanceList.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/FestivalPage/components/PerformanceList/PerformanceList.tsx b/frontend/src/pages/FestivalPage/components/PerformanceList/PerformanceList.tsx index 775807458..bb0c623f6 100644 --- a/frontend/src/pages/FestivalPage/components/PerformanceList/PerformanceList.tsx +++ b/frontend/src/pages/FestivalPage/components/PerformanceList/PerformanceList.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import styled from 'styled-components'; -import { performances } from '../../data/performances'; +import type { Performance } from '../../data/performances'; +import { performances as defaultPerformances } from '../../data/performances'; import { useCurrentTime } from '../../hooks/useCurrentTime'; import PerformanceCard from '../PerformanceCard/PerformanceCard'; import TimelineRow from '../TimelineRow/TimelineRow'; @@ -31,13 +32,25 @@ const List = styled.div` gap: 8px; `; -const PerformanceList = () => { +interface PerformanceListProps { + performances?: Performance[]; + festivalDate?: string; // 'YYYY-MM-DD', 생략 시 2026-03-05 기본값 +} + +const PerformanceList = ({ + performances = defaultPerformances, + festivalDate = '2026-03-05', +}: PerformanceListProps) => { const currentTime = useCurrentTime(); const currentMinutes = toMinutes(currentTime); const activeRef = useRef(null); + const now = new Date(); + const [year, month, day] = festivalDate.split('-').map(Number); const isFestivalDate = - now.getFullYear() === 2026 && now.getMonth() === 2 && now.getDate() === 5; + now.getFullYear() === year && + now.getMonth() === month - 1 && + now.getDate() === day; useEffect(() => { if (activeRef.current) { From 7e3e7efbdf1e82e11d8f6e735b6c1b190c01eb99 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 10 May 2026 01:58:09 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat(festival):=20PerformanceCard=20?= =?UTF-8?q?=EA=B3=A1=20=EB=AF=B8=EA=B3=B5=EA=B0=9C=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit songs가 비어있을 때 '🎵 추후 공개 예정' 텍스트를 표시하고, chevron 아이콘 숨김 및 카드 클릭 비활성화 처리. --- .../PerformanceCard/PerformanceCard.tsx | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/FestivalPage/components/PerformanceCard/PerformanceCard.tsx b/frontend/src/pages/FestivalPage/components/PerformanceCard/PerformanceCard.tsx index 9f16294bb..759c01fe3 100644 --- a/frontend/src/pages/FestivalPage/components/PerformanceCard/PerformanceCard.tsx +++ b/frontend/src/pages/FestivalPage/components/PerformanceCard/PerformanceCard.tsx @@ -28,16 +28,21 @@ const PerformanceCard = ({ performance, active }: PerformanceCardProps) => { setExpanded(nextExpanded); }; + const hasSongs = performance.songs.length > 0; + return ( - + {performance.clubName}
- {performance.songs[0]} + {hasSongs ? performance.songs[0] : '🎵 추후 공개 예정'} - {performance.songs.length > 1 && ( + {hasSongs && performance.songs.length > 1 && ( { )}
- - - - - + {hasSongs && ( + + + + + + )}
); From 3ab456d624337556f427aa37715b93866bf40221 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 10 May 2026 01:58:14 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(festival):=202026=20=EB=8C=80?= =?UTF-8?q?=EB=8F=99=EC=A0=9C=20=EB=B2=84=EC=8A=A4=ED=82=B9=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /festival-busking 라우트 추가 - 날짜 네비게이션 A/B 실험 구현 (tabs vs arrows, 50:50) - framer-motion onPanEnd 기반 swipe 제스처 지원 - interaction: 'click' | 'swipe' 속성으로 Mixpanel 이벤트 구분 - performances가 없는 날짜는 탭에서 자동 제외 - 미사용 실험(mainBanner, applyButtonCopy) 제거 --- .../features/festival/busking-timetable.md | 54 ++++ frontend/src/constants/eventName.ts | 5 + frontend/src/experiments/definitions.ts | 29 +-- .../BuskingPage/BuskingPage.styles.ts | 48 ++++ .../FestivalPage/BuskingPage/BuskingPage.tsx | 134 ++++++++++ .../DayArrowsNav/DayArrowsNav.styles.ts | 44 ++++ .../components/DayArrowsNav/DayArrowsNav.tsx | 37 +++ .../components/DayTabsNav/DayTabsNav.tsx | 19 ++ .../pages/FestivalPage/data/buskingDays.ts | 246 ++++++++++++++++++ frontend/src/routes/AppRoutes.tsx | 9 + 10 files changed, 604 insertions(+), 21 deletions(-) create mode 100644 frontend/docs/features/festival/busking-timetable.md create mode 100644 frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.styles.ts create mode 100644 frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.tsx create mode 100644 frontend/src/pages/FestivalPage/components/DayArrowsNav/DayArrowsNav.styles.ts create mode 100644 frontend/src/pages/FestivalPage/components/DayArrowsNav/DayArrowsNav.tsx create mode 100644 frontend/src/pages/FestivalPage/components/DayTabsNav/DayTabsNav.tsx create mode 100644 frontend/src/pages/FestivalPage/data/buskingDays.ts diff --git a/frontend/docs/features/festival/busking-timetable.md b/frontend/docs/features/festival/busking-timetable.md new file mode 100644 index 000000000..7c6db2077 --- /dev/null +++ b/frontend/docs/features/festival/busking-timetable.md @@ -0,0 +1,54 @@ +# 2026 대동제 버스킹 시간표 페이지 + +4일간 진행되는 버스킹 공연 시간표를 제공하는 페이지. 날짜 네비게이션 방식을 A/B 테스트로 검증한다. + +## 라우트 + +`/festival-busking` → `BuskingPage` + +## 페이지 구조 + +``` +BuskingPage +├── DayTabsNav (variant: 'tabs') — UnderlineTabs로 날짜 4개 탭 +├── DayArrowsNav (variant: 'arrows') — ‹ 날짜 › 화살표 네비게이션 +└── PerformanceList — 날짜별 공연 목록 (공통 컴포넌트) + └── TimelineRow + PerformanceCard +``` + +## A/B 실험 + +| 항목 | 내용 | +| --------- | --------------------------- | +| 실험 키 | `festival_timetable_nav_v1` | +| Variant A | `tabs` — 날짜 4개 탭 | +| Variant B | `arrows` — 화살표 순차 이동 | +| 비율 | 50 : 50 | + +실험 정의: `src/experiments/definitions.ts` → `festivalTimetableNavExperiment` + +## Mixpanel 이벤트 + +| 이벤트명 | 트리거 | 주요 속성 | +| ----------------------------------- | ----------------------- | -------------------------------------------------- | +| `2026-daedong 버스킹 시간표 페이지` | 페이지 진입 | — | +| `2026-daedong Day Changed` | 날짜 전환 | `from_day`, `to_day`, `nav_variant`, `interaction` | +| `2026-daedong Day Duration` | 날짜 이탈 / 페이지 이탈 | `day`, `nav_variant`, `duration_seconds` | + +`interaction` 값: `'click'` (버튼) / `'swipe'` (터치 제스처) + +## 데이터 + +`src/pages/FestivalPage/data/buskingDays.ts` — `BUSKING_DAYS: FestivalDay[]` + +- `performances: []`인 날짜는 자동으로 탭/화살표에서 제외 +- 오늘 날짜가 축제 기간이면 해당 날짜로 자동 진입 + +## 관련 코드 + +- `src/pages/FestivalPage/BuskingPage/BuskingPage.tsx` — 메인 페이지, 실험 분기 및 이벤트 트래킹 +- `src/pages/FestivalPage/components/DayTabsNav/DayTabsNav.tsx` — tabs variant +- `src/pages/FestivalPage/components/DayArrowsNav/DayArrowsNav.tsx` — arrows variant +- `src/pages/FestivalPage/data/buskingDays.ts` — 4일치 공연 데이터 +- `src/pages/FestivalPage/components/PerformanceList/PerformanceList.tsx` — `performances`, `festivalDate` props 지원 (기존 IntroductionPage와 공유) +- `src/pages/FestivalPage/components/PerformanceCard/PerformanceCard.tsx` — `songs: []`이면 "🎵 추후 공개 예정" 표시 diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index afc957bbc..48d89a1cb 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -61,6 +61,10 @@ export const USER_EVENT = { FESTIVAL_PERFORMANCE_CARD_CLICKED: 'Festival PerformanceCard Clicked', FESTIVAL_TAB_DURATION: 'Festival Tab Duration', + // 버스킹 시간표 + DAEDONG2026_DAY_CHANGED: '2026-daedong Day Changed', + DAEDONG2026_DAY_DURATION: '2026-daedong Day Duration', + // 홍보 PROMOTION_BUTTON_CLICKED: 'Promotion Button Clicked', PROMOTION_CARD_CLICKED: 'Promotion Card Clicked', @@ -125,6 +129,7 @@ export const PAGE_VIEW = { INTRODUCE_PAGE: 'IntroducePage', CLUB_UNION_PAGE: 'ClubUnionPage', FESTIVAL_INTRODUCTION_PAGE: '동소한 페이지', + DAEDONG2026_BUSKING_PAGE: '2026-daedong 버스킹 시간표 페이지', PROMOTION_LIST_PAGE: '홍보 목록 페이지', PROMOTION_DETAIL_PAGE: '홍보 상세 페이지', diff --git a/frontend/src/experiments/definitions.ts b/frontend/src/experiments/definitions.ts index 6d1481338..08d136504 100644 --- a/frontend/src/experiments/definitions.ts +++ b/frontend/src/experiments/definitions.ts @@ -1,26 +1,13 @@ import type { ExperimentDefinition } from './types'; -export const mainBannerExperiment = { - key: 'main_banner_v1', - variants: ['A', 'B'] as const, - defaultVariant: 'A', +export const festivalTimetableNavExperiment = { + key: 'festival_timetable_nav_v1', + variants: ['tabs', 'arrows'] as const, + defaultVariant: 'tabs', weights: { - A: 50, - B: 50, + tabs: 50, + arrows: 50, }, -} satisfies ExperimentDefinition<'A' | 'B'>; +} satisfies ExperimentDefinition<'tabs' | 'arrows'>; -export const applyButtonCopyExperiment = { - key: 'apply_button_copy_v1', - variants: ['A', 'B'] as const, - defaultVariant: 'A', - weights: { - A: 50, - B: 50, - }, -} satisfies ExperimentDefinition<'A' | 'B'>; - -export const ALL_EXPERIMENTS = [ - mainBannerExperiment, - applyButtonCopyExperiment, -] as const; +export const ALL_EXPERIMENTS = [festivalTimetableNavExperiment] as const; diff --git a/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.styles.ts b/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.styles.ts new file mode 100644 index 000000000..7ccaef7d0 --- /dev/null +++ b/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.styles.ts @@ -0,0 +1,48 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; + +export const Container = styled.div` + width: 100%; + max-width: 550px; + margin: 0 auto; + padding-top: 92px; + + ${media.mobile} { + padding-top: 70px; + } +`; + +export const NavWrapper = styled.div` + margin-bottom: 16px; +`; + +export const TimetableSection = styled.section` + width: 100%; + max-width: 500px; + margin: 0 auto 20px; +`; + +export const TimetableHeader = styled.div` + margin: 10px 20px 14px; + padding: 14px 16px; + border-radius: 14px; + background: #fff7f3; + border: 1px solid #ffe0d4; + display: flex; + flex-direction: column; + gap: 4px; +`; + +export const TimetableDate = styled.p` + margin: 0; + color: #3a3a3a; + font-size: 14px; + font-weight: 700; +`; + +export const TimetableLocation = styled.p` + margin: 0; + color: #7a7a7a; + font-size: 12px; + font-weight: 600; +`; diff --git a/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.tsx b/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.tsx new file mode 100644 index 000000000..6f42e1821 --- /dev/null +++ b/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import Footer from '@/components/common/Footer/Footer'; +import Header from '@/components/common/Header/Header'; +import { PAGE_VIEW, USER_EVENT } from '@/constants/eventName'; +import { festivalTimetableNavExperiment } from '@/experiments/definitions'; +import { useExperimentVariant } from '@/hooks/Experiment/useExperimentVariant'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import isInAppWebView from '@/utils/isInAppWebView'; +import DayArrowsNav from '../components/DayArrowsNav/DayArrowsNav'; +import DayTabsNav from '../components/DayTabsNav/DayTabsNav'; +import PerformanceList from '../components/PerformanceList/PerformanceList'; +import { BUSKING_DAYS } from '../data/buskingDays'; +import * as Styled from './BuskingPage.styles'; + +const availableDays = BUSKING_DAYS.filter((d) => d.performances.length > 0); + +const getInitialDayId = (): string => { + const now = new Date(); + const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + return ( + availableDays.find((d) => d.date === todayStr)?.id ?? + availableDays[0]?.id ?? + BUSKING_DAYS[0].id + ); +}; + +const BuskingPage = () => { + useTrackPageView(PAGE_VIEW.DAEDONG2026_BUSKING_PAGE); + const trackEvent = useMixpanelTrack(); + const navVariant = useExperimentVariant(festivalTimetableNavExperiment); + + const [activeDayId, setActiveDayId] = useState(getInitialDayId); + const dayStartTime = useRef(Date.now()); + const activeDayIdRef = useRef(activeDayId); + + const activeDay = BUSKING_DAYS.find((d) => d.id === activeDayId)!; + + useEffect(() => { + activeDayIdRef.current = activeDayId; + }, [activeDayId]); + + useEffect(() => { + return () => { + const duration = Date.now() - dayStartTime.current; + trackEvent(USER_EVENT.DAEDONG2026_DAY_DURATION, { + day: activeDayIdRef.current, + nav_variant: navVariant, + duration, + duration_seconds: Math.round(duration / 1000), + }); + }; + }, []); + + const handleDayChange = ( + dayId: string, + interaction: 'click' | 'swipe' = 'click', + ) => { + const duration = Date.now() - dayStartTime.current; + trackEvent(USER_EVENT.DAEDONG2026_DAY_DURATION, { + day: activeDayId, + nav_variant: navVariant, + duration, + duration_seconds: Math.round(duration / 1000), + }); + trackEvent(USER_EVENT.DAEDONG2026_DAY_CHANGED, { + from_day: activeDayId, + to_day: dayId, + nav_variant: navVariant, + interaction, + }); + dayStartTime.current = Date.now(); + setActiveDayId(dayId); + }; + + const handleSwipe = (direction: 'left' | 'right') => { + const currentIndex = availableDays.findIndex((d) => d.id === activeDayId); + if (direction === 'left' && currentIndex < availableDays.length - 1) { + handleDayChange(availableDays[currentIndex + 1].id, 'swipe'); + } else if (direction === 'right' && currentIndex > 0) { + handleDayChange(availableDays[currentIndex - 1].id, 'swipe'); + } + }; + + return ( + <> +
+ + + {navVariant === 'tabs' ? ( + + ) : ( + + )} + + { + const SWIPE_THRESHOLD = 50; + if (info.offset.x < -SWIPE_THRESHOLD) handleSwipe('left'); + else if (info.offset.x > SWIPE_THRESHOLD) handleSwipe('right'); + }} + > + + + + {activeDay.fullDateLabel} {activeDay.timeRange} + + + 공연 장소: {activeDay.location} + + + + + + + {!isInAppWebView() &&