Compare commits

...

7 Commits

Author SHA1 Message Date
82b9fef3a1 chore: 튜토리얼 오버레이와 관련 문구를 제거
랜딩·업로드에서 자동 안내가 더 이상 뜨지 않게 한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:31:44 +09:00
4fe065378f Merge branch 'main' into feature-ssulbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:15:53 +09:00
bdb581459d feat(social): 업로드 폼에 저장된 영상 SEO 메타데이터를 표시
값이 있으면 DB 결과를 바로 채우고, 없으면 video_id 기준으로 한 번만 생성한다.
2026-08-19 10:19:54 +09:00
63d60caabd fix: 이전 공유하기 기능 제거 2026-08-18 17:23:42 +09:00
e400884eb7 fix: 공통으로 공유하기 사용하도록 변경 2026-08-18 14:56:38 +09:00
3566c1f7bc feat: 영상 공유·포스터 썸네일·좋아요 상태를 서버 연동으로 전환 2026-08-18 11:02:29 +09:00
9c0c3de35f UI 수정 2026-08-14 15:29:39 +09:00
22 changed files with 1208 additions and 359 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -1,16 +1,28 @@
import React, { useState, useEffect } from 'react';
import React, { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import 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 SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
import VideoDetailPage from './components/VideoDetailPage';
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
import { clearSessionStorage } from './utils/storageKeys';
import { NAV } from './components/navItems';
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
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';
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
@ -19,7 +31,15 @@ const initializeOnNewSession = () => {
if (!isExistingSession) {
// 새 세션이면 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');
}
};
@ -28,8 +48,61 @@ const initializeOnNewSession = () => {
initializeOnNewSession();
const App: React.FC = () => {
const { t } = useTranslation();
const [initialTab, setInitialTab] = useState<string>(NAV.HOME);
const { t, i18n } = useTranslation();
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);
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
@ -57,8 +130,8 @@ const App: React.FC = () => {
setIsProcessingCallback(true);
handleKakaoCallback(code);
}
// 콜백이 아닌 일반 진입 — 광고 유입 추적
else {
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
else if (viewMode === 'landing') {
trackViewContent();
}
}, []);
@ -82,15 +155,21 @@ const App: React.FC = () => {
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');
sessionStorage.removeItem('castad_login_redirect');
if (redirectPath && redirectPath !== '/') {
window.location.href = redirectPath;
return;
}
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리
setInitialTab(NAV.HOME);
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
} catch (err) {
console.error('Token callback failed:', err);
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 콜백 페이지 처리
const pathname = window.location.pathname;
@ -146,14 +450,12 @@ const App: React.FC = () => {
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
}
// 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능.
// /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라
// 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다.
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
if (ssulDetailMatch) {
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
}
// 카카오 콜백 처리 중 로딩 화면 표시
if (isProcessingCallback) {
return (
@ -165,8 +467,71 @@ const App: React.FC = () => {
);
}
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴
return <GenerationFlow initialActiveItem={initialTab} />;
if (viewMode === 'loading') {
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;

View File

@ -1,7 +1,7 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import React, { useRef, useState } from 'react';
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 LoginPromptModal from './LoginPromptModal';
@ -33,102 +33,29 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
const [likeCount, setLikeCount] = useState(initialLikeCount);
const [isLiked, setIsLiked] = useState(initialIsLiked);
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);
// 종류별 공개 페이지가 다르다 — 합치면 id 가 겹치는 다른 콘텐츠가 열린다
const shareUrl = `${window.location.origin}/${contentType === 'ssul' ? 'ssul' : 'video'}/${videoId}`;
const shareUrl = buildContentShareUrl(API_URL, videoId, contentType);
const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle');
const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' });
useEffect(() => {
if (!shareMenuOpen) return;
const handleShareBtnClick = async (e: React.MouseEvent) => {
e.stopPropagation();
const closeMenu = () => setShareMenuOpen(false);
const handleClickOutside = (e: MouseEvent) => {
if (
shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) &&
shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node)
) {
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;
const handled = await tryNativeShare({
title: shareTitle,
text: shareDescription,
url: shareUrl,
});
if (handled) {
return;
}
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 {
await navigator.clipboard.writeText(shareUrl);
} catch {
// 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) => {
@ -159,17 +86,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
}, 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 (
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
<button
@ -190,7 +106,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
</span>
<button
ref={shareBtnRef}
className="content-card-share-btn"
onClick={handleShareBtnClick}
title={t('videoDetail.share')}
@ -201,42 +116,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
</svg>
</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 && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}

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

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

View File

@ -180,16 +180,24 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
// 소셜 계정 로드
// 소셜 계정 로드 + SEO 자동 채움
useEffect(() => {
if (!isOpen) return;
const now = Date.now();
if (!isOpen) {
loadedForTaskIdRef.current = null;
return;
}
loadSocialAccounts();
// SEO 자동 채움 키. ADO2 는 task_id, 썰박스는 task_id 가 없어(빈 문자열)
// (type, video_id) 로 키를 만든다 — 없으면 썰박스는 자동 채움이 통째로 스킵됐다.
if (video?.title) {
setTitle(video.title);
setDescription(video.description || '');
setTags((video.hashtags || []).join(','));
setIsLoadingAutoDescription(false);
return;
}
const now = Date.now();
const seoKey =
video?.type === 'ssul'
? `ssul:${video.video_id}`
@ -208,7 +216,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
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 () => {
setIsLoadingAccounts(true);
@ -251,11 +259,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
content_type: video.type,
task_id: isSsul ? String(video.video_id) : video.task_id,
};
// Call autoSEO API
console.log('[Upload] Request payload:', requestPayload);
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
// 각 필드가 있을 때만 덮어씌움 (기존 값 보호)
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
@ -266,7 +271,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
});
} catch (error) {
console.error('Failed to load autocomplete:', error);
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
} finally {
setIsLoadingAutoDescription(false);
}

View File

@ -7,8 +7,11 @@ import {
deleteComment,
toggleVideoLike,
isLoggedIn,
getUserMe,
API_URL,
} 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';
interface VideoDetailContentProps {
@ -30,7 +33,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [shareMenuUpward, setShareMenuUpward] = useState(false);
const [isLandscape, setIsLandscape] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
@ -43,16 +45,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const [commentSubmitting, setCommentSubmitting] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const [commentNickname, setCommentNickname] = useState('');
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 [currentUser, setCurrentUser] = useState<UserMeResponse | null>(null);
const fetchComments = useCallback(async (page: number, append = false) => {
setCommentsLoading(true);
@ -93,6 +86,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
fetchComments(1);
}, [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 date = new Date(dateString);
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')}`;
};
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 () => {
try {
@ -115,6 +117,18 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
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 kakao = window.Kakao;
if (kakao?.Share) {
@ -159,23 +173,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [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 handleLike = () => {
@ -215,13 +212,11 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
if (!commentInput.trim() || commentSubmitting) return;
setCommentSubmitting(true);
try {
await postVideoComment(videoId, commentInput.trim(), commentNickname);
await postVideoComment(videoId, commentInput.trim());
setCommentInput('');
if (commentTextareaRef.current) {
commentTextareaRef.current.style.height = 'auto';
}
setCommentNickname('');
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
await fetchComments(1);
} catch (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}>
<button
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">
<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')}
</button>
{shareMenuOpen && (
<div className={`video-detail-share-menu ${shareMenuUpward ? 'upward' : ''}`}>
<div className="video-detail-share-menu">
{/* 카카오톡 */}
<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">
@ -362,25 +357,17 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<span className="video-detail-comments-count">{commentsTotal}</span>
</div>
{/* 댓글 작성자 프로필 선택 */}
{authed && (
{/* 댓글 작성자 프로필 (카카오 로그인 정보) */}
{authed && currentUser && (
<div className="video-detail-comment-profile">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
alt={t('videoDetail.changeAvatarTitle')}
className="video-detail-comment-avatar"
onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }}
title={t('videoDetail.changeAvatarTitle')}
/>
<input
className="video-detail-nickname-input"
type="text"
placeholder={t('videoDetail.nicknamePlaceholder')}
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
/>
{currentUser.profile_image_url && (
<img
src={currentUser.profile_image_url}
alt={currentUser.nickname}
className="video-detail-comment-avatar"
/>
)}
<span className="video-detail-comment-nickname">{currentUser.nickname}</span>
</div>
)}
@ -421,11 +408,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<ul className="video-detail-comment-list">
{comments.map((c) => (
<li key={c.id} className="video-detail-comment-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
alt="avatar"
className="video-detail-comment-avatar"
/>
{c.profile_image_url && (
<img
src={c.profile_image_url}
alt={c.nickname || t('videoDetail.anonymous')}
className="video-detail-comment-avatar"
/>
)}
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{c.nickname || t('videoDetail.anonymous')}
@ -450,11 +439,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<ul className="video-detail-reply-list">
{c.replies.map((r) => (
<li key={r.id} className="video-detail-reply-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
alt="avatar"
className="video-detail-comment-avatar small"
/>
{r.profile_image_url && (
<img
src={r.profile_image_url}
alt={r.nickname || t('videoDetail.anonymous')}
className="video-detail-comment-avatar small"
/>
)}
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{r.nickname || t('videoDetail.anonymous')}

View File

@ -13,7 +13,7 @@
},
"sidebar": {
"dashboard": "Dashboard",
"newProject": "New Project",
"newProject": "Create New Project",
"ado2Contents": "ADO2 Contents",
"myContents": "My Contents",
"myInfo": "My Info",
@ -21,9 +21,9 @@
"credits": "Credits left: {{count}}",
"loggingOut": "Logging out...",
"logout": "Log Out",
"inquiry": "Feedback",
"login": "Log In",
"settings": "Settings",
"inquiry": "Feedback"
"settings": "Settings"
},
"footer": {
"company": "O2O Inc.",
@ -158,29 +158,6 @@
"loggingIn": "Logging in...",
"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": {
"searchTypeBusinessName": "Business Name",
"placeholderBusinessName": "Enter a business name",
@ -429,10 +406,10 @@
},
"myInfo": {
"title": "My Info",
"tabContents": "My Contents",
"tabCalendar": "Calendar",
"tabPayment": "Payment",
"tabBusiness": "SNS",
"tabBasic": "Basic Info",
"tabPayment": "Payment Info",
"tabBusiness": "Social Channel Management",
"basicPlaceholder": "Basic information settings are coming soon.",
"paymentPlaceholder": "Payment information settings are coming soon.",
"myBusiness": "My Business",
"noBusinessTitle": "No registered business yet",
@ -461,7 +438,9 @@
"chargeSubmit": "Submit",
"chargeSubmitting": "Submitting...",
"chargeSuccess": "Your top-up request has been submitted!",
"chargeConfirm": "OK"
"chargeConfirm": "OK",
"tabContents": "My Contents",
"tabCalendar": "Calendar"
},
"ado2Contents": {
"title": "ADO2 Contents",
@ -545,8 +524,29 @@
"scheduled": "Planned",
"failed": "Failed"
},
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
"months": [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"days": [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
],
"yearMonth": "{{month}} {{year}}",
"monthDay": "{{month}} {{day}}",
"loading": "Loading...",
@ -564,14 +564,13 @@
},
"loginPrompt": {
"title": "Login Required",
"description": "Sign in with Kakao in a few seconds\nto keep going.",
"loginBtn": "Continue with Kakao"
"loginBtn": "Login",
"description": "Sign in with Kakao in a few seconds\nto keep going."
},
"videoDetail": {
"dateFormat": "{{month}}/{{day}}/{{year}}",
"kakaoDefaultTitle": "ADO2 Video",
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
"kakaoButtonTitle": "Watch Video",
"deletedComment": "(This comment has been deleted.)",
"closeAriaLabel": "Close",
"share": "Share",
@ -581,8 +580,6 @@
"shareTwitter": "X (Twitter)",
"copyUrl": "Copy URL",
"commentsTitle": "Comments",
"changeAvatarTitle": "Click to change avatar",
"nicknamePlaceholder": "Author name",
"commentPlaceholder": "Write a comment...",
"commentLoginRequired": "Please log in to write a comment",
"commentSubmitting": "Submitting",
@ -603,5 +600,28 @@
"autocompleteError": "No results found. Please check your input and try again.",
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
"pageComingSoon": "{{page}} page is coming soon."
},
"pipelineTabs": {
"ariaLabel": "Content type",
"ado2": "ADO2",
"ssul": "Ssulbox"
},
"ssulbox": {
"steps": {
"pick": "Select",
"making": "Generating",
"done": "Done"
},
"create": {
"step1": "STEP 1 · Pick a story",
"step2": "STEP 2 · Pick a place",
"searchPlaceholder": "Business name, or paste a Naver Map link",
"searchButton": "Search",
"searching": "Searching",
"cost": "Cost",
"costValue": "1 credit",
"submit": "Create Ssulbox",
"submitting": "Requesting…"
}
}
}

View File

@ -21,12 +21,12 @@
"credits": "보유 크레딧: {{count}}",
"loggingOut": "로그아웃 중...",
"logout": "로그아웃",
"inquiry": "고객의견",
"login": "로그인",
"settings": "설정",
"inquiry": "고객의견"
"settings": "설정"
},
"footer": {
"company":"㈜에이아이오투오",
"company": "㈜에이아이오투오",
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
@ -109,7 +109,8 @@
"close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
"goToCalendar": "캘린더에서 확인",
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
},
"landing": {
"hero": {
"searchTypeLabel": "업체명 | URL",
@ -157,78 +158,6 @@
"loggingIn": "로그인 중...",
"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": {
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
@ -477,10 +406,10 @@
},
"myInfo": {
"title": "내 정보",
"tabContents": "내 콘텐츠",
"tabCalendar": "콘텐츠 캘린더",
"tabBasic": "기본 정보",
"tabPayment": "결제 정보",
"tabBusiness": "소셜 채널 관리",
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
"myBusiness": "내 비즈니스",
"noBusinessTitle": "아직 등록된 비즈니스가 없어요",
@ -509,7 +438,9 @@
"chargeSubmit": "요청하기",
"chargeSubmitting": "요청 중...",
"chargeSuccess": "충전 요청이 완료 되었습니다!",
"chargeConfirm": "확인"
"chargeConfirm": "확인",
"tabContents": "내 콘텐츠",
"tabCalendar": "콘텐츠 캘린더"
},
"ado2Contents": {
"title": "ADO2 콘텐츠",
@ -593,8 +524,29 @@
"scheduled": "예약",
"failed": "실패"
},
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
"days": ["일","월","화","수","목","금","토"],
"months": [
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월"
],
"days": [
"일",
"월",
"화",
"수",
"목",
"금",
"토"
],
"yearMonth": "{{year}}년 {{month}}",
"monthDay": "{{month}} {{day}}일",
"loading": "불러오는 중...",
@ -612,14 +564,13 @@
},
"loginPrompt": {
"title": "로그인이 필요합니다.",
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요.",
"loginBtn": "카카오 로그인"
"loginBtn": "로그인",
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요."
},
"videoDetail": {
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
"kakaoDefaultTitle": "ADO2 영상",
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
"kakaoButtonTitle": "영상 보기",
"deletedComment": "(삭제된 댓글입니다.)",
"closeAriaLabel": "닫기",
"share": "공유하기",
@ -629,8 +580,6 @@
"shareTwitter": "X (트위터)",
"copyUrl": "URL 복사",
"commentsTitle": "댓글",
"changeAvatarTitle": "클릭하여 아바타 변경",
"nicknamePlaceholder": "작성자 이름",
"commentPlaceholder": "댓글을 입력하세요...",
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
"commentSubmitting": "작성 중",
@ -651,5 +600,77 @@
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
},
"pipelineTabs": {
"ariaLabel": "생성할 콘텐츠 종류",
"ado2": "ADO2",
"ssul": "썰박스"
},
"ssulbox": {
"steps": {
"pick": "선택",
"making": "생성",
"done": "완성"
},
"create": {
"step1": "STEP 1 · 시나리오 선택",
"step2": "STEP 2 · 업장 검색",
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
"searchButton": "검색",
"searching": "검색 중",
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
"cost": "생성 비용",
"costValue": "1 크레딧",
"submit": "썰박스 만들기",
"submitting": "요청 중…"
},
"making": {
"title": "썰박스를 만들고 있어요",
"step1": "대본 생성 중",
"step2": "스토리보드 구성 중",
"step3": "이미지·목소리 생성 중",
"step4": "영상 합성 중",
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.",
"failTitle": "생성에 실패했어요",
"failDefault": "잠시 후 다시 시도해주세요.",
"refunded": "차감된 크레딧은 환불되었습니다.",
"retry": "다시 만들기"
},
"result": {
"title": "썰박스가 완성됐어요",
"download": "다운로드",
"downloading": "다운로드 중...",
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
"scenarioLabel": "시나리오",
"createdLabel": "생성일시"
},
"viewer": {
"untitled": "이름 없는 콘텐츠"
},
"error": {
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
},
"scenario": {
"joseon": {
"name": "조선왕",
"desc": "조선 27대 왕들의 실화 썰"
},
"samgukji": {
"name": "삼국지",
"desc": "위·촉·오 영웅 28인의 야사"
},
"greek": {
"name": "그리스·로마신화",
"desc": "올림포스 신·영웅들의 전설"
},
"odyssey": {
"name": "오디세이",
"desc": "오디세우스 10년 귀향 대모험"
}
}
}
}

View File

@ -163,7 +163,15 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
}
>
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
{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
src={video.thumbnail_url}
alt={video.store_name}

View File

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

View File

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

View File

@ -264,7 +264,15 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
: 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
src={video.result_movie_url}
className="content-video-preview"

View File

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

176
src/pages/Landing/HeroSection.tsx Executable file
View 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;

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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