Merge branch 'main' into feature-ssulbox

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
김성경 2026-08-19 15:15:53 +09:00
commit 4fe065378f
25 changed files with 2578 additions and 358 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -1,16 +1,28 @@
import React, { useState, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Header from './components/Header';
import HeroSection from './pages/Landing/HeroSection';
import WelcomeSection from './pages/Landing/WelcomeSection';
import DisplaySection from './pages/Landing/DisplaySection';
import LoadingSection from './pages/Analysis/LoadingSection';
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
import LoginSection from './pages/Login/LoginSection';
import GenerationFlow from './pages/Dashboard/GenerationFlow'; import GenerationFlow from './pages/Dashboard/GenerationFlow';
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess'; import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
import SocialConnectError from './pages/Social/SocialConnectError'; import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback'; import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
import VideoDetailPage from './components/VideoDetailPage'; import VideoDetailPage from './components/VideoDetailPage';
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage'; import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api'; import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
import { clearSessionStorage } from './utils/storageKeys'; import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { NAV } from './components/navItems'; import { CrawlingResponse } from './types/api';
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
const VIEW_MODE_KEY = 'castad_view_mode';
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
const SESSION_KEY = 'castad_session_active'; const SESSION_KEY = 'castad_session_active';
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인) // 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
@ -19,7 +31,15 @@ const initializeOnNewSession = () => {
if (!isExistingSession) { if (!isExistingSession) {
// 새 세션이면 localStorage 정리하고 세션 표시 // 새 세션이면 localStorage 정리하고 세션 표시
clearSessionStorage(); localStorage.removeItem(VIEW_MODE_KEY);
localStorage.removeItem(ANALYSIS_DATA_KEY);
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
localStorage.removeItem('castad_song_task_id');
localStorage.removeItem('castad_image_task_id');
localStorage.removeItem('castad_song_generation');
localStorage.removeItem('castad_video_generation');
localStorage.removeItem('castad_video_ratio');
sessionStorage.setItem(SESSION_KEY, 'true'); sessionStorage.setItem(SESSION_KEY, 'true');
} }
}; };
@ -28,9 +48,79 @@ const initializeOnNewSession = () => {
initializeOnNewSession(); initializeOnNewSession();
const App: React.FC = () => { const App: React.FC = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [initialTab, setInitialTab] = useState<string>(NAV.HOME); const containerRef = useRef<HTMLElement>(null);
// localStorage에서 저장된 상태 복원 (새 세션이면 이미 초기화됨)
const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null;
const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY);
// 저장된 분석 데이터 파싱 및 유효성 검사
const parseSavedAnalysisData = (): CrawlingResponse | null => {
if (!savedAnalysisData) return null;
try {
const data = JSON.parse(savedAnalysisData) as CrawlingResponse;
// 기본값 보장
if (data.marketing_analysis) {
data.marketing_analysis.brand_identity = data.marketing_analysis.brand_identity || {
location_feature_analysis: '',
concept_scalability: ''
};
data.marketing_analysis.market_positioning = data.marketing_analysis.market_positioning || {
category_definition: '',
core_value: ''
};
data.marketing_analysis.target_persona = data.marketing_analysis.target_persona || [];
data.marketing_analysis.selling_points = data.marketing_analysis.selling_points || [];
data.marketing_analysis.target_keywords = data.marketing_analysis.target_keywords || [];
}
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
data.processed_info.region = data.processed_info.region || '';
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
}
data.image_list = data.image_list || [];
return data;
} catch {
localStorage.removeItem(ANALYSIS_DATA_KEY);
return null;
}
};
// 초기 viewMode 결정: 로그인 상태면 바로 generation_flow로
const getInitialViewMode = (): ViewMode => {
if (savedViewMode === 'generation_flow') return 'generation_flow';
if (isLoggedIn()) return 'generation_flow';
return 'landing';
};
const [viewMode, setViewMode] = useState<ViewMode>(getInitialViewMode());
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(
parseSavedAnalysisData()
);
const [error, setError] = useState<string | null>(null);
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
const [scrollProgress, setScrollProgress] = useState(0);
const [isProcessingCallback, setIsProcessingCallback] = useState(false); const [isProcessingCallback, setIsProcessingCallback] = useState(false);
const tutorialVideoCheckedRef = useRef(false);
// generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off
useEffect(() => {
if (viewMode !== 'generation_flow') return;
if (tutorialVideoCheckedRef.current) return;
tutorialVideoCheckedRef.current = true;
const ENABLED_KEY = 'ado2_tutorial_enabled';
if (localStorage.getItem(ENABLED_KEY) !== null) return;
getVideosList(1, 1).then(response => {
const hasVideos = response.items.some(v => v.result_movie_url?.trim());
if (hasVideos) {
localStorage.setItem(ENABLED_KEY, 'false');
window.dispatchEvent(new Event('ado2-tutorial-auto-disable'));
}
}).catch(() => {});
}, [viewMode]);
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인) // 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
useEffect(() => { useEffect(() => {
@ -57,8 +147,8 @@ const App: React.FC = () => {
setIsProcessingCallback(true); setIsProcessingCallback(true);
handleKakaoCallback(code); handleKakaoCallback(code);
} }
// 콜백이 아닌 일반 진입 — 광고 유입 추적 // 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
else { else if (viewMode === 'landing') {
trackViewContent(); trackViewContent();
} }
}, []); }, []);
@ -82,15 +172,21 @@ const App: React.FC = () => {
await trackCompleteRegistration(); await trackCompleteRegistration();
} }
// 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀 const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
if (savedData) {
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
}
const redirectPath = sessionStorage.getItem('castad_login_redirect'); const redirectPath = sessionStorage.getItem('castad_login_redirect');
sessionStorage.removeItem('castad_login_redirect'); sessionStorage.removeItem('castad_login_redirect');
if (redirectPath && redirectPath !== '/') { if (redirectPath && redirectPath !== '/') {
window.location.href = redirectPath; window.location.href = redirectPath;
return; return;
} }
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리 setInitialTab('새 프로젝트 만들기');
setInitialTab(NAV.HOME); setViewMode('generation_flow');
} catch (err) { } catch (err) {
console.error('Token callback failed:', err); console.error('Token callback failed:', err);
alert(t('app.loginFailed')); alert(t('app.loginFailed'));
@ -124,6 +220,231 @@ const App: React.FC = () => {
} }
}; };
// viewMode 변경 시 localStorage에 저장
useEffect(() => {
localStorage.setItem(VIEW_MODE_KEY, viewMode);
}, [viewMode]);
// 스크롤 이벤트 핸들러 - 첫 번째 섹션에서 두 번째 섹션으로 넘어갈 때 0~1 값 계산
useEffect(() => {
const container = containerRef.current;
if (!container || viewMode !== 'landing') return;
const handleScroll = () => {
const scrollTop = container.scrollTop;
const sectionHeight = container.clientHeight;
// 첫 번째 섹션 스크롤 진행률 (0 ~ 1)
const progress = Math.min(1, Math.max(0, scrollTop / sectionHeight));
setScrollProgress(progress);
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [viewMode]);
const scrollToSection = (index: number) => {
if (containerRef.current) {
const h = containerRef.current.clientHeight;
containerRef.current.scrollTo({
top: h * index,
behavior: 'smooth'
});
}
};
// 크롤링 응답 유효성 검사
const validateCrawlingResponse = (data: CrawlingResponse): boolean => {
// 필수 필드 존재 여부 확인
if (!data) return false;
if (!data.processed_info) return false;
if (!data.marketing_analysis) return false;
// marketing_analysis 내부 필드 기본값 보장
if (!data.marketing_analysis.brand_identity) {
data.marketing_analysis.brand_identity = {
location_feature_analysis: '',
concept_scalability: ''
};
}
if (!data.marketing_analysis.market_positioning) {
data.marketing_analysis.market_positioning = {
category_definition: '',
core_value: ''
};
}
if (!data.marketing_analysis.target_persona) {
data.marketing_analysis.target_persona = [];
}
if (!data.marketing_analysis.selling_points) {
data.marketing_analysis.selling_points = [];
}
if (!data.marketing_analysis.target_keywords) {
data.marketing_analysis.target_keywords = [];
}
// processed_info 내부 필드 기본값 보장
if (!data.processed_info.customer_name) {
data.processed_info.customer_name = '알 수 없음';
}
if (!data.processed_info.region) {
data.processed_info.region = '';
}
if (!data.processed_info.detail_region_info) {
data.processed_info.detail_region_info = '';
}
// image_list 기본값 보장
if (!data.image_list) {
data.image_list = [];
}
return true;
};
const handleStartAnalysis = async (url: string) => {
if (!url.trim()) return;
setAfterLoadTarget('analysis');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await crawlUrl(url);
// 응답 유효성 검사
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.invalidUrl'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'url', value: url });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Crawling failed:', err);
setError(t('app.analysisError'));
setViewMode('landing');
}
};
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
setAfterLoadTarget('analysis');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await autocomplete(request);
// 응답 유효성 검사
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Autocomplete failed:', err);
setError(t('app.autocompleteError'));
setViewMode('landing');
}
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string, category: string) => {
setAfterLoadTarget('generation_flow');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address, category);
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis failed:', err);
setError(t('app.autocompleteError'));
setViewMode('landing');
}
};
// 테스트 데이터로 브랜드 분석 페이지 이동
const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
setViewMode('analysis');
};
// 언어 변경 시 테스트 데이터 다시 로드
useEffect(() => {
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
if (!saved) return;
try {
const parsed = JSON.parse(saved);
if (!parsed._isTestData) return;
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
fetch(jsonFile)
.then(res => res.json())
.then((data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
})
.catch(err => console.error('Failed to reload test data:', err));
} catch { /* ignore */ }
}, [i18n.language]);
const handleToLogin = async () => {
// 이미 로그인된 상태면 바로 generation_flow로 이동
if (isLoggedIn()) {
// 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록)
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
if (savedData) {
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
}
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
return;
}
// 로그인 안 된 상태면 카카오 로그인 페이지로 리다이렉션
try {
const { getKakaoLoginUrl } = await import('./utils/api');
const response = await getKakaoLoginUrl();
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
alert(t('app.loginUrlFailed'));
}
};
const handleLoginSuccess = () => {
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
};
const handleGoBack = () => {
// localStorage 정리
localStorage.removeItem(VIEW_MODE_KEY);
localStorage.removeItem(ANALYSIS_DATA_KEY);
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
setViewMode('landing');
};
// Social OAuth 콜백 페이지 처리 // Social OAuth 콜백 페이지 처리
const pathname = window.location.pathname; const pathname = window.location.pathname;
@ -146,14 +467,12 @@ const App: React.FC = () => {
return <VideoDetailPage videoId={videoDetailMatch[1]} />; return <VideoDetailPage videoId={videoDetailMatch[1]} />;
} }
// 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능.
// /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라
// 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다.
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/); const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
if (ssulDetailMatch) { if (ssulDetailMatch) {
return <SsulDetailPage contentId={ssulDetailMatch[1]} />; return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
} }
// 카카오 콜백 처리 중 로딩 화면 표시 // 카카오 콜백 처리 중 로딩 화면 표시
if (isProcessingCallback) { if (isProcessingCallback) {
return ( return (
@ -165,8 +484,71 @@ const App: React.FC = () => {
); );
} }
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴 if (viewMode === 'loading') {
return <GenerationFlow initialActiveItem={initialTab} />; return (
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => setViewMode(afterLoadTarget)}
/>
);
}
if (viewMode === 'analysis' && analysisData) {
return (
<AnalysisResultSection
onBack={handleGoBack}
onGenerate={handleToLogin}
data={analysisData}
/>
);
}
if (viewMode === 'login') {
return <LoginSection onBack={() => setViewMode('analysis')} onLogin={handleLoginSuccess} />;
}
if (viewMode === 'generation_flow') {
return (
<GenerationFlow
onHome={handleGoBack}
initialActiveItem={initialTab}
initialImageList={analysisData?.image_list || []}
businessInfo={analysisData?.processed_info}
initialAnalysisData={analysisData}
/>
);
}
// 로그인된 상태에서 "시작하기" 버튼 클릭
const handleHeaderStart = () => {
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
};
return (
<main className="landing-container" ref={containerRef}>
<Header onStartClick={handleHeaderStart} />
<section className="landing-section">
<HeroSection
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onNext={() => scrollToSection(1)}
error={error}
scrollProgress={scrollProgress}
/>
</section>
<section className="landing-section">
<WelcomeSection
onStartClick={() => scrollToSection(0)}
onNext={() => scrollToSection(0)}
/>
</section>
<section className="landing-section">
<DisplaySection onStartClick={() => scrollToSection(0)} />
</section>
</main>
);
}; };
export default App; export default App;

