UI 및 튜토리얼 수정
parent
8734d3388d
commit
1daf6b0eb2
210
index.css
210
index.css
|
|
@ -7721,6 +7721,10 @@
|
|||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.genre-btn.active:hover:not(:disabled) {
|
||||
border-color: var(--color-mint);
|
||||
}
|
||||
|
||||
.genre-btn.active {
|
||||
border-color: var(--color-mint);
|
||||
}
|
||||
|
|
@ -8080,6 +8084,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 +9764,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 +10635,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 +10696,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 +10732,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 +10753,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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ interface TutorialOverlayProps {
|
|||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onSkip: () => void;
|
||||
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | 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;
|
||||
|
|
@ -123,14 +127,13 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
|||
if (shouldClickAdvance) {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('click', onNext);
|
||||
}
|
||||
|
||||
cleanupTarget = () => {
|
||||
if (shouldClickAdvance) {
|
||||
el.style.cursor = '';
|
||||
el.removeEventListener('click', onNext);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
cleanupTarget = () => {};
|
||||
}
|
||||
};
|
||||
|
||||
const tryBind = (): boolean => {
|
||||
|
|
@ -156,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">
|
||||
|
|
@ -218,16 +236,20 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
|||
)}
|
||||
|
||||
<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}>
|
||||
|
|
@ -238,9 +260,11 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
|||
{t('tutorial.prev')}
|
||||
</button>
|
||||
)}
|
||||
{(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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -32,17 +35,33 @@ 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',
|
||||
targetSelector: '.hero-dropdown-trigger',
|
||||
titleKey: 'tutorial.landing.dropdown.title',
|
||||
descriptionKey: 'tutorial.landing.dropdown.desc',
|
||||
position: 'top',
|
||||
position: 'left',
|
||||
clickToAdvance: false,
|
||||
spotlightPaddingOverride: { top: 10, right: 15, bottom: 100, left: 0 },
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
{
|
||||
targetSelector: '.hero-input-wrapper',
|
||||
|
|
@ -50,57 +69,60 @@ export const tutorialSteps: TutorialStepDef[] = [
|
|||
descriptionKey: 'tutorial.landing.field.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: false,
|
||||
spotlightPaddingOverride: { top: 0, right: 0, bottom: 290, left: -105 },
|
||||
noSpotlight: true,
|
||||
variant: 'bubble',
|
||||
},
|
||||
{
|
||||
targetSelector: '.hero-button',
|
||||
titleKey: 'tutorial.landing.button.title',
|
||||
descriptionKey: 'tutorial.landing.button.desc',
|
||||
position: 'bottom',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: TUTORIAL_KEYS.ANALYSIS,
|
||||
hints: [
|
||||
{
|
||||
targetSelector: '.bi2-identity-card',
|
||||
titleKey: 'tutorial.analysis.identity.title',
|
||||
descriptionKey: 'tutorial.analysis.identity.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.bi2-selling-card',
|
||||
titleKey: 'tutorial.analysis.selling.title',
|
||||
descriptionKey: 'tutorial.analysis.selling.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.bi2-persona-grid',
|
||||
titleKey: 'tutorial.analysis.persona.title',
|
||||
descriptionKey: 'tutorial.analysis.persona.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.bi2-keyword-tags',
|
||||
titleKey: 'tutorial.analysis.keywords.title',
|
||||
descriptionKey: 'tutorial.analysis.keywords.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.bi2-generate-btn',
|
||||
titleKey: 'tutorial.analysis.generate.title',
|
||||
descriptionKey: 'tutorial.analysis.generate.desc',
|
||||
position: 'right',
|
||||
clickToAdvance: true,
|
||||
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: [
|
||||
|
|
@ -123,6 +145,7 @@ export const tutorialSteps: TutorialStepDef[] = [
|
|||
titleKey: 'tutorial.asset.ratio.title',
|
||||
descriptionKey: 'tutorial.asset.ratio.desc',
|
||||
position: 'left',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.asset-next-button',
|
||||
|
|
@ -141,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',
|
||||
|
|
@ -149,7 +172,6 @@ export const tutorialSteps: TutorialStepDef[] = [
|
|||
descriptionKey: 'tutorial.sound.language.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
spotlightPaddingOverride: { bottom: 230 },
|
||||
},
|
||||
{
|
||||
targetSelector: '.btn-generate-sound',
|
||||
|
|
@ -170,6 +192,13 @@ export const tutorialSteps: TutorialStepDef[] = [
|
|||
position: 'left',
|
||||
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: [
|
||||
{
|
||||
targetSelector: '.youtube-connect-section',
|
||||
titleKey: 'tutorial.myInfo.connect.title',
|
||||
descriptionKey: 'tutorial.myInfo.connect.desc',
|
||||
titleKey: 'tutorial.myInfo.myInfo.title',
|
||||
descriptionKey: 'tutorial.myInfo.myInfo.desc',
|
||||
position: 'top',
|
||||
clickToAdvance: false,
|
||||
},
|
||||
{
|
||||
targetSelector: '.myinfo-social-btn.connected',
|
||||
titleKey: 'tutorial.myInfo.button.title',
|
||||
descriptionKey: 'tutorial.myInfo.button.desc',
|
||||
targetSelector: '.myinfo-social-btn',
|
||||
titleKey: 'tutorial.myInfo.connect.title',
|
||||
descriptionKey: 'tutorial.myInfo.connect.desc',
|
||||
noteKey: 'tutorial.myInfo.connect.note',
|
||||
position: 'top',
|
||||
clickToAdvance: true,
|
||||
},
|
||||
{
|
||||
targetSelector: '.myinfo-connected-accounts',
|
||||
|
|
@ -232,6 +264,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',
|
||||
|
|
|
|||
|
|
@ -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,7 +70,8 @@ interface UseTutorialReturn {
|
|||
currentHintIndex: number;
|
||||
hints: TutorialHint[];
|
||||
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;
|
||||
prevHint: () => void;
|
||||
skipTutorial: () => void;
|
||||
|
|
@ -73,12 +90,12 @@ export function useTutorial(): UseTutorialReturn {
|
|||
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
|
||||
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);
|
||||
if (!step || step.hints.length === 0) return;
|
||||
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
|
||||
globalSkip?.();
|
||||
const savedIndex = loadProgress(key);
|
||||
const savedIndex = forceFromStart ? 0 : loadProgress(key);
|
||||
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
|
||||
onCompleteRef.current = onComplete;
|
||||
setHints(step.hints);
|
||||
|
|
@ -104,6 +121,7 @@ export function useTutorial(): UseTutorialReturn {
|
|||
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
|
||||
setIsActive(false);
|
||||
if (tutorialKey) markSeen(tutorialKey);
|
||||
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
|
||||
onCompleteRef.current?.();
|
||||
onCompleteRef.current = undefined;
|
||||
return 0;
|
||||
|
|
@ -127,12 +145,13 @@ export function useTutorial(): UseTutorialReturn {
|
|||
setIsRestartPopupVisible(true);
|
||||
}, []);
|
||||
|
||||
// 팝업에서 확인 → seen 초기화 후 튜토리얼 시작
|
||||
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
|
||||
const confirmRestart = useCallback(() => {
|
||||
setIsRestartPopupVisible(false);
|
||||
if (pendingRestartKey) {
|
||||
localStorage.removeItem(SEEN_KEY);
|
||||
startTutorial(pendingRestartKey);
|
||||
localStorage.removeItem(PROGRESS_KEY);
|
||||
startTutorial(pendingRestartKey, undefined, true);
|
||||
}
|
||||
setPendingRestartKey(null);
|
||||
}, [pendingRestartKey, startTutorial]);
|
||||
|
|
@ -153,6 +172,7 @@ export function useTutorial(): UseTutorialReturn {
|
|||
currentHintIndex,
|
||||
hints,
|
||||
tutorialKey,
|
||||
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
|
||||
startTutorial,
|
||||
nextHint,
|
||||
prevHint,
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@
|
|||
"prev": "Back",
|
||||
"finish": "Done",
|
||||
"landing": {
|
||||
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name as your preferred search method." },
|
||||
"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." },
|
||||
"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." },
|
||||
|
|
@ -41,31 +42,34 @@
|
|||
},
|
||||
"sound": {
|
||||
"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." },
|
||||
"generate": { "title": "Generate Sound", "desc": "AI will create lyrics and music in your chosen genre and language." },
|
||||
"lyrics": { "title": "Generated Lyrics", "desc": "AI wrote these lyrics to match the music.\nCheck the generated lyrics." },
|
||||
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
||||
"generate": { "title": "Generate Sound", "desc": "AI will generate music in your chosen genre and language." },
|
||||
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
|
||||
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
||||
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
||||
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
||||
},
|
||||
"myInfo": {
|
||||
"connect": { "title": "Connect Social Account", "desc": "You need to link a social account to upload videos." },
|
||||
"button": { "title": "Connect Now", "desc": "Click the social media button you want to connect." },
|
||||
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here. Check 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." }
|
||||
"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.", "note": "Instagram connection is coming soon." },
|
||||
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
|
||||
"ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
|
||||
},
|
||||
"ado2": {
|
||||
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." },
|
||||
"download": { "title": "Download", "desc": "Download the video to your device." },
|
||||
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
|
||||
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
|
||||
},
|
||||
"completion": {
|
||||
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." },
|
||||
"generating": { "title": "Generating Video", "desc": "AI is creating your video. 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?" },
|
||||
"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." }
|
||||
},
|
||||
|
|
@ -79,7 +83,7 @@
|
|||
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
|
||||
},
|
||||
"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",
|
||||
"desc": "Share any issues or suggestions to help us improve."
|
||||
},
|
||||
|
|
@ -130,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... (30–60 sec)",
|
||||
"autoSeoDescription": "Auto-generating... (30–60 sec)",
|
||||
"autoSeoTags": "Auto-generating... (30–60 sec)"
|
||||
},
|
||||
"upload": {
|
||||
"title": "YouTube Upload",
|
||||
|
|
@ -190,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...",
|
||||
|
|
@ -235,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(30–60 sec)",
|
||||
"generatingSong": "Generating music...\n(1–2 min)",
|
||||
"songQueued": "Song generation queued...",
|
||||
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
|
||||
"lyricGenerationFailed": "Lyrics generation request failed.",
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@
|
|||
"prev": "이전",
|
||||
"finish": "완료",
|
||||
"landing": {
|
||||
"dropdown": { "title": "검색 방식 선택", "desc": "URL 또는 업체명 중 원하는 방식을 선택하세요." },
|
||||
"field": { "title": "URL 또는 업체명 입력", "desc": "네이버 지도에서 장소를 검색하고 공유를 클릭하여 나온 URL을 붙여넣거나 업체명을 입력하고 선택하세요." },
|
||||
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
||||
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
|
||||
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
|
||||
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
||||
},
|
||||
"analysis": {
|
||||
|
|
@ -34,38 +35,41 @@
|
|||
"generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." }
|
||||
},
|
||||
"asset": {
|
||||
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 사진이에요. 더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
|
||||
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
|
||||
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
|
||||
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
|
||||
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
|
||||
},
|
||||
"sound": {
|
||||
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." },
|
||||
"language": { "title": "언어 선택", "desc": "사운드의 언어를 선택하세요. 다음을 눌러주세요." },
|
||||
"generate": { "title": "사운드 생성", "desc": "AI가 선택한 장르와 언어로 가사와 음악을 생성해요." },
|
||||
"lyrics": { "title": "생성된 가사", "desc": "AI가 음악에 맞는 가사를 만들었어요.\n생성된 가사를 확인하세요." },
|
||||
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 미리 들어보세요." },
|
||||
"language": { "title": "언어 선택", "desc": "사운드의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
|
||||
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 선택한 장르와 언어로 가사와 음악을 생성해요."},
|
||||
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
|
||||
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
|
||||
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
|
||||
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
|
||||
},
|
||||
"myInfo": {
|
||||
"connect": { "title": "소셜 연동", "desc": "영상을 업로드하려면 소셜 계정을 연동해야 해요." },
|
||||
"button": { "title": "연동하기", "desc": "원하는 소셜미디어 버튼을 클릭해서 연동하세요." },
|
||||
"connected": { "title": "연동된 계정", "desc": "연동된 소셜 계정 목록이에요. 연동 후 여기서 확인할 수 있어요." },
|
||||
"ado2": { "title": "내 콘텐츠 확인", "desc": "연동 후 내 콘텐츠 메뉴에서 생성된 영상을 확인하고 업로드할 수 있어요. 클릭해서 이동하세요." }
|
||||
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
|
||||
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
|
||||
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
|
||||
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
|
||||
},
|
||||
"ado2": {
|
||||
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 여기서 확인할 수 있어요." },
|
||||
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
|
||||
"download": { "title": "다운로드", "desc": "영상을 다운로드 할 수 있어요." },
|
||||
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
|
||||
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
|
||||
},
|
||||
"completion": {
|
||||
"contentInfo": { "title": "콘텐츠 정보", "desc": "생성된 콘텐츠의 제목, 장르, 규격, 가사 정보를 확인하세요." },
|
||||
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. 잠시만 기다려 주세요." },
|
||||
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. 영상을 확인해 볼까요?" },
|
||||
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. 클릭해서 이동하세요." }
|
||||
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 제목,장르,규격,가사를 확인하세요." },
|
||||
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
|
||||
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
|
||||
"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": "게시 버튼을 눌러 업로드를 시작하세요." }
|
||||
},
|
||||
|
|
@ -79,7 +83,7 @@
|
|||
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
|
||||
},
|
||||
"feedback": {
|
||||
"complete": { "title": "튜토리얼 완료 🎉", "desc": "브랜드 분석부터 유튜브 업로드까지 모든 과정을 완료했어요. 이제 직접 시작해 보세요!" },
|
||||
"complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
|
||||
"title": "고객의견",
|
||||
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
|
||||
},
|
||||
|
|
@ -130,9 +134,9 @@
|
|||
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
|
||||
"uploadStartFailed": "업로드 시작에 실패했습니다.",
|
||||
"uploadFailed": "업로드에 실패했습니다.",
|
||||
"autoSeoTitle": "자동으로 작성중입니다. 기다려주세요.",
|
||||
"autoSeoDescription": "자동으로 작성중입니다. 기다려주세요.",
|
||||
"autoSeoTags": "자동으로 작성중입니다. 기다려주세요."
|
||||
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
|
||||
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
|
||||
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
|
||||
},
|
||||
"upload": {
|
||||
"title": "YouTube 업로드",
|
||||
|
|
@ -154,7 +158,7 @@
|
|||
"hero": {
|
||||
"searchTypeBusinessName": "업체명",
|
||||
"placeholderBusinessName": "업체명을 입력하세요.",
|
||||
"guideUrl": "네이버 Place URL을 입력하세요.",
|
||||
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
|
||||
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||
"errorUrlRequired": "URL을 입력해주세요.",
|
||||
"errorNameRequired": "업체명을 입력해주세요.",
|
||||
|
|
@ -172,7 +176,7 @@
|
|||
"feature1Title": "비즈니스 핵심 정보 분석",
|
||||
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요.",
|
||||
"feature2Title": "홍보 콘텐츠 자동 제작",
|
||||
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 노래, 영상을\n자동으로 제작해요",
|
||||
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 영상을\n자동으로 제작해요",
|
||||
"feature3Title": "멀티채널 자동 배포",
|
||||
"feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요"
|
||||
},
|
||||
|
|
@ -190,7 +194,7 @@
|
|||
"urlInput": {
|
||||
"searchTypeBusinessName": "업체명",
|
||||
"placeholderBusinessName": "업체명을 입력하세요.",
|
||||
"guideUrl": "네이버 Place URL을 입력하세요.",
|
||||
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
|
||||
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||
"searchButton": "검색하기",
|
||||
"searching": "검색 중...",
|
||||
|
|
@ -211,7 +215,7 @@
|
|||
"back": "뒤로가기",
|
||||
"loadMore": "더보기",
|
||||
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
||||
"uploading": "업로드 중...",
|
||||
"uploading": "업로드 중 (30초~2분 소요)",
|
||||
"nextStep": "다음 단계"
|
||||
},
|
||||
"soundStudio": {
|
||||
|
|
@ -235,9 +239,9 @@
|
|||
"videoGenerating": "영상 생성 중",
|
||||
"noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.",
|
||||
"noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.",
|
||||
"generatingLyrics": "가사를 생성하고 있습니다...",
|
||||
"generatingSong": "노래를 생성하고 있습니다...",
|
||||
"songQueued": "노래 생성 대기 중...",
|
||||
"generatingLyrics": "가사를 생성하고 있습니다. (30~60초 소요)",
|
||||
"generatingSong": "음악을 생성하고 있습니다. (1~2분 소요)",
|
||||
"songQueued": "음악 생성 대기 중...",
|
||||
"retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})",
|
||||
"lyricGenerationFailed": "가사 생성 요청에 실패했습니다.",
|
||||
"lyricNotReceived": "가사를 받지 못했습니다.",
|
||||
|
|
@ -266,7 +270,7 @@
|
|||
"statusPlanned": "예약됨",
|
||||
"statusWaiting": "대기 중",
|
||||
"statusTranscribing": "트랜스크립션 중",
|
||||
"statusRendering": "렌더링 중",
|
||||
"statusRendering": "생성 중 (2~3분 소요)",
|
||||
"statusSucceeded": "완료",
|
||||
"statusDefault": "처리 중...",
|
||||
"aiOptimization": "AI 최적화",
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -463,6 +463,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -323,6 +323,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
|
||||
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
|
||||
useEffect(() => {
|
||||
if (tutorial.isActive) tutorial.skipTutorial();
|
||||
if (SIDEBAR_ITEMS.includes(activeItem)) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) {
|
||||
|
|
@ -336,6 +337,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
|
||||
// activeItem 변경 시 튜토리얼 트리거
|
||||
useEffect(() => {
|
||||
if (tutorial.isActive) tutorial.skipTutorial();
|
||||
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
|
||||
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
|
||||
} else if (activeItem === 'ADO2 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
|
||||
|
|
@ -430,6 +432,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
mId={analysisData?.m_id ?? 0}
|
||||
videoGenerationStatus={videoGenerationStatus}
|
||||
videoGenerationProgress={videoGenerationProgress}
|
||||
onStatusChange={(status: string) => {
|
||||
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
|
||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
|
||||
} else if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
|
||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO), 400);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
|
|
@ -497,24 +506,26 @@ 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;
|
||||
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
|
||||
if (activeItem === '새 프로젝트 만들기') {
|
||||
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
|
||||
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
|
||||
}
|
||||
return TUTORIAL_KEYS.ASSET;
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleTutorialRestart = () => {
|
||||
tutorial.showRestartPopup(getCurrentTutorialKey());
|
||||
tutorial.showRestartPopup(getCurrentTutorialKey()!);
|
||||
};
|
||||
|
||||
const tutorialUI = (
|
||||
<>
|
||||
{getCurrentTutorialKey() && (
|
||||
<button
|
||||
className="tutorial-restart-fab"
|
||||
onClick={handleTutorialRestart}
|
||||
|
|
@ -526,6 +537,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
</svg>
|
||||
<span>{t('sidebar.tutorialRestart')}</span>
|
||||
</button>
|
||||
)}
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
|
|
@ -533,6 +545,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
{tutorial.isRestartPopupVisible && (
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/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 {
|
||||
customer_name: string;
|
||||
|
|
@ -19,6 +16,7 @@ interface SoundStudioContentProps {
|
|||
businessInfo?: BusinessInfo;
|
||||
imageTaskId: string | null;
|
||||
mId: number;
|
||||
onStatusChange?: (status: string) => void;
|
||||
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
||||
videoGenerationProgress?: number;
|
||||
}
|
||||
|
|
@ -42,11 +40,11 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
businessInfo,
|
||||
imageTaskId,
|
||||
mId,
|
||||
onStatusChange,
|
||||
videoGenerationStatus = 'idle',
|
||||
videoGenerationProgress = 0
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const tutorial = useTutorial();
|
||||
const [selectedType, setSelectedType] = useState('보컬');
|
||||
const [selectedLang, setSelectedLang] = useState('한국어');
|
||||
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
|
||||
|
|
@ -393,24 +391,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
|
||||
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
|
||||
|
||||
// 가사 생성 완료 시 가사 튜토리얼 트리거
|
||||
// status 변경 시 부모에 알림
|
||||
useEffect(() => {
|
||||
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
|
||||
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);
|
||||
}
|
||||
onStatusChange?.(status);
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
|
|
@ -669,15 +652,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
</div>
|
||||
</main>
|
||||
|
||||
{tutorial.isActive && (
|
||||
<TutorialOverlay
|
||||
hints={tutorial.hints}
|
||||
currentIndex={tutorial.currentHintIndex}
|
||||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -155,6 +155,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
setShowAutocomplete(false);
|
||||
setAutocompleteResults([]);
|
||||
setHighlightedIndex(-1);
|
||||
// LANDING_NAME 튜토리얼 진행 중이면 다음 힌트로 이동
|
||||
if (tutorial.isActive && tutorial.tutorialKey === TUTORIAL_KEYS.LANDING_NAME) {
|
||||
tutorial.nextHint();
|
||||
}
|
||||
};
|
||||
|
||||
// 키보드 네비게이션 핸들러
|
||||
|
|
@ -524,6 +528,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
onNext={tutorial.nextHint}
|
||||
onPrev={tutorial.prevHint}
|
||||
onSkip={tutorial.skipTutorial}
|
||||
groupProgress={tutorial.groupProgress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue