Compare commits
7 Commits
3b8298bba2
...
82b9fef3a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 82b9fef3a1 | |||
| 4fe065378f | |||
| bdb581459d | |||
| 63d60caabd | |||
| e400884eb7 | |||
| 3566c1f7bc | |||
| 9c0c3de35f |
BIN
public/assets/images/ado2_image.png
Normal file
BIN
public/assets/images/ado2_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
399
src/App.tsx
399
src/App.tsx
@ -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, 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,8 +48,61 @@ 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);
|
||||||
|
|
||||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||||
@ -57,8 +130,8 @@ const App: React.FC = () => {
|
|||||||
setIsProcessingCallback(true);
|
setIsProcessingCallback(true);
|
||||||
handleKakaoCallback(code);
|
handleKakaoCallback(code);
|
||||||
}
|
}
|
||||||
// 콜백이 아닌 일반 진입 — 광고 유입 추적
|
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
|
||||||
else {
|
else if (viewMode === 'landing') {
|
||||||
trackViewContent();
|
trackViewContent();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@ -82,15 +155,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 +203,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 +450,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 +467,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;
|
||||||
|
|||||||
@ -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
64
src/components/Header.tsx
Executable 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;
|
||||||
@ -180,16 +180,24 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// 소셜 계정 로드
|
// 소셜 계정 로드 + SEO 자동 채움
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) {
|
||||||
|
loadedForTaskIdRef.current = null;
|
||||||
const now = Date.now();
|
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 +216,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 +259,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 +271,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
@ -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,9 @@
|
|||||||
"credits": "Credits left: {{count}}",
|
"credits": "Credits left: {{count}}",
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
|
"inquiry": "Feedback",
|
||||||
"login": "Log In",
|
"login": "Log In",
|
||||||
"settings": "Settings",
|
"settings": "Settings"
|
||||||
"inquiry": "Feedback"
|
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company": "O2O Inc.",
|
"company": "O2O Inc.",
|
||||||
@ -158,29 +158,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 +406,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 +438,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 +524,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 +564,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 +580,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 +600,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…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,12 +21,12 @@
|
|||||||
"credits": "보유 크레딧: {{count}}",
|
"credits": "보유 크레딧: {{count}}",
|
||||||
"loggingOut": "로그아웃 중...",
|
"loggingOut": "로그아웃 중...",
|
||||||
"logout": "로그아웃",
|
"logout": "로그아웃",
|
||||||
|
"inquiry": "고객의견",
|
||||||
"login": "로그인",
|
"login": "로그인",
|
||||||
"settings": "설정",
|
"settings": "설정"
|
||||||
"inquiry": "고객의견"
|
|
||||||
},
|
},
|
||||||
"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 +109,8 @@
|
|||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||||
"goToCalendar": "캘린더에서 확인",
|
"goToCalendar": "캘린더에서 확인",
|
||||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
|
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
|
||||||
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeLabel": "업체명 | URL",
|
"searchTypeLabel": "업체명 | URL",
|
||||||
@ -157,78 +158,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 +406,10 @@
|
|||||||
},
|
},
|
||||||
"myInfo": {
|
"myInfo": {
|
||||||
"title": "내 정보",
|
"title": "내 정보",
|
||||||
"tabContents": "내 콘텐츠",
|
"tabBasic": "기본 정보",
|
||||||
"tabCalendar": "콘텐츠 캘린더",
|
|
||||||
"tabPayment": "결제 정보",
|
"tabPayment": "결제 정보",
|
||||||
"tabBusiness": "소셜 채널 관리",
|
"tabBusiness": "소셜 채널 관리",
|
||||||
|
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
||||||
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
||||||
"myBusiness": "내 비즈니스",
|
"myBusiness": "내 비즈니스",
|
||||||
"noBusinessTitle": "아직 등록된 비즈니스가 없어요",
|
"noBusinessTitle": "아직 등록된 비즈니스가 없어요",
|
||||||
@ -509,7 +438,9 @@
|
|||||||
"chargeSubmit": "요청하기",
|
"chargeSubmit": "요청하기",
|
||||||
"chargeSubmitting": "요청 중...",
|
"chargeSubmitting": "요청 중...",
|
||||||
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
||||||
"chargeConfirm": "확인"
|
"chargeConfirm": "확인",
|
||||||
|
"tabContents": "내 콘텐츠",
|
||||||
|
"tabCalendar": "콘텐츠 캘린더"
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 콘텐츠",
|
"title": "ADO2 콘텐츠",
|
||||||
@ -593,8 +524,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 +564,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 +580,6 @@
|
|||||||
"shareTwitter": "X (트위터)",
|
"shareTwitter": "X (트위터)",
|
||||||
"copyUrl": "URL 복사",
|
"copyUrl": "URL 복사",
|
||||||
"commentsTitle": "댓글",
|
"commentsTitle": "댓글",
|
||||||
"changeAvatarTitle": "클릭하여 아바타 변경",
|
|
||||||
"nicknamePlaceholder": "작성자 이름",
|
|
||||||
"commentPlaceholder": "댓글을 입력하세요...",
|
"commentPlaceholder": "댓글을 입력하세요...",
|
||||||
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
||||||
"commentSubmitting": "작성 중",
|
"commentSubmitting": "작성 중",
|
||||||
@ -651,5 +600,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년 귀향 대모험"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
51
src/pages/Landing/DisplaySection.tsx
Executable file
51
src/pages/Landing/DisplaySection.tsx
Executable 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;
|
||||||
176
src/pages/Landing/HeroSection.tsx
Executable file
176
src/pages/Landing/HeroSection.tsx
Executable file
@ -0,0 +1,176 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
||||||
|
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[]>([]);
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
{isManualModalOpen && (
|
||||||
|
<BusinessNameInputModal
|
||||||
|
onClose={() => setIsManualModalOpen(false)}
|
||||||
|
onSubmit={(businessName, address, category) => {
|
||||||
|
setIsManualModalOpen(false);
|
||||||
|
onManualInput?.(businessName, address, category);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoginPromptOpen && (
|
||||||
|
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSection;
|
||||||
70
src/pages/Landing/WelcomeSection.tsx
Executable file
70
src/pages/Landing/WelcomeSection.tsx
Executable 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
109
src/pages/Login/LoginSection.tsx
Executable 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;
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
39
src/utils/nativeShare.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user