View File

@ -1,7 +1,7 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toggleVideoLike, isLoggedIn } from '../utils/api'; import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api';
import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare';
import { ContentType } from '../types/api'; import { ContentType } from '../types/api';
import LoginPromptModal from './LoginPromptModal'; import LoginPromptModal from './LoginPromptModal';
@ -33,102 +33,29 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
const [likeCount, setLikeCount] = useState(initialLikeCount); const [likeCount, setLikeCount] = useState(initialLikeCount);
const [isLiked, setIsLiked] = useState(initialIsLiked); const [isLiked, setIsLiked] = useState(initialIsLiked);
const [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null);
const shareBtnRef = useRef<HTMLButtonElement>(null);
const shareMenuRef = useRef<HTMLDivElement>(null);
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 종류별 공개 페이지가 다르다 — 합치면 id 가 겹치는 다른 콘텐츠가 열린다 const shareUrl = buildContentShareUrl(API_URL, videoId, contentType);
const shareUrl = `${window.location.origin}/${contentType === 'ssul' ? 'ssul' : 'video'}/${videoId}`; const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle');
const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' });
useEffect(() => { const handleShareBtnClick = async (e: React.MouseEvent) => {
if (!shareMenuOpen) return; e.stopPropagation();
const closeMenu = () => setShareMenuOpen(false); const handled = await tryNativeShare({
title: shareTitle,
const handleClickOutside = (e: MouseEvent) => { text: shareDescription,
if ( url: shareUrl,
shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) && });
shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node) if (handled) {
) { return;
closeMenu();
}
};
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', closeMenu, true);
window.addEventListener('resize', closeMenu);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', closeMenu, true);
window.removeEventListener('resize', closeMenu);
};
}, [shareMenuOpen]);
// 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정
useLayoutEffect(() => {
if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return;
const margin = 8;
const menuRect = shareMenuRef.current.getBoundingClientRect();
let left = anchorRect.left;
if (left + menuRect.width > window.innerWidth - margin) {
left = window.innerWidth - menuRect.width - margin;
} }
left = Math.max(margin, left);
let top = anchorRect.bottom + 6;
if (top + menuRect.height > window.innerHeight - margin) {
top = anchorRect.top - menuRect.height - 6;
}
top = Math.max(margin, top);
setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
}, [shareMenuOpen, anchorRect]);
const handleCopyLink = async () => {
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
} catch { } catch {
// clipboard API 미지원 환경에서는 무시 // clipboard API 미지원 환경에서는 무시
} }
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleKakaoShare = () => {
const kakao = window.Kakao;
if (kakao?.Share) {
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: storeName || t('videoDetail.kakaoDefaultTitle'),
description: t('videoDetail.kakaoDescription', { region: region ?? '' }),
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
}
setShareMenuOpen(false);
};
const handleFacebookShare = () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleTwitterShare = () => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
}; };
const handleLikeClick = (e: React.MouseEvent) => { const handleLikeClick = (e: React.MouseEvent) => {
@ -159,17 +86,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
}, 500); }, 500);
}; };
const handleShareBtnClick = (e: React.MouseEvent) => {
e.stopPropagation();
const rect = shareBtnRef.current?.getBoundingClientRect();
if (rect) {
// 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다
setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left });
setMenuPos({ top: rect.bottom + 6, left: rect.left });
}
setShareMenuOpen((v) => !v);
};
return ( return (
<div className="content-card-social" onClick={(e) => e.stopPropagation()}> <div className="content-card-social" onClick={(e) => e.stopPropagation()}>
<button <button
@ -190,7 +106,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
</span> </span>
<button <button
ref={shareBtnRef}
className="content-card-share-btn" className="content-card-share-btn"
onClick={handleShareBtnClick} onClick={handleShareBtnClick}
title={t('videoDetail.share')} title={t('videoDetail.share')}
@ -201,42 +116,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
</svg> </svg>
</button> </button>
{shareMenuOpen && menuPos && createPortal(
<div
ref={shareMenuRef}
className="video-detail-share-menu"
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left }}
>
<button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FEE500" />
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E" />
</svg>
{t('videoDetail.shareKakao')}
</button>
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
{t('videoDetail.shareFacebook')}
</button>
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
{t('videoDetail.shareTwitter')}
</button>
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
</button>
</div>,
document.body
)}
{showLoginModal && ( {showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} /> <LoginPromptModal onClose={() => setShowLoginModal(false)} />
)} )}

64
src/components/Header.tsx Executable file
View File

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl, isLoggedIn } from '../utils/api';
import LanguageSwitch from './LanguageSwitch';
interface HeaderProps {
onStartClick?: () => void;
}
const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const loggedIn = isLoggedIn();
const handleLogin = async () => {
if (isLoading) return;
setIsLoading(true);
try {
const response = await getKakaoLoginUrl();
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
alert(t('header.loginFailedAlert'));
setIsLoading(false);
}
};
const handleStart = () => {
if (onStartClick) {
onStartClick();
}
};
return (
<header className="landing-header">
<div className="header-logo">
<img src="/assets/images/ado2-header-logo.svg" alt="ADO2" />
</div>
<div className="header-actions">
<LanguageSwitch />
{loggedIn ? (
<button
className="header-start-btn"
onClick={handleStart}
>
{t('header.start')}
</button>
) : (
<button
className="header-login-btn"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? t('header.loading') : t('header.login')}
</button>
)}
</div>
</header>
);
};
export default Header;

View File

@ -5,6 +5,9 @@ import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredE
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api'; import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
import UploadProgressModal, { UploadStatus } from './UploadProgressModal'; import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
import { useOverlayClose } from '../hooks/useOverlayClose'; import { useOverlayClose } from '../hooks/useOverlayClose';
import { useTutorial } from './Tutorial/useTutorial';
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
import TutorialOverlay from './Tutorial/TutorialOverlay';
interface SocialPostingModalProps { interface SocialPostingModalProps {
isOpen: boolean; isOpen: boolean;
@ -119,6 +122,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onGoToCalendar, onGoToCalendar,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const tutorial = useTutorial();
const hasBeenOpenedRef = useRef(false);
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]); const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [selectedChannel, setSelectedChannel] = useState<string>(''); const [selectedChannel, setSelectedChannel] = useState<string>('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@ -180,16 +185,50 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
return () => { document.body.style.overflow = ''; }; return () => { document.body.style.overflow = ''; };
}, [isOpen]); }, [isOpen]);
// 소셜 계정 로드 // 모달 오픈/닫힘 시 튜토리얼 트리거
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (isOpen) {
hasBeenOpenedRef.current = true;
if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL);
}, 400);
return () => clearTimeout(timer);
}
} else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) {
hasBeenOpenedRef.current = false;
tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK);
}
}, [isOpen]);
const now = Date.now(); // SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거
useEffect(() => {
if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM);
}, 400);
return () => clearTimeout(timer);
}
}, [isLoadingAutoDescription]);
// 소셜 계정 로드 + SEO 자동 채움
useEffect(() => {
if (!isOpen) {
loadedForTaskIdRef.current = null;
return;
}
loadSocialAccounts(); loadSocialAccounts();
// SEO 자동 채움 키. ADO2 는 task_id, 썰박스는 task_id 가 없어(빈 문자열) if (video?.title) {
// (type, video_id) 로 키를 만든다 — 없으면 썰박스는 자동 채움이 통째로 스킵됐다. setTitle(video.title);
setDescription(video.description || '');
setTags((video.hashtags || []).join(','));
setIsLoadingAutoDescription(false);
return;
}
const now = Date.now();
const seoKey = const seoKey =
video?.type === 'ssul' video?.type === 'ssul'
? `ssul:${video.video_id}` ? `ssul:${video.video_id}`
@ -208,7 +247,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
setTags(cached.tags); setTags(cached.tags);
} }
} }
}, [isOpen, video?.task_id, video?.type, video?.video_id]); }, [isOpen, video?.task_id, video?.type, video?.video_id, video?.title, video?.description, video?.hashtags]);
const loadSocialAccounts = async () => { const loadSocialAccounts = async () => {
setIsLoadingAccounts(true); setIsLoadingAccounts(true);
@ -251,11 +290,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
content_type: video.type, content_type: video.type,
task_id: isSsul ? String(video.video_id) : video.task_id, task_id: isSsul ? String(video.video_id) : video.task_id,
}; };
// Call autoSEO API
console.log('[Upload] Request payload:', requestPayload);
const autoSeoResponse = await getAutoSeoYoutube(requestPayload); const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
// 각 필드가 있을 때만 덮어씌움 (기존 값 보호)
if (autoSeoResponse.title) setTitle(autoSeoResponse.title); if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
if (autoSeoResponse.description) setDescription(autoSeoResponse.description); if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(',')); if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
@ -266,7 +302,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
}); });
} catch (error) { } catch (error) {
console.error('Failed to load autocomplete:', error); console.error('Failed to load autocomplete:', error);
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
} finally { } finally {
setIsLoadingAutoDescription(false); setIsLoadingAutoDescription(false);
} }
@ -839,6 +874,16 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
</div> </div>
</div> </div>
{uploadProgressModalElement} {uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</> </>
); );
}; };

