diff --git a/frontend/docs/features/festival/busking-timetable.md b/frontend/docs/features/festival/busking-timetable.md new file mode 100644 index 000000000..3fbcb972c --- /dev/null +++ b/frontend/docs/features/festival/busking-timetable.md @@ -0,0 +1,67 @@ +# 2026 대동제 버스킹 시간표 페이지 + +4일간 진행되는 버스킹 공연 시간표를 제공하는 페이지. 날짜 네비게이션 방식을 A/B 테스트로 검증한다. + +## 라우트 + +`/festival-busking` → `BuskingPage` + +## 페이지 구조 + +``` +BuskingPage +├── DayTabsNav (variant: 'tabs') — UnderlineTabs로 날짜 탭 +├── DayArrowsNav (variant: 'arrows') — ‹ 날짜 › 화살표 네비게이션 +├── SectionLabel "동아리 공연" — 두 섹션 모두 있을 때만 표시 +├── PerformanceList (performances) — 동아리 공연 목록 +├── SectionLabel "🎤 아티스트 공연" +└── PerformanceList (mainStagePerformances, hideSongs) — 아티스트 공연 목록 + └── 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: []`이고 `mainStagePerformances`도 없는 날짜는 탭/화살표에서 제외 +- 오늘 날짜가 축제 기간이면 해당 날짜로 자동 진입 +- `mainStagePerformances`: 동아리 공연 종료 후 진행되는 아티스트 공연 (Day1~4 모두 포함) + +### 아티스트 공연 라인업 + +| 날짜 | 아티스트 | +| ---- | ------------------- | +| Day1 | YB | +| Day2 | 최예나, 이창섭 | +| Day3 | FIFTY FIFTY, 비와이 | +| Day4 | V.O.S, 청하 | + +## 관련 코드 + +- `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일치 공연 데이터 (`performances` + `mainStagePerformances`) +- `src/pages/FestivalPage/components/PerformanceList/PerformanceList.tsx` — `performances`, `festivalDate`, `hideSongs` props 지원 +- `src/pages/FestivalPage/components/PerformanceCard/PerformanceCard.tsx` — `hideSongs`이면 곡목 영역 숨김, `songs: []`이면 "🎵 추후 공개 예정" 표시 diff --git a/frontend/docs/features/main/filter.md b/frontend/docs/features/main/filter.md new file mode 100644 index 000000000..cde0cb45b --- /dev/null +++ b/frontend/docs/features/main/filter.md @@ -0,0 +1,12 @@ +# Filter 칩 NotificationDot — 대동제 세션 기반 표시 + +`Filter` 컴포넌트의 `NotificationDot`은 두 가지 조건으로 표시된다. + +- `홍보`: `usePromotionNotification` 훅이 반환하는 서버 데이터 기반 +- `대동제`: 세션 기반 — 첫 방문 시 항상 표시, 칩 클릭 시 사라짐 (탭 종료 후 재표시) + +`대동제` dot 상태는 `Filter` 내부에서 `sessionStorage('daedong_filter_seen')`으로 자체 관리하며, `MainPage.tsx`나 Filter props 변경 없이 동작한다. + +## 관련 코드 + +- `src/components/common/Filter/Filter.tsx` — `daedongDotSeen` state, `handleFilterOptionClick` 내 sessionStorage 처리 diff --git a/frontend/docs/features/main/popup.md b/frontend/docs/features/main/popup.md new file mode 100644 index 000000000..d99ef136c --- /dev/null +++ b/frontend/docs/features/main/popup.md @@ -0,0 +1,34 @@ +# Popup — 다중 팝업 지원 구조 + +메인 페이지에서 노출되는 팝업 시스템. `PopupConfig` 배열로 여러 팝업을 관리하며, 첫 번째 eligible 팝업을 자동으로 표시한다. + +## 구조 + +- `configs: PopupConfig[]`를 prop으로 받아, 조건을 통과하는 첫 번째 팝업을 표시 +- 각 팝업은 독립적인 `storageKey` / `sessionKey`를 가짐 +- `daysToHide: 0`으로 설정하면 "다시 보지 않기" 클릭 후에도 항상 재표시 + +## PopupConfig 필드 + +| 필드 | 타입 | 설명 | +| -------------- | ---------------------- | ---------------------------------------- | +| `id` | `string` | Mixpanel `popupType` 값으로 사용 | +| `storageKey` | `string` | "다시 보지 않기" localStorage 키 | +| `sessionKey` | `string` | "닫기" sessionStorage 키 | +| `daysToHide` | `number?` | 숨김 유지 일수 (기본 7, 0이면 항상 표시) | +| `image` | `string` | 팝업 이미지 경로 | +| `mobileOnly` | `boolean?` | 모바일 전용 여부 | +| `to` | `string?` | 이미지 클릭 시 내부 라우팅 경로 | +| `onImageClick` | `(trackEvent) => void` | 이미지 클릭 핸들러 (트래킹 포함) | + +## 새 팝업 추가 방법 + +1. `popupConfigs.ts`에 `PopupConfig` 객체 추가 +2. `MainPage.tsx`의 `configs` 배열에 삽입 (앞에 넣을수록 우선 표시) + +## 관련 코드 + +- `src/utils/popupUtils.ts` — `PopupConfig` 인터페이스, `isPopupHidden`, `DAYS_TO_HIDE` +- `src/pages/MainPage/components/Popup/Popup.tsx` — 팝업 렌더링 컴포넌트 +- `src/pages/MainPage/components/Popup/popupConfigs.ts` — 팝업 config 정의 (`APP_DOWNLOAD_POPUP`) +- `src/pages/MainPage/components/Popup/Popup.test.tsx` — `isPopupHidden` 유틸 테스트 diff --git a/frontend/src/assets/images/popup/daedong.png b/frontend/src/assets/images/popup/daedong.png new file mode 100644 index 000000000..149f9804a Binary files /dev/null and b/frontend/src/assets/images/popup/daedong.png differ diff --git a/frontend/src/components/common/Filter/Filter.tsx b/frontend/src/components/common/Filter/Filter.tsx index db625e605..c526f2115 100644 --- a/frontend/src/components/common/Filter/Filter.tsx +++ b/frontend/src/components/common/Filter/Filter.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; @@ -7,11 +8,13 @@ import * as Styled from './Filter.styles'; const WEB_FILTER_OPTIONS = [ { label: '동아리', path: '/' }, { label: '홍보', path: '/promotions' }, + { label: '대동제', path: '/festival-busking' }, ] as const; const WEBVIEW_FILTER_OPTIONS = [ { label: '동아리', path: '/webview/main' }, { label: '홍보', path: '/webview/promotions' }, + { label: '대동제', path: '/webview/festival-busking' }, ] as const; interface FilterProps { @@ -25,12 +28,20 @@ const Filter = ({ alwaysVisible = false, hasNotification }: FilterProps) => { const { pathname } = useLocation(); const trackEvent = useMixpanelTrack(); + const [daedongDotSeen, setDaedongDotSeen] = useState( + () => sessionStorage.getItem('daedong_filter_seen') === 'true', + ); + const isWebview = pathname.startsWith('/webview'); const filterOptions = isWebview ? WEBVIEW_FILTER_OPTIONS : WEB_FILTER_OPTIONS; const shouldShow = alwaysVisible || isMobile || isWebview; const handleFilterOptionClick = (path: string) => { trackEvent(USER_EVENT.FILTER_OPTION_CLICKED, { path }); + if (path.includes('festival-busking')) { + sessionStorage.setItem('daedong_filter_seen', 'true'); + setDaedongDotSeen(true); + } navigate(path); }; @@ -41,7 +52,10 @@ const Filter = ({ alwaysVisible = false, hasNotification }: FilterProps) => { {filterOptions.map((filter) => ( ; +} 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..55e9f2068 --- /dev/null +++ b/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.styles.ts @@ -0,0 +1,60 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + width: 100%; + max-width: 550px; + margin: 0 auto; +`; + +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; +`; + +export const SectionLabel = styled.div` + display: flex; + align-items: center; + gap: 10px; + margin: 20px 20px 12px; + color: #7a7a7a; + font-size: 12px; + font-weight: 600; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: #e8e8e8; + } +`; diff --git a/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.tsx b/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.tsx new file mode 100644 index 000000000..dbca65233 --- /dev/null +++ b/frontend/src/pages/FestivalPage/BuskingPage/BuskingPage.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from 'react'; +import { format } from 'date-fns'; +import { motion } from 'framer-motion'; +import Filter from '@/components/common/Filter/Filter'; +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 usePromotionNotification from '@/hooks/Queries/usePromotionNotification'; +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 || (d.mainStagePerformances?.length ?? 0) > 0, +); + +const getInitialDayId = (): string => { + const now = new Date(); + const todayStr = format(now, 'yyyy-MM-dd'); + 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 hasNotification = usePromotionNotification(); + 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} + + + {activeDay.performances.length > 0 && ( + <> + {(activeDay.mainStagePerformances?.length ?? 0) > 0 && ( + 동아리 공연 + )} + + + )} + {(activeDay.mainStagePerformances?.length ?? 0) > 0 && ( + <> + 🎤 아티스트 공연 + + + )} + + + + {!isInAppWebView() &&