Compare commits
8 Commits
main
...
feature-tu
| Author | SHA1 | Date |
|---|---|---|
|
|
d5647bdfa5 | |
|
|
2c6c4b7c72 | |
|
|
3210efbe30 | |
|
|
cef0919879 | |
|
|
8734d3388d | |
|
|
f0d9106860 | |
|
|
5407812889 | |
|
|
d7cb0167ec |
224
index.css
224
index.css
|
|
@ -7798,7 +7798,7 @@
|
||||||
top: calc(100% + 0.5rem);
|
top: calc(100% + 0.5rem);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
max-height: 240px;
|
max-height: 220px;
|
||||||
background-color: #002224;
|
background-color: #002224;
|
||||||
border: 1px solid #046266;
|
border: 1px solid #046266;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
@ -10551,3 +10551,225 @@
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #067C80 transparent;
|
scrollbar-color: #067C80 transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Tutorial Overlay
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
.tutorial-overlay-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-overlay-svg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-overlay-blocker {
|
||||||
|
position: fixed;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-spotlight-ring {
|
||||||
|
position: fixed;
|
||||||
|
border: 2px solid rgba(166, 255, 234, 0.85);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(166, 255, 234, 0.2);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
width: 300px;
|
||||||
|
background: #1a2630;
|
||||||
|
border: 1px solid rgba(166, 255, 234, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
pointer-events: all;
|
||||||
|
z-index: 10002;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-tooltip-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #a6ffea;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-tooltip-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-tooltip-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-tooltip-counter {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-tooltip-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-btn-skip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-btn-skip:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-btn-prev {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-btn-prev:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-btn-next {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #fff;
|
||||||
|
background: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-btn-next:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-tooltip-action-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #a6ffea;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 튜토리얼 재시작 팝업 */
|
||||||
|
.tutorial-restart-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-popup {
|
||||||
|
background: #1a2630;
|
||||||
|
border: 1px solid rgba(166, 255, 234, 0.25);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
width: 320px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #a6ffea;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-cancel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-confirm {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1a1f;
|
||||||
|
background: #a6ffea;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-confirm:hover {
|
||||||
|
background: #c0fff2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 사이드바 튜토리얼 버튼 */
|
||||||
|
.tutorial-restart-fab {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 900;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: rgba(0, 34, 36, 0.85);
|
||||||
|
border: 1px solid rgba(166, 255, 234, 0.25);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-restart-fab:hover {
|
||||||
|
background: rgba(166, 255, 234, 0.12);
|
||||||
|
border-color: rgba(166, 255, 234, 0.5);
|
||||||
|
color: #a6ffea;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ interface SidebarItemProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick }) => {
|
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={id}
|
||||||
onClick={isDisabled ? undefined : onClick}
|
onClick={isDisabled ? undefined : onClick}
|
||||||
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
|
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||||
title={isCollapsed ? label : ""}
|
title={isCollapsed ? label : ""}
|
||||||
|
|
@ -138,6 +140,13 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||||
{menuItems.map(item => (
|
{menuItems.map(item => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
id={
|
||||||
|
item.id === '내 정보'
|
||||||
|
? 'sidebar-my-info'
|
||||||
|
: item.id === 'ADO2 콘텐츠'
|
||||||
|
? 'sidebar-ado2-contents'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
||||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||||
|
import { useTutorial } from './Tutorial/useTutorial';
|
||||||
|
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
|
||||||
|
import TutorialOverlay from './Tutorial/TutorialOverlay';
|
||||||
|
|
||||||
interface SocialPostingModalProps {
|
interface SocialPostingModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -116,6 +119,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
video
|
video
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const tutorial = useTutorial();
|
||||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
|
@ -135,6 +139,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
|
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
|
||||||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasBeenOpenedRef = useRef(false);
|
||||||
|
|
||||||
// Upload progress modal state
|
// Upload progress modal state
|
||||||
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
||||||
|
|
@ -171,6 +176,32 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 모달 오픈/닫힘 시 튜토리얼 트리거
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
hasBeenOpenedRef.current = true;
|
||||||
|
if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
} else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) {
|
||||||
|
hasBeenOpenedRef.current = false;
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isLoadingAutoDescription]);
|
||||||
|
|
||||||
// 소셜 계정 로드
|
// 소셜 계정 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
@ -399,8 +430,20 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isOpen || !video) {
|
if (!isOpen || !video) {
|
||||||
// Still render upload progress modal even when main modal is closed
|
return (
|
||||||
return showUploadProgress ? uploadProgressModalElement : null;
|
<>
|
||||||
|
{showUploadProgress && uploadProgressModalElement}
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -761,6 +804,15 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{uploadProgressModalElement}
|
{uploadProgressModalElement}
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TutorialHint } from './tutorialSteps';
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipPos {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TutorialOverlayProps {
|
||||||
|
hints: TutorialHint[];
|
||||||
|
currentIndex: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PADDING = 8;
|
||||||
|
|
||||||
|
function getTargetRect(selector: string): Rect | null {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpotlightRect(
|
||||||
|
rect: Rect,
|
||||||
|
padding: number,
|
||||||
|
override?: { top?: number; right?: number; bottom?: number; left?: number }
|
||||||
|
): Rect {
|
||||||
|
const pTop = override?.top ?? padding;
|
||||||
|
const pRight = override?.right ?? padding;
|
||||||
|
const pBottom = override?.bottom ?? padding;
|
||||||
|
const pLeft = override?.left ?? padding;
|
||||||
|
const left = Math.max(0, Math.floor(rect.left - pLeft));
|
||||||
|
const top = Math.max(0, Math.floor(rect.top - pTop));
|
||||||
|
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
|
||||||
|
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
|
||||||
|
|
||||||
|
return {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width: Math.max(0, right - left),
|
||||||
|
height: Math.max(0, bottom - top),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcTooltipPos(rect: Rect, position: TutorialHint['position']): TooltipPos {
|
||||||
|
const tooltipW = 300;
|
||||||
|
const tooltipH = 140;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom':
|
||||||
|
return {
|
||||||
|
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
|
||||||
|
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
||||||
|
};
|
||||||
|
case 'top':
|
||||||
|
return {
|
||||||
|
top: Math.max(rect.top - tooltipH - PADDING, 8),
|
||||||
|
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
||||||
|
};
|
||||||
|
case 'right':
|
||||||
|
return {
|
||||||
|
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||||
|
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
|
||||||
|
};
|
||||||
|
case 'left':
|
||||||
|
return {
|
||||||
|
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
||||||
|
left: Math.max(rect.left - tooltipW - PADDING, 8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
||||||
|
hints,
|
||||||
|
currentIndex,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onSkip,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||||
|
|
||||||
|
const hint = hints[currentIndex];
|
||||||
|
const isLast = currentIndex === hints.length - 1;
|
||||||
|
|
||||||
|
const updateRect = useCallback(() => {
|
||||||
|
if (!hint) return;
|
||||||
|
setTargetRect(getTargetRect(hint.targetSelector));
|
||||||
|
}, [hint]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateRect();
|
||||||
|
window.addEventListener('resize', updateRect);
|
||||||
|
window.addEventListener('scroll', updateRect, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateRect);
|
||||||
|
window.removeEventListener('scroll', updateRect, true);
|
||||||
|
};
|
||||||
|
}, [updateRect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hint) return;
|
||||||
|
let retryTimer: number | undefined;
|
||||||
|
let rectTimer: number | undefined;
|
||||||
|
let cleanupTarget: (() => void) | undefined;
|
||||||
|
|
||||||
|
const bindToTarget = (el: HTMLElement) => {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
rectTimer = window.setTimeout(updateRect, 200);
|
||||||
|
|
||||||
|
const shouldClickAdvance = hint.clickToAdvance !== false;
|
||||||
|
if (shouldClickAdvance) {
|
||||||
|
el.style.cursor = 'pointer';
|
||||||
|
el.addEventListener('click', onNext);
|
||||||
|
cleanupTarget = () => {
|
||||||
|
el.style.cursor = '';
|
||||||
|
el.removeEventListener('click', onNext);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
cleanupTarget = () => {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryBind = (): boolean => {
|
||||||
|
const el = document.querySelector(hint.targetSelector) as HTMLElement | null;
|
||||||
|
if (!el) return false;
|
||||||
|
bindToTarget(el);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tryBind()) {
|
||||||
|
retryTimer = window.setInterval(() => {
|
||||||
|
if (tryBind() && retryTimer) {
|
||||||
|
window.clearInterval(retryTimer);
|
||||||
|
retryTimer = undefined;
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (retryTimer) window.clearInterval(retryTimer);
|
||||||
|
if (rectTimer) window.clearTimeout(rectTimer);
|
||||||
|
cleanupTarget?.();
|
||||||
|
};
|
||||||
|
}, [hint, updateRect, onNext]);
|
||||||
|
|
||||||
|
if (!hint) return null;
|
||||||
|
|
||||||
|
const tooltipPos: TooltipPos = targetRect
|
||||||
|
? calcTooltipPos(targetRect, hint.position)
|
||||||
|
: {
|
||||||
|
top: window.innerHeight / 2 - 70,
|
||||||
|
left: window.innerWidth / 2 - 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
const spotlightPadding = hint.spotlightPadding ?? PADDING;
|
||||||
|
const spotlightRect = targetRect ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tutorial-overlay-root">
|
||||||
|
{spotlightRect ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="tutorial-overlay-blocker"
|
||||||
|
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="tutorial-overlay-blocker"
|
||||||
|
style={{
|
||||||
|
top: spotlightRect.top,
|
||||||
|
left: 0,
|
||||||
|
width: spotlightRect.left,
|
||||||
|
height: spotlightRect.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="tutorial-overlay-blocker"
|
||||||
|
style={{
|
||||||
|
top: spotlightRect.top,
|
||||||
|
left: spotlightRect.left + spotlightRect.width,
|
||||||
|
right: 0,
|
||||||
|
height: spotlightRect.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="tutorial-overlay-blocker"
|
||||||
|
style={{
|
||||||
|
top: spotlightRect.top + spotlightRect.height,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="tutorial-spotlight-ring"
|
||||||
|
style={{
|
||||||
|
top: spotlightRect.top,
|
||||||
|
left: spotlightRect.left,
|
||||||
|
width: spotlightRect.width,
|
||||||
|
height: spotlightRect.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="tutorial-overlay-blocker" style={{ inset: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="tutorial-tooltip"
|
||||||
|
style={{ top: tooltipPos.top, left: tooltipPos.left }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
|
||||||
|
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
|
||||||
|
|
||||||
|
<div className="tutorial-tooltip-footer">
|
||||||
|
<span className="tutorial-tooltip-counter">
|
||||||
|
{currentIndex + 1} / {hints.length}
|
||||||
|
</span>
|
||||||
|
<div className="tutorial-tooltip-actions">
|
||||||
|
<button className="tutorial-btn-skip" onClick={onSkip}>
|
||||||
|
{t('tutorial.skip')}
|
||||||
|
</button>
|
||||||
|
{currentIndex > 0 && (
|
||||||
|
<button className="tutorial-btn-prev" onClick={onPrev}>
|
||||||
|
{t('tutorial.prev')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(hint.clickToAdvance !== true || isLast) && (
|
||||||
|
<button className="tutorial-btn-next" onClick={onNext}>
|
||||||
|
{isLast ? t('tutorial.finish') : t('tutorial.next')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TutorialOverlay;
|
||||||
|
|
||||||
|
interface TutorialRestartPopupProps {
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="tutorial-restart-backdrop">
|
||||||
|
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
|
||||||
|
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
|
||||||
|
<div className="tutorial-restart-actions">
|
||||||
|
<button className="tutorial-restart-cancel" onClick={onCancel}>
|
||||||
|
{t('tutorial.restart.cancel')}
|
||||||
|
</button>
|
||||||
|
<button className="tutorial-restart-confirm" onClick={onConfirm}>
|
||||||
|
{t('tutorial.restart.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
export interface TutorialHint {
|
||||||
|
targetSelector: string;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
position: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
clickToAdvance?: boolean;
|
||||||
|
advanceSelector?: string;
|
||||||
|
spotlightPadding?: number;
|
||||||
|
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorialStepDef {
|
||||||
|
key: string;
|
||||||
|
hints: TutorialHint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TUTORIAL_KEYS = {
|
||||||
|
LANDING: 'landing',
|
||||||
|
LANDING_URL: 'landingUrl',
|
||||||
|
LANDING_NAME: 'landingName',
|
||||||
|
ANALYSIS: 'analysis',
|
||||||
|
ASSET: 'asset',
|
||||||
|
SOUND: 'sound',
|
||||||
|
SOUND_LYRICS: 'soundLyrics',
|
||||||
|
SOUND_AUDIO: 'soundAudio',
|
||||||
|
GENERATING:'generating',
|
||||||
|
COMPLETION: 'completion',
|
||||||
|
MY_INFO: 'myInfo',
|
||||||
|
ADO2_CONTENTS: 'ado2Contents',
|
||||||
|
UPLOAD_MODAL: 'uploadModal',
|
||||||
|
UPLOAD_FORM: 'uploadForm',
|
||||||
|
DASHBOARD: 'dashboard',
|
||||||
|
CONTENT_CALENDAR: 'contentCalendar',
|
||||||
|
FEEDBACK: 'feedback',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const tutorialSteps: TutorialStepDef[] = [
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.LANDING,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-dropdown-container',
|
||||||
|
titleKey: 'tutorial.landing.intro.title',
|
||||||
|
descriptionKey: 'tutorial.landing.intro.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-dropdown-menu',
|
||||||
|
titleKey: 'tutorial.landing.dropdown.title',
|
||||||
|
descriptionKey: 'tutorial.landing.dropdown.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.LANDING_URL,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-input-wrapper',
|
||||||
|
titleKey: 'tutorial.landing.url.title',
|
||||||
|
descriptionKey: 'tutorial.landing.url.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-button',
|
||||||
|
titleKey: 'tutorial.landing.button.title',
|
||||||
|
descriptionKey: 'tutorial.landing.button.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.LANDING_NAME,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-input-wrapper',
|
||||||
|
titleKey: 'tutorial.landing.name.title',
|
||||||
|
descriptionKey: 'tutorial.landing.name.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
spotlightPaddingOverride: { top: 0, right: 0, bottom: 290, left: -105 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.hero-button',
|
||||||
|
titleKey: 'tutorial.landing.button.title',
|
||||||
|
descriptionKey: 'tutorial.landing.button.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.ANALYSIS,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.bi2-identity-card',
|
||||||
|
titleKey: 'tutorial.analysis.identity.title',
|
||||||
|
descriptionKey: 'tutorial.analysis.identity.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.bi2-selling-card',
|
||||||
|
titleKey: 'tutorial.analysis.selling.title',
|
||||||
|
descriptionKey: 'tutorial.analysis.selling.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.bi2-persona-grid',
|
||||||
|
titleKey: 'tutorial.analysis.persona.title',
|
||||||
|
descriptionKey: 'tutorial.analysis.persona.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.bi2-keyword-tags',
|
||||||
|
titleKey: 'tutorial.analysis.keywords.title',
|
||||||
|
descriptionKey: 'tutorial.analysis.keywords.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.bi2-generate-btn',
|
||||||
|
titleKey: 'tutorial.analysis.generate.title',
|
||||||
|
descriptionKey: 'tutorial.analysis.generate.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.ASSET,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.asset-column.asset-column-left',
|
||||||
|
titleKey: 'tutorial.asset.image.title',
|
||||||
|
descriptionKey: 'tutorial.asset.image.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.asset-upload-zone',
|
||||||
|
titleKey: 'tutorial.asset.upload.title',
|
||||||
|
descriptionKey: 'tutorial.asset.upload.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.asset-ratio-section',
|
||||||
|
titleKey: 'tutorial.asset.ratio.title',
|
||||||
|
descriptionKey: 'tutorial.asset.ratio.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.asset-next-button',
|
||||||
|
titleKey: 'tutorial.asset.next.title',
|
||||||
|
descriptionKey: 'tutorial.asset.next.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.SOUND,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.genre-grid',
|
||||||
|
titleKey: 'tutorial.sound.genre.title',
|
||||||
|
descriptionKey: 'tutorial.sound.genre.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.language-selector-wrapper',
|
||||||
|
titleKey: 'tutorial.sound.language.title',
|
||||||
|
descriptionKey: 'tutorial.sound.language.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.btn-generate-sound',
|
||||||
|
titleKey: 'tutorial.sound.generate.title',
|
||||||
|
descriptionKey: 'tutorial.sound.generate.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.SOUND_LYRICS,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.lyrics-display',
|
||||||
|
titleKey: 'tutorial.sound.lyrics.title',
|
||||||
|
descriptionKey: 'tutorial.sound.lyrics.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.status-message-new',
|
||||||
|
titleKey: 'tutorial.sound.lyricsWait.title',
|
||||||
|
descriptionKey: 'tutorial.sound.lyricsWait.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.SOUND_AUDIO,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.audio-player',
|
||||||
|
titleKey: 'tutorial.sound.audioPlayer.title',
|
||||||
|
descriptionKey: 'tutorial.sound.audioPlayer.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.btn-video-generate',
|
||||||
|
titleKey: 'tutorial.sound.video.title',
|
||||||
|
descriptionKey: 'tutorial.sound.video.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.MY_INFO,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.youtube-connect-section',
|
||||||
|
titleKey: 'tutorial.myInfo.myInfo.title',
|
||||||
|
descriptionKey: 'tutorial.myInfo.myInfo.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.myinfo-social-buttons',
|
||||||
|
titleKey: 'tutorial.myInfo.connect.title',
|
||||||
|
descriptionKey: 'tutorial.myInfo.connect.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.myinfo-connected-accounts',
|
||||||
|
titleKey: 'tutorial.myInfo.connected.title',
|
||||||
|
descriptionKey: 'tutorial.myInfo.connected.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '#sidebar-ado2-contents',
|
||||||
|
titleKey: 'tutorial.myInfo.ado2.title',
|
||||||
|
descriptionKey: 'tutorial.myInfo.ado2.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.ADO2_CONTENTS,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.ado2-content-card',
|
||||||
|
titleKey: 'tutorial.ado2.list.title',
|
||||||
|
descriptionKey: 'tutorial.ado2.list.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.content-download-btn',
|
||||||
|
titleKey: 'tutorial.ado2.upload.title',
|
||||||
|
descriptionKey: 'tutorial.ado2.upload.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.GENERATING,
|
||||||
|
hints:[
|
||||||
|
{
|
||||||
|
targetSelector: '.comp2-info-section',
|
||||||
|
titleKey: 'tutorial.completion.contentInfo.title',
|
||||||
|
descriptionKey: 'tutorial.completion.contentInfo.desc',
|
||||||
|
position: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.comp2-video-section',
|
||||||
|
titleKey: 'tutorial.completion.generating.title',
|
||||||
|
descriptionKey: 'tutorial.completion.generating.desc',
|
||||||
|
position: 'right',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.COMPLETION,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.comp2-video-section',
|
||||||
|
titleKey: 'tutorial.completion.completion.title',
|
||||||
|
descriptionKey: 'tutorial.completion.completion.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '#sidebar-my-info',
|
||||||
|
titleKey: 'tutorial.completion.myInfo.title',
|
||||||
|
descriptionKey: 'tutorial.completion.myInfo.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.UPLOAD_MODAL,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.social-posting-content',
|
||||||
|
titleKey: 'tutorial.upload.seo.title',
|
||||||
|
descriptionKey: 'tutorial.upload.seo.desc',
|
||||||
|
position: 'right',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.UPLOAD_FORM,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.social-posting-form',
|
||||||
|
titleKey: 'tutorial.upload.required.title',
|
||||||
|
descriptionKey: 'tutorial.upload.required.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.social-posting-radio-group',
|
||||||
|
titleKey: 'tutorial.upload.schedule.title',
|
||||||
|
descriptionKey: 'tutorial.upload.schedule.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.social-posting-btn:not(.cancel)',
|
||||||
|
titleKey: 'tutorial.upload.submit.title',
|
||||||
|
descriptionKey: 'tutorial.upload.submit.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.DASHBOARD,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.stats-grid-8',
|
||||||
|
titleKey: 'tutorial.dashboard.metrics.title',
|
||||||
|
descriptionKey: 'tutorial.dashboard.metrics.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.yoy-chart-card',
|
||||||
|
titleKey: 'tutorial.dashboard.chart.title',
|
||||||
|
descriptionKey: 'tutorial.dashboard.chart.desc',
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.tutorial-center-anchor',
|
||||||
|
titleKey: 'tutorial.dashboard.more.title',
|
||||||
|
descriptionKey: 'tutorial.dashboard.more.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.CONTENT_CALENDAR,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.calendar-grid-area',
|
||||||
|
titleKey: 'tutorial.contentCalendar.grid.title',
|
||||||
|
descriptionKey: 'tutorial.contentCalendar.grid.desc',
|
||||||
|
position: 'top',
|
||||||
|
clickToAdvance: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.calendar-side-panel',
|
||||||
|
titleKey: 'tutorial.contentCalendar.panel.title',
|
||||||
|
descriptionKey: 'tutorial.contentCalendar.panel.desc',
|
||||||
|
position: 'left',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TUTORIAL_KEYS.FEEDBACK,
|
||||||
|
hints: [
|
||||||
|
{
|
||||||
|
targetSelector: '.tutorial-center-anchor',
|
||||||
|
titleKey: 'tutorial.feedback.complete.title',
|
||||||
|
descriptionKey: 'tutorial.feedback.complete.desc',
|
||||||
|
position: 'bottom',
|
||||||
|
clickToAdvance: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetSelector: '.sidebar-inquiry-btn',
|
||||||
|
titleKey: 'tutorial.feedback.title',
|
||||||
|
descriptionKey: 'tutorial.feedback.desc',
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { tutorialSteps, TutorialHint } from './tutorialSteps';
|
||||||
|
|
||||||
|
const SEEN_KEY = 'ado2_tutorial_seen';
|
||||||
|
const PROGRESS_KEY = 'ado2_tutorial_progress';
|
||||||
|
|
||||||
|
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
|
||||||
|
let globalSkip: (() => void) | null = null;
|
||||||
|
|
||||||
|
function getSeenKeys(): string[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSeen(key: string) {
|
||||||
|
const seen = getSeenKeys();
|
||||||
|
if (!seen.includes(key)) {
|
||||||
|
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
|
||||||
|
}
|
||||||
|
clearProgress(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProgress(key: string, index: number) {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||||
|
progress[key] = index;
|
||||||
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProgress(key: string): number {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||||
|
return progress[key] ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProgress(key: string) {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
||||||
|
delete progress[key];
|
||||||
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTutorialReturn {
|
||||||
|
isActive: boolean;
|
||||||
|
isRestartPopupVisible: boolean;
|
||||||
|
currentHintIndex: number;
|
||||||
|
hints: TutorialHint[];
|
||||||
|
tutorialKey: string | null;
|
||||||
|
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
|
||||||
|
nextHint: () => void;
|
||||||
|
prevHint: () => void;
|
||||||
|
skipTutorial: () => void;
|
||||||
|
showRestartPopup: (key: string) => void;
|
||||||
|
confirmRestart: () => void;
|
||||||
|
cancelRestart: () => void;
|
||||||
|
hasSeen: (key: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTutorial(): UseTutorialReturn {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
|
||||||
|
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
|
||||||
|
const [currentHintIndex, setCurrentHintIndex] = useState(0);
|
||||||
|
const [hints, setHints] = useState<TutorialHint[]>([]);
|
||||||
|
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
|
||||||
|
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
|
||||||
|
|
||||||
|
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
|
||||||
|
const step = tutorialSteps.find(s => s.key === key);
|
||||||
|
if (!step || step.hints.length === 0) return;
|
||||||
|
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
|
||||||
|
globalSkip?.();
|
||||||
|
const savedIndex = forceFromStart ? 0 : loadProgress(key);
|
||||||
|
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
setHints(step.hints);
|
||||||
|
setTutorialKey(key);
|
||||||
|
setCurrentHintIndex(resumeIndex);
|
||||||
|
setIsActive(true);
|
||||||
|
// 이 인스턴스의 skip을 전역에 등록
|
||||||
|
globalSkip = () => {
|
||||||
|
if (key) saveProgress(key, resumeIndex);
|
||||||
|
setIsActive(false);
|
||||||
|
setCurrentHintIndex(0);
|
||||||
|
globalSkip = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextHint = useCallback(() => {
|
||||||
|
setCurrentHintIndex(prev => {
|
||||||
|
if (prev < hints.length - 1) {
|
||||||
|
const next = prev + 1;
|
||||||
|
if (tutorialKey) saveProgress(tutorialKey, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
|
||||||
|
setIsActive(false);
|
||||||
|
if (tutorialKey) markSeen(tutorialKey);
|
||||||
|
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
|
||||||
|
onCompleteRef.current?.();
|
||||||
|
onCompleteRef.current = undefined;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [hints.length, tutorialKey]);
|
||||||
|
|
||||||
|
const prevHint = useCallback(() => {
|
||||||
|
setCurrentHintIndex(prev => Math.max(0, prev - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시
|
||||||
|
const skipTutorial = useCallback(() => {
|
||||||
|
if (tutorialKey) saveProgress(tutorialKey, currentHintIndex);
|
||||||
|
setIsActive(false);
|
||||||
|
setCurrentHintIndex(0);
|
||||||
|
}, [tutorialKey, currentHintIndex]);
|
||||||
|
|
||||||
|
// 튜토리얼 다시 보기: 팝업 표시만
|
||||||
|
const showRestartPopup = useCallback((key: string) => {
|
||||||
|
setPendingRestartKey(key);
|
||||||
|
setIsRestartPopupVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
|
||||||
|
const confirmRestart = useCallback(() => {
|
||||||
|
setIsRestartPopupVisible(false);
|
||||||
|
if (pendingRestartKey) {
|
||||||
|
localStorage.removeItem(SEEN_KEY);
|
||||||
|
localStorage.removeItem(PROGRESS_KEY);
|
||||||
|
startTutorial(pendingRestartKey, undefined, true);
|
||||||
|
}
|
||||||
|
setPendingRestartKey(null);
|
||||||
|
}, [pendingRestartKey, startTutorial]);
|
||||||
|
|
||||||
|
// 팝업에서 취소
|
||||||
|
const cancelRestart = useCallback(() => {
|
||||||
|
setIsRestartPopupVisible(false);
|
||||||
|
setPendingRestartKey(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasSeen = useCallback((key: string) => {
|
||||||
|
return getSeenKeys().includes(key);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive,
|
||||||
|
isRestartPopupVisible,
|
||||||
|
currentHintIndex,
|
||||||
|
hints,
|
||||||
|
tutorialKey,
|
||||||
|
startTutorial,
|
||||||
|
nextHint,
|
||||||
|
prevHint,
|
||||||
|
skipTutorial,
|
||||||
|
showRestartPopup,
|
||||||
|
confirmRestart,
|
||||||
|
cancelRestart,
|
||||||
|
hasSeen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,85 @@
|
||||||
"myInfo": "My Info",
|
"myInfo": "My Info",
|
||||||
"defaultUser": "User",
|
"defaultUser": "User",
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
"logout": "Log Out"
|
"logout": "Log Out",
|
||||||
|
"tutorialRestart": "Restart Tutorial"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"skip": "Skip",
|
||||||
|
"next": "Next",
|
||||||
|
"prev": "Back",
|
||||||
|
"finish": "Done",
|
||||||
|
"landing": {
|
||||||
|
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step.\nFirst, please select your search method." },
|
||||||
|
"dropdown": { "title": "Choose Search Type", "desc": "Click the dropdown and select URL or business name as your preferred search method." },
|
||||||
|
"url": { "title": "Enter Naver Place URL", "desc": "Search for a place on Naver Maps, click Share, and paste the URL here." },
|
||||||
|
"name": { "title": "Enter Business Name", "desc": "Type a business name and the autocomplete list will appear.\nChoose your business from the list." },
|
||||||
|
"button": { "title": "Start Brand Analysis", "desc": "Click to let AI analyze your brand." }
|
||||||
|
},
|
||||||
|
"analysis": {
|
||||||
|
"identity": { "title": "Brand Identity", "desc": "Check the core values and market positioning AI analyzed for your brand." },
|
||||||
|
"selling": { "title": "Key Selling Points", "desc": "See your brand's strengths." },
|
||||||
|
"persona": { "title": "Target Customer Types", "desc": "AI analyzed what kind of customers visit this brand." },
|
||||||
|
"keywords": { "title": "Recommended Keywords", "desc": "Keywords your customers are likely to search for." },
|
||||||
|
"generate": { "title": "Generate Content", "desc": "Create video content based on the analysis.\nClick to sign in with Kakao and continue." }
|
||||||
|
},
|
||||||
|
"asset": {
|
||||||
|
"image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." },
|
||||||
|
"upload": { "title": "Add Images", "desc": "You can freely add more images." },
|
||||||
|
"ratio": { "title": "Select Video Ratio", "desc": "Choose the ratio for the video to be generated." },
|
||||||
|
"next": { "title": "Next Step", "desc": "Proceed to the next step when ready." }
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand." },
|
||||||
|
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
||||||
|
"generate": { "title": "Generate Sound", "desc": "AI will generate music in your chosen genre and language." },
|
||||||
|
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
|
||||||
|
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
||||||
|
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
||||||
|
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
||||||
|
},
|
||||||
|
"myInfo": {
|
||||||
|
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
|
||||||
|
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button.\n(Instagram connection is under development.)" },
|
||||||
|
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
|
||||||
|
"ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
|
||||||
|
},
|
||||||
|
"ado2": {
|
||||||
|
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." },
|
||||||
|
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
|
||||||
|
},
|
||||||
|
"completion": {
|
||||||
|
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." },
|
||||||
|
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
|
||||||
|
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
|
||||||
|
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. \nPlease wait a moment." },
|
||||||
|
"required": { "title": "Required Fields", "desc": "Fields marked with * are required. Please fill them in before uploading." },
|
||||||
|
"schedule": { "title": "Schedule Upload", "desc": "Post now or schedule for a specific time." },
|
||||||
|
"submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." }
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats for content uploaded via ADO2." },
|
||||||
|
"chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." },
|
||||||
|
"more": { "title": "More Analytics", "desc": "Even more statistics are available at a glance on the dashboard." }
|
||||||
|
},
|
||||||
|
"contentCalendar": {
|
||||||
|
"grid": { "title": "Content Calendar", "desc": "View your content schedule by date.\nWhy not select today?" },
|
||||||
|
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right." },
|
||||||
|
"title": "Customer Feedback",
|
||||||
|
"desc": "Share any issues or suggestions to help us improve."
|
||||||
|
},
|
||||||
|
"restart": {
|
||||||
|
"title": "Restart Tutorial?",
|
||||||
|
"desc": "The tutorial will restart from the current screen.",
|
||||||
|
"confirm": "Start",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company":"O2O Inc.",
|
"company":"O2O Inc.",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,85 @@
|
||||||
"myInfo": "내 정보",
|
"myInfo": "내 정보",
|
||||||
"defaultUser": "사용자",
|
"defaultUser": "사용자",
|
||||||
"loggingOut": "로그아웃 중...",
|
"loggingOut": "로그아웃 중...",
|
||||||
"logout": "로그아웃"
|
"logout": "로그아웃",
|
||||||
|
"tutorialRestart": "튜토리얼 다시 보기"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"skip": "건너뛰기",
|
||||||
|
"next": "다음",
|
||||||
|
"prev": "이전",
|
||||||
|
"finish": "완료",
|
||||||
|
"landing": {
|
||||||
|
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요.\n먼저 검색 방식을 선택해 주세요." },
|
||||||
|
"dropdown": { "title": "검색 방식 선택", "desc": "URL 또는 업체명 중 원하는 방식을 선택하세요." },
|
||||||
|
"url": { "title": "네이버 Place URL 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣으세요." },
|
||||||
|
"name": { "title": "업체명 입력", "desc": "업체명을 입력하면 자동완성 목록이 나타나요.\n목록에서 원하는 업체를 선택하세요." },
|
||||||
|
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
||||||
|
},
|
||||||
|
"analysis": {
|
||||||
|
"identity": { "title": "브랜드 정체성", "desc": "AI가 분석한 펜션의 핵심 가치와 시장 포지셔닝을 확인하세요." },
|
||||||
|
"selling": { "title": "주요 셀링 포인트", "desc": "펜션의 강점을 확인할 수 있어요." },
|
||||||
|
"persona": { "title": "주요 고객 유형", "desc": "어떤 고객이 이 펜션을 찾는지 분석했어요." },
|
||||||
|
"keywords": { "title": "추천 타겟 키워드", "desc": "고객이 검색할 가능성이 높은 키워드들이에요." },
|
||||||
|
"generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." }
|
||||||
|
},
|
||||||
|
"asset": {
|
||||||
|
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
|
||||||
|
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
|
||||||
|
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
|
||||||
|
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." },
|
||||||
|
"language": { "title": "언어 선택", "desc": "사운드의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
|
||||||
|
"generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 음악을 생성해요." },
|
||||||
|
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
|
||||||
|
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
|
||||||
|
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 미리 들어보세요." },
|
||||||
|
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
|
||||||
|
},
|
||||||
|
"myInfo": {
|
||||||
|
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
|
||||||
|
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 클릭하세요. \n(Instaram연결은 개발 중입니다.)" },
|
||||||
|
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
|
||||||
|
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
|
||||||
|
},
|
||||||
|
"ado2": {
|
||||||
|
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
|
||||||
|
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
|
||||||
|
},
|
||||||
|
"completion": {
|
||||||
|
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 제목, 장르, 규격, 가사를 확인하세요." },
|
||||||
|
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
|
||||||
|
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
|
||||||
|
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." }
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"seo": { "title": "제목 및 설명", "desc": "영상에 표시될 제목과 설명을 AI가 만들고 있어요. \n잠시만 기다려 주세요." },
|
||||||
|
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수항목으로 반드시 확인해 주세요." },
|
||||||
|
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
|
||||||
|
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요." },
|
||||||
|
"chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." },
|
||||||
|
"more": { "title": "더 많은 통계", "desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요." }
|
||||||
|
},
|
||||||
|
"contentCalendar": {
|
||||||
|
"grid": { "title": "콘텐츠 캘린더", "desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?" },
|
||||||
|
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
|
||||||
|
"title": "고객의견",
|
||||||
|
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
|
||||||
|
},
|
||||||
|
"restart": {
|
||||||
|
"title": "튜토리얼을 다시 시작할까요?",
|
||||||
|
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
|
||||||
|
"confirm": "시작하기",
|
||||||
|
"cancel": "취소"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company":"㈜에이아이오투오",
|
"company":"㈜에이아이오투오",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CrawlingResponse, TargetPersona } from '../../types/api';
|
import { CrawlingResponse, TargetPersona } from '../../types/api';
|
||||||
import { GeometricChart } from './GeometricChart';
|
import { GeometricChart } from './GeometricChart';
|
||||||
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
|
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
|
||||||
interface AnalysisResultSectionProps {
|
interface AnalysisResultSectionProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
|
@ -12,8 +15,19 @@ interface AnalysisResultSectionProps {
|
||||||
|
|
||||||
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const tutorial = useTutorial();
|
||||||
const { processed_info, marketing_analysis } = data;
|
const { processed_info, marketing_analysis } = data;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!tutorial.hasSeen(TUTORIAL_KEYS.ANALYSIS)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.ANALYSIS);
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const brandIdentity = marketing_analysis?.brand_identity;
|
const brandIdentity = marketing_analysis?.brand_identity;
|
||||||
const marketPositioning = marketing_analysis?.market_positioning;
|
const marketPositioning = marketing_analysis?.market_positioning;
|
||||||
const targetPersonas = marketing_analysis?.target_persona || [];
|
const targetPersonas = marketing_analysis?.target_persona || [];
|
||||||
|
|
@ -24,6 +38,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="bi2-page">
|
<div className="bi2-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bi2-header">
|
<div className="bi2-header">
|
||||||
|
|
@ -175,6 +190,17 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generateVideo, waitForVideoComplete } from '../../utils/api';
|
import { generateVideo, waitForVideoComplete } from '../../utils/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
|
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
|
||||||
interface CompletionContentProps {
|
interface CompletionContentProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
|
@ -39,6 +42,20 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
const [renderProgress, setRenderProgress] = useState(0);
|
const [renderProgress, setRenderProgress] = useState(0);
|
||||||
const hasStartedGeneration = useRef(false);
|
const hasStartedGeneration = useRef(false);
|
||||||
|
|
||||||
|
const tutorial = useTutorial();
|
||||||
|
|
||||||
|
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
|
||||||
|
useEffect(() => {
|
||||||
|
const isComplete = videoStatus === 'complete';
|
||||||
|
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
|
||||||
|
|
||||||
|
if (isProcessing && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
|
||||||
|
} else if (isComplete && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
|
||||||
|
}
|
||||||
|
}, [videoStatus, tutorial]);
|
||||||
|
|
||||||
// 소셜 미디어 포스팅 모달
|
// 소셜 미디어 포스팅 모달
|
||||||
const [showSocialModal, setShowSocialModal] = useState(false);
|
const [showSocialModal, setShowSocialModal] = useState(false);
|
||||||
const [videoDbId, setVideoDbId] = useState<number | null>(null);
|
const [videoDbId, setVideoDbId] = useState<number | null>(null);
|
||||||
|
|
@ -438,6 +455,16 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
} : null}
|
} : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -758,7 +758,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
flex: 1, minHeight: 0,
|
flex: 1, minHeight: 0,
|
||||||
}}>
|
}}>
|
||||||
{/* 캘린더 영역 */}
|
{/* 캘린더 영역 */}
|
||||||
<div style={{
|
<div className="calendar-grid-area" style={{
|
||||||
gridColumn: '1 / span 8',
|
gridColumn: '1 / span 8',
|
||||||
backgroundColor: '#01393b',
|
backgroundColor: '#01393b',
|
||||||
borderRadius: 20, padding: 16,
|
borderRadius: 20, padding: 16,
|
||||||
|
|
@ -780,7 +780,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽 패널 */}
|
{/* 오른쪽 패널 */}
|
||||||
<div style={{
|
<div className="calendar-side-panel" style={{
|
||||||
gridColumn: '9 / span 1',
|
gridColumn: '9 / span 1',
|
||||||
backgroundColor: '#01393b', borderRadius: 20,
|
backgroundColor: '#01393b', borderRadius: 20,
|
||||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api';
|
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api';
|
||||||
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
|
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
|
||||||
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
||||||
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
||||||
|
|
@ -112,6 +115,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
|
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
|
||||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||||
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
||||||
|
const tutorial = useTutorial();
|
||||||
|
|
||||||
// 로그인 직후 사용자 정보 조회
|
// 로그인 직후 사용자 정보 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -316,6 +320,35 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
||||||
}, [activeItem]);
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
|
||||||
|
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
|
||||||
|
useEffect(() => {
|
||||||
|
if (tutorial.isActive) tutorial.skipTutorial();
|
||||||
|
if (SIDEBAR_ITEMS.includes(activeItem)) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.ASSET);
|
||||||
|
} else if (wizardStep === 2 && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.SOUND);
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [wizardStep, activeItem]);
|
||||||
|
|
||||||
|
// activeItem 변경 시 튜토리얼 트리거
|
||||||
|
useEffect(() => {
|
||||||
|
if (tutorial.isActive) tutorial.skipTutorial();
|
||||||
|
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
|
||||||
|
} else if (activeItem === 'ADO2 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
|
||||||
|
} else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD);
|
||||||
|
} else if (activeItem === '콘텐츠 캘린더' && !tutorial.hasSeen(TUTORIAL_KEYS.CONTENT_CALENDAR)) {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.CONTENT_CALENDAR);
|
||||||
|
}
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
|
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
|
||||||
const handleNavigate = (item: string) => {
|
const handleNavigate = (item: string) => {
|
||||||
if (item === '새 프로젝트 만들기') {
|
if (item === '새 프로젝트 만들기') {
|
||||||
|
|
@ -399,6 +432,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
mId={analysisData?.m_id ?? 0}
|
mId={analysisData?.m_id ?? 0}
|
||||||
videoGenerationStatus={videoGenerationStatus}
|
videoGenerationStatus={videoGenerationStatus}
|
||||||
videoGenerationProgress={videoGenerationProgress}
|
videoGenerationProgress={videoGenerationProgress}
|
||||||
|
onStatusChange={(status: string) => {
|
||||||
|
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
|
||||||
|
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
|
||||||
|
} else if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
|
||||||
|
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO), 400);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
|
|
@ -466,6 +506,54 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
||||||
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
|
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
|
||||||
|
|
||||||
|
// 현재 화면에 맞는 튜토리얼 키 반환
|
||||||
|
const getCurrentTutorialKey = (): string => {
|
||||||
|
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
|
||||||
|
if (activeItem === 'ADO2 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
|
||||||
|
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
|
||||||
|
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
|
||||||
|
if (activeItem === '새 프로젝트 만들기') {
|
||||||
|
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
|
||||||
|
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
|
||||||
|
}
|
||||||
|
return TUTORIAL_KEYS.ASSET;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTutorialRestart = () => {
|
||||||
|
tutorial.showRestartPopup(getCurrentTutorialKey());
|
||||||
|
};
|
||||||
|
|
||||||
|
const tutorialUI = (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="tutorial-restart-fab"
|
||||||
|
onClick={handleTutorialRestart}
|
||||||
|
title={t('sidebar.tutorialRestart')}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M12 8v4l3 3"/>
|
||||||
|
</svg>
|
||||||
|
<span>{t('sidebar.tutorialRestart')}</span>
|
||||||
|
</button>
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tutorial.isRestartPopupVisible && (
|
||||||
|
<TutorialRestartPopup
|
||||||
|
onConfirm={tutorial.confirmRestart}
|
||||||
|
onCancel={tutorial.cancelRestart}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
// 브랜드 분석일 때는 전체 화면 스크롤
|
// 브랜드 분석일 때는 전체 화면 스크롤
|
||||||
if (isBrandAnalysis) {
|
if (isBrandAnalysis) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -476,6 +564,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
<main className="analysis-page-main">
|
<main className="analysis-page-main">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</main>
|
</main>
|
||||||
|
{tutorialUI}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -485,6 +574,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
||||||
)}
|
)}
|
||||||
|
{tutorialUI}
|
||||||
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ interface SoundStudioContentProps {
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
imageTaskId: string | null;
|
imageTaskId: string | null;
|
||||||
mId: number;
|
mId: number;
|
||||||
|
onStatusChange?: (status: string) => void;
|
||||||
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
||||||
videoGenerationProgress?: number;
|
videoGenerationProgress?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +40,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
businessInfo,
|
businessInfo,
|
||||||
imageTaskId,
|
imageTaskId,
|
||||||
mId,
|
mId,
|
||||||
|
onStatusChange,
|
||||||
videoGenerationStatus = 'idle',
|
videoGenerationStatus = 'idle',
|
||||||
videoGenerationProgress = 0
|
videoGenerationProgress = 0
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -389,7 +391,13 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
|
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
|
||||||
|
|
||||||
|
// status 변경 시 부모에 알림
|
||||||
|
useEffect(() => {
|
||||||
|
onStatusChange?.(status);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<main className="sound-studio-page">
|
<main className="sound-studio-page">
|
||||||
{audioUrl && (
|
{audioUrl && (
|
||||||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||||
|
|
@ -643,6 +651,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
|
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
|
||||||
import { CrawlingResponse } from '../../types/api';
|
import { CrawlingResponse } from '../../types/api';
|
||||||
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
|
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
|
||||||
type SearchType = 'url' | 'name';
|
type SearchType = 'url' | 'name';
|
||||||
|
|
||||||
|
|
@ -92,6 +95,18 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const tutorial = useTutorial();
|
||||||
|
|
||||||
|
// 첫 방문 시 랜딩 튜토리얼 시작 (URL/업체명 분기 튜토리얼도 아직 안 본 경우)
|
||||||
|
useEffect(() => {
|
||||||
|
const neitherBranchSeen = !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_URL) && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_NAME);
|
||||||
|
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING) && neitherBranchSeen) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
|
||||||
|
}, 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const searchTypeOptions = [
|
const searchTypeOptions = [
|
||||||
{ value: 'url' as SearchType, label: 'URL' },
|
{ value: 'url' as SearchType, label: 'URL' },
|
||||||
|
|
@ -141,6 +156,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setAutocompleteResults([]);
|
setAutocompleteResults([]);
|
||||||
setHighlightedIndex(-1);
|
setHighlightedIndex(-1);
|
||||||
|
// LANDING_NAME 튜토리얼 진행 중이면 다음 힌트로 이동
|
||||||
|
if (tutorial.isActive && tutorial.tutorialKey === TUTORIAL_KEYS.LANDING_NAME) {
|
||||||
|
tutorial.nextHint();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 키보드 네비게이션 핸들러
|
// 키보드 네비게이션 핸들러
|
||||||
|
|
@ -362,6 +381,11 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchType(option.value);
|
setSearchType(option.value);
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
|
if (option.value === 'url' && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_URL)) {
|
||||||
|
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.LANDING_URL, undefined, true), 300);
|
||||||
|
} else if (option.value === 'name' && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_NAME)) {
|
||||||
|
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.LANDING_NAME, undefined, true), 300);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|
@ -502,6 +526,16 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
|
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tutorial.isActive && (
|
||||||
|
<TutorialOverlay
|
||||||
|
hints={tutorial.hints}
|
||||||
|
currentIndex={tutorial.currentHintIndex}
|
||||||
|
onNext={tutorial.nextHint}
|
||||||
|
onPrev={tutorial.prevHint}
|
||||||
|
onSkip={tutorial.skipTutorial}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue