Compare commits

..

No commits in common. "dd1f873a6e28eb00c96f85eff0a10ed7dceba9d2" and "faaca0f4fb7a0bf3f40bffedf95b510da4392b3c" have entirely different histories.

15 changed files with 62 additions and 1756 deletions

368
index.css
View File

@ -7721,10 +7721,6 @@
border-color: rgba(255, 255, 255, 0.1);
}
.genre-btn.active:hover:not(:disabled) {
border-color: var(--color-mint);
}
.genre-btn.active {
border-color: var(--color-mint);
}
@ -7802,7 +7798,7 @@
top: calc(100% + 0.5rem);
left: 0;
right: 0;
max-height: 220px;
max-height: 240px;
background-color: #002224;
border: 1px solid #046266;
border-radius: 8px;
@ -8084,7 +8080,6 @@
color: var(--color-mint);
font-size: var(--text-sm);
text-align: center;
white-space: pre-line;
display: flex;
align-items: center;
justify-content: center;
@ -9764,47 +9759,6 @@
color: rgba(255, 255, 255, 0.3);
}
/* SEO 자동 생성 shimmer 효과 */
@keyframes seo-shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
.seo-input-wrapper {
position: relative;
}
.seo-input-wrapper .social-posting-input,
.seo-input-wrapper .social-posting-textarea {
width: 100%;
}
.seo-shimmer-text {
position: absolute;
top: 50%;
left: 12px;
transform: translateY(-50%);
pointer-events: none;
font-size: 14px;
font-family: 'Pretendard', sans-serif;
background: linear-gradient(
90deg,
rgba(166, 255, 234, 0.25) 0%,
#a6ffea 40%,
rgba(166, 255, 234, 0.25) 80%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: seo-shimmer 3s linear infinite;
}
.seo-input-wrapper:has(.social-posting-textarea) .seo-shimmer-text {
top: 14px;
transform: none;
}
.social-posting-input:focus,
.social-posting-select:focus,
.social-posting-textarea:focus {
@ -10597,323 +10551,3 @@
scrollbar-width: thin;
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;
pointer-events: all;
z-index: 10002;
display: flex;
width: 370px;
padding: 20px;
flex-direction: column;
gap: 8px;
border-radius: 12px;
border: 1px solid var(--Color-teal-600, #046266);
background: var(--Color-teal-800, #002224);
}
.tutorial-tooltip-title {
align-self: stretch;
color: rgba(255, 255, 255, 0.70);
/* 14 */
font-family: Pretendard;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
}
.tutorial-tooltip-desc {
display: flex;
padding-bottom: 16px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
line-height: 1.5;
white-space: pre-line;
align-self: stretch;
color: var(--Color-white, #FFF);
font-family: Pretendard;
font-size: 18px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 133.333% */
letter-spacing: -0.108px;
}
.tutorial-tooltip-note {
align-self: stretch;
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 100%;
letter-spacing: -0.072px;
margin: -8px 0 14px;
white-space: pre-line;
}
.tutorial-tooltip-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.tutorial-tooltip-footer-left {
display: flex;
align-items: center;
gap: 8px;
}
.tutorial-tooltip-counter {
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 12px */
letter-spacing: -0.072px;
}
.tutorial-tooltip-actions {
display: flex;
gap: 10px;
}
.tutorial-btn-skip {
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 12px */
letter-spacing: -0.072px;
}
.tutorial-btn-skip:hover {
color: rgba(255, 255, 255, 0.7);
}
.tutorial-btn-prev {
display: flex;
padding: 8px 12px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 4px;
background: var(--Color-teal-200, #9BCACC);
color: var(--Color-teal-800, #002224);
font-family: Pretendard;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 13px */
letter-spacing: -0.078px;
}
.tutorial-btn-prev:hover {
background: rgba(255, 255, 255, 0.14);
}
.tutorial-btn-next {
display: flex;
padding: 8px 12px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 4px;
background: var(--Color-teal-200, #9BCACC);
color: var(--Color-teal-800, #002224);
font-family: Pretendard;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 13px */
letter-spacing: -0.078px;
}
.tutorial-btn-next:hover {
background: rgba(255, 255, 255, 0.14);
}
/* 말풍선 variant — 색상은 기본 tooltip과 동일, 꼬리만 추가 */
.tutorial-tooltip-bubble {
border-radius: 12px;
}
/* 꼬리 (화살표) */
.tutorial-tooltip-bubble::before {
content: '';
position: absolute;
width: 0;
height: 0;
border: 10px solid transparent;
}
.tutorial-tooltip-bubble--bottom::before {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-bottom-color: #1a2630;
}
.tutorial-tooltip-bubble--top::before {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: #1a2630;
}
.tutorial-tooltip-bubble--right::before {
right: 100%;
top: 50%;
transform: translateY(-50%);
border-right-color: #1a2630;
}
.tutorial-tooltip-bubble--left::before {
left: 100%;
top: 50%;
transform: translateY(-50%);
border-left-color: #1a2630;
}
.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;
}

View File

@ -12,13 +12,11 @@ interface SidebarItemProps {
isCollapsed: boolean;
isDisabled?: boolean;
onClick?: () => void;
id?: string;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => {
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick }) => {
return (
<div
id={id}
onClick={isDisabled ? undefined : onClick}
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
title={isCollapsed ? label : ""}
@ -140,13 +138,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{menuItems.map(item => (
<SidebarItem
key={item.id}
id={
item.id === '내 정보'
? 'sidebar-my-info'
: item.id === 'ADO2 콘텐츠'
? 'sidebar-ado2-contents'
: undefined
}
icon={item.icon}
label={item.label}
isCollapsed={isCollapsed}

View File

@ -4,9 +4,6 @@ import { useTranslation } from 'react-i18next';
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
import { useTutorial } from './Tutorial/useTutorial';
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
import TutorialOverlay from './Tutorial/TutorialOverlay';
interface SocialPostingModalProps {
isOpen: boolean;
@ -119,7 +116,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
video
}) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [selectedChannel, setSelectedChannel] = useState<string>('');
const [title, setTitle] = useState('');
@ -139,7 +135,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null);
const hasBeenOpenedRef = useRef(false);
// Upload progress modal state
const [showUploadProgress, setShowUploadProgress] = useState(false);
@ -176,32 +171,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
return () => { document.body.style.overflow = ''; };
}, [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(() => {
if (isOpen) {
@ -430,21 +399,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
);
if (!isOpen || !video) {
return (
<>
{showUploadProgress && uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
// Still render upload progress modal even when main modal is closed
return showUploadProgress ? uploadProgressModalElement : null;
}
return (
@ -566,60 +522,48 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
<label className="social-posting-label">
{t('social.postTitleLabel')} <span className="required">*</span>
</label>
<div className="seo-input-wrapper">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.postTitlePlaceholder')}
placeholder={isLoadingAutoDescription ? t('social.autoSeoTitle') : t('social.postTitlePlaceholder')}
// placeholder={t('social.postTitlePlaceholder')}
className="social-posting-input"
maxLength={100}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoTitle')}</span>
)}
</div>
<span className="social-posting-char-count">{title.length}/100</span>
</div>
{/* Description */}
<div className="social-posting-field">
<label className="social-posting-label">{t('social.postContentLabel')}</label>
<div className="seo-input-wrapper">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.postContentPlaceholder')}
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoDescription') : t('social.postContentPlaceholder'))}
// placeholder={t('social.postContentPlaceholder')}
className="social-posting-textarea"
maxLength={5000}
rows={4}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoDescription')}</span>
)}
</div>
<span className="social-posting-char-count">{description.length}/5,000</span>
</div>
{/* Tags */}
<div className="social-posting-field">
<label className="social-posting-label">{t('social.tagsLabel')}</label>
<div className="seo-input-wrapper">
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.tagsPlaceholder')}
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoTags') : t('social.tagsPlaceholder'))}
// placeholder={t('social.tagsPlaceholder')}
className="social-posting-input"
maxLength={500}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoTags')}</span>
)}
</div>
<span className="social-posting-char-count">{tags.length}/500</span>
</div>
@ -817,16 +761,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
</div>
</div>
{uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
};

View File

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

View File

@ -1,425 +0,0 @@
export interface TutorialHint {
targetSelector: string;
titleKey: string;
descriptionKey: string;
position: 'top' | 'bottom' | 'left' | 'right';
noteKey?: string;
variant?: 'bubble';
clickToAdvance?: boolean;
advanceSelector?: string;
noSpotlight?: boolean;
spotlightPadding?: number;
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
}
export interface TutorialStepDef {
key: string;
hints: TutorialHint[];
}
export const TUTORIAL_KEYS = {
LANDING: 'landing',
ANALYSIS: 'analysis',
ASSET: 'asset',
SOUND: 'sound',
SOUND_LYRICS: 'soundLyrics',
SOUND_AUDIO: 'soundAudio',
GENERATING:'generating',
COMPLETION: 'completion',
MY_INFO: 'myInfo',
ADO2_CONTENTS: 'ado2Contents',
UPLOAD_MODAL: 'uploadModal',
UPLOAD_FORM: 'uploadForm',
DASHBOARD: 'dashboard',
CONTENT_CALENDAR: 'contentCalendar',
FEEDBACK: 'feedback',
} as const;
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
export const TUTORIAL_PAGE_GROUPS: string[][] = [
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
];
export const tutorialSteps: TutorialStepDef[] = [
{
key: TUTORIAL_KEYS.LANDING,
hints: [
// {
// targetSelector: '.tutorial-center-anchor',
// titleKey: 'tutorial.landing.intro.title',
// descriptionKey: 'tutorial.landing.intro.desc',
// position: 'bottom',
// clickToAdvance: false,
// noSpotlight: true,
// },
{
targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'left',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc',
position: 'right',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc',
position: 'right',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
],
},
// {
// 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: false,
},
{
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-btn',
titleKey: 'tutorial.myInfo.connect.title',
descriptionKey: 'tutorial.myInfo.connect.desc',
noteKey: 'tutorial.myInfo.connect.note',
position: 'top',
clickToAdvance: true,
},
{
targetSelector: '.myinfo-connected-accounts',
titleKey: 'tutorial.myInfo.connected.title',
descriptionKey: 'tutorial.myInfo.connected.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '#sidebar-ado2-contents',
titleKey: 'tutorial.myInfo.ado2.title',
descriptionKey: 'tutorial.myInfo.ado2.desc',
position: 'right',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.ADO2_CONTENTS,
hints: [
{
targetSelector: '.ado2-content-card',
titleKey: 'tutorial.ado2.list.title',
descriptionKey: 'tutorial.ado2.list.desc',
position: 'right',
clickToAdvance: false,
},
{
targetSelector: '.content-upload-btn',
titleKey: 'tutorial.ado2.download.title',
descriptionKey: 'tutorial.ado2.download.desc',
position: 'right',
clickToAdvance: false,
},
{
targetSelector: '.content-delete-btn',
titleKey: 'tutorial.ado2.delete.title',
descriptionKey: 'tutorial.ado2.delete.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',
},
],
},
];

View File

@ -1,185 +0,0 @@
import React, { useState, useCallback } from 'react';
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
if (!group) return null;
let offset = 0;
let total = 0;
for (const k of group) {
const step = tutorialSteps.find(s => s.key === k);
const count = step?.hints.length ?? 0;
if (k === key) offset = total;
total += count;
}
const isLastKeyInGroup = group[group.length - 1] === key;
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
}
const SEEN_KEY = 'ado2_tutorial_seen';
const PROGRESS_KEY = 'ado2_tutorial_progress';
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 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;
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | 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,
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
startTutorial,
nextHint,
prevHint,
skipTutorial,
showRestartPopup,
confirmRestart,
cancelRestart,
hasSeen,
};
}

View File

@ -13,86 +13,7 @@
"myInfo": "My Info",
"defaultUser": "User",
"loggingOut": "Logging 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." },
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing 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": "Click the button and AI will generate lyrics and 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 to go to the connection page.", "note": "Instagram connection is coming soon." },
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
"ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
},
"ado2": {
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." },
"download": { "title": "Download", "desc": "Download the video to your device." },
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
},
"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. Please wait a moment." },
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
"schedule": { "title": "Schedule Upload", "desc": "Post now or schedule for a specific time." },
"submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." }
},
"dashboard": {
"metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats for content uploaded via ADO2." },
"chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." },
"more": { "title": "More Analytics", "desc": "Even more statistics are available at a glance on the dashboard." }
},
"contentCalendar": {
"grid": { "title": "Content Calendar", "desc": "View your content schedule by date.\nWhy not select today?" },
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
},
"feedback": {
"complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right." },
"title": "Customer Feedback",
"desc": "Share any issues or suggestions to help us improve."
},
"restart": {
"title": "Restart Tutorial?",
"desc": "The tutorial will restart from the current screen.",
"confirm": "Start",
"cancel": "Cancel"
}
"logout": "Log Out"
},
"footer": {
"company":"O2O Inc.",
@ -134,9 +55,9 @@
"invalidVideoInfo": "Video information is invalid. (missing video_id)",
"uploadStartFailed": "Failed to start upload.",
"uploadFailed": "Upload failed.",
"autoSeoTitle": "Auto-generating... (3060 sec)",
"autoSeoDescription": "Auto-generating... (3060 sec)",
"autoSeoTags": "Auto-generating... (3060 sec)"
"autoSeoTitle": "This will be automatically generated. please wait.",
"autoSeoDescription": "This will be automatically generated. please wait.",
"autoSeoTags": "This will be automatically generated. please wait."
},
"upload": {
"title": "YouTube Upload",
@ -158,7 +79,7 @@
"hero": {
"searchTypeBusinessName": "Business Name",
"placeholderBusinessName": "Enter a business name",
"guideUrl": "Search for a place on Naver Maps, tap Share,\nthen paste the URL here.",
"guideUrl": "Enter the Naver Place URL.",
"guideBusinessName": "Search by business name to retrieve information.",
"errorUrlRequired": "Please enter a URL.",
"errorNameRequired": "Please enter a business name.",
@ -176,7 +97,7 @@
"feature1Title": "Business Core Information Analysis",
"feature1Desc": "Enter the URL of your homepage,\nNaver Map, blog, etc.",
"feature2Title": "Automated Promotional Content Creation",
"feature2Desc": "Based on analyzed information,\nautomatically create music, subtitles,\nand videos tailored to your business",
"feature2Desc": "Based on analyzed information,\nautomatically create music, subtitles, songs,\nand videos tailored to your business",
"feature3Title": "Multi-Channel Auto Distribution",
"feature3Desc": "Completed videos can be downloaded\nor uploaded directly to social media"
},
@ -194,7 +115,7 @@
"urlInput": {
"searchTypeBusinessName": "Business Name",
"placeholderBusinessName": "Enter a business name",
"guideUrl": "Search for a place on Naver Maps, tap Share, then paste the URL here.",
"guideUrl": "Select a place on Naver Maps, click Share,\nand paste the URL that appears.",
"guideBusinessName": "Search by business name to retrieve information.",
"searchButton": "Search",
"searching": "Searching...",
@ -215,7 +136,7 @@
"back": "Go Back",
"loadMore": "Load more",
"uploadFailed": "Image upload failed.",
"uploading": "Uploading... (30 sec 2 min)",
"uploading": "Uploading...",
"nextStep": "Next Step"
},
"soundStudio": {
@ -239,8 +160,8 @@
"videoGenerating": "Generating Video",
"noBusinessInfo": "No business information. Please try again.",
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
"generatingLyrics": "Generating lyrics...\n(3060 sec)",
"generatingSong": "Generating music...\n(12 min)",
"generatingLyrics": "Generating lyrics...",
"generatingSong": "Generating song...",
"songQueued": "Song generation queued...",
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
"lyricGenerationFailed": "Lyrics generation request failed.",
@ -270,7 +191,7 @@
"statusPlanned": "Scheduled",
"statusWaiting": "Waiting",
"statusTranscribing": "Transcribing",
"statusRendering": "Rendering (23 min)",
"statusRendering": "Rendering",
"statusSucceeded": "Completed",
"statusDefault": "Processing...",
"aiOptimization": "AI Optimization",

View File

@ -13,86 +13,7 @@
"myInfo": "내 정보",
"defaultUser": "사용자",
"loggingOut": "로그아웃 중...",
"logout": "로그아웃",
"tutorialRestart": "튜토리얼 다시 보기"
},
"tutorial": {
"skip": "건너뛰기",
"next": "다음",
"prev": "이전",
"finish": "완료",
"landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\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 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
},
"ado2": {
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
"download": { "title": "다운로드", "desc": "영상을 다운로드 할 수 있어요." },
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
},
"completion": {
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 제목,장르,규격,가사를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
},
"upload": {
"seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
},
"dashboard": {
"metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요." },
"chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." },
"more": { "title": "더 많은 통계", "desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요." }
},
"contentCalendar": {
"grid": { "title": "콘텐츠 캘린더", "desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?" },
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
},
"feedback": {
"complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
"title": "고객의견",
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
},
"restart": {
"title": "튜토리얼을 다시 시작할까요?",
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
"confirm": "시작하기",
"cancel": "취소"
}
"logout": "로그아웃"
},
"footer": {
"company":"㈜에이아이오투오",
@ -134,9 +55,9 @@
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
"uploadStartFailed": "업로드 시작에 실패했습니다.",
"uploadFailed": "업로드에 실패했습니다.",
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
"autoSeoTitle": "자동으로 작성중입니다. 기다려주세요.",
"autoSeoDescription": "자동으로 작성중입니다. 기다려주세요.",
"autoSeoTags": "자동으로 작성중입니다. 기다려주세요."
},
"upload": {
"title": "YouTube 업로드",
@ -158,7 +79,7 @@
"hero": {
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"guideUrl": "네이버 Place URL을 입력하세요.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
@ -176,7 +97,7 @@
"feature1Title": "비즈니스 핵심 정보 분석",
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요.",
"feature2Title": "홍보 콘텐츠 자동 제작",
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 영상을\n자동으로 제작해요",
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 노래, 영상을\n자동으로 제작해요",
"feature3Title": "멀티채널 자동 배포",
"feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요"
},
@ -194,7 +115,7 @@
"urlInput": {
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"guideUrl": "네이버 Place URL을 입력하세요.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기",
"searching": "검색 중...",
@ -215,7 +136,7 @@
"back": "뒤로가기",
"loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중 (30초~2분 소요)",
"uploading": "업로드 중...",
"nextStep": "다음 단계"
},
"soundStudio": {
@ -239,9 +160,9 @@
"videoGenerating": "영상 생성 중",
"noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.",
"noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.",
"generatingLyrics": "가사를 생성하고 있습니다. (30~60초 소요)",
"generatingSong": "음악을 생성하고 있습니다. (1~2분 소요)",
"songQueued": "음악 생성 대기 중...",
"generatingLyrics": "가사를 생성하고 있습니다...",
"generatingSong": "노래를 생성하고 있습니다...",
"songQueued": "노래 생성 대기 중...",
"retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})",
"lyricGenerationFailed": "가사 생성 요청에 실패했습니다.",
"lyricNotReceived": "가사를 받지 못했습니다.",
@ -270,7 +191,7 @@
"statusPlanned": "예약됨",
"statusWaiting": "대기 중",
"statusTranscribing": "트랜스크립션 중",
"statusRendering": "생성 중 (2~3분 소요)",
"statusRendering": "렌더링 중",
"statusSucceeded": "완료",
"statusDefault": "처리 중...",
"aiOptimization": "AI 최적화",

View File

@ -1,11 +1,8 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CrawlingResponse, TargetPersona } from '../../types/api';
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 {
onBack: () => void;
@ -15,19 +12,8 @@ interface AnalysisResultSectionProps {
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
const { t } = useTranslation();
const tutorial = useTutorial();
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 marketPositioning = marketing_analysis?.market_positioning;
const targetPersonas = marketing_analysis?.target_persona || [];
@ -38,7 +24,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
return (
<>
<div className="bi2-page">
{/* Header */}
<div className="bi2-header">
@ -190,18 +175,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</button>
</div>
</div>
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
};

View File

@ -3,9 +3,6 @@ import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { generateVideo, waitForVideoComplete } from '../../utils/api';
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 {
onBack: () => void;
@ -42,20 +39,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
const [renderProgress, setRenderProgress] = useState(0);
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 [videoDbId, setVideoDbId] = useState<number | null>(null);
@ -455,17 +438,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
created_at: new Date().toISOString(),
} : null}
/>
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</main>
);
};

