UI 및 튜토리얼 수정

김성경 2026-04-15 17:15:22 +09:00
parent d5647bdfa5
commit c938c81875
12 changed files with 431 additions and 230 deletions

206
index.css
View File

@ -8080,6 +8080,7 @@
color: var(--color-mint);
font-size: var(--text-sm);
text-align: center;
white-space: pre-line;
display: flex;
align-items: center;
justify-content: center;
@ -9759,6 +9760,47 @@
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 {
@ -10589,29 +10631,59 @@
.tutorial-tooltip {
position: fixed;
width: 300px;
background: #1a2630;
border: 1px solid rgba(166, 255, 234, 0.25);
border-radius: 12px;
padding: 16px;
pointer-events: all;
z-index: 10002;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
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 {
font-size: 15px;
font-weight: 700;
color: #a6ffea;
margin: 0 0 8px;
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 {
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
margin: 0 0 14px;
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 {
@ -10620,23 +10692,35 @@
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;
color: rgba(255, 255, 255, 0.4);
font-style: normal;
font-weight: 400;
line-height: 100%; /* 12px */
letter-spacing: -0.072px;
}
.tutorial-tooltip-actions {
display: flex;
gap: 6px;
gap: 10px;
}
.tutorial-btn-skip {
color: rgba(255, 255, 255, 0.60);
font-family: Pretendard;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 12px */
letter-spacing: -0.072px;
}
.tutorial-btn-skip:hover {
@ -10644,13 +10728,20 @@
}
.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;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 6px;
cursor: pointer;
padding: 5px 12px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 13px */
letter-spacing: -0.078px;
}
.tutorial-btn-prev:hover {
@ -10658,17 +10749,66 @@
}
.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;
color: #fff;
background: #6366f1;
border: none;
border-radius: 6px;
cursor: pointer;
padding: 5px 12px;
font-style: normal;
font-weight: 400;
line-height: 100%; /* 13px */
letter-spacing: -0.078px;
}
.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 {

View File

@ -440,6 +440,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
@ -565,48 +566,60 @@ 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.autoSeoTitle') : t('social.postTitlePlaceholder')}
// placeholder={t('social.postTitlePlaceholder')}
placeholder={isLoadingAutoDescription ? '' : 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={t(isLoadingAutoDescription ? t('social.autoSeoDescription') : t('social.postContentPlaceholder'))}
// placeholder={t('social.postContentPlaceholder')}
placeholder={isLoadingAutoDescription ? '' : 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={t(isLoadingAutoDescription ? t('social.autoSeoTags') : t('social.tagsPlaceholder'))}
// placeholder={t('social.tagsPlaceholder')}
placeholder={isLoadingAutoDescription ? '' : 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>
@ -811,6 +824,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>

View File

@ -20,6 +20,7 @@ interface TutorialOverlayProps {
onNext: () => void;
onPrev: () => void;
onSkip: () => void;
groupProgress?: { groupTotal: number; groupOffset: number } | null;
}
const PADDING = 8;
@ -53,9 +54,7 @@ function getSpotlightRect(
};
}
function calcTooltipPos(rect: Rect, position: TutorialHint['position']): TooltipPos {
const tooltipW = 300;
const tooltipH = 140;
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
switch (position) {
case 'bottom':
@ -87,12 +86,17 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
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;
@ -155,17 +159,32 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
};
}, [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 tooltipPos: TooltipPos = targetRect
? calcTooltipPos(targetRect, hint.position)
: {
top: window.innerHeight / 2 - 70,
left: window.innerWidth / 2 - 150,
};
const spotlightPadding = hint.spotlightPadding ?? PADDING;
const spotlightRect = targetRect ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
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">
@ -212,21 +231,23 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
}}
/>
</>
) : (
<div className="tutorial-overlay-blocker" style={{ inset: 0 }} />
)}
) : null}
<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 }}
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">
{currentIndex + 1} / {hints.length}
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
</span>
<div className="tutorial-tooltip-actions">
<button className="tutorial-btn-skip" onClick={onSkip}>
@ -237,9 +258,9 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
{t('tutorial.prev')}
</button>
)}
{(hint.clickToAdvance !== true || isLast) && (
{(hint.clickToAdvance !== true || isFinish) && (
<button className="tutorial-btn-next" onClick={onNext}>
{isLast ? t('tutorial.finish') : t('tutorial.next')}
{isFinish ? t('tutorial.finish') : t('tutorial.next')}
</button>
)}
</div>

View File

@ -3,8 +3,11 @@ export interface TutorialHint {
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 };
}
@ -16,8 +19,6 @@ export interface TutorialStepDef {
export const TUTORIAL_KEYS = {
LANDING: 'landing',
LANDING_URL: 'landingUrl',
LANDING_NAME: 'landingName',
ANALYSIS: 'analysis',
ASSET: 'asset',
SOUND: 'sound',
@ -34,105 +35,94 @@ export const TUTORIAL_KEYS = {
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-container',
titleKey: 'tutorial.landing.intro.title',
descriptionKey: 'tutorial.landing.intro.desc',
position: 'top',
clickToAdvance: true,
},
{
targetSelector: '.hero-dropdown-menu',
targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'top',
clickToAdvance: true,
position: 'left',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
],
},
{
key: TUTORIAL_KEYS.LANDING_URL,
hints: [
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.url.title',
descriptionKey: 'tutorial.landing.url.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc',
position: 'bottom',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.LANDING_NAME,
hints: [
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.name.title',
descriptionKey: 'tutorial.landing.name.desc',
position: 'top',
clickToAdvance: false,
spotlightPaddingOverride: { top: 0, right: 0, bottom: 290, left: -105 },
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc',
position: 'bottom',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.ANALYSIS,
hints: [
{
targetSelector: '.bi2-identity-card',
titleKey: 'tutorial.analysis.identity.title',
descriptionKey: 'tutorial.analysis.identity.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.bi2-selling-card',
titleKey: 'tutorial.analysis.selling.title',
descriptionKey: 'tutorial.analysis.selling.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.bi2-persona-grid',
titleKey: 'tutorial.analysis.persona.title',
descriptionKey: 'tutorial.analysis.persona.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.bi2-keyword-tags',
titleKey: 'tutorial.analysis.keywords.title',
descriptionKey: 'tutorial.analysis.keywords.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.bi2-generate-btn',
titleKey: 'tutorial.analysis.generate.title',
descriptionKey: 'tutorial.analysis.generate.desc',
titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc',
position: 'right',
clickToAdvance: true,
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: [
@ -174,7 +164,7 @@ export const tutorialSteps: TutorialStepDef[] = [
titleKey: 'tutorial.sound.genre.title',
descriptionKey: 'tutorial.sound.genre.desc',
position: 'top',
clickToAdvance: true,
clickToAdvance: false,
},
{
targetSelector: '.language-selector-wrapper',
@ -244,8 +234,10 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.myinfo-social-buttons',
titleKey: 'tutorial.myInfo.connect.title',
descriptionKey: 'tutorial.myInfo.connect.desc',
noteKey: 'tutorial.myInfo.connect.note',
position: 'top',
clickToAdvance: true,
spotlightPaddingOverride: { right: -505},
},
{
targetSelector: '.myinfo-connected-accounts',
@ -273,6 +265,20 @@ export const tutorialSteps: TutorialStepDef[] = [
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',

View File

@ -1,5 +1,21 @@
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 PROGRESS_KEY = 'ado2_tutorial_progress';
@ -54,6 +70,7 @@ interface UseTutorialReturn {
currentHintIndex: number;
hints: TutorialHint[];
tutorialKey: string | null;
groupProgress: { groupTotal: number; groupOffset: number } | null;
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
nextHint: () => void;
prevHint: () => void;
@ -155,6 +172,7 @@ export function useTutorial(): UseTutorialReturn {
currentHintIndex,
hints,
tutorialKey,
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
startTutorial,
nextHint,
prevHint,

View File

@ -22,11 +22,10 @@
"prev": "Back",
"finish": "Done",
"landing": {
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step.\nFirst, please select your search method." },
"dropdown": { "title": "Choose Search Type", "desc": "Click the dropdown and select URL or business name as your preferred search method." },
"url": { "title": "Enter Naver Place URL", "desc": "Search for a place on Naver Maps, click Share, and paste the URL here." },
"name": { "title": "Enter Business Name", "desc": "Type a business name and the autocomplete list will appear.\nChoose your business from the list." },
"button": { "title": "Start Brand Analysis", "desc": "Click to let AI analyze your brand." }
"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." },
@ -52,12 +51,14 @@
},
"myInfo": {
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button.\n(Instagram connection is under development.)" },
"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.\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": {
@ -67,8 +68,8 @@
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
},
"upload": {
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. \nPlease wait a moment." },
"required": { "title": "Required Fields", "desc": "Fields marked with * are required. Please fill them in before uploading." },
"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." }
},
@ -133,9 +134,9 @@
"invalidVideoInfo": "Video information is invalid. (missing video_id)",
"uploadStartFailed": "Failed to start upload.",
"uploadFailed": "Upload failed.",
"autoSeoTitle": "This will be automatically generated. please wait.",
"autoSeoDescription": "This will be automatically generated. please wait.",
"autoSeoTags": "This will be automatically generated. please wait."
"autoSeoTitle": "Auto-generating... (3060 sec)",
"autoSeoDescription": "Auto-generating... (3060 sec)",
"autoSeoTags": "Auto-generating... (3060 sec)"
},
"upload": {
"title": "YouTube Upload",
@ -193,7 +194,7 @@
"urlInput": {
"searchTypeBusinessName": "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.",
"searchButton": "Search",
"searching": "Searching...",
@ -238,8 +239,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...",
"generatingSong": "Generating song...",
"generatingLyrics": "Generating lyrics...\n(3060 sec)",
"generatingSong": "Generating music...\n(12 min)",
"songQueued": "Song generation queued...",
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
"lyricGenerationFailed": "Lyrics generation request failed.",

View File

@ -22,10 +22,9 @@
"prev": "이전",
"finish": "완료",
"landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요.\n먼저 검색 방식을 선택해 주세요." },
"dropdown": { "title": "검색 방식 선택", "desc": "URL 또는 업체명 중 원하는 방식을 선택하세요." },
"url": { "title": "네이버 Place URL 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣으세요." },
"name": { "title": "업체명 입력", "desc": "업체명을 입력하면 자동완성 목록이 나타나요.\n목록에서 원하는 업체를 선택하세요." },
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
},
"analysis": {
@ -44,31 +43,33 @@
"sound": {
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." },
"language": { "title": "언어 선택", "desc": "사운드의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
"generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 음악을 생성해요." },
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 선택한 장르와 언어로 가사와 음악을 생성해요."},
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 미리 들어보세요." },
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
},
"myInfo": {
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 클릭하세요. \n(Instaram연결은 개발 중입니다.)" },
"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": "콘텐츠의 제목, 장르, 규격, 가사를 확인하세요." },
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 제목,장르,규격,가사를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." }
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
},
"upload": {
"seo": { "title": "제목 및 설명", "desc": "영상에 표시될 제목과 설명을 AI가 만들고 있어요. \n잠시만 기다려 주세요." },
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수항목으로 반드시 확인해 주세요." },
"seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
},
@ -133,9 +134,9 @@
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
"uploadStartFailed": "업로드 시작에 실패했습니다.",
"uploadFailed": "업로드에 실패했습니다.",
"autoSeoTitle": "자동으로 작성중입니다. 기다려주세요.",
"autoSeoDescription": "자동으로 작성중입니다. 기다려주세요.",
"autoSeoTags": "자동으로 작성중입니다. 기다려주세요."
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
},
"upload": {
"title": "YouTube 업로드",
@ -157,7 +158,7 @@
"hero": {
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버 Place URL을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
@ -175,7 +176,7 @@
"feature1Title": "비즈니스 핵심 정보 분석",
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요.",
"feature2Title": "홍보 콘텐츠 자동 제작",
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 노래, 영상을\n자동으로 제작해요",
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 영상을\n자동으로 제작해요",
"feature3Title": "멀티채널 자동 배포",
"feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요"
},
@ -193,7 +194,7 @@
"urlInput": {
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버 Place URL을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기",
"searching": "검색 중...",
@ -238,9 +239,9 @@
"videoGenerating": "영상 생성 중",
"noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.",
"noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.",
"generatingLyrics": "가사를 생성하고 있습니다...",
"generatingSong": "노래를 생성하고 있습니다...",
"songQueued": "노래 생성 대기 중...",
"generatingLyrics": "가사를 생성하고 있습니다.\n(30~60초 소요)",
"generatingSong": "음악을 생성하고 있습니다.\n(1~2분 소요)",
"songQueued": "음악 생성 대기 중...",
"retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})",
"lyricGenerationFailed": "가사 생성 요청에 실패했습니다.",
"lyricNotReceived": "가사를 받지 못했습니다.",
@ -269,7 +270,7 @@
"statusPlanned": "예약됨",
"statusWaiting": "대기 중",
"statusTranscribing": "트랜스크립션 중",
"statusRendering": "렌더링 중",
"statusRendering": "생성 중 (2~3분 소요)",
"statusSucceeded": "완료",
"statusDefault": "처리 중...",
"aiOptimization": "AI 최적화",

View File

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

View File

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

View File

@ -506,8 +506,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
// 현재 화면에 맞는 튜토리얼 키 반환
const getCurrentTutorialKey = (): string => {
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 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;
@ -516,15 +516,16 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
}
return TUTORIAL_KEYS.ASSET;
return null;
};
const handleTutorialRestart = () => {
tutorial.showRestartPopup(getCurrentTutorialKey());
tutorial.showRestartPopup(getCurrentTutorialKey()!);
};
const tutorialUI = (
<>
{getCurrentTutorialKey() && (
<button
className="tutorial-restart-fab"
onClick={handleTutorialRestart}
@ -536,6 +537,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
</svg>
<span>{t('sidebar.tutorialRestart')}</span>
</button>
)}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
@ -543,6 +545,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
{tutorial.isRestartPopupVisible && (

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>('url');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
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 { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('url');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false);
@ -97,10 +97,9 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작 (URL/업체명 분기 튜토리얼도 아직 안 본 경우)
// 첫 방문 시 랜딩 튜토리얼 시작
useEffect(() => {
const neitherBranchSeen = !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_URL) && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_NAME);
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING) && neitherBranchSeen) {
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
}, 800);
@ -381,11 +380,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
onClick={() => {
setSearchType(option.value);
setIsDropdownOpen(false);
if (option.value === 'url' && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_URL)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.LANDING_URL, undefined, true), 300);
} else if (option.value === 'name' && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_NAME)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.LANDING_NAME, undefined, true), 300);
}
}}
>
{option.label}
@ -534,6 +528,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</div>