UI 및 튜토리얼 수정

feature-tutorial
김성경 2026-04-16 16:17:49 +09:00
parent 8734d3388d
commit 1daf6b0eb2
13 changed files with 490 additions and 240 deletions

210
index.css
View File

@ -7721,6 +7721,10 @@
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
} }
.genre-btn.active:hover:not(:disabled) {
border-color: var(--color-mint);
}
.genre-btn.active { .genre-btn.active {
border-color: var(--color-mint); border-color: var(--color-mint);
} }
@ -8080,6 +8084,7 @@
color: var(--color-mint); color: var(--color-mint);
font-size: var(--text-sm); font-size: var(--text-sm);
text-align: center; text-align: center;
white-space: pre-line;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -9759,6 +9764,47 @@
color: rgba(255, 255, 255, 0.3); 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-input:focus,
.social-posting-select:focus, .social-posting-select:focus,
.social-posting-textarea:focus { .social-posting-textarea:focus {
@ -10589,29 +10635,59 @@
.tutorial-tooltip { .tutorial-tooltip {
position: fixed; position: fixed;
width: 300px;
background: #1a2630;
border: 1px solid rgba(166, 255, 234, 0.25);
border-radius: 12px;
padding: 16px;
pointer-events: all; pointer-events: all;
z-index: 10002; z-index: 10002;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); 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 { .tutorial-tooltip-title {
font-size: 15px; align-self: stretch;
font-weight: 700; color: rgba(255, 255, 255, 0.70);
color: #a6ffea; /* 14 */
margin: 0 0 8px; font-family: Pretendard;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
} }
.tutorial-tooltip-desc { .tutorial-tooltip-desc {
font-size: 13px; display: flex;
color: rgba(255, 255, 255, 0.75); padding-bottom: 16px;
margin: 0 0 14px; flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
line-height: 1.5; line-height: 1.5;
white-space: pre-line; 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 { .tutorial-tooltip-footer {
@ -10620,23 +10696,35 @@
justify-content: space-between; justify-content: space-between;
} }
.tutorial-tooltip-footer-left {
display: flex;
align-items: center;
gap: 8px;
}
.tutorial-tooltip-counter { .tutorial-tooltip-counter {
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.4); font-style: normal;
font-weight: 400;
line-height: 100%; /* 12px */
letter-spacing: -0.072px;
} }
.tutorial-tooltip-actions { .tutorial-tooltip-actions {
display: flex; display: flex;
gap: 6px; gap: 10px;
} }
.tutorial-btn-skip { .tutorial-btn-skip {
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.4); font-style: normal;
background: none; font-weight: 400;
border: none; line-height: 100%; /* 12px */
cursor: pointer; letter-spacing: -0.072px;
padding: 4px 8px;
} }
.tutorial-btn-skip:hover { .tutorial-btn-skip:hover {
@ -10644,13 +10732,20 @@
} }
.tutorial-btn-prev { .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-size: 13px;
color: rgba(255, 255, 255, 0.7); font-style: normal;
background: rgba(255, 255, 255, 0.08); font-weight: 400;
border: none; line-height: 100%; /* 13px */
border-radius: 6px; letter-spacing: -0.078px;
cursor: pointer;
padding: 5px 12px;
} }
.tutorial-btn-prev:hover { .tutorial-btn-prev:hover {
@ -10658,17 +10753,66 @@
} }
.tutorial-btn-next { .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-size: 13px;
color: #fff; font-style: normal;
background: #6366f1; font-weight: 400;
border: none; line-height: 100%; /* 13px */
border-radius: 6px; letter-spacing: -0.078px;
cursor: pointer;
padding: 5px 12px;
} }
.tutorial-btn-next:hover { .tutorial-btn-next:hover {
background: #4f46e5; 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 { .tutorial-tooltip-action-hint {

View File

@ -440,6 +440,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onNext={tutorial.nextHint} onNext={tutorial.nextHint}
onPrev={tutorial.prevHint} onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial} onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/> />
)} )}
</> </>
@ -565,48 +566,60 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
<label className="social-posting-label"> <label className="social-posting-label">
{t('social.postTitleLabel')} <span className="required">*</span> {t('social.postTitleLabel')} <span className="required">*</span>
</label> </label>
<input <div className="seo-input-wrapper">
type="text" <input
value={title} type="text"
onChange={(e) => setTitle(e.target.value)} value={title}
placeholder={isLoadingAutoDescription ? t('social.autoSeoTitle') : t('social.postTitlePlaceholder')} onChange={(e) => setTitle(e.target.value)}
// placeholder={t('social.postTitlePlaceholder')} placeholder={isLoadingAutoDescription ? '' : t('social.postTitlePlaceholder')}
className="social-posting-input" className="social-posting-input"
maxLength={100} maxLength={100}
disabled={isLoadingAutoDescription} disabled={isLoadingAutoDescription}
/> />
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoTitle')}</span>
)}
</div>
<span className="social-posting-char-count">{title.length}/100</span> <span className="social-posting-char-count">{title.length}/100</span>
</div> </div>
{/* Description */} {/* Description */}
<div className="social-posting-field"> <div className="social-posting-field">
<label className="social-posting-label">{t('social.postContentLabel')}</label> <label className="social-posting-label">{t('social.postContentLabel')}</label>
<textarea <div className="seo-input-wrapper">
value={description} <textarea
onChange={(e) => setDescription(e.target.value)} value={description}
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoDescription') : t('social.postContentPlaceholder'))} onChange={(e) => setDescription(e.target.value)}
// placeholder={t('social.postContentPlaceholder')} placeholder={isLoadingAutoDescription ? '' : t('social.postContentPlaceholder')}
className="social-posting-textarea" className="social-posting-textarea"
maxLength={5000} maxLength={5000}
rows={4} rows={4}
disabled={isLoadingAutoDescription} disabled={isLoadingAutoDescription}
/> />
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoDescription')}</span>
)}
</div>
<span className="social-posting-char-count">{description.length}/5,000</span> <span className="social-posting-char-count">{description.length}/5,000</span>
</div> </div>
{/* Tags */} {/* Tags */}
<div className="social-posting-field"> <div className="social-posting-field">
<label className="social-posting-label">{t('social.tagsLabel')}</label> <label className="social-posting-label">{t('social.tagsLabel')}</label>
<input <div className="seo-input-wrapper">
type="text" <input
value={tags} type="text"
onChange={(e) => setTags(e.target.value)} value={tags}
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoTags') : t('social.tagsPlaceholder'))} onChange={(e) => setTags(e.target.value)}
// placeholder={t('social.tagsPlaceholder')} placeholder={isLoadingAutoDescription ? '' : t('social.tagsPlaceholder')}
className="social-posting-input" className="social-posting-input"
maxLength={500} maxLength={500}
disabled={isLoadingAutoDescription} disabled={isLoadingAutoDescription}
/> />
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoTags')}</span>
)}
</div>
<span className="social-posting-char-count">{tags.length}/500</span> <span className="social-posting-char-count">{tags.length}/500</span>
</div> </div>
@ -811,6 +824,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onNext={tutorial.nextHint} onNext={tutorial.nextHint}
onPrev={tutorial.prevHint} onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial} onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/> />
)} )}
</> </>