View File

@ -0,0 +1,314 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TutorialHint } from './tutorialSteps';
interface Rect {
top: number;
left: number;
width: number;
height: number;
bottom: number;
}
interface TooltipPos {
top: number;
left: number;
}
interface TutorialOverlayProps {
hints: TutorialHint[];
currentIndex: number;
onNext: () => void;
onPrev: () => void;
onSkip: () => void;
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
}
const PADDING = 8;
function getTargetRect(selector: string): Rect | null {
const els = Array.from(document.querySelectorAll(selector));
const el = els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0];
if (!el) return null;
const r = el.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
}
function getSpotlightRect(
rect: Rect,
padding: number,
override?: { top?: number; right?: number; bottom?: number; left?: number }
): Rect {
const pTop = override?.top ?? padding;
const pRight = override?.right ?? padding;
const pBottom = override?.bottom ?? padding;
const pLeft = override?.left ?? padding;
const left = Math.max(0, Math.floor(rect.left - pLeft));
const top = Math.max(0, Math.floor(rect.top - pTop));
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
return {
top,
bottom,
left,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
}
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
switch (position) {
case 'bottom':
return {
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
};
case 'top':
return {
top: Math.max(rect.top - tooltipH - PADDING, 8),
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
};
case 'right':
return {
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
};
case 'left':
return {
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
left: Math.max(rect.left - tooltipW - PADDING, 8),
};
}
}
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
hints,
currentIndex,
onNext,
onPrev,
onSkip,
groupProgress,
}) => {
const { t } = useTranslation();
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const tooltipRef = React.useRef<HTMLDivElement>(null);
const [tooltipSize, setTooltipSize] = useState({ w: 300, h: 160 });
const hint = hints[currentIndex];
const isLast = currentIndex === hints.length - 1;
// 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로
const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true);
const updateRect = useCallback(() => {
if (!hint) return;
setTargetRect(getTargetRect(hint.targetSelector));
}, [hint]);
useEffect(() => {
updateRect();
window.addEventListener('resize', updateRect);
window.addEventListener('scroll', updateRect, true);
return () => {
window.removeEventListener('resize', updateRect);
window.removeEventListener('scroll', updateRect, true);
};
}, [updateRect]);
useEffect(() => {
if (!hint) return;
let retryTimer: number | undefined;
let rectTimer: number | undefined;
let cleanupTarget: (() => void) | undefined;
const bindToTarget = (el: HTMLElement) => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
rectTimer = window.setTimeout(updateRect, 200);
const shouldClickAdvance = hint.clickToAdvance !== false;
if (shouldClickAdvance) {
el.style.cursor = 'pointer';
el.addEventListener('click', onNext);
cleanupTarget = () => {
el.style.cursor = '';
el.removeEventListener('click', onNext);
};
} else {
cleanupTarget = () => {};
}
};
const tryBind = (): boolean => {
const els = Array.from(document.querySelectorAll(hint.targetSelector));
const el = (els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0]) as HTMLElement | null;
if (!el) return false;
bindToTarget(el);
return true;
};
if (!tryBind()) {
retryTimer = window.setInterval(() => {
if (tryBind() && retryTimer) {
window.clearInterval(retryTimer);
retryTimer = undefined;
}
}, 80);
}
return () => {
if (retryTimer) window.clearInterval(retryTimer);
if (rectTimer) window.clearTimeout(rectTimer);
cleanupTarget?.();
};
}, [hint, updateRect, onNext]);
// 툴팁 DOM 크기 측정 — 힌트가 바뀔 때만 재측정
useEffect(() => {
const el = tooltipRef.current;
if (!el) return;
const { offsetWidth, offsetHeight } = el;
if (offsetWidth && offsetHeight) {
setTooltipSize(prev =>
prev.w === offsetWidth && prev.h === offsetHeight
? prev
: { w: offsetWidth, h: offsetHeight }
);
}
}, [currentIndex, hints]);
if (!hint) return null;
const isTargetVisible = targetRect
? targetRect.top + 60 < window.innerHeight && targetRect.bottom > 60
: true;
if (!isTargetVisible) return null;
const spotlightPadding = hint.spotlightPadding ?? PADDING;
const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
// 툴팁은 spotlightRect 기준으로 배치해야 스포트라이트와 겹치지 않음
const tooltipPos: TooltipPos = (spotlightRect ?? targetRect)
? calcTooltipPos((spotlightRect ?? targetRect)!, hint.position, tooltipSize.w, tooltipSize.h)
: {
top: window.innerHeight / 2 - tooltipSize.h / 2,
left: window.innerWidth / 2 - tooltipSize.w / 2,
};
return (
<div className="tutorial-overlay-root">
{spotlightRect ? (
<>
<div
className="tutorial-overlay-blocker"
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top,
left: 0,
width: spotlightRect.left,
height: spotlightRect.height,
}}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top,
left: spotlightRect.left + spotlightRect.width,
right: 0,
height: spotlightRect.height,
}}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top + spotlightRect.height,
left: 0,
right: 0,
bottom: 0,
}}
/>
<div
className="tutorial-spotlight-ring"
style={{
top: spotlightRect.top,
left: spotlightRect.left,
width: spotlightRect.width,
height: spotlightRect.height,
}}
/>
</>
) : null}
<div
ref={tooltipRef}
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
style={{ top: tooltipPos.top, left: tooltipPos.left }}
onClick={(e) => e.stopPropagation()}
>
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
{hint.noteKey && (
<p className="tutorial-tooltip-note">{t(hint.noteKey)}</p>
)}
<div className="tutorial-tooltip-footer">
<span className="tutorial-tooltip-counter">
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
</span>
<div className="tutorial-tooltip-actions">
<button className="tutorial-btn-skip" onClick={onSkip}>
{t('tutorial.skip')}
</button>
{currentIndex > 0 && (
<button className="tutorial-btn-prev" onClick={onPrev}>
{t('tutorial.prev')}
</button>
)}
{(hint.clickToAdvance !== true || isFinish) && (
<button className="tutorial-btn-next" onClick={onNext}>
{isFinish ? t('tutorial.finish') : t('tutorial.next')}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default TutorialOverlay;
interface TutorialRestartPopupProps {
onConfirm: () => void;
onCancel: () => void;
}
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
const { t } = useTranslation();
return (
<div className="tutorial-restart-backdrop">
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
<div className="tutorial-restart-actions">
<button className="tutorial-restart-cancel" onClick={onCancel}>
{t('tutorial.restart.cancel')}
</button>
<button className="tutorial-restart-confirm" onClick={onConfirm}>
{t('tutorial.restart.confirm')}
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,377 @@
export interface TutorialHint {
targetSelector: string;
titleKey: string;
descriptionKey: string;
position: 'top' | 'bottom' | 'left' | 'right';
noteKey?: string;
variant?: 'bubble';
clickToAdvance?: boolean;
advanceSelector?: string;
noSpotlight?: boolean;
spotlightPadding?: number;
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
}
export interface TutorialStepDef {
key: string;
hints: TutorialHint[];
}
export const TUTORIAL_KEYS = {
LANDING: 'landing',
ANALYSIS: 'analysis',
ASSET: 'asset',
SOUND: 'sound',
SOUND_LYRICS: 'soundLyrics',
SOUND_AUDIO: 'soundAudio',
GENERATING:'generating',
COMPLETION: 'completion',
MY_INFO: 'myInfo',
ADO2_CONTENTS: 'ado2Contents',
UPLOAD_MODAL: 'uploadModal',
UPLOAD_FORM: 'uploadForm',
DASHBOARD: 'dashboard',
CONTENT_CALENDAR: 'contentCalendar',
FEEDBACK: 'feedback',
} as const;
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
export const TUTORIAL_PAGE_GROUPS: string[][] = [
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
];
export const tutorialSteps: TutorialStepDef[] = [
{
key: TUTORIAL_KEYS.LANDING,
hints: [
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-manual-card-title',
titleKey: 'tutorial.landing.manual.title',
descriptionKey: 'tutorial.landing.manual.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc',
position: 'bottom',
clickToAdvance: true,
noSpotlight: true,
variant: 'bubble',
},
],
},
{
key: TUTORIAL_KEYS.ASSET,
hints: [
{
targetSelector: '.asset-column.asset-column-left',
titleKey: 'tutorial.asset.image.title',
descriptionKey: 'tutorial.asset.image.desc',
position: 'left',
clickToAdvance: false,
},
{
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn',
titleKey: 'tutorial.asset.upload.title',
descriptionKey: 'tutorial.asset.upload.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.asset-ratio-section',
titleKey: 'tutorial.asset.ratio.title',
descriptionKey: 'tutorial.asset.ratio.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.asset-next-button',
titleKey: 'tutorial.asset.next.title',
descriptionKey: 'tutorial.asset.next.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.SOUND,
hints: [
{
targetSelector: '.genre-grid',
titleKey: 'tutorial.sound.genre.title',
descriptionKey: 'tutorial.sound.genre.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.language-grid',
titleKey: 'tutorial.sound.language.title',
descriptionKey: 'tutorial.sound.language.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.btn-generate-sound',
titleKey: 'tutorial.sound.generate.title',
descriptionKey: 'tutorial.sound.generate.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.SOUND_LYRICS,
hints: [
{
targetSelector: '.lyrics-display',
titleKey: 'tutorial.sound.lyrics.title',
descriptionKey: 'tutorial.sound.lyrics.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.status-message-new',
titleKey: 'tutorial.sound.lyricsWait.title',
descriptionKey: 'tutorial.sound.lyricsWait.desc',
position: 'top',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.SOUND_AUDIO,
hints: [
{
targetSelector: '.audio-player',
titleKey: 'tutorial.sound.audioPlayer.title',
descriptionKey: 'tutorial.sound.audioPlayer.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.btn-video-generate',
titleKey: 'tutorial.sound.video.title',
descriptionKey: 'tutorial.sound.video.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.MY_INFO,
hints: [
{
targetSelector: '.youtube-connect-section',
titleKey: 'tutorial.myInfo.myInfo.title',
descriptionKey: 'tutorial.myInfo.myInfo.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.myinfo-social-btn',
titleKey: 'tutorial.myInfo.connect.title',
descriptionKey: 'tutorial.myInfo.connect.desc',
noteKey: 'tutorial.myInfo.connect.note',
position: 'top',
clickToAdvance: true,
},
{
targetSelector: '.myinfo-connected-accounts',
titleKey: 'tutorial.myInfo.connected.title',
descriptionKey: 'tutorial.myInfo.connected.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '#sidebar-ado2-contents',
titleKey: 'tutorial.myInfo.ado2.title',
descriptionKey: 'tutorial.myInfo.ado2.desc',
position: 'right',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.ADO2_CONTENTS,
hints: [
{
targetSelector: '.ado2-content-card',
titleKey: 'tutorial.ado2.list.title',
descriptionKey: 'tutorial.ado2.list.desc',
position: 'right',
clickToAdvance: false,
},
{
targetSelector: '.content-upload-btn',
titleKey: 'tutorial.ado2.download.title',
descriptionKey: 'tutorial.ado2.download.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.content-delete-btn',
titleKey: 'tutorial.ado2.delete.title',
descriptionKey: 'tutorial.ado2.delete.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.content-download-btn',
titleKey: 'tutorial.ado2.upload.title',
descriptionKey: 'tutorial.ado2.upload.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.GENERATING,
hints:[
{
targetSelector: '.comp2-info-section',
titleKey: 'tutorial.completion.contentInfo.title',
descriptionKey: 'tutorial.completion.contentInfo.desc',
position: 'left',
},
{
targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.generating.title',
descriptionKey: 'tutorial.completion.generating.desc',
position: 'top',
}
]
},
{
key: TUTORIAL_KEYS.COMPLETION,
hints: [
{
targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.completion.title',
descriptionKey: 'tutorial.completion.completion.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '#sidebar-my-info',
titleKey: 'tutorial.completion.myInfo.title',
descriptionKey: 'tutorial.completion.myInfo.desc',
position: 'right',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.UPLOAD_MODAL,
hints: [
{
targetSelector: '.social-posting-content',
titleKey: 'tutorial.upload.seo.title',
descriptionKey: 'tutorial.upload.seo.desc',
position: 'right',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.UPLOAD_FORM,
hints: [
{
targetSelector: '.social-posting-form',
titleKey: 'tutorial.upload.required.title',
descriptionKey: 'tutorial.upload.required.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.social-posting-radio-group',
titleKey: 'tutorial.upload.schedule.title',
descriptionKey: 'tutorial.upload.schedule.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.social-posting-btn:not(.cancel)',
titleKey: 'tutorial.upload.submit.title',
descriptionKey: 'tutorial.upload.submit.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.DASHBOARD,
hints: [
{
targetSelector: '.stats-grid-8',
titleKey: 'tutorial.dashboard.metrics.title',
descriptionKey: 'tutorial.dashboard.metrics.desc',
position: 'bottom',
},
{
targetSelector: '.yoy-chart-card',
titleKey: 'tutorial.dashboard.chart.title',
descriptionKey: 'tutorial.dashboard.chart.desc',
position: 'top',
},
{
targetSelector: '.tutorial-center-anchor',
titleKey: 'tutorial.dashboard.more.title',
descriptionKey: 'tutorial.dashboard.more.desc',
position: 'bottom',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.CONTENT_CALENDAR,
hints: [
{
targetSelector: '.calendar-grid-area',
titleKey: 'tutorial.contentCalendar.grid.title',
descriptionKey: 'tutorial.contentCalendar.grid.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.calendar-side-panel',
titleKey: 'tutorial.contentCalendar.panel.title',
descriptionKey: 'tutorial.contentCalendar.panel.desc',
position: 'left',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.FEEDBACK,
hints: [
{
targetSelector: '.tutorial-center-anchor',
titleKey: 'tutorial.feedback.complete.title',
descriptionKey: 'tutorial.feedback.complete.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.sidebar-inquiry-btn',
titleKey: 'tutorial.feedback.title',
descriptionKey: 'tutorial.feedback.desc',
position: 'right',
},
],
},
];

View File

@ -0,0 +1,220 @@
import React, { useState, useCallback, useEffect } from 'react';
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
if (!group) return null;
let offset = 0;
let total = 0;
for (const k of group) {
const step = tutorialSteps.find(s => s.key === k);
const count = step?.hints.length ?? 0;
if (k === key) offset = total;
total += count;
}
const isLastKeyInGroup = group[group.length - 1] === key;
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
}
const SEEN_KEY = 'ado2_tutorial_seen';
const PROGRESS_KEY = 'ado2_tutorial_progress';
const ENABLED_KEY = 'ado2_tutorial_enabled';
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
let globalSkip: (() => void) | null = null;
function getSeenKeys(): string[] {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
} catch {
return [];
}
}
function markSeen(key: string) {
const seen = getSeenKeys();
if (!seen.includes(key)) {
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
}
clearProgress(key);
}
function saveProgress(key: string, index: number) {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
progress[key] = index;
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
} catch {}
}
function loadProgress(key: string): number {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
return progress[key] ?? 0;
} catch {
return 0;
}
}
function clearProgress(key: string) {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
delete progress[key];
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
} catch {}
}
interface UseTutorialReturn {
isActive: boolean;
isEnabled: boolean;
isRestartPopupVisible: boolean;
currentHintIndex: number;
hints: TutorialHint[];
tutorialKey: string | null;
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
nextHint: () => void;
prevHint: () => void;
skipTutorial: () => void;
toggleTutorial: (currentKey: string | null) => void;
showRestartPopup: (key: string) => void;
confirmRestart: () => void;
cancelRestart: () => void;
hasSeen: (key: string) => boolean;
}
export function useTutorial(): UseTutorialReturn {
const [isActive, setIsActive] = useState(false);
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
const [currentHintIndex, setCurrentHintIndex] = useState(0);
const [hints, setHints] = useState<TutorialHint[]>([]);
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
const step = tutorialSteps.find(s => s.key === key);
if (!step || step.hints.length === 0) return;
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
globalSkip?.();
const savedIndex = forceFromStart ? 0 : loadProgress(key);
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
onCompleteRef.current = onComplete;
setHints(step.hints);
setTutorialKey(key);
setCurrentHintIndex(resumeIndex);
setIsActive(true);
// 이 인스턴스의 skip을 전역에 등록
globalSkip = () => {
if (key) saveProgress(key, resumeIndex);
setIsActive(false);
setCurrentHintIndex(0);
globalSkip = null;
};
}, []);
const nextHint = useCallback(() => {
setCurrentHintIndex(prev => {
if (prev < hints.length - 1) {
const next = prev + 1;
if (tutorialKey) saveProgress(tutorialKey, next);
return next;
}
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
setIsActive(false);
if (tutorialKey) markSeen(tutorialKey);
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
onCompleteRef.current?.();
onCompleteRef.current = undefined;
return 0;
});
}, [hints.length, tutorialKey]);
const prevHint = useCallback(() => {
setCurrentHintIndex(prev => Math.max(0, prev - 1));
}, []);
// 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시
const skipTutorial = useCallback(() => {
if (tutorialKey) saveProgress(tutorialKey, currentHintIndex);
setIsActive(false);
setCurrentHintIndex(0);
}, [tutorialKey, currentHintIndex]);
const toggleTutorial = useCallback((currentKey: string | null) => {
if (isEnabled) {
// off: 튜토리얼 중단 + 비활성화
setIsActive(false);
setIsEnabled(false);
localStorage.setItem(ENABLED_KEY, 'false');
} else {
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
localStorage.removeItem(SEEN_KEY);
localStorage.removeItem(PROGRESS_KEY);
localStorage.setItem(ENABLED_KEY, 'true');
setIsEnabled(true);
if (currentKey) {
startTutorial(currentKey, undefined, true);
}
}
}, [isEnabled, startTutorial]);
// 튜토리얼 다시 보기: 팝업 표시만
const showRestartPopup = useCallback((key: string) => {
setPendingRestartKey(key);
setIsRestartPopupVisible(true);
}, []);
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
const confirmRestart = useCallback(() => {
setIsRestartPopupVisible(false);
if (pendingRestartKey) {
localStorage.removeItem(SEEN_KEY);
localStorage.removeItem(PROGRESS_KEY);
startTutorial(pendingRestartKey, undefined, true);
}
setPendingRestartKey(null);
}, [pendingRestartKey, startTutorial]);
// 팝업에서 취소
const cancelRestart = useCallback(() => {
setIsRestartPopupVisible(false);
setPendingRestartKey(null);
}, []);
const hasSeen = useCallback((key: string) => {
return getSeenKeys().includes(key);
}, []);
// 영상 보유 사용자 자동 off 이벤트 수신
useEffect(() => {
const handler = () => {
setIsActive(false);
setIsEnabled(false);
};
window.addEventListener('ado2-tutorial-auto-disable', handler);
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
}, []);
return {
isActive,
isEnabled,
isRestartPopupVisible,
currentHintIndex,
hints,
tutorialKey,
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
startTutorial,
nextHint,
prevHint,
skipTutorial,
toggleTutorial,
showRestartPopup,
confirmRestart,
cancelRestart,
hasSeen,
};
}

View File

@ -7,8 +7,11 @@ import {
deleteComment, deleteComment,
toggleVideoLike, toggleVideoLike,
isLoggedIn, isLoggedIn,
getUserMe,
API_URL,
} from '../utils/api'; } from '../utils/api';
import { VideoDetailItem, CommentItem } from '../types/api'; import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
import LoginPromptModal from './LoginPromptModal'; import LoginPromptModal from './LoginPromptModal';
interface VideoDetailContentProps { interface VideoDetailContentProps {
@ -30,7 +33,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false); const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [shareMenuUpward, setShareMenuUpward] = useState(false);
const [isLandscape, setIsLandscape] = useState(false); const [isLandscape, setIsLandscape] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
@ -43,16 +45,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const [commentSubmitting, setCommentSubmitting] = useState(false); const [commentSubmitting, setCommentSubmitting] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null); const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const [commentNickname, setCommentNickname] = useState(''); const [currentUser, setCurrentUser] = useState<UserMeResponse | null>(null);
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
// 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
const handleChangeAvatar = useCallback(() => {
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
}, []);
const fetchComments = useCallback(async (page: number, append = false) => { const fetchComments = useCallback(async (page: number, append = false) => {
setCommentsLoading(true); setCommentsLoading(true);
@ -93,6 +86,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
fetchComments(1); fetchComments(1);
}, [videoId, fetchComments]); }, [videoId, fetchComments]);
useEffect(() => {
if (!authed) return;
getUserMe().then(setCurrentUser).catch((err) => {
console.error('Failed to fetch current user:', err);
});
}, [authed]);
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }); return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
@ -103,7 +103,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
}; };
const shareUrl = `${window.location.origin}/video/${videoId}`; const shareUrl = buildVideoShareUrl(API_URL, videoId);
const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle');
const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' });
const handleCopyLink = async () => { const handleCopyLink = async () => {
try { try {
@ -115,6 +117,18 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
const handleShareButtonClick = async () => {
const handled = await tryNativeShare({
title: shareTitle,
text: shareDescription,
url: shareUrl,
});
if (handled) {
return;
}
setShareMenuOpen(v => !v);
};
const handleKakaoShare = () => { const handleKakaoShare = () => {
const kakao = window.Kakao; const kakao = window.Kakao;
if (kakao?.Share) { if (kakao?.Share) {
@ -159,23 +173,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [shareMenuOpen]); }, [shareMenuOpen]);
// 모달 내부는 스크롤 영역(.video-detail-body)이 곧 화면 경계라, 버튼이 그 하단에
// 가까우면 아래로 펼치는 메뉴가 잘려서 안 보인다. 열기 직전 남은 공간을 재서 뒤집는다.
const handleToggleShareMenu = () => {
if (!shareMenuOpen) {
const btnRect = shareMenuRef.current?.getBoundingClientRect();
if (btnRect) {
const scrollBox = shareMenuRef.current?.closest('.video-detail-body');
const boundaryBottom = scrollBox
? scrollBox.getBoundingClientRect().bottom
: window.innerHeight;
const ESTIMATED_MENU_HEIGHT = 220;
setShareMenuUpward(btnRect.bottom + ESTIMATED_MENU_HEIGHT > boundaryBottom);
}
}
setShareMenuOpen(v => !v);
};
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLike = () => { const handleLike = () => {
@ -215,13 +212,11 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
if (!commentInput.trim() || commentSubmitting) return; if (!commentInput.trim() || commentSubmitting) return;
setCommentSubmitting(true); setCommentSubmitting(true);
try { try {
await postVideoComment(videoId, commentInput.trim(), commentNickname); await postVideoComment(videoId, commentInput.trim());
setCommentInput(''); setCommentInput('');
if (commentTextareaRef.current) { if (commentTextareaRef.current) {
commentTextareaRef.current.style.height = 'auto'; commentTextareaRef.current.style.height = 'auto';
} }
setCommentNickname('');
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
await fetchComments(1); await fetchComments(1);
} catch (err) { } catch (err) {
console.error('Failed to post comment:', err); console.error('Failed to post comment:', err);
@ -310,7 +305,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<div style={{ position: 'relative' }} ref={shareMenuRef}> <div style={{ position: 'relative' }} ref={shareMenuRef}>
<button <button
className="video-detail-copy-btn" className="video-detail-copy-btn"
onClick={handleToggleShareMenu} onClick={handleShareButtonClick}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/> <circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
@ -319,7 +314,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
{copied ? t('videoDetail.copied') : t('videoDetail.share')} {copied ? t('videoDetail.copied') : t('videoDetail.share')}
</button> </button>
{shareMenuOpen && ( {shareMenuOpen && (
<div className={`video-detail-share-menu ${shareMenuUpward ? 'upward' : ''}`}> <div className="video-detail-share-menu">
{/* 카카오톡 */} {/* 카카오톡 */}
<button className="video-detail-share-item" onClick={handleKakaoShare}> <button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -362,25 +357,17 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<span className="video-detail-comments-count">{commentsTotal}</span> <span className="video-detail-comments-count">{commentsTotal}</span>
</div> </div>
{/* 댓글 작성자 프로필 선택 */} {/* 댓글 작성자 프로필 (카카오 로그인 정보) */}
{authed && ( {authed && currentUser && (
<div className="video-detail-comment-profile"> <div className="video-detail-comment-profile">
<img {currentUser.profile_image_url && (
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`} <img
alt={t('videoDetail.changeAvatarTitle')} src={currentUser.profile_image_url}
className="video-detail-comment-avatar" alt={currentUser.nickname}
onClick={handleChangeAvatar} className="video-detail-comment-avatar"
style={{ cursor: 'pointer' }} />
title={t('videoDetail.changeAvatarTitle')} )}
/> <span className="video-detail-comment-nickname">{currentUser.nickname}</span>
<input
className="video-detail-nickname-input"
type="text"
placeholder={t('videoDetail.nicknamePlaceholder')}
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
/>
</div> </div>
)} )}
@ -421,11 +408,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<ul className="video-detail-comment-list"> <ul className="video-detail-comment-list">
{comments.map((c) => ( {comments.map((c) => (
<li key={c.id} className="video-detail-comment-item"> <li key={c.id} className="video-detail-comment-item">
<img {c.profile_image_url && (
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`} <img
alt="avatar" src={c.profile_image_url}
className="video-detail-comment-avatar" alt={c.nickname || t('videoDetail.anonymous')}
/> className="video-detail-comment-avatar"
/>
)}
<div className="video-detail-comment-body"> <div className="video-detail-comment-body">
<span className="video-detail-comment-nickname"> <span className="video-detail-comment-nickname">
{c.nickname || t('videoDetail.anonymous')} {c.nickname || t('videoDetail.anonymous')}
@ -450,11 +439,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<ul className="video-detail-reply-list"> <ul className="video-detail-reply-list">
{c.replies.map((r) => ( {c.replies.map((r) => (
<li key={r.id} className="video-detail-reply-item"> <li key={r.id} className="video-detail-reply-item">
<img {r.profile_image_url && (
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`} <img
alt="avatar" src={r.profile_image_url}
className="video-detail-comment-avatar small" alt={r.nickname || t('videoDetail.anonymous')}
/> className="video-detail-comment-avatar small"
/>
)}
<div className="video-detail-comment-body"> <div className="video-detail-comment-body">
<span className="video-detail-comment-nickname"> <span className="video-detail-comment-nickname">
{r.nickname || t('videoDetail.anonymous')} {r.nickname || t('videoDetail.anonymous')}

View File

@ -13,7 +13,7 @@
}, },
"sidebar": { "sidebar": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"newProject": "New Project", "newProject": "Create New Project",
"ado2Contents": "ADO2 Contents", "ado2Contents": "ADO2 Contents",
"myContents": "My Contents", "myContents": "My Contents",
"myInfo": "My Info", "myInfo": "My Info",
@ -21,9 +21,197 @@
"credits": "Credits left: {{count}}", "credits": "Credits left: {{count}}",
"loggingOut": "Logging out...", "loggingOut": "Logging out...",
"logout": "Log Out", "logout": "Log Out",
"tutorialRestart": "Restart Tutorial",
"tutorial": "Tutorial",
"tutorialOn": "Enable Tutorial",
"tutorialOff": "Disable Tutorial",
"inquiry": "Feedback",
"login": "Log In", "login": "Log In",
"settings": "Settings", "settings": "Settings"
"inquiry": "Feedback" },
"tutorial": {
"skip": "Skip",
"next": "Next",
"prev": "Back",
"finish": "Done",
"landing": {
"intro": {
"title": "Welcome to ADO2 Tutorial",
"desc": "We'll guide you through ADO2 step by step."
},
"field": {
"title": "Enter Search Term",
"desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list."
},
"manual": {
"title": "Direct Input",
"desc": "You can also enter the business name and address manually to start analysis."
},
"button": {
"title": "Start Brand Analysis",
"desc": "Click the button to let AI start analyzing your brand."
}
},
"asset": {
"image": {
"title": "Image List",
"desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any."
},
"upload": {
"title": "Add Images",
"desc": "You can freely add more images."
},
"ratio": {
"title": "Select Video Ratio",
"desc": "Choose the ratio for the video to be generated."
},
"next": {
"title": "Next Step",
"desc": "Proceed to the next step when ready."
}
},
"sound": {
"genre": {
"title": "Select Genre",
"desc": "Pick a music genre that fits your brand.",
"note": "Background music is coming soon."
},
"language": {
"title": "Select Language",
"desc": "You can choose the language for the sound.\nWant to continue with Korean?"
},
"generate": {
"title": "Generate Sound",
"desc": "Click the button and AI will generate lyrics and music."
},
"lyrics": {
"title": "Lyrics Complete",
"desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics."
},
"lyricsWait": {
"title": "Generating Music",
"desc": "AI is composing music based on the lyrics.\nPlease wait a moment."
},
"audioPlayer": {
"title": "Preview the Music",
"desc": "Music generation is complete.\nPress play to listen to the generated music."
},
"video": {
"title": "Generate Video",
"desc": "Click the button to start generating your video."
}
},
"completion": {
"contentInfo": {
"title": "Content Info",
"desc": "Check the file name, genre, resolution, and lyrics of the generated content."
},
"generating": {
"title": "Generating Video",
"desc": "AI is creating your video.\nPlease wait a moment."
},
"completion": {
"title": "Video Complete!",
"desc": "Your video is ready. Want to take a look?"
},
"myInfo": {
"title": "Connect Social Account",
"desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there."
}
},
"myInfo": {
"myInfo": {
"title": "My Info",
"desc": "In My Info, you can manage your social connections and view connected accounts."
},
"connect": {
"title": "Connect Now",
"desc": "Click the YouTube connect button to go to the connection page.",
"note": "Instagram connection is coming soon."
},
"connected": {
"title": "Connected Accounts",
"desc": "Your linked social accounts appear here.\nCheck after connecting."
},
"ado2": {
"title": "ADO2 Contents",
"desc": "You can now upload the generated video.\nClick to navigate."
}
},
"ado2": {
"list": {
"title": "Generated Videos",
"desc": "View all AI-created videos here."
},
"download": {
"title": "Download",
"desc": "Download the video to your device."
},
"delete": {
"title": "Delete",
"desc": "Remove videos you no longer need."
},
"upload": {
"title": "Upload to Social Media",
"desc": "Select a video and upload it to social media."
}
},
"upload": {
"seo": {
"title": "Title & Description",
"desc": "AI is generating the title and description for your video. Please wait a moment."
},
"required": {
"title": "Required Fields",
"desc": "Fields marked with * are required.\nPlease check them before uploading."
},
"schedule": {
"title": "Schedule Upload",
"desc": "Post now or schedule for a specific time."
},
"submit": {
"title": "Start Upload",
"desc": "Click the Post button to start uploading."
}
},
"dashboard": {
"metrics": {
"title": "Key Metrics",
"desc": "Check views, subscribers, and other stats for content uploaded via ADO2."
},
"chart": {
"title": "Growth Chart",
"desc": "Track your channel's growth over time."
},
"more": {
"title": "More Analytics",
"desc": "Even more statistics are available at a glance on the dashboard."
}
},
"contentCalendar": {
"grid": {
"title": "Content Calendar",
"desc": "View your content schedule by date.\nWhy not select today?"
},
"panel": {
"title": "Content List",
"desc": "Check the detailed content schedule here."
}
},
"feedback": {
"complete": {
"title": "Tutorial Complete 🎉",
"desc": "You've completed the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right."
},
"title": "Customer Feedback",
"desc": "Share any issues or suggestions to help us improve."
},
"restart": {
"title": "Restart Tutorial?",
"desc": "The tutorial will restart from the current screen.",
"confirm": "Start",
"cancel": "Cancel"
}
}, },
"footer": { "footer": {
"company": "O2O Inc.", "company": "O2O Inc.",
@ -158,29 +346,6 @@
"loggingIn": "Logging in...", "loggingIn": "Logging in...",
"kakaoStart": "Start with Kakao" "kakaoStart": "Start with Kakao"
}, },
"pipelineTabs": {
"ariaLabel": "Content type",
"ado2": "ADO2",
"ssul": "Ssulbox"
},
"ssulbox": {
"steps": {
"pick": "Select",
"making": "Generating",
"done": "Done"
},
"create": {
"step1": "STEP 1 · Pick a story",
"step2": "STEP 2 · Pick a place",
"searchPlaceholder": "Business name, or paste a Naver Map link",
"searchButton": "Search",
"searching": "Searching",
"cost": "Cost",
"costValue": "1 credit",
"submit": "Create Ssulbox",
"submitting": "Requesting…"
}
},
"urlInput": { "urlInput": {
"searchTypeBusinessName": "Business Name", "searchTypeBusinessName": "Business Name",
"placeholderBusinessName": "Enter a business name", "placeholderBusinessName": "Enter a business name",
@ -429,10 +594,10 @@
}, },
"myInfo": { "myInfo": {
"title": "My Info", "title": "My Info",
"tabContents": "My Contents", "tabBasic": "Basic Info",
"tabCalendar": "Calendar", "tabPayment": "Payment Info",
"tabPayment": "Payment", "tabBusiness": "Social Channel Management",
"tabBusiness": "SNS", "basicPlaceholder": "Basic information settings are coming soon.",
"paymentPlaceholder": "Payment information settings are coming soon.", "paymentPlaceholder": "Payment information settings are coming soon.",
"myBusiness": "My Business", "myBusiness": "My Business",
"noBusinessTitle": "No registered business yet", "noBusinessTitle": "No registered business yet",
@ -461,7 +626,9 @@
"chargeSubmit": "Submit", "chargeSubmit": "Submit",
"chargeSubmitting": "Submitting...", "chargeSubmitting": "Submitting...",
"chargeSuccess": "Your top-up request has been submitted!", "chargeSuccess": "Your top-up request has been submitted!",
"chargeConfirm": "OK" "chargeConfirm": "OK",
"tabContents": "My Contents",
"tabCalendar": "Calendar"
}, },
"ado2Contents": { "ado2Contents": {
"title": "ADO2 Contents", "title": "ADO2 Contents",
@ -545,8 +712,29 @@
"scheduled": "Planned", "scheduled": "Planned",
"failed": "Failed" "failed": "Failed"
}, },
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"], "months": [
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"], "Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"days": [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
],
"yearMonth": "{{month}} {{year}}", "yearMonth": "{{month}} {{year}}",
"monthDay": "{{month}} {{day}}", "monthDay": "{{month}} {{day}}",
"loading": "Loading...", "loading": "Loading...",
@ -564,14 +752,13 @@
}, },
"loginPrompt": { "loginPrompt": {
"title": "Login Required", "title": "Login Required",
"description": "Sign in with Kakao in a few seconds\nto keep going.", "loginBtn": "Login",
"loginBtn": "Continue with Kakao" "description": "Sign in with Kakao in a few seconds\nto keep going."
}, },
"videoDetail": { "videoDetail": {
"dateFormat": "{{month}}/{{day}}/{{year}}", "dateFormat": "{{month}}/{{day}}/{{year}}",
"kakaoDefaultTitle": "ADO2 Video", "kakaoDefaultTitle": "ADO2 Video",
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video", "kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
"kakaoButtonTitle": "Watch Video",
"deletedComment": "(This comment has been deleted.)", "deletedComment": "(This comment has been deleted.)",
"closeAriaLabel": "Close", "closeAriaLabel": "Close",
"share": "Share", "share": "Share",
@ -581,8 +768,6 @@
"shareTwitter": "X (Twitter)", "shareTwitter": "X (Twitter)",
"copyUrl": "Copy URL", "copyUrl": "Copy URL",
"commentsTitle": "Comments", "commentsTitle": "Comments",
"changeAvatarTitle": "Click to change avatar",
"nicknamePlaceholder": "Author name",
"commentPlaceholder": "Write a comment...", "commentPlaceholder": "Write a comment...",
"commentLoginRequired": "Please log in to write a comment", "commentLoginRequired": "Please log in to write a comment",
"commentSubmitting": "Submitting", "commentSubmitting": "Submitting",
@ -603,5 +788,28 @@
"autocompleteError": "No results found. Please check your input and try again.", "autocompleteError": "No results found. Please check your input and try again.",
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.", "autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
"pageComingSoon": "{{page}} page is coming soon." "pageComingSoon": "{{page}} page is coming soon."
},
"pipelineTabs": {
"ariaLabel": "Content type",
"ado2": "ADO2",
"ssul": "Ssulbox"
},
"ssulbox": {
"steps": {
"pick": "Select",
"making": "Generating",
"done": "Done"
},
"create": {
"step1": "STEP 1 · Pick a story",
"step2": "STEP 2 · Pick a place",
"searchPlaceholder": "Business name, or paste a Naver Map link",
"searchButton": "Search",
"searching": "Searching",
"cost": "Cost",
"costValue": "1 credit",
"submit": "Create Ssulbox",
"submitting": "Requesting…"
}
} }
} }

View File

@ -21,12 +21,200 @@
"credits": "보유 크레딧: {{count}}", "credits": "보유 크레딧: {{count}}",
"loggingOut": "로그아웃 중...", "loggingOut": "로그아웃 중...",
"logout": "로그아웃", "logout": "로그아웃",
"tutorialRestart": "튜토리얼 다시 보기",
"tutorial": "튜토리얼",
"tutorialOn": "튜토리얼 켜기",
"tutorialOff": "튜토리얼 끄기",
"inquiry": "고객의견",
"login": "로그인", "login": "로그인",
"settings": "설정", "settings": "설정"
"inquiry": "고객의견" },
"tutorial": {
"skip": "건너뛰기",
"next": "다음",
"prev": "이전",
"finish": "완료",
"landing": {
"intro": {
"title": "ADO2 튜토리얼 시작",
"desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요."
},
"field": {
"title": "입력하기",
"desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요."
},
"manual": {
"title": "직접 입력",
"desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요."
},
"button": {
"title": "브랜드 분석 시작",
"desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요."
}
},
"asset": {
"image": {
"title": "이미지 목록",
"desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요."
},
"upload": {
"title": "이미지 추가",
"desc": "이미지를 자유롭게 추가 할 수 있어요."
},
"ratio": {
"title": "영상 비율 선택",
"desc": "생성 할 영상의 비율을 선택하세요."
},
"next": {
"title": "다음 단계로",
"desc": "설정이 완료되면 다음 단계로 진행하세요."
}
},
"sound": {
"genre": {
"title": "장르 선택",
"desc": "영상에 어울리는 음악 장르를 선택하세요.",
"note": "배경음악은 이후 오픈 예정입니다."
},
"language": {
"title": "언어 선택",
"desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?"
},
"generate": {
"title": "사운드 생성",
"desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."
},
"lyrics": {
"title": "가사 생성 완료",
"desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요."
},
"lyricsWait": {
"title": "음악 생성 중",
"desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요."
},
"audioPlayer": {
"title": "음악 미리 듣기",
"desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요."
},
"video": {
"title": "영상 생성",
"desc": "버튼을 클릭해서 영상 생성을 시작하세요."
}
},
"completion": {
"contentInfo": {
"title": "콘텐츠 정보",
"desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요."
},
"generating": {
"title": "영상 제작 중",
"desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요."
},
"completion": {
"title": "영상 완성!",
"desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?"
},
"myInfo": {
"title": "소셜 계정 연동",
"desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요."
}
},
"myInfo": {
"myInfo": {
"title": "내 정보",
"desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요."
},
"connect": {
"title": "연결하기",
"desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.",
"note": "Instagram 연결은 오픈 예정입니다."
},
"connected": {
"title": "연결 계정",
"desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요."
},
"ado2": {
"title": "ADO2 콘텐츠",
"desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요."
}
},
"ado2": {
"list": {
"title": "생성된 영상 목록",
"desc": "ADO2에서 만든 영상들을 확인할 수 있어요."
},
"download": {
"title": "다운로드",
"desc": "영상을 다운로드 할 수 있어요."
},
"delete": {
"title": "삭제",
"desc": "필요없는 영상을 삭제할 수 있어요."
},
"upload": {
"title": "소셜 업로드",
"desc": "선택해서 소셜미디어에 업로드하세요."
}
},
"upload": {
"seo": {
"title": "제목 및 설명",
"desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요."
},
"required": {
"title": "필수 항목",
"desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요."
},
"schedule": {
"title": "업로드 예약",
"desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요."
},
"submit": {
"title": "업로드 시작",
"desc": "게시 버튼을 눌러 업로드를 시작하세요."
}
},
"dashboard": {
"metrics": {
"title": "핵심 지표",
"desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요."
},
"chart": {
"title": "성장 추이 차트",
"desc": "기간별 성장 추이를 그래프로 확인할 수 있어요."
},
"more": {
"title": "더 많은 통계",
"desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요."
}
},
"contentCalendar": {
"grid": {
"title": "콘텐츠 캘린더",
"desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?"
},
"panel": {
"title": "콘텐츠 목록",
"desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요."
}
},
"feedback": {
"complete": {
"title": "튜토리얼 완료 🎉",
"desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요."
},
"title": "고객의견",
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
},
"restart": {
"title": "튜토리얼을 다시 시작할까요?",
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
"confirm": "시작하기",
"cancel": "취소"
}
}, },
"footer": { "footer": {
"company":"㈜에이아이오투오", "company": "㈜에이아이오투오",
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민", "businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호", "headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)", "researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
@ -109,7 +297,8 @@
"close": "닫기", "close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.", "doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
"goToCalendar": "캘린더에서 확인", "goToCalendar": "캘린더에서 확인",
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." }, "scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
},
"landing": { "landing": {
"hero": { "hero": {
"searchTypeLabel": "업체명 | URL", "searchTypeLabel": "업체명 | URL",
@ -157,78 +346,6 @@
"loggingIn": "로그인 중...", "loggingIn": "로그인 중...",
"kakaoStart": "카카오로 시작하기" "kakaoStart": "카카오로 시작하기"
}, },
"pipelineTabs": {
"ariaLabel": "생성할 콘텐츠 종류",
"ado2": "ADO2",
"ssul": "썰박스"
},
"ssulbox": {
"steps": {
"pick": "선택",
"making": "생성",
"done": "완성"
},
"create": {
"step1": "STEP 1 · 시나리오 선택",
"step2": "STEP 2 · 업장 검색",
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
"searchButton": "검색",
"searching": "검색 중",
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
"cost": "생성 비용",
"costValue": "1 크레딧",
"submit": "썰박스 만들기",
"submitting": "요청 중…"
},
"making": {
"title": "썰박스를 만들고 있어요",
"step1": "대본 생성 중",
"step2": "스토리보드 구성 중",
"step3": "이미지·목소리 생성 중",
"step4": "영상 합성 중",
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.",
"failTitle": "생성에 실패했어요",
"failDefault": "잠시 후 다시 시도해주세요.",
"refunded": "차감된 크레딧은 환불되었습니다.",
"retry": "다시 만들기"
},
"result": {
"title": "썰박스가 완성됐어요",
"download": "다운로드",
"downloading": "다운로드 중...",
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
"scenarioLabel": "시나리오",
"createdLabel": "생성일시"
},
"viewer": {
"untitled": "이름 없는 콘텐츠"
},
"error": {
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
},
"scenario": {
"joseon": {
"name": "조선왕",
"desc": "조선 27대 왕들의 실화 썰"
},
"samgukji": {
"name": "삼국지",
"desc": "위·촉·오 영웅 28인의 야사"
},
"greek": {
"name": "그리스·로마신화",
"desc": "올림포스 신·영웅들의 전설"
},
"odyssey": {
"name": "오디세이",
"desc": "오디세우스 10년 귀향 대모험"
}
}
},
"urlInput": { "urlInput": {
"searchTypeBusinessName": "업체명", "searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.", "placeholderBusinessName": "업체명을 입력하세요.",
@ -477,10 +594,10 @@
}, },
"myInfo": { "myInfo": {
"title": "내 정보", "title": "내 정보",
"tabContents": "내 콘텐츠", "tabBasic": "기본 정보",
"tabCalendar": "콘텐츠 캘린더",
"tabPayment": "결제 정보", "tabPayment": "결제 정보",
"tabBusiness": "소셜 채널 관리", "tabBusiness": "소셜 채널 관리",
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.", "paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
"myBusiness": "내 비즈니스", "myBusiness": "내 비즈니스",
"noBusinessTitle": "아직 등록된 비즈니스가 없어요", "noBusinessTitle": "아직 등록된 비즈니스가 없어요",
@ -509,7 +626,9 @@
"chargeSubmit": "요청하기", "chargeSubmit": "요청하기",
"chargeSubmitting": "요청 중...", "chargeSubmitting": "요청 중...",
"chargeSuccess": "충전 요청이 완료 되었습니다!", "chargeSuccess": "충전 요청이 완료 되었습니다!",
"chargeConfirm": "확인" "chargeConfirm": "확인",
"tabContents": "내 콘텐츠",
"tabCalendar": "콘텐츠 캘린더"
}, },
"ado2Contents": { "ado2Contents": {
"title": "ADO2 콘텐츠", "title": "ADO2 콘텐츠",
@ -593,8 +712,29 @@
"scheduled": "예약", "scheduled": "예약",
"failed": "실패" "failed": "실패"
}, },
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"], "months": [
"days": ["일","월","화","수","목","금","토"], "1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월"
],
"days": [
"일",
"월",
"화",
"수",
"목",
"금",
"토"
],
"yearMonth": "{{year}}년 {{month}}", "yearMonth": "{{year}}년 {{month}}",
"monthDay": "{{month}} {{day}}일", "monthDay": "{{month}} {{day}}일",
"loading": "불러오는 중...", "loading": "불러오는 중...",
@ -612,14 +752,13 @@
}, },
"loginPrompt": { "loginPrompt": {
"title": "로그인이 필요합니다.", "title": "로그인이 필요합니다.",
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요.", "loginBtn": "로그인",
"loginBtn": "카카오 로그인" "description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요."
}, },
"videoDetail": { "videoDetail": {
"dateFormat": "{{year}}년 {{month}}월 {{day}}일", "dateFormat": "{{year}}년 {{month}}월 {{day}}일",
"kakaoDefaultTitle": "ADO2 영상", "kakaoDefaultTitle": "ADO2 영상",
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상", "kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
"kakaoButtonTitle": "영상 보기",
"deletedComment": "(삭제된 댓글입니다.)", "deletedComment": "(삭제된 댓글입니다.)",
"closeAriaLabel": "닫기", "closeAriaLabel": "닫기",
"share": "공유하기", "share": "공유하기",
@ -629,8 +768,6 @@
"shareTwitter": "X (트위터)", "shareTwitter": "X (트위터)",
"copyUrl": "URL 복사", "copyUrl": "URL 복사",
"commentsTitle": "댓글", "commentsTitle": "댓글",
"changeAvatarTitle": "클릭하여 아바타 변경",
"nicknamePlaceholder": "작성자 이름",
"commentPlaceholder": "댓글을 입력하세요...", "commentPlaceholder": "댓글을 입력하세요...",
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다", "commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
"commentSubmitting": "작성 중", "commentSubmitting": "작성 중",
@ -651,5 +788,77 @@
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.", "autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.", "autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
"pageComingSoon": "{{page}} 페이지 준비 중입니다." "pageComingSoon": "{{page}} 페이지 준비 중입니다."
},
"pipelineTabs": {
"ariaLabel": "생성할 콘텐츠 종류",
"ado2": "ADO2",
"ssul": "썰박스"
},
"ssulbox": {
"steps": {
"pick": "선택",
"making": "생성",
"done": "완성"
},
"create": {
"step1": "STEP 1 · 시나리오 선택",
"step2": "STEP 2 · 업장 검색",
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
"searchButton": "검색",
"searching": "검색 중",
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
"cost": "생성 비용",
"costValue": "1 크레딧",
"submit": "썰박스 만들기",
"submitting": "요청 중…"
},
"making": {
"title": "썰박스를 만들고 있어요",
"step1": "대본 생성 중",
"step2": "스토리보드 구성 중",
"step3": "이미지·목소리 생성 중",
"step4": "영상 합성 중",
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.",
"failTitle": "생성에 실패했어요",
"failDefault": "잠시 후 다시 시도해주세요.",
"refunded": "차감된 크레딧은 환불되었습니다.",
"retry": "다시 만들기"
},
"result": {
"title": "썰박스가 완성됐어요",
"download": "다운로드",
"downloading": "다운로드 중...",
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
"scenarioLabel": "시나리오",
"createdLabel": "생성일시"
},
"viewer": {
"untitled": "이름 없는 콘텐츠"
},
"error": {
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
},
"scenario": {
"joseon": {
"name": "조선왕",
"desc": "조선 27대 왕들의 실화 썰"
},
"samgukji": {
"name": "삼국지",
"desc": "위·촉·오 영웅 28인의 야사"
},
"greek": {
"name": "그리스·로마신화",
"desc": "올림포스 신·영웅들의 전설"
},
"odyssey": {
"name": "오디세이",
"desc": "오디세우스 10년 귀향 대모험"
}
}
} }
} }

View File

@ -163,7 +163,15 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
} }
> >
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap"> <div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
{video.thumbnail_url ? ( {video.poster_url ? (
<img
src={video.poster_url}
alt={video.store_name}
loading="lazy"
decoding="async"
className="content-video-preview"
/>
) : video.thumbnail_url ? (
<img <img
src={video.thumbnail_url} src={video.thumbnail_url}
alt={video.store_name} alt={video.store_name}

View File

@ -478,6 +478,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
like_count: 0, like_count: 0,
comment_count: 0, comment_count: 0,
is_liked_by_me: false,
} }
: null : null
} }

View File

@ -79,6 +79,7 @@ interface BusinessInfo {
} }
interface GenerationFlowProps { interface GenerationFlowProps {
onHome?: () => void;
initialActiveItem?: string; initialActiveItem?: string;
initialImageList?: ImageListItem[]; initialImageList?: ImageListItem[];
businessInfo?: BusinessInfo; businessInfo?: BusinessInfo;
@ -94,6 +95,7 @@ interface GenerationFlowProps {
// 3: 완료 (Completion) // 3: 완료 (Completion)
const GenerationFlow: React.FC<GenerationFlowProps> = ({ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onHome,
initialActiveItem = NAV.HOME, initialActiveItem = NAV.HOME,
initialImageList = [], initialImageList = [],
businessInfo, businessInfo,
@ -311,7 +313,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setImageList(prev => [...newImages, ...prev]); setImageList(prev => [...newImages, ...prev]);
}; };
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 ADO2 콘텐츠 갤러리로 이동 // 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고, 부모가 있으면 랜딩으로 돌아간다.
const handleHome = () => { const handleHome = () => {
clearProjectStorage(); clearProjectStorage();
localStorage.removeItem(ANALYSIS_DATA_KEY); localStorage.removeItem(ANALYSIS_DATA_KEY);
@ -321,6 +323,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisData(null); setAnalysisData(null);
revokeAllPreviews(imageListRef.current); revokeAllPreviews(imageListRef.current);
setImageList([]); setImageList([]);
if (onHome) {
onHome();
return;
}
setActiveItem(NAV.HOME); setActiveItem(NAV.HOME);
}; };

View File

@ -264,7 +264,15 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
: setSelectedVideoId(video.video_id) : setSelectedVideoId(video.video_id)
} }
> >
{video.result_movie_url ? ( {video.poster_url ? (
<img
src={video.poster_url}
alt={video.store_name}
loading="lazy"
decoding="async"
className="content-video-preview"
/>
) : video.result_movie_url ? (
<VideoPreviewCard <VideoPreviewCard
src={video.result_movie_url} src={video.result_movie_url}
className="content-video-preview" className="content-video-preview"

View File

@ -0,0 +1,51 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import Footer from '../../components/Footer';
interface DisplaySectionProps {
onStartClick?: () => void;
}
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const { t } = useTranslation();
// YouTube Shorts 영상 ID들
const videos = [
{ id: 1, videoId: 'M3iuPZ59X1I' },
{ id: 2, videoId: 'JxWQxELDHSs' },
{ id: 3, videoId: 'c2ZdwhaB7S4' },
];
return (
<div className="display-section">
<div className="content-safe-area">
{/* Main visual frames container */}
<div className="display-frames">
{videos.map((video, index) => (
<div
key={video.id}
className={`display-frame ${index === 2 ? 'display-frame-hidden-mobile' : ''}`}
>
<iframe
src={`https://www.youtube.com/embed/${video.videoId}?autoplay=1&mute=1&loop=1&playlist=${video.videoId}&controls=0&showinfo=0&rel=0&modestbranding=1&playsinline=1`}
title={`YouTube Shorts ${video.id}`}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="display-video"
/>
</div>
))}
</div>
{/* Action Button */}
<button onClick={onStartClick} className="display-button">
{t('landing.display.startButton')}
</button>
</div>
<Footer />
</div>
);
};
export default DisplaySection;

202
src/pages/Landing/HeroSection.tsx Executable file
View File

@ -0,0 +1,202 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
import LoginPromptModal from '../../components/LoginPromptModal';
import SearchInputForm from '../../components/SearchInputForm';
import { SearchType } from '../../components/SearchInputForm';
// Orb configuration with movement zones to prevent overlap
interface OrbConfig {
id: string;
size: number;
initialX: number;
initialY: number;
color: string;
minX: number;
maxX: number;
minY: number;
maxY: number;
}
const orbConfigs: OrbConfig[] = [
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
];
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number;
}
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
const { t } = useTranslation();
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
}, 800);
return () => clearTimeout(timer);
}
}, []);
// Orb 랜덤 이동 애니메이션
useEffect(() => {
const moveOrb = (orb: HTMLDivElement, index: number) => {
const config = orbConfigs[index];
let currentX = config.initialX;
let currentY = config.initialY;
let targetX = currentX;
let targetY = currentY;
let scale = 1;
let targetScale = 1;
let isFirstMove = true;
const generateNewTarget = () => {
const rangeX = config.maxX - config.minX;
const rangeY = config.maxY - config.minY;
if (isFirstMove) {
const smallRangeX = rangeX * 0.3;
const smallRangeY = rangeY * 0.3;
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
isFirstMove = false;
} else {
targetX = config.minX + Math.random() * rangeX;
targetY = config.minY + Math.random() * rangeY;
}
targetScale = 0.9 + Math.random() * 0.2;
};
const animate = () => {
const speed = 0.003;
currentX += (targetX - currentX) * speed;
currentY += (targetY - currentY) * speed;
scale += (targetScale - scale) * speed;
orb.style.left = `${currentX}%`;
orb.style.top = `${currentY}%`;
orb.style.transform = `scale(${scale})`;
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
if (distance < 1) generateNewTarget();
animationRefs.current[index] = requestAnimationFrame(animate);
};
generateNewTarget();
animate();
};
orbRefs.current.forEach((orb, index) => {
if (orb) moveOrb(orb, index);
});
return () => {
animationRefs.current.forEach(id => cancelAnimationFrame(id));
};
}, []);
return (
<div className="hero-section">
{/* Animated background orbs */}
<div
className="hero-bg-orbs"
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
>
{orbConfigs.map((config, index) => (
<div
key={config.id}
ref={el => { orbRefs.current[index] = el; }}
className="hero-orb-random"
style={{
width: `${config.size}px`,
height: `${config.size}px`,
background: config.color,
left: `${config.initialX}%`,
top: `${config.initialY}%`,
}}
/>
))}
</div>
<div className="hero-content">
<img
src="/assets/images/ADO2_with Slogan_white.svg"
alt="ADO2"
className="hero-logo"
/>
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
onManualButtonClick={() => {
if (isLoggedIn()) {
setIsManualModalOpen(true);
} else {
setIsLoginPromptOpen(true);
}
}}
error={externalError || null}
/>
</div>
{/* Footer Indicator */}
<button onClick={onNext} className="scroll-indicator">
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
<div className="scroll-indicator-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 10l5 5 5-5" />
</svg>
</div>
</button>
{tutorial.isActive && !isManualModalOpen && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category);
}}
/>
)}
{isLoginPromptOpen && (
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
)}
</div>
);
};
export default HeroSection;

View File

@ -0,0 +1,70 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface WelcomeSectionProps {
onStartClick?: () => void;
onNext?: () => void;
}
const WelcomeSection: React.FC<WelcomeSectionProps> = () => {
const { t } = useTranslation();
const features = [
{
id: 1,
title: t('landing.welcome.feature1Title'),
description: t('landing.welcome.feature1Desc'),
iconBg: '#9BCACC',
icon: '/assets/images/icon-analysis.svg'
},
{
id: 2,
title: t('landing.welcome.feature2Title'),
description: t('landing.welcome.feature2Desc'),
iconBg: '#DFC7FD',
icon: '/assets/images/icon-content.svg'
},
{
id: 3,
title: t('landing.welcome.feature3Title'),
description: t('landing.welcome.feature3Desc'),
iconBg: '#D4FDF3',
icon: '/assets/images/icon-deploy.svg'
},
];
return (
<div className="welcome-section">
<div className="welcome-content">
{/* Star Icon - Top Center */}
<div className="welcome-star">
<img src="/assets/images/star-icon.svg" alt="star" />
</div>
{/* Header */}
<div className="welcome-header">
<h2 className="welcome-title">{t('landing.welcome.title')}</h2>
<p className="welcome-subtitle">{t('landing.welcome.subtitle')}</p>
</div>
{/* Feature Cards */}
<div className="feature-grid">
{features.map((feature) => (
<div key={feature.id} className="feature-card">
<div className="feature-number-badge">
<span>{feature.id}</span>
</div>
<h3 className="feature-card-title">{feature.title}</h3>
<div className="feature-icon-box" style={{ backgroundColor: feature.iconBg }}>
<img src={feature.icon} alt={feature.title} className="feature-icon-img" />
</div>
<p className="feature-card-description">{feature.description}</p>
</div>
))}
</div>
</div>
</div>
);
};
export default WelcomeSection;

109
src/pages/Login/LoginSection.tsx Executable file
View File

@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api';
interface LoginSectionProps {
onBack: () => void;
onLogin: () => void;
}
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 카카오 콜백 처리 (URL에서 code 파라미터 확인)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code) {
handleKakaoCallback(code);
}
}, []);
const handleKakaoCallback = async (code: string) => {
setIsLoading(true);
setError(null);
try {
const response = await kakaoCallback(code);
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
// (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해)
if (response.is_new_user) {
await trackCompleteRegistration();
}
// URL에서 code 파라미터 제거
const url = new URL(window.location.href);
url.searchParams.delete('code');
window.history.replaceState({}, document.title, url.pathname);
// 로그인 성공
onLogin();
} catch (err) {
console.error('Kakao callback failed:', err);
setError(t('login.kakaoLoginFailed'));
// URL에서 code 파라미터 제거
const url = new URL(window.location.href);
url.searchParams.delete('code');
window.history.replaceState({}, document.title, url.pathname);
} finally {
setIsLoading(false);
}
};
const handleKakaoLogin = async () => {
setIsLoading(true);
setError(null);
try {
const response = await getKakaoLoginUrl();
// 카카오 로그인 페이지로 리다이렉트
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
setError(t('login.loginUrlFailed'));
setIsLoading(false);
}
};
return (
<div className="login-container">
{/* Back Button */}
<button onClick={onBack} className="login-back-btn" disabled={isLoading}>
<img src="/assets/images/icon-back.svg" alt="Back" />
<span>{t('login.back')}</span>
</button>
<div className="login-content">
{/* Logo */}
<div className="login-logo">
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
</div>
{/* Error Message */}
{error && (
<div className="login-error">
{error}
</div>
)}
{/* Kakao Login Button */}
<button
onClick={handleKakaoLogin}
className="btn-kakao"
disabled={isLoading}
>
{isLoading ? t('login.loggingIn') : t('login.kakaoStart')}
</button>
</div>
</div>
);
};
export default LoginSection;

View File

@ -247,7 +247,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
if (!commentInput.trim() || commentSubmitting) return; if (!commentInput.trim() || commentSubmitting) return;
setCommentSubmitting(true); setCommentSubmitting(true);
try { try {
await postVideoComment(contentId, commentInput.trim(), commentNickname, undefined, 'ssul'); await postVideoComment(contentId, commentInput.trim(), undefined, 'ssul');
setCommentInput(''); setCommentInput('');
if (commentTextareaRef.current) { if (commentTextareaRef.current) {
commentTextareaRef.current.style.height = 'auto'; commentTextareaRef.current.style.height = 'auto';

View File

@ -2886,6 +2886,7 @@
.wizard-stepper { .wizard-stepper {
padding: 2rem; padding: 2rem;
max-width: 100%; max-width: 100%;
margin-top: 30px;
} }
.wizard-stepper-node { .wizard-stepper-node {

View File

@ -1145,6 +1145,36 @@
} }
} }
/* 모바일: 제목은 한 줄로 유지하고, 권장 수량/카운트는 그 아래 줄로 내린다 */
@media (max-width: 1023px) {
.asset-section-header {
align-items: flex-start;
}
.asset-section-header-left {
flex: 1;
min-width: 0;
flex-wrap: wrap;
row-gap: 4px;
}
.asset-section-header-left .asset-section-title {
order: 1;
white-space: nowrap;
}
.asset-section-header-left .asset-section-count {
order: 2;
white-space: nowrap;
}
.asset-section-header-left .asset-section-subtitle {
order: 3;
width: 100%;
white-space: nowrap;
}
}
/* Asset Image List */ /* Asset Image List */
.asset-image-list { .asset-image-list {
background-color: #002224; background-color: #002224;

View File

@ -287,17 +287,22 @@ export interface VideoListItem {
region: string; region: string;
task_id: string; task_id: string;
result_movie_url: string; result_movie_url: string;
poster_url?: string | null;
thumbnail_url?: string; thumbnail_url?: string;
title?: string | null;
description?: string | null;
hashtags?: string[] | null;
created_at: string; created_at: string;
like_count: number; like_count: number;
comment_count: number; comment_count: number;
is_liked_by_me?: boolean; is_liked_by_me: boolean;
} }
// 비디오 상세 아이템 // 비디오 상세 아이템
export interface VideoDetailItem { export interface VideoDetailItem {
video_id: number; video_id: number;
result_movie_url: string; result_movie_url: string;
poster_url?: string | null;
store_name: string; store_name: string;
region: string; region: string;
created_at: string; created_at: string;
@ -320,6 +325,7 @@ export interface VideosListResponse {
export interface CommentReply { export interface CommentReply {
id: number; id: number;
nickname: string; nickname: string;
profile_image_url: string | null;
content: string | null; content: string | null;
is_deleted: boolean; is_deleted: boolean;
is_mine: boolean; is_mine: boolean;
@ -330,6 +336,7 @@ export interface CommentReply {
export interface CommentItem { export interface CommentItem {
id: number; id: number;
nickname: string; nickname: string;
profile_image_url: string | null;
content: string | null; content: string | null;
is_deleted: boolean; is_deleted: boolean;
is_mine: boolean; is_mine: boolean;

View File

@ -658,18 +658,17 @@ export async function getVideoComments(
return response.json(); return response.json();
} }
// 댓글 작성 // 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
export async function postVideoComment( export async function postVideoComment(
videoId: string, videoId: string,
content: string, content: string,
nickname?: string,
parentId?: number, parentId?: number,
contentType: ContentType = 'video' contentType: ContentType = 'video'
): Promise<CommentItem> { ): Promise<CommentItem> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, { const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }), body: JSON.stringify({ content, parent_id: parentId ?? null }),
}); });
if (!response.ok) { if (!response.ok) {

39
src/utils/nativeShare.ts Normal file
View File

@ -0,0 +1,39 @@
/**
* 기기의 네이티브 공유 시트를 열고 요청을 처리했는지 반환합니다.
*
* 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주합니다.
*/
export async function tryNativeShare(data: ShareData): Promise<boolean> {
if (typeof navigator.share !== 'function') {
return false;
}
try {
await navigator.share(data);
return true;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return true;
}
return false;
}
}
/** API 서버가 제공하는 영상별 Open Graph 공유 페이지 URL을 만듭니다. */
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`;
}
/** 콘텐츠 종류별 공개 공유 URL. video.id 와 ssul_content.id 가 겹치므로 경로를 갈라야 한다. */
export function buildContentShareUrl(
apiBaseUrl: string,
contentId: number | string,
contentType: 'video' | 'ssul' = 'video',
): string {
if (contentType === 'ssul') {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}/ssul/${encodeURIComponent(String(contentId))}`;
}
return buildVideoShareUrl(apiBaseUrl, contentId);
}