View File

@ -758,7 +758,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
flex: 1, minHeight: 0,
}}>
{/* 캘린더 영역 */}
<div className="calendar-grid-area" style={{
<div style={{
gridColumn: '1 / span 8',
backgroundColor: '#01393b',
borderRadius: 20, padding: 16,
@ -780,7 +780,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</div>
{/* 오른쪽 패널 */}
<div className="calendar-side-panel" style={{
<div style={{
gridColumn: '9 / span 1',
backgroundColor: '#01393b', borderRadius: 20,
display: 'flex', flexDirection: 'column', overflow: 'hidden',

View File

@ -15,9 +15,6 @@ import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/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 ACTIVE_ITEM_KEY = 'castad_active_item';
@ -115,7 +112,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
const [analysisError, setAnalysisError] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
const tutorial = useTutorial();
// 로그인 직후 사용자 정보 조회
useEffect(() => {
@ -320,35 +316,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
localStorage.setItem(ACTIVE_ITEM_KEY, 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) => {
if (item === '새 프로젝트 만들기') {
@ -432,13 +399,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
mId={analysisData?.m_id ?? 0}
videoGenerationStatus={videoGenerationStatus}
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:
@ -506,57 +466,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
const getCurrentTutorialKey = (): string | null => {
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 null;
};
const handleTutorialRestart = () => {
tutorial.showRestartPopup(getCurrentTutorialKey()!);
};
const tutorialUI = (
<>
{getCurrentTutorialKey() && (
<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}
groupProgress={tutorial.groupProgress}
/>
)}
{tutorial.isRestartPopupVisible && (
<TutorialRestartPopup
onConfirm={tutorial.confirmRestart}
onCancel={tutorial.cancelRestart}
/>
)}
</>
);
// 브랜드 분석일 때는 전체 화면 스크롤
if (isBrandAnalysis) {
return (
@ -567,7 +476,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<main className="analysis-page-main">
{renderContent()}
</main>
{tutorialUI}
</div>
);
}
@ -577,7 +485,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
{showSidebar && (
<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'}`}>
{renderContent()}
</div>

View File

@ -16,7 +16,6 @@ interface SoundStudioContentProps {
businessInfo?: BusinessInfo;
imageTaskId: string | null;
mId: number;
onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number;
}
@ -40,7 +39,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo,
imageTaskId,
mId,
onStatusChange,
videoGenerationStatus = 'idle',
videoGenerationProgress = 0
}) => {
@ -391,13 +389,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
// status 변경 시 부모에 알림
useEffect(() => {
onStatusChange?.(status);
}, [status]);
return (
<>
<main className="sound-studio-page">
{audioUrl && (
<audio ref={audioRef} src={audioUrl} preload="metadata" />
@ -651,8 +643,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</button>
</div>
</main>
</>
);
};

View File

@ -19,7 +19,7 @@ interface UrlInputContentProps {
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [searchType, setSearchType] = useState<SearchType>('url');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);

View File

@ -3,9 +3,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/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';
@ -63,7 +60,7 @@ const orbConfigs: OrbConfig[] = [
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [searchType, setSearchType] = useState<SearchType>('url');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false);
@ -95,17 +92,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
}, 800);
return () => clearTimeout(timer);
}
}, []);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
@ -155,10 +141,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
// LANDING_NAME 튜토리얼 진행 중이면 다음 힌트로 이동
if (tutorial.isActive && tutorial.tutorialKey === TUTORIAL_KEYS.LANDING_NAME) {
tutorial.nextHint();
}
};
// 키보드 네비게이션 핸들러
@ -520,17 +502,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
</button>
)}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</div>
);
};