From bb0ab91bcc0f1760089b139b82c9d6390dcc8c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 11 Aug 2026 14:57:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(ssulbox):=20=EC=8D=B0=EB=B0=95=EC=8A=A4=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=ED=83=AD=C2=B7=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=EC=99=80=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=AA=A9=EB=A1=9D=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- src/App.tsx | 15 +- src/components/PipelineTabs.tsx | 57 +++ src/components/WizardStepper.tsx | 11 +- src/locales/en.json | 24 + src/locales/ko.json | 71 +++ src/pages/Dashboard/ADO2ContentsPage.tsx | 44 +- src/pages/Dashboard/CompletionContent.tsx | 2 + src/pages/Dashboard/GenerationFlow.tsx | 265 ++++++++-- src/pages/Dashboard/MyContentsPage.tsx | 90 ++-- src/pages/Dashboard/UrlInputContent.tsx | 45 +- src/pages/Ssulbox/SsulCreateForm.tsx | 229 +++++++++ src/pages/Ssulbox/SsulMakingContent.tsx | 111 ++++ src/pages/Ssulbox/SsulResultContent.tsx | 133 +++++ src/pages/Ssulbox/SsulViewerModal.tsx | 128 +++++ src/pages/Ssulbox/ssulData.ts | 53 ++ src/pages/Ssulbox/ssulIcons.tsx | 111 ++++ src/styles/generation-flow.css | 88 ++++ src/styles/index.css | 6 + src/styles/landing.css | 2 +- src/styles/ssulbox-castad.css | 587 ++++++++++++++++++++++ src/styles/ssulbox.css | 571 +++++++++++++++++++++ src/styles/tokens.css | 3 + src/types/api.ts | 11 + src/utils/api.ts | 174 ++++++- src/utils/storageKeys.ts | 76 +++ 26 files changed, 2793 insertions(+), 119 deletions(-) create mode 100644 src/components/PipelineTabs.tsx create mode 100644 src/pages/Ssulbox/SsulCreateForm.tsx create mode 100644 src/pages/Ssulbox/SsulMakingContent.tsx create mode 100644 src/pages/Ssulbox/SsulResultContent.tsx create mode 100644 src/pages/Ssulbox/SsulViewerModal.tsx create mode 100644 src/pages/Ssulbox/ssulData.ts create mode 100644 src/pages/Ssulbox/ssulIcons.tsx create mode 100644 src/styles/ssulbox-castad.css create mode 100644 src/styles/ssulbox.css create mode 100644 src/utils/storageKeys.ts diff --git a/package.json b/package.json index bdf154e..4b8e2ff 100755 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "typecheck": "tsc --noEmit", + "build": "tsc --noEmit && vite build", "preview": "vite preview" }, "dependencies": { @@ -17,6 +18,8 @@ }, "devDependencies": { "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.8.2", "vite": "^6.2.0" diff --git a/src/App.tsx b/src/App.tsx index 0a97d10..5cf1aa7 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,11 +17,12 @@ import VideoDetailPage from './components/VideoDetailPage'; import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api'; import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; import { CrawlingResponse } from './types/api'; +import { K, clearSessionStorage } from './utils/storageKeys'; type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow'; -const VIEW_MODE_KEY = 'castad_view_mode'; -const ANALYSIS_DATA_KEY = 'castad_analysis_data'; +const VIEW_MODE_KEY = K.VIEW_MODE; +const ANALYSIS_DATA_KEY = K.ANALYSIS_DATA; const SESSION_KEY = 'castad_session_active'; // 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인) @@ -30,15 +31,7 @@ const initializeOnNewSession = () => { if (!isExistingSession) { // 새 세션이면 localStorage 정리하고 세션 표시 - 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'); + clearSessionStorage(); sessionStorage.setItem(SESSION_KEY, 'true'); } }; diff --git a/src/components/PipelineTabs.tsx b/src/components/PipelineTabs.tsx new file mode 100644 index 0000000..8142e7e --- /dev/null +++ b/src/components/PipelineTabs.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +/** 생성할 콘텐츠 종류. 진입 화면에서 고르면 이후 플로우 전체가 갈린다. */ +export type Pipeline = 'ado2' | 'ssul'; + +interface PipelineTabsProps { + value: Pipeline; + onChange: (value: Pipeline) => void; +} + +const TABS: { id: Pipeline; labelKey: string; emoji?: string }[] = [ + { id: 'ado2', labelKey: 'pipelineTabs.ado2' }, + { id: 'ssul', labelKey: 'pipelineTabs.ssul', emoji: '📋' }, +]; + +/** + * ADO2 / 썰박스 전환 탭 (밑줄 탭). + * + * 활성 탭 아래에만 밑줄이 얹히고, 색이 민트(ADO2) ↔ 앰버(썰박스)로 바뀐다. + * 스타일은 generation-flow.css 의 `.pipeline-tabs*` 참조. + * + * 접근성: castad 의 기존 탭(`.myinfo-tab` 등)에는 role/aria 가 없지만, + * 이 컴포넌트를 기점으로 표준 tablist 패턴을 도입한다. + */ +const PipelineTabs: React.FC = ({ value, onChange }) => { + const { t } = useTranslation(); + + return ( +
+ {TABS.map((tab) => ( + + ))} +
+ ); +}; + +export default PipelineTabs; diff --git a/src/components/WizardStepper.tsx b/src/components/WizardStepper.tsx index 6c4863e..ee0f5ac 100644 --- a/src/components/WizardStepper.tsx +++ b/src/components/WizardStepper.tsx @@ -3,13 +3,18 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; interface WizardStepperProps { - currentStep: number; // 0~3, matching wizardStep in GenerationFlow + currentStep: number; // 0-based. ADO2 는 wizardStep 0~3 과 일치 + /** + * 단계 라벨. 생략하면 ADO2 기본 4단계를 쓴다(기존 호출부 무수정). + * 썰박스처럼 단계 구성이 다른 파이프라인이 직접 넘긴다. + */ + steps?: string[]; } -const WizardStepper: React.FC = ({ currentStep }) => { +const WizardStepper: React.FC = ({ currentStep, steps: stepsProp }) => { const { t } = useTranslation(); - const steps = [ + const steps = stepsProp ?? [ t('wizardSteps.brandAnalysis'), t('wizardSteps.asset'), t('wizardSteps.sound'), diff --git a/src/locales/en.json b/src/locales/en.json index 3c4d0fc..7a218f1 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -231,6 +231,30 @@ "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", + "change": "Change", + "cost": "Cost", + "costValue": "1 credit", + "submit": "Create Ssulbox", + "submitting": "Requesting…" + } + }, "urlInput": { "searchTypeBusinessName": "Business Name", "placeholderBusinessName": "Enter a business name", diff --git a/src/locales/ko.json b/src/locales/ko.json index 8afb089..d33f577 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -230,6 +230,77 @@ "loggingIn": "로그인 중...", "kakaoStart": "카카오로 시작하기" }, + "pipelineTabs": { + "ariaLabel": "생성할 콘텐츠 종류", + "ado2": "ADO2", + "ssul": "썰박스" + }, + "ssulbox": { + "steps": { + "pick": "선택", + "making": "생성", + "done": "완성" + }, + "create": { + "step1": "STEP 1 · 시나리오 선택", + "step2": "STEP 2 · 업장 검색", + "searchPlaceholder": "가게 이름 (예: 골목냉면) 또는 네이버 지도 링크", + "searchButton": "검색", + "searching": "검색 중", + "placeLoading": "가게를 찾는 중… (몇 초 걸려요)", + "change": "변경", + "hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.", + "hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.", + "hintScenario": "{{name}} 시나리오로 수 분 내외 생성됩니다", + "noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.", + "cost": "생성 비용", + "costValue": "1 크레딧", + "submit": "썰박스 만들기", + "submitting": "요청 중…" + }, + "making": { + "title": "썰박스를 만들고 있어요", + "step1": "대본 생성 중", + "step2": "스토리보드 구성 중", + "step3": "이미지·목소리 생성 중", + "step4": "영상 합성 중", + "takesTime": "보통 수 분 걸려요. 다른 작업을 하셔도 계속 진행됩니다.", + "failTitle": "생성에 실패했어요", + "failDefault": "잠시 후 다시 시도해주세요.", + "refunded": "차감된 크레딧은 환불되었습니다.", + "retry": "다시 만들기" + }, + "result": { + "title": "썰박스가 완성됐어요", + "download": "다운로드", + "downloading": "다운로드 중...", + "loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요." + }, + "viewer": { + "untitled": "이름 없는 콘텐츠" + }, + "error": { + "createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요." + }, + "scenario": { + "joseon": { + "name": "조선왕", + "desc": "조선 27대 왕들의 실화 썰" + }, + "samgukji": { + "name": "삼국지", + "desc": "위·촉·오 영웅 28인의 야사" + }, + "greek": { + "name": "그리스·로마신화", + "desc": "올림포스 신·영웅들의 전설" + }, + "odyssey": { + "name": "오디세이", + "desc": "오디세우스 10년 귀향 대모험" + } + } + }, "urlInput": { "searchTypeBusinessName": "업체명", "placeholderBusinessName": "업체명을 입력하세요.", diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx index 002e9a5..fc13ceb 100644 --- a/src/pages/Dashboard/ADO2ContentsPage.tsx +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -6,6 +6,7 @@ import LoginPromptModal from '../../components/LoginPromptModal'; import VideoDetailModal from '../../components/VideoDetailModal'; import CitySelectModal from '../../components/CitySelectModal'; import ContentCardSocialActions from '../../components/ContentCardSocialActions'; +import SsulViewerModal from '../Ssulbox/SsulViewerModal'; interface ADO2ContentsPageProps { onBack?: () => void; @@ -15,6 +16,9 @@ const ADO2ContentsPage: React.FC = () => { const { t } = useTranslation(); const authed = isLoggedIn(); const [selectedVideoId, setSelectedVideoId] = useState(null); + // 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로 + // 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다. + const [selectedSsul, setSelectedSsul] = useState(null); const [videos, setVideos] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(authed); @@ -141,14 +145,25 @@ const ADO2ContentsPage: React.FC = () => { <>
{videos.map((video) => ( + // key·상세 열기 모두 (type, video_id) 로 다뤄야 한다 — + // video_id 는 종류별 독립 시퀀스라 값이 겹친다.
handleCardClick(video.video_id)} + onClick={() => + video.type === 'ssul' + ? setSelectedSsul(video) + : handleCardClick(video.video_id) + } role="button" tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)} + onKeyDown={(e) => + e.key === 'Enter' && + (video.type === 'ssul' + ? setSelectedSsul(video) + : handleCardClick(video.video_id)) + } >
{video.thumbnail_url ? ( @@ -182,14 +197,19 @@ const ADO2ContentsPage: React.FC = () => {

{video.store_name}

{formatDate(video.created_at)}

- + {/* 좋아요·댓글은 castad API 를 video_id 로 호출한다. + 썰박스 id 를 넘기면 id 가 겹치는 다른 영상에 반영되므로 + `/ssul/*` 반응 API 가 붙기 전까지 노출하지 않는다. */} + {video.type === 'video' && ( + + )}
@@ -221,6 +241,8 @@ const ADO2ContentsPage: React.FC = () => { { window.location.href = '/'; }} /> )} + setSelectedSsul(null)} /> + {selectedVideoId !== null && ( = ({ onClose={handleCloseSocialConnect} onGoToCalendar={onGoToCalendar} video={videoUrl && videoDbId ? { + // ADO2 파이프라인 완료 화면이므로 항상 영상이다 + type: 'video', video_id: videoDbId, store_name: songCompletionData?.businessName || '', region: '', diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 0d5748d..2e374bd 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -15,32 +15,50 @@ import ContentCalendarContent from './ContentCalendarContent'; import LoadingSection from '../Analysis/LoadingSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api'; -import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api'; +import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens, getActiveSsulTask, waitForSsulComplete } from '../../utils/api'; import { useTutorial } from '../../components/Tutorial/useTutorial'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; import WizardStepper from '../../components/WizardStepper'; -const WIZARD_STEP_KEY = 'castad_wizard_step'; -const ACTIVE_ITEM_KEY = 'castad_active_item'; -const SONG_TASK_ID_KEY = 'castad_song_task_id'; -const IMAGE_TASK_ID_KEY = 'castad_image_task_id'; -const ANALYSIS_DATA_KEY = 'castad_analysis_data'; import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory'; +import { Pipeline } from '../../components/PipelineTabs'; +import { Scen } from '../Ssulbox/ssulData'; +import SsulMakingContent, { SsulJob } from '../Ssulbox/SsulMakingContent'; +import SsulResultContent from '../Ssulbox/SsulResultContent'; +import { + K, + clearProjectStorage, + clearSessionStorage, +} from '../../utils/storageKeys'; -// 다른 컴포넌트에서 사용하는 storage key들 (초기화용) -const SONG_GENERATION_KEY = 'castad_song_generation'; -const VIDEO_GENERATION_KEY = 'castad_video_generation'; -const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보 +const WIZARD_STEP_KEY = K.WIZARD_STEP; +const ACTIVE_ITEM_KEY = K.ACTIVE_ITEM; +const SONG_TASK_ID_KEY = K.SONG_TASK_ID; +const IMAGE_TASK_ID_KEY = K.IMAGE_TASK_ID; +const ANALYSIS_DATA_KEY = K.ANALYSIS_DATA; -// 모든 프로젝트 관련 localStorage 초기화 -const clearAllProjectStorage = () => { - localStorage.removeItem(WIZARD_STEP_KEY); - localStorage.removeItem(SONG_TASK_ID_KEY); - localStorage.removeItem(IMAGE_TASK_ID_KEY); - localStorage.removeItem(SONG_GENERATION_KEY); - localStorage.removeItem(VIDEO_GENERATION_KEY); - localStorage.removeItem(VIDEO_COMPLETE_KEY); +/** + * 랜딩 CTA 에서 썰박스를 고르고 로그인하러 갈 때 남기는 1회성 프리셋. + * 카카오 로그인은 외부 리다이렉트 왕복이고 initializeOnNewSession 이 + * localStorage 만 지우므로 sessionStorage 를 쓴다. + */ +const PENDING_PIPELINE_KEY = 'castad_pending_pipeline'; + +/** 썰박스 진행 단계. ADO2 의 wizardStep(-2~3) 과 별도 축이다 */ +type SsulStep = 'entry' | 'making' | 'result'; + +/** 썰박스 스텝퍼 라벨(i18n 키)과 0-based 인덱스 */ +const SSUL_STEP_LABELS = [ + 'ssulbox.steps.pick', + 'ssulbox.steps.making', + 'ssulbox.steps.done', +] as const; + +const SSUL_STEP_INDEX: Record = { + entry: 0, + making: 1, + result: 2, }; interface BusinessInfo { @@ -111,6 +129,42 @@ const GenerationFlow: React.FC = ({ }; const [wizardStep, setWizardStep] = useState(getInitialWizardStep()); + + /** + * 초기 파이프라인 결정 — 우선순위를 지켜야 한다. + * + * 2번이 없으면 위 getInitialWizardStep() 의 "분석 데이터가 있으면 step 1부터" + * 강제 로직과 충돌한다. ADO2 분석을 마치고 돌아온 상태에서 파이프라인만 + * 'ssul' 로 남아 있으면, 썰박스 화면인데 ADO2 위저드 단계가 그려진다. + */ + const getInitialPipeline = (): Pipeline => { + // 1) 랜딩 CTA 프리셋 (1회성). 카카오 로그인 리다이렉트 왕복을 견뎌야 해서 sessionStorage + const pending = sessionStorage.getItem(PENDING_PIPELINE_KEY); + if (pending === 'ssul' || pending === 'ado2') { + sessionStorage.removeItem(PENDING_PIPELINE_KEY); + return pending; + } + // 2) 분석 데이터가 있으면 ADO2 흐름 한가운데이므로 강제 + if (initialAnalysisData || savedAnalysisData) return 'ado2'; + // 3) 저장된 선택 + const saved = localStorage.getItem(K.PIPELINE); + return saved === 'ssul' ? 'ssul' : 'ado2'; + }; + + const [pipeline, setPipeline] = useState(getInitialPipeline); + /** 썰박스 전용 진행 축. wizardStep 숫자 축은 확장하지 않는다 */ + const [ssulStep, setSsulStep] = useState('entry'); + const [ssulJob, setSsulJob] = useState(null); + /** 언마운트·로그아웃·재시도 시 진행 중인 폴링을 멈추는 플래그 */ + const ssulCancelledRef = useRef(false); + + const changePipeline = (next: Pipeline) => { + setPipeline(next); + localStorage.setItem(K.PIPELINE, next); + }; + + /** 진입 화면(탭이 보이는 단계) 여부. 두 파이프라인이 공유한다 */ + const isEntry = pipeline === 'ado2' ? wizardStep === -2 : ssulStep === 'entry'; const [songTaskId, setSongTaskId] = useState(savedSongTaskId); const [imageTaskId, setImageTaskId] = useState(savedImageTaskId); const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle'); @@ -156,10 +210,9 @@ const GenerationFlow: React.FC = ({ // 로그아웃 핸들러 const handleLogout = () => { clearTokens(); - localStorage.removeItem('castad_view_mode'); - localStorage.removeItem('castad_analysis_data'); - localStorage.removeItem(WIZARD_STEP_KEY); - localStorage.removeItem(ACTIVE_ITEM_KEY); + // 세션 키를 전부 지운다. 이전에는 view_mode/analysis/wizard/active_item 만 지워 + // 생성 중이던 song·video 상태가 남았고, 다른 계정으로 로그인하면 그대로 복원됐다. + clearSessionStorage(); window.location.href = '/'; }; @@ -212,7 +265,7 @@ const GenerationFlow: React.FC = ({ // 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동 const handleHome = () => { - clearAllProjectStorage(); + clearProjectStorage(); localStorage.removeItem(ANALYSIS_DATA_KEY); setWizardStep(-2); setSongTaskId(null); @@ -395,16 +448,113 @@ const GenerationFlow: React.FC = ({ setActiveItem('내 정보'); }; + /** + * 썰박스 잡 폴링을 시작한다. + * + * 폴링 소유가 GenerationFlow 인 이유: SsulMakingContent 가 들고 있으면 + * 사이드바로 이동하는 순간 언마운트되면서 폴링이 끊긴다. + * 잡은 화면보다 오래 살아야 한다. + */ + const watchSsulTask = useCallback( + (taskId: number, scenario: Scen) => { + ssulCancelledRef.current = false; + setSsulJob({ taskId, scenario, status: 'queued', step: 0, error: null }); + + waitForSsulComplete( + taskId, + (task) => { + if (ssulCancelledRef.current) return; + setSsulJob({ + taskId, + scenario, + status: task.status, + step: task.step, + error: task.error, + }); + }, + () => ssulCancelledRef.current + ) + .then(() => { + if (ssulCancelledRef.current) return; + localStorage.removeItem(K.SSUL_TASK_ID); + setSsulStep('result'); + }) + .catch((e: Error) => { + if (ssulCancelledRef.current || e.message === 'CANCELLED') return; + localStorage.removeItem(K.SSUL_TASK_ID); + setSsulJob((prev) => + prev ? { ...prev, status: 'error', error: e.message } : prev + ); + }) + .finally(() => { + if (ssulCancelledRef.current) return; + // 완료든 실패든 잔액을 갱신한다. 실패 시 백엔드가 환불하므로 + // 갱신하지 않으면 사이드바 잔액이 낡은 값으로 굳는다. + refreshCredits(); + }); + }, + [refreshCredits] + ); + + /** 생성 요청이 접수됐을 때 */ + const handleSsulJobStarted = (taskId: number, scenario: Scen) => { + localStorage.setItem(K.SSUL_TASK_ID, String(taskId)); + setSsulStep('making'); + // 생성 시작 시점에 크레딧이 선차감되므로 사이드바 잔액을 즉시 갱신 + refreshCredits(); + watchSsulTask(taskId, scenario); + }; + + /** 실패 후 처음부터 */ + const handleSsulRetry = () => { + ssulCancelledRef.current = true; + localStorage.removeItem(K.SSUL_TASK_ID); + setSsulJob(null); + setSsulStep('entry'); + }; + + /** + * 새로고침·새 탭 진입 시 진행 중인 잡을 복구한다. + * + * 권위는 서버(`/ssul/tasks/active`)다. localStorage 의 SSUL_TASK_ID 는 힌트일 뿐이고, + * initializeOnNewSession 이 새 탭에서 그것을 지우기 때문에 서버를 물어야 한다. + */ + useEffect(() => { + let alive = true; + getActiveSsulTask().then((task) => { + if (!alive || !task) return; + changePipeline('ssul'); + setSsulStep('making'); + watchSsulTask(task.id, task.scenario as Scen); + }); + return () => { + alive = false; + }; + // 마운트 시 1회만 — watchSsulTask 는 refreshCredits 에만 의존한다 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** 언마운트 시 폴링 중단 (로그아웃·화면 전환) */ + useEffect(() => { + return () => { + ssulCancelledRef.current = true; + }; + }, []); + // 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화 const handleNavigate = (item: string) => { setMyInfoInitialTab(undefined); if (item === '새 프로젝트 만들기') { // 기존 프로젝트 데이터 초기화 - clearAllProjectStorage(); + clearProjectStorage(); localStorage.removeItem(ANALYSIS_DATA_KEY); // URL 입력 단계로 명시적 저장 (다음 진입 시 올바른 단계 복원) localStorage.setItem(WIZARD_STEP_KEY, '-2'); setWizardStep(-2); + // 파이프라인도 기본값으로 되돌린다. clearProjectStorage() 가 K.PIPELINE 을 + // 지우므로 state 만 남으면 저장소와 어긋난다. + setPipeline('ado2'); + setSsulStep('entry'); setSongTaskId(null); setImageTaskId(null); setAnalysisData(null); @@ -419,17 +569,40 @@ const GenerationFlow: React.FC = ({ // 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링 const renderWizardContent = () => { + // 진입 화면은 두 파이프라인이 공유하므로 switch 밖에서 먼저 처리한다. + // ADO2 는 wizardStep -2, 썰박스는 ssulStep 'entry' 가 여기 해당한다. + if (isEntry) { + return ( + + ); + } + + // 썰박스는 자체 축으로 진행한다 + if (pipeline === 'ssul') { + switch (ssulStep) { + case 'making': + return ( + + ); + case 'result': + return ssulJob ? ( + + ) : null; + default: + return null; + } + } + switch (wizardStep) { - case -2: - // URL 입력 단계 - return ( - - ); case -1: // 로딩 단계 return ( @@ -457,8 +630,8 @@ const GenerationFlow: React.FC = ({ onBack={() => goToWizardStep(0)} onNext={(taskId: string) => { // Clear video generation state to start fresh - localStorage.removeItem(VIDEO_GENERATION_KEY); - localStorage.removeItem(VIDEO_COMPLETE_KEY); + localStorage.removeItem(K.VIDEO_GENERATION); + localStorage.removeItem(K.VIDEO_COMPLETE); setVideoGenerationStatus('idle'); setVideoGenerationProgress(0); @@ -502,8 +675,8 @@ const GenerationFlow: React.FC = ({ onBack={() => { // 뒤로가기 시 비디오 생성 상태 초기화 // 새 노래 생성 후 다시 영상 생성할 수 있도록 - localStorage.removeItem(VIDEO_GENERATION_KEY); - localStorage.removeItem(VIDEO_COMPLETE_KEY); + localStorage.removeItem(K.VIDEO_GENERATION); + localStorage.removeItem(K.VIDEO_COMPLETE); setVideoGenerationStatus('idle'); setVideoGenerationProgress(0); goToWizardStep(2); @@ -539,10 +712,22 @@ const GenerationFlow: React.FC = ({ case '내 정보': return ; case '새 프로젝트 만들기': - // 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시 - if (wizardStep === -1 || wizardStep === -2) { + // 진입 화면(탭)과 ADO2 로딩(-1)은 스텝퍼 없이 전체 화면으로 표시 + if (isEntry || (pipeline === 'ado2' && wizardStep === -1)) { return renderWizardContent(); } + // 썰박스는 자체 3단계 스텝퍼를 쓴다 + if (pipeline === 'ssul') { + return ( +
+ t(key))} + currentStep={SSUL_STEP_INDEX[ssulStep]} + /> + {renderWizardContent()} +
+ ); + } // 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시 if (wizardStep === 0) { return ( diff --git a/src/pages/Dashboard/MyContentsPage.tsx b/src/pages/Dashboard/MyContentsPage.tsx index b479c41..65ecbf1 100644 --- a/src/pages/Dashboard/MyContentsPage.tsx +++ b/src/pages/Dashboard/MyContentsPage.tsx @@ -6,6 +6,7 @@ import { VideoListItem } from '../../types/api'; import SocialPostingModal from '../../components/SocialPostingModal'; import VideoDetailModal from '../../components/VideoDetailModal'; import ContentCardSocialActions from '../../components/ContentCardSocialActions'; +import SsulViewerModal from '../Ssulbox/SsulViewerModal'; import { useTutorial } from '../../components/Tutorial/useTutorial'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; @@ -108,6 +109,9 @@ const MyContentsPage: React.FC = ({ onNavigate }) => { const [uploadModalOpen, setUploadModalOpen] = useState(false); const [uploadTargetVideo, setUploadTargetVideo] = useState(null); const [selectedVideoId, setSelectedVideoId] = useState(null); + // 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로 + // 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다. + const [selectedSsul, setSelectedSsul] = useState(null); const pageSize = 12; @@ -241,12 +245,18 @@ const MyContentsPage: React.FC = ({ onNavigate }) => { <>
{videos.map((video) => ( -
+ // key 는 (type, video_id) 쌍이어야 한다 — task_id 는 썰박스에 없고(빈 문자열) + // video_id 는 종류별 독립 시퀀스라 값이 겹친다. +
{/* Video Thumbnail */}
setSelectedVideoId(video.video_id)} + onClick={() => + video.type === 'ssul' + ? setSelectedSsul(video) + : setSelectedVideoId(video.video_id) + } > {video.result_movie_url ? ( = ({ onNavigate }) => {

{formatDate(video.created_at)}

- + {/* 좋아요·댓글은 castad API 를 video_id 로 호출한다. + 썰박스 id 를 넘기면 id 가 겹치는 다른 영상에 반영되므로 + `/ssul/*` 반응 API 가 붙기 전까지 노출하지 않는다. */} + {video.type === 'video' && ( + + )}
{/* Action Buttons */}
- + {/* SNS 업로드는 SocialPostingModal 이 video_id 로 업로드 API 를 부른다. + 썰박스 id 를 넘기면 **엉뚱한 ADO2 영상이 업로드된다.** + `/ssul/upload/*` 가 붙기 전까지 노출하지 않는다. */} + {video.type === 'video' && ( + + )} - + {/* ⚠️ 삭제는 파괴적이다. `DELETE /archive/videos/{id}` 는 `Video.id` 로 + 지우므로 썰박스 id 를 넘기면 **id 가 겹치는 ADO2 영상이 삭제된다.** + 소유권 검증도 통과한다(같은 사용자가 양쪽을 다 가진 경우). + 썰박스 삭제 API 가 붙기 전까지 절대 노출하지 않는다. */} + {video.type === 'video' && ( + + )}
@@ -384,6 +410,8 @@ const MyContentsPage: React.FC = ({ onNavigate }) => { /> )} + setSelectedSsul(null)} /> + {/* 소셜 미디어 업로드 모달 */} void; onAnalyze: (value: string, type?: SearchType) => void; onAutocomplete?: (data: AutocompleteRequest) => void; onManualInput?: (businessName: string, address: string, category: string) => void; error: string | null; + /** 썰박스 생성 요청 접수 시. 폴링 소유는 GenerationFlow */ + onSsulJobStarted: (taskId: number, scenario: Scen) => void; + /** 크레딧 부족 시 충전 화면으로 */ + onGoToPayment: () => void; } -const UrlInputContent: React.FC = ({ onAnalyze, onAutocomplete, onManualInput, error }) => { +/** + * 대시보드 진입 화면. + * + * 탭바는 로고와 하위 폼 **사이에 형제로** 얹고 하위 폼만 교체한다. + * SearchInputForm 은 랜딩 HeroSection 과 공유되므로 어떤 경우에도 수정하지 않는다. + */ +const UrlInputContent: React.FC = ({ + pipeline, + onPipelineChange, + onAnalyze, + onAutocomplete, + onManualInput, + error, + onSsulJobStarted, + onGoToPayment, +}) => { return (
@@ -17,12 +42,18 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp ADO2
- + + + {pipeline === 'ado2' ? ( + + ) : ( + + )}
); diff --git a/src/pages/Ssulbox/SsulCreateForm.tsx b/src/pages/Ssulbox/SsulCreateForm.tsx new file mode 100644 index 0000000..10443a2 --- /dev/null +++ b/src/pages/Ssulbox/SsulCreateForm.tsx @@ -0,0 +1,229 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + InsufficientCreditError, + SsulPlaceItem, + createSsulJob, + searchSsulPlace, +} from '../../utils/api'; +import { SCEN, SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData'; + +interface SsulCreateFormProps { + /** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow */ + onSubmitted: (taskId: number, scenario: Scen) => void; + /** 크레딧 부족(402) 시 충전 화면으로 유도 */ + onNeedCredit: () => void; +} + +/** 네이버 지도 링크로 보이는가 — 링크면 검색을 건너뛰고 그대로 크롤링한다 */ +const isNaverUrl = (s: string): boolean => + /naver\.me|map\.naver|place\.naver|^https?:\/\//i.test(s.trim()); + +/** + * 썰박스 생성 폼. 진입 화면의 썰박스 탭에 들어간다. + * + * 원본 썰박스의 CreateSheet(바텀시트) 를 시트 크롬 없이 인라인으로 재배치했다. + * 시나리오 선택과 업장 검색을 한 화면에 둔다(원본은 2단계로 나뉘어 있었으나, + * 여기서는 위저드 스텝퍼가 이미 단계를 보여주므로 중복이다). + */ +const SsulCreateForm: React.FC = ({ onSubmitted, onNeedCredit }) => { + const { t } = useTranslation(); + + const [scenario, setScenario] = useState(null); + const [input, setInput] = useState(''); + const [results, setResults] = useState([]); + const [selected, setSelected] = useState(null); + const [searching, setSearching] = useState(false); + const [searched, setSearched] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const isUrl = isNaverUrl(input); + // place_url 이 확정됐거나(검색 결과 선택) 링크를 직접 붙여넣었을 때만 생성 가능 + const canSubmit = !!scenario && (!!selected || isUrl) && !submitting; + + const runSearch = async () => { + const query = input.trim(); + if (query.length < 2 || isUrl) return; + + setSearching(true); + setSearched(false); + setResults([]); + setSelected(null); + try { + setResults(await searchSsulPlace(query)); + } finally { + setSearching(false); + setSearched(true); + } + }; + + const handleSubmit = async () => { + if (!scenario || submitting) return; + + // 검색으로 고른 가게가 있으면 그 place_url 로 정확히 크롤링한다 + const payload = selected ? selected.place_url : input.trim(); + setSubmitting(true); + setError(null); + try { + const res = await createSsulJob({ + scenario, + input: payload, + // 고른 가게가 있을 때만 함께 보낸다. 통합 목록의 업장명 표시와 + // 업장명·지역 필터가 이 값에 걸린다(링크 직접 입력 시에는 없다). + // 도로명·지번을 모두 보낸다. 백엔드가 도로명에서 시/군 추출에 실패하면 + // 지번으로 재시도한다(한쪽만 보내면 지역이 비는 경우가 생긴다). + ...(selected && { + store_name: selected.title, + road_address: selected.roadAddress, + address: selected.address, + }), + }); + onSubmitted(res.id, scenario); + } catch (e) { + if (e instanceof InsufficientCreditError) { + onNeedCredit(); + } else { + setError(t('ssulbox.error.createFailed')); + } + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {/* ── 1단계: 시나리오 ─────────────────────────────── */} + {t('ssulbox.create.step1')} + +
+ {SCEN_KEYS.map((key) => { + const pal = SCEN[key]; + const active = scenario === key; + return ( + + ); + })} +
+ + {/* ── 2단계: 업장 ─────────────────────────────────── */} + {/* 2단계는 약하게 — 시선이 1단계(아직 안 고른 경우)로 먼저 가야 한다 */} + {t('ssulbox.create.step2')} + +
+ { + setInput(e.target.value); + if (selected) setSelected(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + runSearch(); + } + }} + /> + +
+ + {/* 검색 상태에 따른 안내 — 한 번에 하나만 보인다 */} + {selected ? ( +
+
+ {selected.title} + + {selected.category ? `${selected.category} · ` : ''} + {selected.address || selected.roadAddress} + +
+ +
+ ) : isUrl ? ( +
{t('ssulbox.create.hintUrlOk')}
+ ) : searching ? ( +
{t('ssulbox.create.placeLoading')}
+ ) : results.length > 0 ? ( +
    + {results.map((place, i) => ( +
  • + +
  • + ))} +
+ ) : searched ? ( +
{t('ssulbox.create.noResult')}
+ ) : scenario ? ( + // 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라 + // 분 단위를 못 박지 않는다("수 분 내외"). +
+ {t('ssulbox.create.hintScenario', { name: t(scenName(scenario)) })} +
+ ) : ( +
{t('ssulbox.create.hintDefault')}
+ )} + + {error &&
{error}
} + + {/* ── 비용 고지 + 제출 ────────────────────────────── */} + {/* 요청 시점에 즉시 차감되므로 누르기 전에 반드시 알린다 */} +
+ {t('ssulbox.create.cost')} + + + {t('ssulbox.create.costValue')} + +
+ + +
+ ); +}; + +export default SsulCreateForm; diff --git a/src/pages/Ssulbox/SsulMakingContent.tsx b/src/pages/Ssulbox/SsulMakingContent.tsx new file mode 100644 index 0000000..f40f5ae --- /dev/null +++ b/src/pages/Ssulbox/SsulMakingContent.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SCEN, Scen, scenName } from './ssulData'; + +/** 생성 잡의 현재 상태. GenerationFlow 가 폴링해 내려준다 */ +export interface SsulJob { + taskId: number; + scenario: Scen; + status: 'queued' | 'running' | 'done' | 'error'; + /** 0~4. 0=준비, 4=영상 합성 완료 */ + step: number; + error: string | null; +} + +interface SsulMakingContentProps { + job: SsulJob | null; + /** 실패 후 처음부터 다시 */ + onRetry: () => void; +} + +/** 백엔드 generator 가 뱉는 4단계. `ssul_content.step` 과 1:1 대응 */ +const TOTAL_STEPS = 4; + +const STEP_LABEL_KEYS = [ + 'ssulbox.making.step1', + 'ssulbox.making.step2', + 'ssulbox.making.step3', + 'ssulbox.making.step4', +] as const; + +/** + * 썰박스 생성 진행 화면. + * + * 원본은 SSE 로 진행률만 받아 막대 하나를 그렸지만, castad 는 폴링으로 + * `step`(0~4)을 받으므로 **지금 무슨 작업 중인지 단계명까지** 보여준다. + * 수 분~십수 분이 걸리는 작업이라 진행바만 있으면 멈춘 것처럼 보인다. + * + * 잡 소유는 GenerationFlow 다. 이 컴포넌트는 표시만 하므로 + * 사이드바로 이동해 언마운트돼도 폴링이 끊기지 않는다. + * 그래서 "백그라운드로 전환" 버튼을 두지 않는다 — 사이드바로 그냥 이동하면 + * 되고, 버튼이 있으면 마치 전환해야 계속 도는 것처럼 오해를 준다. + */ +const SsulMakingContent: React.FC = ({ job, onRetry }) => { + const { t } = useTranslation(); + + if (!job) return null; + + const palette = SCEN[job.scenario]; + const failed = job.status === 'error'; + // step 은 "완료한 단계 수"라 진행률은 step/4. queued(0) 면 0% + const percent = Math.round((Math.min(job.step, TOTAL_STEPS) / TOTAL_STEPS) * 100); + // 진행 중인 단계는 완료 수 + 1 (마지막 단계 완료 시엔 더 올리지 않음) + const currentIndex = Math.min(job.step, TOTAL_STEPS - 1); + + return ( +
+ + {t(scenName(job.scenario))} + + + {failed ? ( + <> +

{t('ssulbox.making.failTitle')}

+

{job.error || t('ssulbox.making.failDefault')}

+ {/* 백엔드가 실패 시 크레딧을 환불한다 — 사용자가 손해 보지 않았음을 알린다 */} +

{t('ssulbox.making.refunded')}

+ + + ) : ( + <> +

{t('ssulbox.making.title')}

+ + {/* + 단계 사이 구간에서는 진행바가 멈춰 있어 고장처럼 보인다. + 스피너는 "살아 있다"는 신호 전용이라 진행률을 표현하지 않는다. + 색은 시나리오 대표색을 따른다(민트 고정이면 삼국지·오디세이와 부딪힌다). + */} +