1+ /**
2+ * tour.js — 회원가입 직후 feed로 넘어올 때 nav바를 하나씩 설명하는 온보딩 투어
3+ *
4+ * 실행 조건: URL에 ?first_visit=1 이 있으면 무조건 시작한다("최초 1회만"이
5+ * 아니라 회원가입 → feed 진입이라는 동작 자체에 붙는 연출). daily_rating.js가
6+ * 회원가입 직후 설문(sleep-logs/rating) 제출 성공 시 /feed/?first_visit=1
7+ * 로 보내는 것과 짝을 이룬다.
8+ *
9+ * nav바의 각 항목은 base.html에서 data-tour="..." 속성으로 표시해뒀다
10+ * (href가 바뀌어도 셀렉터가 안 깨지도록 href 대신 이 속성으로 찾는다).
11+ */
12+
13+ ( function ( ) {
14+ const steps = [
15+ { selector : '[data-tour="main"]' , title : '메인 화면' , desc : '마신 카페인 음료를 기록하면서 오늘의 카페인 마감 시간을 확인할 수 있어요.' } ,
16+ { selector : '[data-tour="feed"]' , title : '나의 다이어리' , desc : '마신 카페인의 양, 수면 시간 등 지금까지 기록한 카페인 일지를 한눈에 볼 수 있어요.' } ,
17+ { selector : '[data-tour="drinks"]' , title : '음료 추가' , desc : '즐겨 마시는 카페인 음료를 등록하고 수정할 수 있어요.' } ,
18+ { selector : '[data-tour="allnight"]' , title : '밤샘 모드' , desc : '밤을 새울 계획이라면 여기에서 시간을 설정하고 계획을 추천받아 보세요.' } ,
19+ { selector : '[data-tour="mypage"]' , title : '마이페이지' , desc : '내 수면 시각과 체중을 수정할 수 있어요.' } ,
20+ ] ;
21+
22+ function shouldRunTour ( ) {
23+ const params = new URLSearchParams ( window . location . search ) ;
24+ return params . get ( 'first_visit' ) === '1' ;
25+ }
26+
27+ // 쿼리스트링을 지워서, 새로고침했을 때 투어가 다시 시작되지 않게 한다
28+ // (localStorage로도 막히지만, 주소창이 지저분해 보이는 것도 같이 정리).
29+ function cleanUrl ( ) {
30+ const url = new URL ( window . location . href ) ;
31+ url . searchParams . delete ( 'first_visit' ) ;
32+ window . history . replaceState ( { } , '' , url . pathname + url . search ) ;
33+ }
34+
35+ let currentIndex = 0 ;
36+ let els = { } ;
37+ let resizeHandler = null ;
38+
39+ function buildDom ( ) {
40+ const spotlight = document . createElement ( 'div' ) ;
41+ spotlight . className = 'cd-tour-spotlight' ;
42+
43+ const catcher = document . createElement ( 'div' ) ;
44+ catcher . className = 'cd-tour-catcher' ;
45+
46+ const card = document . createElement ( 'div' ) ;
47+ card . className = 'cd-tour-card' ;
48+ card . innerHTML = `
49+ <div class="cd-tour-card__body">
50+ <img src="/static/images/dailysurvey-logo.svg" alt="" class="cd-tour-card__icon">
51+ <div class="cd-tour-card__bubble">
52+ <p class="cd-tour-card__title"></p>
53+ <p class="cd-tour-card__desc"></p>
54+ <p class="cd-tour-card__progress"></p>
55+ </div>
56+ </div>
57+ ` ;
58+
59+ document . body . appendChild ( spotlight ) ;
60+ document . body . appendChild ( catcher ) ;
61+ document . body . appendChild ( card ) ;
62+
63+ els = { spotlight, catcher, card } ;
64+
65+ card . addEventListener ( 'click' , advance ) ;
66+ catcher . addEventListener ( 'click' , advance ) ;
67+
68+ // 화면 크기가 바뀌면(반응형 레이아웃으로 nav 위치 자체가 이동하므로)
69+ // 현재 단계를 다시 그려서 스포트라이트/카드가 새 위치를 따라가게 한다.
70+ resizeHandler = ( ) => renderStep ( ) ;
71+ window . addEventListener ( 'resize' , resizeHandler ) ;
72+ }
73+
74+ function renderStep ( ) {
75+ const step = steps [ currentIndex ] ;
76+ const target = document . querySelector ( step . selector ) ;
77+
78+ els . card . querySelector ( '.cd-tour-card__title' ) . textContent = step . title ;
79+ els . card . querySelector ( '.cd-tour-card__desc' ) . textContent = step . desc ;
80+ els . card . querySelector ( '.cd-tour-card__progress' ) . textContent =
81+ `${ currentIndex + 1 } / ${ steps . length } · 클릭해서 계속` ;
82+
83+ const isMobile = window . matchMedia ( '(max-width: 480px)' ) . matches ;
84+
85+ // 모바일이거나(햄버거 메뉴 안에 nav가 숨어있어 못 가리킴), 대상 요소를
86+ // 못 찾은 경우 스포트라이트 없이 카드만 화면 중앙 하단에 띄운다.
87+ if ( ! target || isMobile ) {
88+ els . spotlight . style . display = 'none' ;
89+ els . card . classList . add ( 'cd-tour-card--centered' ) ;
90+ return ;
91+ }
92+
93+ els . card . classList . remove ( 'cd-tour-card--centered' ) ;
94+ els . spotlight . style . display = 'block' ;
95+
96+ const rect = target . getBoundingClientRect ( ) ;
97+ const pad = 8 ;
98+ els . spotlight . style . top = `${ rect . top - pad } px` ;
99+ els . spotlight . style . left = `${ rect . left - pad } px` ;
100+ els . spotlight . style . width = `${ rect . width + pad * 2 } px` ;
101+ els . spotlight . style . height = `${ rect . height + pad * 2 } px` ;
102+
103+ // 카드는 강조된 영역 바로 아래에 두되, 화면 아래쪽에 공간이 부족하면 위로
104+ const cardRect = els . card . getBoundingClientRect ( ) ;
105+ const spaceBelow = window . innerHeight - rect . bottom ;
106+ const top = spaceBelow > cardRect . height + 24
107+ ? rect . bottom + 30
108+ : rect . top - cardRect . height - 16 ;
109+
110+ els . card . style . top = `${ Math . max ( 16 , top ) } px` ;
111+ els . card . style . left = `${ Math . max ( 16 , Math . min ( rect . left , window . innerWidth - cardRect . width - 16 ) ) } px` ;
112+ }
113+
114+ function advance ( ) {
115+ currentIndex += 1 ;
116+ if ( currentIndex >= steps . length ) {
117+ endTour ( ) ;
118+ return ;
119+ }
120+ renderStep ( ) ;
121+ }
122+
123+ function endTour ( ) {
124+ if ( resizeHandler ) {
125+ window . removeEventListener ( 'resize' , resizeHandler ) ;
126+ resizeHandler = null ;
127+ }
128+ els . spotlight ?. remove ( ) ;
129+ els . catcher ?. remove ( ) ;
130+ els . card ?. remove ( ) ;
131+ }
132+
133+ function startTour ( ) {
134+ if ( ! shouldRunTour ( ) ) return ;
135+ buildDom ( ) ;
136+ renderStep ( ) ;
137+ cleanUrl ( ) ;
138+ }
139+
140+ document . addEventListener ( 'DOMContentLoaded' , startTour ) ;
141+ } ) ( ) ;
0 commit comments