View File

@ -20,6 +20,7 @@ interface TutorialOverlayProps {
onNext: () => void; onNext: () => void;
onPrev: () => void; onPrev: () => void;
onSkip: () => void; onSkip: () => void;
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
} }
const PADDING = 8; const PADDING = 8;
@ -53,9 +54,7 @@ function getSpotlightRect(
}; };
} }
function calcTooltipPos(rect: Rect, position: TutorialHint['position']): TooltipPos { function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
const tooltipW = 300;
const tooltipH = 140;
switch (position) { switch (position) {
case 'bottom': case 'bottom':
@ -87,12 +86,17 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
onNext, onNext,
onPrev, onPrev,
onSkip, onSkip,
groupProgress,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [targetRect, setTargetRect] = useState<Rect | null>(null); 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 hint = hints[currentIndex];
const isLast = currentIndex === hints.length - 1; const isLast = currentIndex === hints.length - 1;
// 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로
const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true);
const updateRect = useCallback(() => { const updateRect = useCallback(() => {
if (!hint) return; if (!hint) return;
@ -123,14 +127,13 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
if (shouldClickAdvance) { if (shouldClickAdvance) {
el.style.cursor = 'pointer'; el.style.cursor = 'pointer';
el.addEventListener('click', onNext); el.addEventListener('click', onNext);
} cleanupTarget = () => {
cleanupTarget = () => {
if (shouldClickAdvance) {
el.style.cursor = ''; el.style.cursor = '';
el.removeEventListener('click', onNext); el.removeEventListener('click', onNext);
} };
}; } else {
cleanupTarget = () => {};
}
}; };
const tryBind = (): boolean => { const tryBind = (): boolean => {
@ -156,17 +159,32 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
}; };
}, [hint, updateRect, onNext]); }, [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; 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 spotlightPadding = hint.spotlightPadding ?? PADDING;
const spotlightRect = targetRect ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null; 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 ( return (
<div className="tutorial-overlay-root"> <div className="tutorial-overlay-root">
@ -218,16 +236,20 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
)} )}
<div <div
className="tutorial-tooltip" ref={tooltipRef}
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
style={{ top: tooltipPos.top, left: tooltipPos.left }} style={{ top: tooltipPos.top, left: tooltipPos.left }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p> <p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</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"> <div className="tutorial-tooltip-footer">
<span className="tutorial-tooltip-counter"> <span className="tutorial-tooltip-counter">
{currentIndex + 1} / {hints.length} {groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
</span> </span>
<div className="tutorial-tooltip-actions"> <div className="tutorial-tooltip-actions">
<button className="tutorial-btn-skip" onClick={onSkip}> <button className="tutorial-btn-skip" onClick={onSkip}>
@ -238,9 +260,11 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
{t('tutorial.prev')} {t('tutorial.prev')}
</button> </button>
)} )}
<button className="tutorial-btn-next" onClick={onNext}> {(hint.clickToAdvance !== true || isFinish) && (
{isLast ? t('tutorial.finish') : t('tutorial.next')} <button className="tutorial-btn-next" onClick={onNext}>
</button> {isFinish ? t('tutorial.finish') : t('tutorial.next')}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,8 +3,11 @@ export interface TutorialHint {
titleKey: string; titleKey: string;
descriptionKey: string; descriptionKey: string;
position: 'top' | 'bottom' | 'left' | 'right'; position: 'top' | 'bottom' | 'left' | 'right';
noteKey?: string;
variant?: 'bubble';
clickToAdvance?: boolean; clickToAdvance?: boolean;
advanceSelector?: string; advanceSelector?: string;
noSpotlight?: boolean;
spotlightPadding?: number; spotlightPadding?: number;
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number }; spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
} }
@ -32,17 +35,33 @@ export const TUTORIAL_KEYS = {
FEEDBACK: 'feedback', FEEDBACK: 'feedback',
} as const; } 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[] = [ export const tutorialSteps: TutorialStepDef[] = [
{ {
key: TUTORIAL_KEYS.LANDING, key: TUTORIAL_KEYS.LANDING,
hints: [ hints: [
// {
// targetSelector: '.tutorial-center-anchor',
// titleKey: 'tutorial.landing.intro.title',
// descriptionKey: 'tutorial.landing.intro.desc',
// position: 'bottom',
// clickToAdvance: false,
// noSpotlight: true,
// },
{ {
targetSelector: '.hero-dropdown-container', targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.dropdown.title', titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.dropdown.desc', descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
spotlightPaddingOverride: { top: 10, right: 15, bottom: 100, left: 0 }, noSpotlight: true,
variant: 'bubble',
}, },
{ {
targetSelector: '.hero-input-wrapper', targetSelector: '.hero-input-wrapper',
@ -50,57 +69,60 @@ export const tutorialSteps: TutorialStepDef[] = [
descriptionKey: 'tutorial.landing.field.desc', descriptionKey: 'tutorial.landing.field.desc',
position: 'right', position: 'right',
clickToAdvance: false, clickToAdvance: false,
spotlightPaddingOverride: { top: 0, right: 0, bottom: 290, left: -105 }, noSpotlight: true,
variant: 'bubble',
}, },
{ {
targetSelector: '.hero-button', targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title', titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc', 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', position: 'right',
clickToAdvance: true, 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, key: TUTORIAL_KEYS.ASSET,
hints: [ hints: [
@ -123,6 +145,7 @@ export const tutorialSteps: TutorialStepDef[] = [
titleKey: 'tutorial.asset.ratio.title', titleKey: 'tutorial.asset.ratio.title',
descriptionKey: 'tutorial.asset.ratio.desc', descriptionKey: 'tutorial.asset.ratio.desc',
position: 'left', position: 'left',
clickToAdvance: false,
}, },
{ {
targetSelector: '.asset-next-button', targetSelector: '.asset-next-button',
@ -141,7 +164,7 @@ export const tutorialSteps: TutorialStepDef[] = [
titleKey: 'tutorial.sound.genre.title', titleKey: 'tutorial.sound.genre.title',
descriptionKey: 'tutorial.sound.genre.desc', descriptionKey: 'tutorial.sound.genre.desc',
position: 'top', position: 'top',
clickToAdvance: true, clickToAdvance: false,
}, },
{ {
targetSelector: '.language-selector-wrapper', targetSelector: '.language-selector-wrapper',
@ -149,7 +172,6 @@ export const tutorialSteps: TutorialStepDef[] = [
descriptionKey: 'tutorial.sound.language.desc', descriptionKey: 'tutorial.sound.language.desc',
position: 'top', position: 'top',
clickToAdvance: false, clickToAdvance: false,
spotlightPaddingOverride: { bottom: 230 },
}, },
{ {
targetSelector: '.btn-generate-sound', targetSelector: '.btn-generate-sound',
@ -170,6 +192,13 @@ export const tutorialSteps: TutorialStepDef[] = [
position: 'left', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{
targetSelector: '.status-message-new',
titleKey: 'tutorial.sound.lyricsWait.title',
descriptionKey: 'tutorial.sound.lyricsWait.desc',
position: 'right',
clickToAdvance: false,
},
], ],
}, },
{ {
@ -196,15 +225,18 @@ export const tutorialSteps: TutorialStepDef[] = [
hints: [ hints: [
{ {
targetSelector: '.youtube-connect-section', targetSelector: '.youtube-connect-section',
titleKey: 'tutorial.myInfo.connect.title', titleKey: 'tutorial.myInfo.myInfo.title',
descriptionKey: 'tutorial.myInfo.connect.desc', descriptionKey: 'tutorial.myInfo.myInfo.desc',
position: 'top', position: 'top',
clickToAdvance: false,
}, },
{ {
targetSelector: '.myinfo-social-btn.connected', targetSelector: '.myinfo-social-btn',
titleKey: 'tutorial.myInfo.button.title', titleKey: 'tutorial.myInfo.connect.title',
descriptionKey: 'tutorial.myInfo.button.desc', descriptionKey: 'tutorial.myInfo.connect.desc',
noteKey: 'tutorial.myInfo.connect.note',
position: 'top', position: 'top',
clickToAdvance: true,
}, },
{ {
targetSelector: '.myinfo-connected-accounts', targetSelector: '.myinfo-connected-accounts',
@ -232,6 +264,20 @@ export const tutorialSteps: TutorialStepDef[] = [
position: 'right', position: 'right',
clickToAdvance: false, 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', targetSelector: '.content-download-btn',
titleKey: 'tutorial.ado2.upload.title', titleKey: 'tutorial.ado2.upload.title',

View File

@ -1,5 +1,21 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { tutorialSteps, TutorialHint } from './tutorialSteps'; 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 SEEN_KEY = 'ado2_tutorial_seen';
const PROGRESS_KEY = 'ado2_tutorial_progress'; const PROGRESS_KEY = 'ado2_tutorial_progress';
@ -54,7 +70,8 @@ interface UseTutorialReturn {
currentHintIndex: number; currentHintIndex: number;
hints: TutorialHint[]; hints: TutorialHint[];
tutorialKey: string | null; tutorialKey: string | null;
startTutorial: (key: string, onComplete?: () => void) => void; groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
nextHint: () => void; nextHint: () => void;
prevHint: () => void; prevHint: () => void;
skipTutorial: () => void; skipTutorial: () => void;
@ -73,12 +90,12 @@ export function useTutorial(): UseTutorialReturn {
const [tutorialKey, setTutorialKey] = useState<string | null>(null); const [tutorialKey, setTutorialKey] = useState<string | null>(null);
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined); const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
const startTutorial = useCallback((key: string, onComplete?: () => void) => { const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
const step = tutorialSteps.find(s => s.key === key); const step = tutorialSteps.find(s => s.key === key);
if (!step || step.hints.length === 0) return; if (!step || step.hints.length === 0) return;
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리 // 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
globalSkip?.(); globalSkip?.();
const savedIndex = loadProgress(key); const savedIndex = forceFromStart ? 0 : loadProgress(key);
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0; const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
onCompleteRef.current = onComplete; onCompleteRef.current = onComplete;
setHints(step.hints); setHints(step.hints);
@ -104,6 +121,7 @@ export function useTutorial(): UseTutorialReturn {
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제 // 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
setIsActive(false); setIsActive(false);
if (tutorialKey) markSeen(tutorialKey); if (tutorialKey) markSeen(tutorialKey);
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
onCompleteRef.current?.(); onCompleteRef.current?.();
onCompleteRef.current = undefined; onCompleteRef.current = undefined;
return 0; return 0;
@ -127,12 +145,13 @@ export function useTutorial(): UseTutorialReturn {
setIsRestartPopupVisible(true); setIsRestartPopupVisible(true);
}, []); }, []);
// 팝업에서 확인 → seen 초기화 후 튜토리얼 시작 // 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
const confirmRestart = useCallback(() => { const confirmRestart = useCallback(() => {
setIsRestartPopupVisible(false); setIsRestartPopupVisible(false);
if (pendingRestartKey) { if (pendingRestartKey) {
localStorage.removeItem(SEEN_KEY); localStorage.removeItem(SEEN_KEY);
startTutorial(pendingRestartKey); localStorage.removeItem(PROGRESS_KEY);
startTutorial(pendingRestartKey, undefined, true);
} }
setPendingRestartKey(null); setPendingRestartKey(null);
}, [pendingRestartKey, startTutorial]); }, [pendingRestartKey, startTutorial]);
@ -153,6 +172,7 @@ export function useTutorial(): UseTutorialReturn {
currentHintIndex, currentHintIndex,
hints, hints,
tutorialKey, tutorialKey,
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
startTutorial, startTutorial,
nextHint, nextHint,
prevHint, prevHint,

View File

@ -22,9 +22,10 @@
"prev": "Back", "prev": "Back",
"finish": "Done", "finish": "Done",
"landing": { "landing": {
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name as your preferred search method." }, "intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
"field": { "title": "Enter URL or Business Name", "desc": "Search for a place on Naver Maps, click Share, paste the URL — or type a business name and select it." }, "dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"button": { "title": "Start Brand Analysis", "desc": "Click to let AI analyze your brand." } "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": { "analysis": {
"identity": { "title": "Brand Identity", "desc": "Check the core values and market positioning AI analyzed for your brand." }, "identity": { "title": "Brand Identity", "desc": "Check the core values and market positioning AI analyzed for your brand." },
@ -41,31 +42,34 @@
}, },
"sound": { "sound": {
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand." }, "genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand." },
"language": { "title": "Select Language", "desc": "Choose the language for the sound. Then click Next." }, "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 create lyrics and music in your chosen genre and language." }, "generate": { "title": "Generate Sound", "desc": "AI will generate music in your chosen genre and language." },
"lyrics": { "title": "Generated Lyrics", "desc": "AI wrote these lyrics to match the music.\nCheck the generated lyrics." }, "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." }, "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." } "video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
}, },
"myInfo": { "myInfo": {
"connect": { "title": "Connect Social Account", "desc": "You need to link a social account to upload videos." }, "myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
"button": { "title": "Connect Now", "desc": "Click the social media button you want to connect." }, "connect": { "title": "Connect Now", "desc": "Click the YouTube connect button.", "note": "Instagram connection is coming soon." },
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here. Check after connecting." }, "connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
"ado2": { "title": "Check My Contents", "desc": "After connecting, go to My Contents in the sidebar to view and upload your videos. Click to navigate." } "ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
}, },
"ado2": { "ado2": {
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." }, "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." } "upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
}, },
"completion": { "completion": {
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." }, "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. Please wait a moment." }, "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?" }, "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." } "myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
}, },
"upload": { "upload": {
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video.\nPlease wait a moment." }, "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. Please fill them in before uploading." }, "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." }, "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." } "submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." }
}, },
@ -79,7 +83,7 @@
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." } "panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
}, },
"feedback": { "feedback": {
"complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed everything from brand analysis to YouTube upload. Now give it a try on your own!" }, "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", "title": "Customer Feedback",
"desc": "Share any issues or suggestions to help us improve." "desc": "Share any issues or suggestions to help us improve."
}, },
@ -130,9 +134,9 @@
"invalidVideoInfo": "Video information is invalid. (missing video_id)", "invalidVideoInfo": "Video information is invalid. (missing video_id)",
"uploadStartFailed": "Failed to start upload.", "uploadStartFailed": "Failed to start upload.",
"uploadFailed": "Upload failed.", "uploadFailed": "Upload failed.",
"autoSeoTitle": "This will be automatically generated. please wait.", "autoSeoTitle": "Auto-generating... (3060 sec)",
"autoSeoDescription": "This will be automatically generated. please wait.", "autoSeoDescription": "Auto-generating... (3060 sec)",
"autoSeoTags": "This will be automatically generated. please wait." "autoSeoTags": "Auto-generating... (3060 sec)"
}, },
"upload": { "upload": {
"title": "YouTube Upload", "title": "YouTube Upload",
@ -190,7 +194,7 @@
"urlInput": { "urlInput": {
"searchTypeBusinessName": "Business Name", "searchTypeBusinessName": "Business Name",
"placeholderBusinessName": "Enter a business name", "placeholderBusinessName": "Enter a business name",
"guideUrl": "Select a place on Naver Maps, click Share,\nand paste the URL that appears.", "guideUrl": "Enter a Naver Place URL.",
"guideBusinessName": "Search by business name to retrieve information.", "guideBusinessName": "Search by business name to retrieve information.",
"searchButton": "Search", "searchButton": "Search",
"searching": "Searching...", "searching": "Searching...",
@ -235,8 +239,8 @@
"videoGenerating": "Generating Video", "videoGenerating": "Generating Video",
"noBusinessInfo": "No business information. Please try again.", "noBusinessInfo": "No business information. Please try again.",
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.", "noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
"generatingLyrics": "Generating lyrics...", "generatingLyrics": "Generating lyrics...\n(3060 sec)",
"generatingSong": "Generating song...", "generatingSong": "Generating music...\n(12 min)",
"songQueued": "Song generation queued...", "songQueued": "Song generation queued...",
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})", "retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
"lyricGenerationFailed": "Lyrics generation request failed.", "lyricGenerationFailed": "Lyrics generation request failed.",

View File

@ -22,8 +22,9 @@
"prev": "이전", "prev": "이전",
"finish": "완료", "finish": "완료",
"landing": { "landing": {
"dropdown": { "title": "검색 방식 선택", "desc": "URL 또는 업체명 중 원하는 방식을 선택하세요." }, "intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"field": { "title": "URL 또는 업체명 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣거나 업체명을 입력하고 선택하세요." }, "dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
}, },
"analysis": { "analysis": {
@ -34,38 +35,41 @@
"generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." } "generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." }
}, },
"asset": { "asset": {
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 사진이에요. 더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." }, "image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." }, "upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." }, "ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." } "next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
}, },
"sound": { "sound": {
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." }, "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." },
"language": { "title": "언어 선택", "desc": "사운드의 언어를 선택하세요. 다음을 눌러주세요." }, "language": { "title": "언어 선택", "desc": "사운드의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
"generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 가사와 음악을 생성해요." }, "generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 선택한 장르와 언어로 가사와 음악을 생성해요."},
"lyrics": { "title": "생성된 가사", "desc": "AI가 음악에 맞는 가사를 만들었어요.\n생성된 가사를 확인하세요." }, "lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 미리 들어보세요." }, "lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." } "video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
}, },
"myInfo": { "myInfo": {
"connect": { "title": "소셜 연동", "desc": "영상을 업로드하려면 소셜 계정을 연동해야 해요." }, "myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
"button": { "title": "연동하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요." }, "connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
"connected": { "title": "연동된 계정", "desc": "연동된 소셜 계정 목록이에요. 연동 후 여기서 확인할 수 있어요." }, "connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "내 콘텐츠 확인", "desc": "연동 후 내 콘텐츠 메뉴에서 생성된 영상을 확인하고 업로드할 수 있어요. 클릭해서 이동하세요." } "ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
}, },
"ado2": { "ado2": {
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 여기서 확인할 수 있어요." }, "list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
"download": { "title": "다운로드", "desc": "영상을 다운로드 할 수 있어요." },
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." } "upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
}, },
"completion": { "completion": {
"contentInfo": { "title": "콘텐츠 정보", "desc": "생성된 콘텐츠의 제목, 장르, 규격, 가사 정보를 확인하세요." }, "contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 제목,장르,규격,가사를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. 잠시만 기다려 주세요." }, "generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. 영상을 확인해 볼까요?" }, "completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." } "myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
}, },
"upload": { "upload": {
"seo": { "title": "제목 및 설명", "desc": "영상에 표시될 제목과 설명을 AI가 만들고 있어요. \n잠시만 기다려 주세요." }, "seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수항목으로 반드시 확인해 주세요." }, "required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." }, "schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." } "submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
}, },
@ -79,7 +83,7 @@
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." } "panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
}, },
"feedback": { "feedback": {
"complete": { "title": "튜토리얼 완료 🎉", "desc": "브랜드 분석부터 유튜브 업로드까지 모든 과정을 완료했어요. 이제 직접 시작해 보세요!" }, "complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
"title": "고객의견", "title": "고객의견",
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요." "desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
}, },
@ -130,9 +134,9 @@
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)", "invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
"uploadStartFailed": "업로드 시작에 실패했습니다.", "uploadStartFailed": "업로드 시작에 실패했습니다.",
"uploadFailed": "업로드에 실패했습니다.", "uploadFailed": "업로드에 실패했습니다.",
"autoSeoTitle": "자동으로 작성중입니다. 기다려주세요.", "autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoDescription": "자동으로 작성중입니다. 기다려주세요.", "autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoTags": "자동으로 작성중입니다. 기다려주세요." "autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
}, },
"upload": { "upload": {
"title": "YouTube 업로드", "title": "YouTube 업로드",
@ -154,7 +158,7 @@
"hero": { "hero": {
"searchTypeBusinessName": "업체명", "searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.", "placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버 Place URL을 입력하세요.", "guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.", "guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"errorUrlRequired": "URL을 입력해주세요.", "errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.", "errorNameRequired": "업체명을 입력해주세요.",
@ -172,7 +176,7 @@
"feature1Title": "비즈니스 핵심 정보 분석", "feature1Title": "비즈니스 핵심 정보 분석",
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요.", "feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요.",
"feature2Title": "홍보 콘텐츠 자동 제작", "feature2Title": "홍보 콘텐츠 자동 제작",
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 노래, 영상을\n자동으로 제작해요", "feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 영상을\n자동으로 제작해요",
"feature3Title": "멀티채널 자동 배포", "feature3Title": "멀티채널 자동 배포",
"feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요" "feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요"
}, },
@ -190,7 +194,7 @@
"urlInput": { "urlInput": {
"searchTypeBusinessName": "업체명", "searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.", "placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버 Place URL을 입력하세요.", "guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.", "guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기", "searchButton": "검색하기",
"searching": "검색 중...", "searching": "검색 중...",
@ -211,7 +215,7 @@
"back": "뒤로가기", "back": "뒤로가기",
"loadMore": "더보기", "loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.", "uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중...", "uploading": "업로드 중 (30초~2분 소요)",
"nextStep": "다음 단계" "nextStep": "다음 단계"
}, },
"soundStudio": { "soundStudio": {
@ -235,9 +239,9 @@
"videoGenerating": "영상 생성 중", "videoGenerating": "영상 생성 중",
"noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.", "noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.",
"noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.", "noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.",
"generatingLyrics": "가사를 생성하고 있습니다...", "generatingLyrics": "가사를 생성하고 있습니다. (30~60초 소요)",
"generatingSong": "노래를 생성하고 있습니다...", "generatingSong": "음악을 생성하고 있습니다. (1~2분 소요)",
"songQueued": "노래 생성 대기 중...", "songQueued": "음악 생성 대기 중...",
"retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})", "retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})",
"lyricGenerationFailed": "가사 생성 요청에 실패했습니다.", "lyricGenerationFailed": "가사 생성 요청에 실패했습니다.",
"lyricNotReceived": "가사를 받지 못했습니다.", "lyricNotReceived": "가사를 받지 못했습니다.",
@ -266,7 +270,7 @@
"statusPlanned": "예약됨", "statusPlanned": "예약됨",
"statusWaiting": "대기 중", "statusWaiting": "대기 중",
"statusTranscribing": "트랜스크립션 중", "statusTranscribing": "트랜스크립션 중",
"statusRendering": "렌더링 중", "statusRendering": "생성 중 (2~3분 소요)",
"statusSucceeded": "완료", "statusSucceeded": "완료",
"statusDefault": "처리 중...", "statusDefault": "처리 중...",
"aiOptimization": "AI 최적화", "aiOptimization": "AI 최적화",

View File

@ -198,6 +198,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
onNext={tutorial.nextHint} onNext={tutorial.nextHint}
onPrev={tutorial.prevHint} onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial} onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/> />
)} )}
</> </>

View File

@ -463,6 +463,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
onNext={tutorial.nextHint} onNext={tutorial.nextHint}
onPrev={tutorial.prevHint} onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial} onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/> />
)} )}
</main> </main>

View File

@ -323,6 +323,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외) // wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더']; const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
useEffect(() => { useEffect(() => {
if (tutorial.isActive) tutorial.skipTutorial();
if (SIDEBAR_ITEMS.includes(activeItem)) return; if (SIDEBAR_ITEMS.includes(activeItem)) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) { if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) {
@ -336,6 +337,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// activeItem 변경 시 튜토리얼 트리거 // activeItem 변경 시 튜토리얼 트리거
useEffect(() => { useEffect(() => {
if (tutorial.isActive) tutorial.skipTutorial();
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) { if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO); tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
} else if (activeItem === 'ADO2 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) { } else if (activeItem === 'ADO2 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
@ -430,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:
@ -497,35 +506,38 @@ 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;
// 현재 화면에 맞는 튜토리얼 키 반환 // 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
const getCurrentTutorialKey = (): string => { const getCurrentTutorialKey = (): string | null => {
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO; if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
if (activeItem === 'ADO2 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS; if (activeItem === 'ADO2 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD; if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
if (activeItem === '새 프로젝트 만들기') { if (activeItem === '새 프로젝트 만들기') {
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET; if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND; if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
} }
return TUTORIAL_KEYS.ASSET; return null;
}; };
const handleTutorialRestart = () => { const handleTutorialRestart = () => {
tutorial.showRestartPopup(getCurrentTutorialKey()); tutorial.showRestartPopup(getCurrentTutorialKey()!);
}; };
const tutorialUI = ( const tutorialUI = (
<> <>
<button {getCurrentTutorialKey() && (
className="tutorial-restart-fab" <button
onClick={handleTutorialRestart} className="tutorial-restart-fab"
title={t('sidebar.tutorialRestart')} 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"/> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 8v4l3 3"/> <circle cx="12" cy="12" r="10"/>
</svg> <path d="M12 8v4l3 3"/>
<span>{t('sidebar.tutorialRestart')}</span> </svg>
</button> <span>{t('sidebar.tutorialRestart')}</span>
</button>
)}
{tutorial.isActive && ( {tutorial.isActive && (
<TutorialOverlay <TutorialOverlay
hints={tutorial.hints} hints={tutorial.hints}
@ -533,6 +545,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onNext={tutorial.nextHint} onNext={tutorial.nextHint}
onPrev={tutorial.prevHint} onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial} onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/> />
)} )}
{tutorial.isRestartPopupVisible && ( {tutorial.isRestartPopupVisible && (

View File

@ -3,9 +3,6 @@ import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api'; import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
import { LANGUAGE_MAP } from '../../types/api'; import { LANGUAGE_MAP } from '../../types/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
interface BusinessInfo { interface BusinessInfo {
customer_name: string; customer_name: string;
@ -19,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;
} }
@ -42,11 +40,11 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo, businessInfo,
imageTaskId, imageTaskId,
mId, mId,
onStatusChange,
videoGenerationStatus = 'idle', videoGenerationStatus = 'idle',
videoGenerationProgress = 0 videoGenerationProgress = 0
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const tutorial = useTutorial();
const [selectedType, setSelectedType] = useState('보컬'); const [selectedType, setSelectedType] = useState('보컬');
const [selectedLang, setSelectedLang] = useState('한국어'); const [selectedLang, setSelectedLang] = useState('한국어');
const [selectedGenre, setSelectedGenre] = useState('자동 선택'); const [selectedGenre, setSelectedGenre] = useState('자동 선택');
@ -393,24 +391,9 @@ 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(() => { useEffect(() => {
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) { onStatusChange?.(status);
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS);
}, 400);
return () => clearTimeout(timer);
}
}, [status]);
// 음악 생성 완료 시 오디오 플레이어 튜토리얼 트리거
useEffect(() => {
if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO);
}, 400);
return () => clearTimeout(timer);
}
}, [status]); }, [status]);
return ( return (
@ -669,15 +652,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</div> </div>
</main> </main>
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
/>
)}
</> </>
); );
}; };

View File

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

View File

@ -63,7 +63,7 @@ const orbConfigs: OrbConfig[] = [
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => { const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('url'); const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false); const [isLoadingTest, setIsLoadingTest] = useState(false);
@ -155,6 +155,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();
}
}; };
// 키보드 네비게이션 핸들러 // 키보드 네비게이션 핸들러
@ -524,6 +528,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
onNext={tutorial.nextHint} onNext={tutorial.nextHint}
onPrev={tutorial.prevHint} onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial} onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/> />
)} )}
</div> </div>