Compare commits

..

No commits in common. "main" and "feature-tutorial" have entirely different histories.

74 changed files with 13585 additions and 17346 deletions

View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,233 @@
import React, { useMemo } from 'react';
import { ArrowLeft, Sparkles, MapPin, Target, Zap, LayoutGrid, Users, Crown, TrendingUp } from 'lucide-react';
import { LALA_CABIN_DATA } from './constants';
import { GeometricChart } from './components/GeometricChart';
import { KeywordBubble } from './components/KeywordBubble';
import { BrandData, USP } from './types';
// Logic to calculate scores based on ICP (Ideal Customer Profile) signals
const calculateDynamicScores = (data: BrandData): USP[] => {
// 1. Extract Demand Signals from Targets (Needs + Triggers)
const marketSignals = data.targets.flatMap(t => [...t.needs, ...t.triggers]).map(s => s.replace(/\s+/g, ''));
// High value keywords that represent the "Core Value" of this specific property type
const coreKeywords = ['프라이빗', '감성', '독채', '캐빈', '조명', '불멍', 'Private', 'Mood'];
return data.usps.map(usp => {
let calculatedScore = 65; // Base score
const contentStr = (usp.label + usp.subLabel + usp.description).replace(/\s+/g, '');
// 2. Cross-reference USP with Market Signals
marketSignals.forEach(signal => {
if (contentStr.includes(signal)) calculatedScore += 4;
});
// 3. Boost based on Core Keywords (Weighted Importance)
coreKeywords.forEach(keyword => {
if (contentStr.includes(keyword)) calculatedScore += 6;
});
// 4. Special Boost based on specific Marketing Pillars (checking English SubLabels)
if (usp.subLabel === 'CONCEPT') calculatedScore += 10;
if (usp.subLabel === 'PRIVACY') calculatedScore += 8;
if (usp.subLabel === 'NIGHT MOOD') calculatedScore += 8;
if (usp.subLabel === 'PHOTO SPOT') calculatedScore += 5;
return {
...usp,
score: Math.min(Math.round(calculatedScore), 99) // Cap at 99
};
});
};
export default function App() {
const rawData = LALA_CABIN_DATA;
// Calculate scores on mount (or when data changes)
const scoredUSPs = useMemo(() => calculateDynamicScores(rawData), [rawData]);
// Find the top selling point
const topUSP = useMemo(() => [...scoredUSPs].sort((a, b) => b.score - a.score)[0], [scoredUSPs]);
return (
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
{/* Top Navigation */}
<div className="p-6">
<button className="flex items-center gap-2 px-4 py-2 rounded-full border border-brand-muted/30 text-brand-muted hover:text-brand-accent hover:border-brand-accent transition-all text-sm group">
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
<span></span>
</button>
</div>
{/* Main Header */}
<div className="text-center mb-10 px-4">
<div className="flex justify-center mb-4">
<div className="relative">
<Sparkles className="text-brand-accent w-8 h-8 animate-pulse relative z-10" />
</div>
</div>
<h1 className="text-4xl font-bold mb-3 tracking-tight text-white"> </h1>
<p className="text-brand-muted text-lg max-w-xl mx-auto">
<span className="text-brand-accent font-semibold">AI </span> .
</p>
</div>
{/* Grid Container */}
<div className="max-w-7xl mx-auto px-4 md:px-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* LEFT COLUMN: Identity & Text Analysis */}
<div className="space-y-6">
{/* Main Identity Card */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 shadow-lg relative overflow-hidden group hover:border-brand-accent/20 transition-all duration-500">
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-brand-accent to-brand-purple"></div>
<div className="mb-4 flex items-center gap-2">
<span className="text-brand-accent font-bold text-sm uppercase tracking-wider flex items-center gap-2">
<LayoutGrid size={14} />
</span>
</div>
<h2 className="text-3xl font-bold mb-2 text-white tracking-tight">{rawData.name}</h2>
<div className="flex items-start gap-2 text-brand-muted text-sm mb-6">
<MapPin size={14} className="mt-0.5 shrink-0 text-brand-accent" />
<div>
<p>{rawData.address}</p>
<p className="opacity-70">{rawData.subAddress}</p>
</div>
</div>
<div className="space-y-5 text-gray-300 leading-relaxed border-t border-white/5 pt-5">
<div>
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted"> </h3>
<p className="text-sm opacity-90 leading-6">{rawData.locationAnalysis}</p>
</div>
<div>
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted"> </h3>
<p className="text-sm opacity-90 leading-6">{rawData.conceptAnalysis}</p>
</div>
</div>
</div>
{/* Positioning & Strategy Card */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
<Target size={20} className="text-brand-purple" />
</h3>
<div className="grid grid-cols-1 gap-4">
<div className="bg-brand-bg/50 p-5 rounded-xl border border-brand-muted/20 hover:border-brand-accent/30 transition-colors group">
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors"> </span>
<span className="font-bold text-lg text-white">{rawData.positioning.category}</span>
</div>
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
<span className="block text-xs text-brand-accent mb-1 font-semibold"> (Core Value)</span>
<span className="font-semibold text-white">{rawData.positioning.coreValue}</span>
</div>
</div>
</div>
{/* Target Audience Card */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
<Users size={20} className="text-brand-purple" />
</h3>
<div className="space-y-4">
{rawData.targets.map((target, idx) => (
<div key={idx} className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 bg-brand-bg/30 rounded-xl border border-white/5 hover:border-brand-accent/20 transition-all group">
<div className="min-w-[120px]">
<div className="font-bold text-white group-hover:text-brand-accent transition-colors">{target.segment}</div>
<div className="text-xs text-brand-muted">{target.age}</div>
</div>
<div className="flex-1">
<div className="flex flex-wrap gap-1.5 mb-2">
{target.needs.map((need, i) => (
<span key={i} className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium">{need}</span>
))}
</div>
<p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2">
<span className="text-brand-muted">Trigger:</span> {target.triggers.join(', ')}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* RIGHT COLUMN: Visuals & Keywords */}
<div className="space-y-6">
{/* Chart Card */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden">
<div className="flex justify-between items-center mb-2 z-10">
<h3 className="text-xl font-bold text-white"> (USP)</h3>
<div className="flex items-center gap-1 text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded">
<TrendingUp size={12} />
<span>AI Data Analysis</span>
</div>
</div>
<div className="flex-1 flex items-center justify-center relative z-10 -my-4">
<GeometricChart data={scoredUSPs} />
</div>
{/* Core Competitiveness Highlight */}
{topUSP && (
<div className="mt-2 mb-6 p-4 rounded-xl bg-gradient-to-r from-brand-accent/10 to-transparent border border-brand-accent/20 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 opacity-10">
<Crown size={64} className="text-brand-accent" />
</div>
<div className="relative z-10">
<div className="text-xs text-brand-accent font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
<Crown size={12} /> Core Competitiveness
</div>
<div className="flex justify-between items-end">
<div>
<div className="text-lg font-bold text-white">{topUSP.label}</div>
<div className="text-xs text-brand-accent/80 font-mono tracking-wider">{topUSP.subLabel}</div>
</div>
{/* Score removed */}
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 z-10">
{scoredUSPs.filter(u => u.label !== topUSP.label).slice(0, 4).map((usp, idx) => (
<div key={idx} className="p-3 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-colors">
<div className="flex justify-between items-start mb-1">
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight">{usp.subLabel}</div>
{/* Score removed */}
</div>
<div className="text-sm font-bold text-white mb-1">{usp.label}</div>
<div className="text-xs text-gray-400 leading-tight line-clamp-1">{usp.description}</div>
</div>
))}
</div>
</div>
{/* Keywords Card */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden">
<h3 className="text-xl font-bold mb-6 text-center text-white"> </h3>
<div className="flex flex-wrap justify-center gap-3 relative z-10">
{rawData.keywords.map((keyword, idx) => (
<KeywordBubble key={idx} text={keyword} />
))}
</div>
</div>
</div>
</div>
{/* Floating Action Button (Sticky Bottom) */}
<div className="fixed bottom-8 left-0 right-0 flex justify-center z-50 pointer-events-none">
<button className="pointer-events-auto bg-brand-purple hover:bg-brand-purpleHover text-white font-bold py-3 px-12 rounded-full shadow-2xl shadow-brand-purple/40 transform hover:scale-105 transition-all duration-300 flex items-center gap-2">
<Sparkles size={18} />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/198bB4uG9EOOi0btQWINncwYJz7xEjWS3
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@ -0,0 +1,159 @@
import React from 'react';
import { USP } from '../types';
interface GeometricChartProps {
data: USP[];
}
export const GeometricChart: React.FC<GeometricChartProps> = ({ data }) => {
// Increased canvas size to prevent labels (especially on the right side like 'Privacy') from being cut off
const size = 500;
const center = size / 2;
const radius = 110; // Slightly increased radius for better visibility
const sides = data.length;
// Mint Color #94FBE0
const accentColor = "#94FBE0";
// Calculate polygon points
const getPoints = (r: number) => {
return data.map((_, i) => {
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + r * Math.cos(angle);
const y = center + r * Math.sin(angle);
return `${x},${y}`;
}).join(' ');
};
// Calculate data points based on score
const getDataPoints = () => {
return data.map((item, i) => {
const normalizedScore = item.score / 100;
const r = radius * normalizedScore;
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + r * Math.cos(angle);
const y = center + r * Math.sin(angle);
return `${x},${y}`;
}).join(' ');
};
// Calculate label positions (pushed out slightly further)
const labelRadius = radius + 55; // Increased padding for labels
const labels = data.map((item, i) => {
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + labelRadius * Math.cos(angle);
const y = center + labelRadius * Math.sin(angle);
// Adjust text anchor based on position
let anchor: 'start' | 'middle' | 'end' = 'middle';
if (x < center - 20) anchor = 'end';
if (x > center + 20) anchor = 'start';
return { x, y, text: item.label, sub: item.subLabel, anchor, score: item.score };
});
return (
<div className="flex flex-col items-center justify-center py-2 relative w-full h-full">
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto max-w-[500px]" style={{ overflow: 'visible' }}>
<defs>
<radialGradient id="polyGradient" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
<stop offset="0%" stopColor={accentColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={accentColor} stopOpacity="0.05" />
</radialGradient>
</defs>
{/* Background Grids (Concentric) - Increased Opacity for Visibility */}
{[1, 0.75, 0.5, 0.25].map((scale, i) => (
<polygon
key={i}
points={getPoints(radius * scale)}
fill="none"
stroke={accentColor}
strokeOpacity={0.25 - (0.02 * i)}
strokeWidth="1"
strokeDasharray={i === 0 ? "0" : "4 2"}
/>
))}
{/* Axes Lines - Increased Opacity */}
{data.map((_, i) => {
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + radius * Math.cos(angle);
const y = center + radius * Math.sin(angle);
return (
<line
key={i}
x1={center}
y1={center}
x2={x}
y2={y}
stroke={accentColor}
strokeOpacity="0.25"
strokeWidth="1"
/>
);
})}
{/* Data Shape */}
<polygon
points={getDataPoints()}
fill="url(#polyGradient)"
stroke={accentColor}
strokeWidth="2.5"
strokeLinejoin="round"
/>
{/* Data Points (Dots) - Increased base size and highlight size */}
{data.map((item, i) => {
const normalizedScore = item.score / 100;
const r = radius * normalizedScore;
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
const x = center + r * Math.cos(angle);
const y = center + r * Math.sin(angle);
const isHigh = item.score >= 90;
return (
<g key={i}>
{isHigh && (
<circle cx={x} cy={y} r="10" fill={accentColor} fillOpacity="0.4" />
)}
{/* Base dot is brighter (white) for all, slightly smaller for non-high */}
<circle cx={x} cy={y} r={isHigh ? 5 : 4} fill="#fff" />
</g>
)
})}
{/* Labels */}
{labels.map((l, i) => {
const isHigh = l.score >= 90;
return (
<g key={i}>
<text
x={l.x}
y={l.y - 7}
textAnchor={l.anchor}
fill={isHigh ? "#fff" : "#e2e8f0"}
fontSize={isHigh ? "14" : "12"}
fontWeight={isHigh ? "700" : "600"}
className="tracking-tight"
style={{ fontFamily: "sans-serif" }}
>
{l.text}
</text>
<text
x={l.x}
y={l.y + 9}
textAnchor={l.anchor}
fill={isHigh ? accentColor : "#94a3b8"}
fontSize={isHigh ? "11" : "10"}
fontWeight={isHigh ? "600" : "400"}
className="uppercase tracking-wider"
>
{l.sub}
</text>
</g>
);
})}
</svg>
</div>
);
};

View File

@ -0,0 +1,13 @@
import React from 'react';
interface KeywordBubbleProps {
text: string;
}
export const KeywordBubble: React.FC<KeywordBubbleProps> = ({ text }) => {
return (
<div className="bg-brand-cardHover/50 border border-brand-accent/20 rounded-full px-4 py-2 text-sm text-brand-text shadow-sm hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all duration-300 cursor-default whitespace-nowrap">
# {text}
</div>
);
};

View File

@ -0,0 +1,49 @@
import { BrandData } from './types';
export const LALA_CABIN_DATA: BrandData = {
name: "라라캐빈 펜션",
address: "경기 가평군 조종면 와곡길 176",
subAddress: "[별관] 경기 가평군 조종면 운악리 175-6",
summary: "서울·경기 북부 근교 1~2시간대 숏브레이크 권역에 위치한 캐빈 감성 스테이. 조종면의 한적함과 프라이빗한 독채 구조를 강점으로 내세우며, '숲속 캐빈 무드'와 '야간 조명 감성'이 핵심입니다.",
locationAnalysis: "조종면은 청평/가평읍 대비 '한적함·프라이빗' 이미지가 강해 커플 및 소규모 힐링 고객에게 유리하며, 운악산 권역의 자연형 스테이 포지셔닝에 적합합니다.",
conceptAnalysis: "'라라캐빈'은 오두막(Cabin) 연상을 강하게 주며, 감성 목조/숲속/프라이빗 스테이로 스토리텔링 확장성이 큽니다. 별관의 동 분리 구조는 '세컨드 하우스' 느낌을 강화합니다.",
usps: [
{ label: "입지 환경", subLabel: "LOCATION", score: 85, description: "서울근교 한적한 조종면 자연권 숲속 드라이브" },
{ label: "브랜드 컨셉", subLabel: "CONCEPT", score: 95, description: "숙소 자체가 여행 목적이 되는 캐빈 감성" },
{ label: "프라이버시", subLabel: "PRIVACY", score: 90, description: "우리만 쓰는 느낌의 별관 분리동 독립 동선" },
{ label: "야간 무드", subLabel: "NIGHT MOOD", score: 92, description: "어두울수록 예쁜 스테이 장면과 불멍" },
{ label: "힐링 요소", subLabel: "HEALING", score: 88, description: "창밖 자연로딩, 숲뷰 산책 리셋" },
{ label: "포토 스팟", subLabel: "PHOTO SPOT", score: 94, description: "찍는 곳마다 커버샷 무드, 인스타 각" },
{ label: "숏브레이크", subLabel: "SHORT GETAWAY", score: 80, description: "1박2일 가볍게 떠나는 주말 리셋 여행" }
],
keywords: [
"가평 독채 펜션", "숲속 오두막", "감성 숙소", "불멍 스테이",
"서울근교 드라이브", "프라이빗 힐링", "커플 여행", "운악산 펜션",
"반려견 동반", "비오는날 감성"
],
targets: [
{
segment: "서울·경기 커플",
age: "25~39세",
needs: ["사진", "분위기", "프라이빗"],
triggers: ["숲속 캐빈 무드", "비 오는 날 감성", "밤 조명"]
},
{
segment: "소규모 우정여행",
age: "20~34세",
needs: ["예쁜 공간", "수다", "바베큐"],
triggers: ["인스타 각", "감성 포토존", "테이블 셋업"]
},
{
segment: "힐링 부부",
age: "30~50세",
needs: ["조용한 휴식", "숙면", "따뜻함"],
triggers: ["조용한 숲뷰", "따뜻한 캐빈", "기념일"]
}
],
positioning: {
category: "서울근교 숲속 캐빈 감성 스테이",
coreValue: "프라이빗한 휴식 + 사진이 되는 공간 + 밤 무드",
strategy: "시설 나열보다 '장면(Scenes)' 중심의 마케팅 전개"
}
};

View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ADO2 Brand Analysis</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
bg: '#011c1e',
card: '#083336',
cardHover: '#0b3f42',
accent: '#94FBE0',
purple: '#a855f7',
purpleHover: '#9333ea',
text: '#e2e8f0',
muted: '#94a3b8'
}
},
fontFamily: {
sans: ['Pretendard', 'Apple SD Gothic Neo', 'Malgun Gothic', 'sans-serif'],
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; background-color: #011c1e; color: white; }
/* Custom scrollbar for refined look */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #011c1e; }
::-webkit-scrollbar-thumb { background: #083336; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94FBE0; }
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"lucide-react": "https://esm.sh/lucide-react@^0.563.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,5 @@
{
"name": "ADO2 Marketing Intelligence - Lala Cabin",
"description": "AI-powered marketing analysis dashboard for Lala Cabin Pension visualizing brand identity, target demographics, and key selling points.",
"requestFramePermissions": []
}

View File

@ -0,0 +1,22 @@
{
"name": "ado2-marketing-intelligence---lala-cabin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react-dom": "^19.2.3",
"react": "^19.2.3",
"lucide-react": "^0.563.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@ -0,0 +1,30 @@
export interface USP {
label: string;
subLabel: string;
score: number; // 0-100
description: string;
}
export interface TargetPersona {
segment: string;
age: string;
needs: string[];
triggers: string[];
}
export interface BrandData {
name: string;
address: string;
subAddress: string;
summary: string;
locationAnalysis: string;
conceptAnalysis: string;
usps: USP[];
keywords: string[];
targets: TargetPersona[];
positioning: {
category: string;
coreValue: string;
strategy: string;
};
}

View File

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});

10919
index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ADO2</title> <title>CASTAD</title>
<!-- ① 페이지 설명 -->
<meta name="description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
<!-- ② Open Graph 제목 -->
<meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" />
<!-- ③ Open Graph 설명 -->
<meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
<meta property="og:url" content="https://ado2.o2osolution.ai" />
<meta property="og:type" content="website" />
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32"> <link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48"> <link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
@ -36,7 +28,6 @@
} }
} }
</script> </script>
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
@ -162,6 +153,7 @@
} }
} }
</script> </script>
<link rel="stylesheet" href="/index.css">
</head> </head>
<body class="bg-[#121a1d]"> <body class="bg-[#121a1d]">
<div id="root"></div> <div id="root"></div>

View File

@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 660.69 145.69">
<!-- Generator: Adobe Illustrator 30.5.1, SVG Export Plug-In . SVG Version: 2.1.4 Build 3) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<g>
<g>
<path class="st0" d="M53.46,93.51l11.28-12.51h64.08l-31.22-63.81L32.69,93.51H15.09L90.17,5.91c2.48-2.89,6.19-5.64,10.73-5.64s7.29,2.48,8.8,5.64l43.45,87.6H53.46Z"/>
<path class="st0" d="M162.65,93.51l10.04-57.34h14.99l-7.84,44.83h42.22c23.38,0,43.18-16.78,43.18-38.5,0-16.64-12.51-28.33-31.35-28.33h-57.21l11.83-12.65h47.58c26.68,0,44.14,15.68,44.14,38.5,0,29.84-27.5,53.49-60.37,53.49h-57.21Z"/>
<path class="st0" d="M343.75,95.02c-36.17,0-54.45-12.1-54.45-38.37,0-38.09,23.24-56.65,71.23-56.65,36.17,0,54.46,11.96,54.46,38.23,0,38.09-23.1,56.79-71.23,56.79ZM358.33,12.51c-39.6,0-53.9,12.24-53.9,42.63,0,19.94,13.2,27.36,41.53,27.36,39.47,0,53.91-12.38,53.91-42.77,0-19.94-13.2-27.23-41.53-27.23Z"/>
<path class="st0" d="M417.47,93.51l4.81-27.23c2.89-16.5,15.68-24.48,36.58-24.48h40.15c16.91,0,23.79-5.78,23.79-15.68,0-9.08-9.08-11.96-26.4-11.96h-59.54l11.96-12.65h55.97c23.51,0,32.73,8.66,32.73,21.59,0,16.91-11,29.7-39.05,29.7h-40.15c-12.65,0-19.8,3.99-21.59,13.34l-2.47,14.85h94.75l-11.83,12.51h-99.7Z"/>
</g>
<g>
<path class="st0" d="M542.35,92.89c-2.38,0-4.15-1.38-4.15-3.61,0-3.07,2.69-4.84,5.68-4.84,2.46,0,4.3,1.31,4.3,3.53,0,3.07-2.69,4.92-5.84,4.92Z"/>
<path class="st0" d="M577.37,92.2l6.3-6.99h35.79l-17.43-35.63-36.25,42.62h-9.83l41.93-48.92c1.38-1.61,3.46-3.15,5.99-3.15s4.07,1.38,4.92,3.15l24.27,48.92h-55.68Z"/>
<path class="st0" d="M643.26,92.2l9.14-51.38h8.29l-9.14,51.38h-8.29Z"/>
</g>
</g>
<g>
<path class="st0" d="M.43,125.73c1.34-5.83,5.61-10.49,11.96-10.49,5.4,0,8.98,3.3,9.1,8.33,0,.17-.08.25-.23.25h-2.56c-.15,0-.23-.08-.23-.23-.17-3.74-2.48-5.9-6.18-5.9-4.69,0-7.56,3.67-8.65,8.31-1.31,5.71.33,11.09,6.32,11.09,3.43,0,6.17-1.81,7.52-6.23.03-.13.13-.2.27-.2h2.61c.17,0,.25.12.2.3-1.66,5.75-5.58,8.58-10.76,8.58-8.23,0-10.96-6.85-9.37-13.81Z"/>
<path class="st0" d="M25.26,129.28c1.02-4.34,4.26-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM38.31,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
<path class="st0" d="M44.55,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M66.61,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
<path class="st0" d="M78.34,129.22c.92-4.04,3.95-7.71,8.98-7.71s7.96,3.69,6.8,9.15c-.02.13-.12.2-.25.2h-12.78c-.37,3.18.57,6.5,4.51,6.5,2.35,0,4.21-1.12,5.09-3.94.05-.13.15-.2.28-.2h2.41c.17,0,.25.12.2.29-1.17,3.99-4.07,5.96-8.01,5.96-6.68,0-8.38-5.39-7.24-10.25ZM91.3,128.82c.47-3-1.14-5.21-4.15-5.21-3.17,0-4.98,2.43-5.71,5.21h9.87Z"/>
<path class="st0" d="M97.15,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M119.21,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
<path class="st0" d="M138.69,138.85l2.7-11.23,2.63-11.75c.03-.15.13-.22.27-.22h3.48c.15,0,.23.08.25.22l2.25,14.38.77,4.94,3.05-5.01,8.73-14.36c.07-.12.17-.17.3-.17h3.84c.15,0,.25.1.2.27l-2.76,11.69-2.53,11.28c-.03.15-.13.22-.27.22h-2.66c-.17,0-.25-.1-.22-.27l2.13-9.05,2.4-9.94-11.64,19.08c-.07.12-.17.17-.29.17h-2.11c-.15,0-.23-.08-.27-.22l-3.03-19.33-2.23,10.24-2.08,9.1c-.03.15-.13.22-.27.22h-2.41c-.17,0-.25-.1-.22-.27Z"/>
<path class="st0" d="M168.95,129.55c1.01-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM181.38,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
<path class="st0" d="M187.85,138.85l2.04-8.54,1.83-8.23c.03-.15.13-.22.27-.22h2.56c.15,0,.25.1.2.27l-.79,3.07c1.74-2.29,4.07-3.5,6.42-3.5.37,0,.72.03,1.02.12.15.03.2.13.17.28l-.44,1.88c-.03.15-.13.2-.28.18-.27-.07-.59-.08-.9-.08-2.76,0-5.55,1.93-6.89,5.38l-2.14,9.45c-.03.15-.13.22-.27.22h-2.58c-.17,0-.27-.1-.22-.27Z"/>
<path class="st0" d="M202.32,138.87l2.71-11.38,2.58-11.61c.03-.15.13-.22.27-.22h2.58c.17,0,.27.1.22.27l-3.08,12.9,7.81-6.85c.1-.07.18-.12.3-.12h3.32c.22,0,.28.15.12.3l-7.04,5.93,3.94,10.74c.05.18-.02.29-.2.29h-2.68c-.13,0-.22-.05-.27-.2l-.8-2.51-2.33-6.4-2.98,2.61-1.39,6.3c-.03.15-.13.22-.27.22h-2.58c-.17,0-.25-.1-.22-.27Z"/>
<path class="st0" d="M219.82,129.22c.92-4.04,3.95-7.71,8.98-7.71s7.96,3.69,6.8,9.15c-.02.13-.12.2-.25.2h-12.78c-.37,3.18.57,6.5,4.51,6.5,2.35,0,4.21-1.12,5.09-3.94.05-.13.15-.2.28-.2h2.41c.17,0,.25.12.2.29-1.17,3.99-4.07,5.96-8.01,5.96-6.68,0-8.38-5.39-7.24-10.25ZM232.79,128.82c.47-3-1.14-5.21-4.15-5.21-3.17,0-4.98,2.43-5.71,5.21h9.87Z"/>
<path class="st0" d="M240.64,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
<path class="st0" d="M251.75,138.85l2.04-8.65,1.83-8.13c.03-.15.13-.22.27-.22h2.65c.17,0,.25.1.22.27l-1.94,8.06-1.94,8.71c-.02.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.22-.27ZM256.2,119.55l.65-2.73c.03-.15.13-.22.27-.22h2.61c.17,0,.25.1.22.27l-.64,2.75c-.03.13-.13.22-.27.22h-2.63c-.17,0-.25-.12-.22-.28Z"/>
<path class="st0" d="M260.47,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M279.79,140.28c0-.15.1-.25.25-.25h2.58c.15,0,.23.08.25.25.03,2.09,1.71,3.28,4.04,3.28,3.1,0,4.71-1.78,5.63-5.71l.28-1.26c-1.34,1.56-3.12,2.58-5.31,2.58-5.06,0-6.85-4.72-5.7-9.68.99-4.31,3.89-7.89,8.34-7.89,2.88,0,4.54,1.44,5.26,3.54l.65-3.05c.03-.15.13-.22.27-.22h2.58c.17,0,.25.1.22.27l-2.04,8.63-1.61,7.12c-1.21,5.26-4,7.81-8.73,7.81-4.04,0-6.97-2.14-6.97-5.41ZM294.28,130.83c.82-3.6.13-7.12-3.59-7.12-3.23,0-5.08,2.7-5.83,5.93-.87,3.77-.02,7.42,3.54,7.42,3.02,0,5.09-2.78,5.88-6.23Z"/>
<path class="st0" d="M307.42,138.82l13.57-22.99c.07-.12.15-.17.28-.17h3.69c.15,0,.23.07.25.23l2.85,22.95c.02.17-.08.27-.23.27h-2.56c-.15,0-.23-.08-.25-.23l-.79-6.85h-9.53l-3.95,6.92c-.07.12-.15.17-.29.17h-2.88c-.18,0-.25-.12-.15-.3ZM323.99,129.52l-1.31-11.61-6.62,11.61h7.92Z"/>
<path class="st0" d="M332.8,132.49l.74-3.25,1.61-7.15c.03-.15.13-.22.27-.22h2.63c.17,0,.25.1.2.27l-1.64,6.82-.72,3.3c-.75,3.3.49,4.94,2.78,4.94,2.63,0,4.49-2.43,5.63-5.19l.72-3.08,1.51-6.84c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-1.84,7.57-2.08,9.2c-.02.15-.12.22-.25.22h-2.51c-.15,0-.25-.1-.2-.27l.69-2.76c-1.24,1.86-3.43,3.43-6.01,3.43-3.38,0-5.71-2.48-4.66-7.04Z"/>
<path class="st0" d="M353.75,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
<path class="st0" d="M365.46,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM378.51,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
<path class="st0" d="M384.76,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.19-1.84,3.22-3.43,5.71-3.43,2.18,0,3.85,1.17,4.31,3.45,1.27-1.84,3.42-3.45,5.93-3.45,3.25,0,5.55,2.45,4.51,7.04l-.75,3.25-1.61,7.15c-.02.15-.12.22-.25.22h-2.63c-.17,0-.25-.1-.22-.27l1.64-6.82.74-3.3c.79-3.47-.49-4.94-2.46-4.94-2.24,0-3.84,2.04-4.98,4.52-.02.25-.05.55-.12.84l-.6,2.6-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.15,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.77-3.47-.49-4.94-2.48-4.94-2.41,0-4.09,2.41-5.23,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M415.88,129.55c1-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM428.31,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
<path class="st0" d="M437.29,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
<path class="st0" d="M448.39,138.85l2.04-8.65,1.83-8.13c.03-.15.13-.22.27-.22h2.65c.17,0,.25.1.22.27l-1.94,8.06-1.94,8.71c-.02.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.22-.27ZM452.85,119.55l.65-2.73c.03-.15.13-.22.27-.22h2.61c.17,0,.25.1.22.27l-.64,2.75c-.03.13-.13.22-.27.22h-2.63c-.17,0-.25-.12-.22-.28Z"/>
<path class="st0" d="M458.71,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM471.76,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
<path class="st0" d="M478,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M506.33,138.85l2.81-11.76,2.5-11.21c.03-.15.13-.22.27-.22h7.04c5.68,0,8.44,3.43,7.37,8.13-.9,3.89-4.31,6.28-9.6,6.28h-5.23l-1.98,8.83c-.03.15-.13.22-.27.22h-2.7c-.17,0-.25-.1-.22-.27ZM516.95,127.74c3.6,0,5.7-1.62,6.3-4.22.7-3.08-.79-5.51-4.52-5.51h-4.41l-2.33,9.73h4.96Z"/>
<path class="st0" d="M527.7,138.85l2.78-11.61,2.53-11.36c.03-.15.13-.22.27-.22h2.56c.17,0,.25.1.22.27l-2.7,11.28-2.61,11.69c-.03.15-.13.22-.27.22h-2.56c-.17,0-.27-.1-.22-.27Z"/>
<path class="st0" d="M537.92,129.55c1-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM550.35,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
<path class="st0" d="M559.32,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
<path class="st0" d="M572.12,138.85l1.83-7.52,1.71-7.22h-2.45c-.17,0-.25-.1-.22-.29l.42-1.74c.03-.15.13-.22.27-.22h2.5l.37-1.56c.85-3.77,3.32-5.09,5.71-5.09.8,0,1.64.12,2.23.25.15.05.22.15.18.3l-.4,1.69c-.03.17-.13.22-.3.17-.45-.12-.97-.17-1.57-.17-1.36,0-2.4.85-2.9,3.08l-.32,1.32h3.79c.17,0,.25.1.22.27l-.4,1.76c-.03.13-.13.22-.27.22h-3.85l-1.66,7.22-1.64,7.57c-.03.15-.13.22-.27.22h-2.75c-.17,0-.25-.1-.22-.27Z"/>
<path class="st0" d="M584.65,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM597.7,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
<path class="st0" d="M603.95,138.85l2.04-8.54,1.83-8.23c.03-.15.13-.22.27-.22h2.56c.15,0,.25.1.2.27l-.79,3.07c1.74-2.29,4.07-3.5,6.42-3.5.37,0,.72.03,1.02.12.15.03.2.13.17.28l-.44,1.88c-.03.15-.13.2-.29.18-.27-.07-.59-.08-.9-.08-2.76,0-5.55,1.93-6.89,5.38l-2.14,9.45c-.03.15-.13.22-.27.22h-2.58c-.17,0-.27-.1-.22-.27Z"/>
<path class="st0" d="M618.42,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.19-1.84,3.22-3.43,5.71-3.43,2.18,0,3.85,1.17,4.31,3.45,1.27-1.84,3.42-3.45,5.93-3.45,3.25,0,5.55,2.45,4.51,7.04l-.75,3.25-1.61,7.15c-.02.15-.12.22-.25.22h-2.63c-.17,0-.25-.1-.22-.27l1.64-6.82.74-3.3c.79-3.47-.49-4.94-2.46-4.94-2.25,0-3.84,2.04-4.98,4.52-.02.25-.05.55-.12.84l-.6,2.6-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.15,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.77-3.47-.49-4.94-2.48-4.94-2.41,0-4.09,2.41-5.23,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,244 @@
{
"status": "completed",
"image_list": [
"https://ldb-phinf.pstatic.net/20260112_189/1768176025117RmItD_PNG/ChatGPT_Image_2026%B3%E2_1%BF%F9_9%C0%CF_%BF%C0%C8%C4_03_21_56.png",
"https://ldb-phinf.pstatic.net/20251215_290/1765776039787CALYj_JPEG/IMG_0060.JPG",
"https://ldb-phinf.pstatic.net/20251215_70/1765776016674Nbo0Q_JPEG/IMG_0067_%BA%B9%BB%E7.jpg",
"https://ldb-phinf.pstatic.net/20251215_136/17657760168457kDf6_JPEG/KakaoTalk_20251214_181208552_03_%BA%B9%BB%E7.jpg",
"https://ldb-phinf.pstatic.net/20251215_38/1765776039760cYgOj_JPEG/IMG_0044.JPG",
"https://ldb-phinf.pstatic.net/20251128_248/1764320049551B1gHl_JPEG/KakaoTalk_20251128_174635084_27.jpg",
"https://ldb-phinf.pstatic.net/20251128_209/1764320105053L8V2f_JPEG/KakaoTalk_20251128_174635084_17.jpg",
"https://ldb-phinf.pstatic.net/20241205_181/1733360083850LsEVv_JPEG/KakaoTalk_20241203_155353799_09.jpg",
"https://ldb-phinf.pstatic.net/20251128_209/1764320017959Rh3vs_JPEG/KakaoTalk_20251128_174635084_13.jpg",
"https://ldb-phinf.pstatic.net/20251128_102/1764320169035vluJN_JPEG/KakaoTalk_20251128_174635084_12.jpg",
"https://ldb-phinf.pstatic.net/20241129_249/17328584834569zWJT_JPEG/6S5A0642-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241129_196/1732858501391FjLpd_JPEG/6S5A0657.jpg",
"https://ldb-phinf.pstatic.net/20241202_230/1733105198846QGbcW_JPEG/8.jpg",
"https://ldb-phinf.pstatic.net/20241202_94/1733105214373Q8ncR_JPEG/%B5%BF%B1%D7%B6%F3%B9%CC_%C3%A2%B9%AE_%B9%E6%B0%A1%C0%BB_2.jpg",
"https://ldb-phinf.pstatic.net/20231207_110/1701910802632rDM5r_JPEG/a_%28301%29-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_7/1733106037765MNR8e_JPEG/9-1.jpg",
"https://ldb-phinf.pstatic.net/20240118_42/17055625561156LIx7_JPEG/6S5A0809-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_246/1705562556109bihHB_JPEG/6S5A1820-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_259/1733104881690lDgnH_JPEG/3-2.jpg",
"https://ldb-phinf.pstatic.net/20231207_76/1701910651888PpVap_JPEG/%C6%EA%B5%E5%B6%F3%C0%CC%B7%EB.jpg",
"https://ldb-phinf.pstatic.net/20231207_242/1701910651863IAAlE_JPEG/%B0%AD%BE%C6%C1%F6_%BA%F1%C7%B0.jpg",
"https://ldb-phinf.pstatic.net/20241202_120/17331038087267aQ0S_JPEG/KakaoTalk_20241015_154536888_04.jpg",
"https://ldb-phinf.pstatic.net/20250710_164/17521338301365LRwl_JPEG/6S5A6748-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_169/1752133830016fqFlL_JPEG/6S5A6760-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_80/1701920469022ef7TW_JPEG/6S5A0614-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_140/17173835278369nntD_JPEG/6S5A6707.jpg",
"https://ldb-phinf.pstatic.net/20250710_173/1752133829861tPALd_JPEG/6S5A7136.jpg",
"https://ldb-phinf.pstatic.net/20241202_248/1733106037776BGjJx_JPEG/1-1.jpg",
"https://ldb-phinf.pstatic.net/20240603_275/1717383527874oJOpz_JPEG/DJI_0040-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_153/1752133811173ba7oE_JPEG/6S5A6458.jpg",
"https://ldb-phinf.pstatic.net/20250710_185/1752133829868FyD76_JPEG/6S5A6815.jpg",
"https://ldb-phinf.pstatic.net/20250710_220/1752133829963w1aee_JPEG/6S5A6856.jpg",
"https://ldb-phinf.pstatic.net/20250710_228/1752133830002U65jg_JPEG/6S5A6864.jpg",
"https://ldb-phinf.pstatic.net/20250710_178/1752133829793fQQ2E_JPEG/6S5A7016-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_279/1752133829838lTSC9_JPEG/6S5A7034-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_179/1752133811452tOl7F_JPEG/6S5A6363.jpg",
"https://ldb-phinf.pstatic.net/20250710_75/1752133811152vd5ts_JPEG/6S5A6513.jpg",
"https://ldb-phinf.pstatic.net/20250710_147/1752133810693TfR5M_JPEG/6S5A6556.jpg",
"https://ldb-phinf.pstatic.net/20250710_202/1752133811177NRBDn_JPEG/6S5A6619-2.jpg",
"https://ldb-phinf.pstatic.net/20250710_196/1752133811066ufvpA_JPEG/6S5A6657.jpg",
"https://ldb-phinf.pstatic.net/20250710_280/1752133811307QCs3n_JPEG/6S5A6709.jpg",
"https://ldb-phinf.pstatic.net/20240603_85/1717383527884ST2DF_JPEG/6S5A6540.jpg",
"https://ldb-phinf.pstatic.net/20240603_249/1717383528285Om6dI_JPEG/6S5A6648.jpg",
"https://ldb-phinf.pstatic.net/20240603_288/1717383528022TxA4c_JPEG/6S5A6785.jpg",
"https://ldb-phinf.pstatic.net/20240909_284/1725865224424ux2W4_JPEG/6S5A9299-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241129_87/1732858513628bWPt7_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_50/1733105457578cGfYD_JPEG/6S5A9807-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_100/1717383527950q4yNf_JPEG/6S5A7056.jpg",
"https://ldb-phinf.pstatic.net/20240603_221/1717383527791s4ziP_JPEG/6S5A6673.jpg",
"https://ldb-phinf.pstatic.net/20241202_134/17331166295182o4vs_JPEG/47%C6%F2_%2829%29.jpg",
"https://ldb-phinf.pstatic.net/20241202_249/17331060371685uFQi_JPEG/3-1_%281%29.jpg",
"https://ldb-phinf.pstatic.net/20240909_238/1725865224397wnWE5_JPEG/DJI_0157.jpg",
"https://ldb-phinf.pstatic.net/20231206_34/1701833355386Nsg5j_JPEG/6S5A9995.jpg",
"https://ldb-phinf.pstatic.net/20241202_293/1733105206236d8l2f_JPEG/8-1.jpg",
"https://ldb-phinf.pstatic.net/20241202_224/1733105239528RLN59_JPEG/3-1-min.jpg",
"https://ldb-phinf.pstatic.net/20241202_31/1733116629874BYgpO_JPEG/47%C6%F2_%2823%29.jpg",
"https://ldb-phinf.pstatic.net/20241205_106/1733360083918ER1RI_JPEG/KakaoTalk_20241203_155353799_27.jpg",
"https://ldb-phinf.pstatic.net/20241129_126/17328584834200qVDw_JPEG/6S5A0645.jpg",
"https://ldb-phinf.pstatic.net/20240909_35/1725865128231ar2eM_PNG/KakaoTalk_20240710_163045662.png",
"https://ldb-phinf.pstatic.net/20241202_153/1733105180908PyVIC_JPEG/KakaoTalk_20231027_143438563_25-min.jpg",
"https://ldb-phinf.pstatic.net/20241129_128/1732858517984PFmrJ_JPEG/KakaoTalk_20241119_105023750.jpg",
"https://ldb-phinf.pstatic.net/20231207_124/1701910720268qeACJ_JPEG/%B9%D9%BA%A3%C5%A5.jpg",
"https://ldb-phinf.pstatic.net/20241202_55/173310545746333mf8_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_11/1733106430749ovoeY_JPEG/37%C6%F2_%2820%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_185/1701910772479mexsh_JPEG/a_%28282%29.jpg",
"https://ldb-phinf.pstatic.net/20240118_211/17055625561294XsN2_JPEG/6S5A1803-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_156/17055625560847R7SL_JPEG/6S5A0934-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_179/17019107724184CCEU_JPEG/6S5A9906.jpg",
"https://ldb-phinf.pstatic.net/20231207_287/1701910772339cG7K0_JPEG/a_%28119%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_40/1701910675360dvC1i_JPEG/%C8%AD%C0%E5%BD%C7_%281%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_263/1701910675262pevKk_JPEG/%C8%AD%C0%E5%BD%C7_%284%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_73/1701910651864xPI48_JPEG/%BB%E7%B6%F7_%BC%A4%C7%AA.jpg",
"https://ldb-phinf.pstatic.net/20231207_265/17019204486629hzae_JPEG/6S5A0645.jpg",
"https://ldb-phinf.pstatic.net/20241202_147/1733103809000xsMrG_JPEG/KakaoTalk_20241015_154532027_01.jpg",
"https://ldb-phinf.pstatic.net/20241202_77/1733103809037HSO4S_JPEG/KakaoTalk_20241015_154532027_02.jpg",
"https://ldb-phinf.pstatic.net/20241202_233/1733103809020IMEdL_JPEG/KakaoTalk_20241015_154532027_06.jpg",
"https://ldb-phinf.pstatic.net/20241202_53/1733103808802Wui9x_JPEG/KakaoTalk_20241015_154532027_04.jpg",
"https://ldb-phinf.pstatic.net/20241202_230/1733103808998dOq0b_JPEG/KakaoTalk_20241015_154532027_15.jpg",
"https://ldb-phinf.pstatic.net/20241202_122/1733103808509lOt9x_JPEG/KakaoTalk_20241015_154536888_13.jpg",
"https://ldb-phinf.pstatic.net/20241202_246/1733103809045RdnEP_JPEG/KakaoTalk_20241015_154536888_11.jpg",
"https://ldb-phinf.pstatic.net/20241202_287/1733103808656oB1xz_JPEG/KakaoTalk_20241015_154536888_06.jpg",
"https://ldb-phinf.pstatic.net/20241202_222/1733103808508hkf5K_JPEG/KakaoTalk_20241015_154536888_08.jpg",
"https://ldb-phinf.pstatic.net/20241202_229/1733103808818KGAps_JPEG/KakaoTalk_20241015_154536888_02.jpg",
"https://ldb-phinf.pstatic.net/20241202_12/1733103808751cVAsT_JPEG/KakaoTalk_20241015_154536888_03.jpg",
"https://ldb-phinf.pstatic.net/20240909_261/1725865224473Uo7V9_JPEG/6S5A9371-%C6%ED%C1%FD-%C6%ED%C1%FD-2.jpg",
"https://ldb-phinf.pstatic.net/20240909_22/1725865224495AL798_JPEG/6S5A9557-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_177/1725865224462eX1Pi_JPEG/DJI_0149-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_165/1725865224480S7c1j_JPEG/6S5A9468-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_85/1725865224421CNRrk_JPEG/DJI_0155.jpg",
"https://ldb-phinf.pstatic.net/20240909_62/1725865224361fP2d2_JPEG/6S5A9434.jpg",
"https://ldb-phinf.pstatic.net/20240909_155/1725865224363Xxt8y_JPEG/6S5A9431-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_13/1725865224457BGLGs_JPEG/6S5A9456.jpg",
"https://ldb-phinf.pstatic.net/20240603_162/1717383527882Vnrsk_JPEG/6S5A6786.jpg",
"https://ldb-phinf.pstatic.net/20240603_256/1717383527892WXOKA_JPEG/6S5A6896.jpg",
"https://ldb-phinf.pstatic.net/20240603_130/1717383527800oHSKA_JPEG/6S5A6904.jpg",
"https://ldb-phinf.pstatic.net/20240603_118/17173835277794IXlt_JPEG/6S5A6946.jpg",
"https://ldb-phinf.pstatic.net/20240603_257/1717383527822ITvHM_JPEG/6S5A6973.jpg",
"https://ldb-phinf.pstatic.net/20240603_170/1717383527880sTdFE_JPEG/DJI_0039-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_10/1717383527863CVLJ2_JPEG/DJI_0087-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_94/1717383527921sDeDB_JPEG/DJI_0091-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231206_81/1701833426718D5Qwk_JPEG/b_%28119%29-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_153/17055625561274LAs5_JPEG/6S5A1460-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_8/1705562556139zGkjb_JPEG/6S5A1780.jpg",
"https://ldb-phinf.pstatic.net/20240118_57/1705562556129IR40O_JPEG/6S5A1199.jpg",
"https://ldb-phinf.pstatic.net/20240118_260/1705562556111cA7PG_JPEG/6S5A1927-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_183/1701910720221oI66j_JPEG/%B9%D9%BA%A3%C5%A52.jpg",
"https://ldb-phinf.pstatic.net/20231207_247/1701910720113MBgKD_JPEG/%B9%D9%BA%A3%C5%A5_%282%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_227/1701910720098FE7Si_JPEG/6S5A0001.jpg",
"https://ldb-phinf.pstatic.net/20231206_24/1701833408064fgoVj_JPEG/b_%2874%29.jpg",
"https://ldb-phinf.pstatic.net/20240603_100/1717383527883LWVq4_JPEG/6S5A6022-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_169/17173835278856AEUV_JPEG/6S5A5974-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_155/1717383527937gxiyc_JPEG/6S5A5988.jpg",
"https://ldb-phinf.pstatic.net/20231206_160/170183333003287LrV_JPEG/6S5A0243-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_217/1717383528190t41X5_JPEG/6S5A6027.jpg",
"https://ldb-phinf.pstatic.net/20240603_142/1717383527892jHtgI_JPEG/6S5A6643.jpg",
"https://ldb-phinf.pstatic.net/20240603_251/1717383527948jTN2a_JPEG/6S5A6125.jpg",
"https://ldb-phinf.pstatic.net/20240603_189/1717383527880wu4Bv_JPEG/6S5A6326.jpg",
"https://ldb-phinf.pstatic.net/20240603_14/1717383527794qCuuu_JPEG/6S5A6337.jpg",
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
],
"image_count": 119,
"m_id": 1,
"processed_info": {
"customer_name": "풀스테이 스테이펫 홍천",
"region": "",
"detail_region_info": "강원 홍천군 화촌면 답연밭길 5-2 스테이펫 홍천"
},
"marketing_analysis": {
"brand_identity": {
"location_feature_analysis": "강원 홍천 화촌면의 자연 친화적 입지는 도심 소음에서 벗어난 ‘조용한 숲세권’ 체류 니즈에 최적이며, 수도권에서 주말 이동이 가능한 거리감으로 숏브레이크 수요를 흡수하기 유리합니다.",
"concept_scalability": "‘스테이펫’ 콘셉트는 반려동물 동반 숙박을 넘어 펫 어메니티·펫 포토·펫 웰니스(산책 코스/케어 서비스)까지 확장 가능해, 체류 전 과정의 경험 가치를 패키지화하기 좋습니다."
},
"market_positioning": {
"category_definition": "자연 속 프라이빗 애견 동반 힐링 스테이",
"core_value": "반려동물과 ‘눈치 없이’ 온전히 쉬는 프라이빗한 쉼"
},
"target_persona": [
{
"persona": "주말 펫캉스 커플(반려견 1마리) 조용한 자연에서 둘+한 마리의 리셋 여행을 찾는 2030",
"age": {
"min_age": 27,
"max_age": 39
},
"favor_target": [
"반려견 동반 가능 숙소",
"자연 뷰/산책하기 좋은 동선",
"청결한 침구와 탈취 관리",
"프라이빗한 공간 구성",
"감성 사진이 잘 나오는 무드"
],
"decision_trigger": "반려견 동반 + 프라이빗하게 쉴 수 있다는 확신(후기/시설 안내)"
},
{
"persona": "도심 탈출 1인/2인 힐링러 조용한 곳에서 휴식·독서를 즐기며 재충전하는 직장인",
"age": {
"min_age": 29,
"max_age": 45
},
"favor_target": [
"소음 적은 입지",
"자연 속 뷰",
"편안한 침대/휴식형 라운지",
"야간 감성 조명",
"체크인/이용 동선의 간편함"
],
"decision_trigger": "‘조용함’과 ‘자연 몰입’이 보장되는 입지/분위기"
},
{
"persona": "가족형 펫오너 반려견과 함께 안전하고 편하게 머물며 추억을 만드는 3040",
"age": {
"min_age": 33,
"max_age": 49
},
"favor_target": [
"반려견 안전 동선(미끄럼/울타리 등)",
"넓은 휴식 공간",
"바베큐 등 가족형 액티비티",
"주차/이동 편의",
"위생/청결 신뢰"
],
"decision_trigger": "가족 단위도 불편 없는 공간감 + 펫 프렌들리 정책"
}
],
"selling_points": [
{
"english_category": "PET FRIENDLY",
"korean_category": "애견 동반",
"description": "반려견과 함께하는 완전한 휴식",
"score": 95
},
{
"english_category": "HEALING",
"korean_category": "힐링 요소",
"description": "홍천 자연 속 리셋 스테이",
"score": 90
},
{
"english_category": "PRIVACY",
"korean_category": "프라이버시",
"description": "눈치 없이 즐기는 프라이빗 쉼",
"score": 88
},
{
"english_category": "SHORT GETAWAY",
"korean_category": "숏브레이크",
"description": "주말에 딱, 가볍게 떠나는 홍천",
"score": 82
},
{
"english_category": "NIGHT MOOD",
"korean_category": "야간 감성",
"description": "밤이 더 예쁜 감성 무드",
"score": 78
},
{
"english_category": "BBQ PARTY",
"korean_category": "바베큐",
"description": "자연을 곁들인 바베큐 한 판",
"score": 75
},
{
"english_category": "PHOTO SPOT",
"korean_category": "포토 스팟",
"description": "반려견 인생샷 남기는 공간",
"score": 73
}
],
"target_keywords": [
"홍천애견동반숙소",
"강원도애견동반펜션",
"스테이펫",
"펫캉스",
"반려견동반여행",
"홍천감성숙소",
"강원도힐링스테이",
"숲뷰숙소",
"주말여행추천",
"프라이빗스테이"
]
}
}

View File

@ -0,0 +1,244 @@
{
"status": "completed",
"image_list": [
"https://ldb-phinf.pstatic.net/20260112_189/1768176025117RmItD_PNG/ChatGPT_Image_2026%B3%E2_1%BF%F9_9%C0%CF_%BF%C0%C8%C4_03_21_56.png",
"https://ldb-phinf.pstatic.net/20251215_290/1765776039787CALYj_JPEG/IMG_0060.JPG",
"https://ldb-phinf.pstatic.net/20251215_70/1765776016674Nbo0Q_JPEG/IMG_0067_%BA%B9%BB%E7.jpg",
"https://ldb-phinf.pstatic.net/20251215_136/17657760168457kDf6_JPEG/KakaoTalk_20251214_181208552_03_%BA%B9%BB%E7.jpg",
"https://ldb-phinf.pstatic.net/20251215_38/1765776039760cYgOj_JPEG/IMG_0044.JPG",
"https://ldb-phinf.pstatic.net/20251128_248/1764320049551B1gHl_JPEG/KakaoTalk_20251128_174635084_27.jpg",
"https://ldb-phinf.pstatic.net/20251128_209/1764320105053L8V2f_JPEG/KakaoTalk_20251128_174635084_17.jpg",
"https://ldb-phinf.pstatic.net/20241205_181/1733360083850LsEVv_JPEG/KakaoTalk_20241203_155353799_09.jpg",
"https://ldb-phinf.pstatic.net/20251128_209/1764320017959Rh3vs_JPEG/KakaoTalk_20251128_174635084_13.jpg",
"https://ldb-phinf.pstatic.net/20251128_102/1764320169035vluJN_JPEG/KakaoTalk_20251128_174635084_12.jpg",
"https://ldb-phinf.pstatic.net/20241129_249/17328584834569zWJT_JPEG/6S5A0642-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241129_196/1732858501391FjLpd_JPEG/6S5A0657.jpg",
"https://ldb-phinf.pstatic.net/20241202_230/1733105198846QGbcW_JPEG/8.jpg",
"https://ldb-phinf.pstatic.net/20241202_94/1733105214373Q8ncR_JPEG/%B5%BF%B1%D7%B6%F3%B9%CC_%C3%A2%B9%AE_%B9%E6%B0%A1%C0%BB_2.jpg",
"https://ldb-phinf.pstatic.net/20231207_110/1701910802632rDM5r_JPEG/a_%28301%29-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_7/1733106037765MNR8e_JPEG/9-1.jpg",
"https://ldb-phinf.pstatic.net/20240118_42/17055625561156LIx7_JPEG/6S5A0809-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_246/1705562556109bihHB_JPEG/6S5A1820-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_259/1733104881690lDgnH_JPEG/3-2.jpg",
"https://ldb-phinf.pstatic.net/20231207_76/1701910651888PpVap_JPEG/%C6%EA%B5%E5%B6%F3%C0%CC%B7%EB.jpg",
"https://ldb-phinf.pstatic.net/20231207_242/1701910651863IAAlE_JPEG/%B0%AD%BE%C6%C1%F6_%BA%F1%C7%B0.jpg",
"https://ldb-phinf.pstatic.net/20241202_120/17331038087267aQ0S_JPEG/KakaoTalk_20241015_154536888_04.jpg",
"https://ldb-phinf.pstatic.net/20250710_164/17521338301365LRwl_JPEG/6S5A6748-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_169/1752133830016fqFlL_JPEG/6S5A6760-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_80/1701920469022ef7TW_JPEG/6S5A0614-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_140/17173835278369nntD_JPEG/6S5A6707.jpg",
"https://ldb-phinf.pstatic.net/20250710_173/1752133829861tPALd_JPEG/6S5A7136.jpg",
"https://ldb-phinf.pstatic.net/20241202_248/1733106037776BGjJx_JPEG/1-1.jpg",
"https://ldb-phinf.pstatic.net/20240603_275/1717383527874oJOpz_JPEG/DJI_0040-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_153/1752133811173ba7oE_JPEG/6S5A6458.jpg",
"https://ldb-phinf.pstatic.net/20250710_185/1752133829868FyD76_JPEG/6S5A6815.jpg",
"https://ldb-phinf.pstatic.net/20250710_220/1752133829963w1aee_JPEG/6S5A6856.jpg",
"https://ldb-phinf.pstatic.net/20250710_228/1752133830002U65jg_JPEG/6S5A6864.jpg",
"https://ldb-phinf.pstatic.net/20250710_178/1752133829793fQQ2E_JPEG/6S5A7016-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_279/1752133829838lTSC9_JPEG/6S5A7034-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_179/1752133811452tOl7F_JPEG/6S5A6363.jpg",
"https://ldb-phinf.pstatic.net/20250710_75/1752133811152vd5ts_JPEG/6S5A6513.jpg",
"https://ldb-phinf.pstatic.net/20250710_147/1752133810693TfR5M_JPEG/6S5A6556.jpg",
"https://ldb-phinf.pstatic.net/20250710_202/1752133811177NRBDn_JPEG/6S5A6619-2.jpg",
"https://ldb-phinf.pstatic.net/20250710_196/1752133811066ufvpA_JPEG/6S5A6657.jpg",
"https://ldb-phinf.pstatic.net/20250710_280/1752133811307QCs3n_JPEG/6S5A6709.jpg",
"https://ldb-phinf.pstatic.net/20240603_85/1717383527884ST2DF_JPEG/6S5A6540.jpg",
"https://ldb-phinf.pstatic.net/20240603_249/1717383528285Om6dI_JPEG/6S5A6648.jpg",
"https://ldb-phinf.pstatic.net/20240603_288/1717383528022TxA4c_JPEG/6S5A6785.jpg",
"https://ldb-phinf.pstatic.net/20240909_284/1725865224424ux2W4_JPEG/6S5A9299-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241129_87/1732858513628bWPt7_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_50/1733105457578cGfYD_JPEG/6S5A9807-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_100/1717383527950q4yNf_JPEG/6S5A7056.jpg",
"https://ldb-phinf.pstatic.net/20240603_221/1717383527791s4ziP_JPEG/6S5A6673.jpg",
"https://ldb-phinf.pstatic.net/20241202_134/17331166295182o4vs_JPEG/47%C6%F2_%2829%29.jpg",
"https://ldb-phinf.pstatic.net/20241202_249/17331060371685uFQi_JPEG/3-1_%281%29.jpg",
"https://ldb-phinf.pstatic.net/20240909_238/1725865224397wnWE5_JPEG/DJI_0157.jpg",
"https://ldb-phinf.pstatic.net/20231206_34/1701833355386Nsg5j_JPEG/6S5A9995.jpg",
"https://ldb-phinf.pstatic.net/20241202_293/1733105206236d8l2f_JPEG/8-1.jpg",
"https://ldb-phinf.pstatic.net/20241202_224/1733105239528RLN59_JPEG/3-1-min.jpg",
"https://ldb-phinf.pstatic.net/20241202_31/1733116629874BYgpO_JPEG/47%C6%F2_%2823%29.jpg",
"https://ldb-phinf.pstatic.net/20241205_106/1733360083918ER1RI_JPEG/KakaoTalk_20241203_155353799_27.jpg",
"https://ldb-phinf.pstatic.net/20241129_126/17328584834200qVDw_JPEG/6S5A0645.jpg",
"https://ldb-phinf.pstatic.net/20240909_35/1725865128231ar2eM_PNG/KakaoTalk_20240710_163045662.png",
"https://ldb-phinf.pstatic.net/20241202_153/1733105180908PyVIC_JPEG/KakaoTalk_20231027_143438563_25-min.jpg",
"https://ldb-phinf.pstatic.net/20241129_128/1732858517984PFmrJ_JPEG/KakaoTalk_20241119_105023750.jpg",
"https://ldb-phinf.pstatic.net/20231207_124/1701910720268qeACJ_JPEG/%B9%D9%BA%A3%C5%A7.jpg",
"https://ldb-phinf.pstatic.net/20241202_55/173310545746333mf8_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_11/1733106430749ovoeY_JPEG/37%C6%F2_%2820%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_185/1701910772479mexsh_JPEG/a_%28282%29.jpg",
"https://ldb-phinf.pstatic.net/20240118_211/17055625561294XsN2_JPEG/6S5A1803-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_156/17055625560847R7SL_JPEG/6S5A0934-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_179/17019107724184CCEU_JPEG/6S5A9906.jpg",
"https://ldb-phinf.pstatic.net/20231207_287/1701910772339cG7K0_JPEG/a_%28119%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_40/1701910675360dvC1i_JPEG/%C8%AD%C0%E5%BD%C7_%281%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_263/1701910675262pevKk_JPEG/%C8%AD%C0%E5%BD%C7_%284%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_73/1701910651864xPI48_JPEG/%BB%E7%B6%F7_%BC%A4%C7%AA.jpg",
"https://ldb-phinf.pstatic.net/20231207_265/17019204486629hzae_JPEG/6S5A0645.jpg",
"https://ldb-phinf.pstatic.net/20241202_147/1733103809000xsMrG_JPEG/KakaoTalk_20241015_154532027_01.jpg",
"https://ldb-phinf.pstatic.net/20241202_77/1733103809037HSO4S_JPEG/KakaoTalk_20241015_154532027_02.jpg",
"https://ldb-phinf.pstatic.net/20241202_233/1733103809020IMEdL_JPEG/KakaoTalk_20241015_154532027_06.jpg",
"https://ldb-phinf.pstatic.net/20241202_53/1733103808802Wui9x_JPEG/KakaoTalk_20241015_154532027_04.jpg",
"https://ldb-phinf.pstatic.net/20241202_230/1733103808998dOq0b_JPEG/KakaoTalk_20241015_154532027_15.jpg",
"https://ldb-phinf.pstatic.net/20241202_122/1733103808509lOt9x_JPEG/KakaoTalk_20241015_154536888_13.jpg",
"https://ldb-phinf.pstatic.net/20241202_246/1733103809045RdnEP_JPEG/KakaoTalk_20241015_154536888_11.jpg",
"https://ldb-phinf.pstatic.net/20241202_287/1733103808656oB1xz_JPEG/KakaoTalk_20241015_154536888_06.jpg",
"https://ldb-phinf.pstatic.net/20241202_222/1733103808508hkf5K_JPEG/KakaoTalk_20241015_154536888_08.jpg",
"https://ldb-phinf.pstatic.net/20241202_229/1733103808818KGAps_JPEG/KakaoTalk_20241015_154536888_02.jpg",
"https://ldb-phinf.pstatic.net/20241202_12/1733103808751cVAsT_JPEG/KakaoTalk_20241015_154536888_03.jpg",
"https://ldb-phinf.pstatic.net/20240909_261/1725865224473Uo7V9_JPEG/6S5A9371-%C6%ED%C1%FD-%C6%ED%C1%FD-2.jpg",
"https://ldb-phinf.pstatic.net/20240909_22/1725865224495AL798_JPEG/6S5A9557-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_177/1725865224462eX1Pi_JPEG/DJI_0149-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_165/1725865224480S7c1j_JPEG/6S5A9468-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_85/1725865224421CNRrk_JPEG/DJI_0155.jpg",
"https://ldb-phinf.pstatic.net/20240909_62/1725865224361fP2d2_JPEG/6S5A9434.jpg",
"https://ldb-phinf.pstatic.net/20240909_155/1725865224363Xxt8y_JPEG/6S5A9431-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_13/1725865224457BGLGs_JPEG/6S5A9456.jpg",
"https://ldb-phinf.pstatic.net/20240603_162/1717383527882Vnrsk_JPEG/6S5A6786.jpg",
"https://ldb-phinf.pstatic.net/20240603_256/1717383527892WXOKA_JPEG/6S5A6896.jpg",
"https://ldb-phinf.pstatic.net/20240603_130/1717383527800oHSKA_JPEG/6S5A6904.jpg",
"https://ldb-phinf.pstatic.net/20240603_118/17173835277794IXlt_JPEG/6S5A6946.jpg",
"https://ldb-phinf.pstatic.net/20240603_257/1717383527822ITvHM_JPEG/6S5A6973.jpg",
"https://ldb-phinf.pstatic.net/20240603_170/1717383527880sTdFE_JPEG/DJI_0039-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_10/1717383527863CVLJ2_JPEG/DJI_0087-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_94/1717383527921sDeDB_JPEG/DJI_0091-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231206_81/1701833426718D5Qwk_JPEG/b_%28119%29-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_153/17055625561274LAs5_JPEG/6S5A1460-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_8/1705562556139zGkjb_JPEG/6S5A1780.jpg",
"https://ldb-phinf.pstatic.net/20240118_57/1705562556129IR40O_JPEG/6S5A1199.jpg",
"https://ldb-phinf.pstatic.net/20240118_260/1705562556111cA7PG_JPEG/6S5A1927-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_183/1701910720221oI66j_JPEG/%B9%D9%BA%A3%C5%A52.jpg",
"https://ldb-phinf.pstatic.net/20231207_247/1701910720113MBgKD_JPEG/%B9%D9%BA%A3%C5%A5_%282%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_227/1701910720098FE7Si_JPEG/6S5A0001.jpg",
"https://ldb-phinf.pstatic.net/20231206_24/1701833408064fgoVj_JPEG/b_%2874%29.jpg",
"https://ldb-phinf.pstatic.net/20240603_100/1717383527883LWVq4_JPEG/6S5A6022-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_169/17173835278856AEUV_JPEG/6S5A5974-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_155/1717383527937gxiyc_JPEG/6S5A5988.jpg",
"https://ldb-phinf.pstatic.net/20231206_160/170183333003287LrV_JPEG/6S5A0243-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_217/1717383528190t41X5_JPEG/6S5A6027.jpg",
"https://ldb-phinf.pstatic.net/20240603_142/1717383527892jHtgI_JPEG/6S5A6643.jpg",
"https://ldb-phinf.pstatic.net/20240603_251/1717383527948jTN2a_JPEG/6S5A6125.jpg",
"https://ldb-phinf.pstatic.net/20240603_189/1717383527880wu4Bv_JPEG/6S5A6326.jpg",
"https://ldb-phinf.pstatic.net/20240603_14/1717383527794qCuuu_JPEG/6S5A6337.jpg",
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
],
"image_count": 119,
"m_id": 1,
"processed_info": {
"customer_name": "Fullstay StayPet Hongcheon",
"region": "",
"detail_region_info": "5-2 Dapyeonbat-gil, Hwachon-myeon, Hongcheon-gun, Gangwon-do, Korea"
},
"marketing_analysis": {
"brand_identity": {
"location_feature_analysis": "Located in the nature-rich Hwachon-myeon area of Hongcheon, Gangwon-do, this property offers the ideal setting for a 'quiet forest retreat' away from urban noise. Its proximity to the Seoul metropolitan area makes it perfect for weekend short-break getaways.",
"concept_scalability": "The 'StayPet' concept extends beyond simple pet-friendly accommodation to include pet amenities, pet photo zones, and pet wellness experiences (walking trails/care services), enabling a comprehensive packaged experience throughout the entire stay."
},
"market_positioning": {
"category_definition": "Private Pet-Friendly Healing Stay in Nature",
"core_value": "A private retreat where you and your pet can relax without worry"
},
"target_persona": [
{
"persona": "Weekend Pet-cation Couples (with 1 dog) Millennials seeking a reset trip for two + one furry friend in peaceful nature",
"age": {
"min_age": 27,
"max_age": 39
},
"favor_target": [
"Pet-friendly accommodation",
"Nature views / great walking trails",
"Clean bedding and odor management",
"Private space configuration",
"Aesthetic mood for great photos"
],
"decision_trigger": "Confidence in pet-friendly + private relaxation (verified through reviews and facility info)"
},
{
"persona": "Urban Escape Solo/Duo Healers Working professionals recharging with quiet rest and reading in nature",
"age": {
"min_age": 29,
"max_age": 45
},
"favor_target": [
"Low-noise location",
"Nature views",
"Comfortable beds / relaxation lounge",
"Ambient night lighting",
"Simple check-in / easy access"
],
"decision_trigger": "Guaranteed 'quietness' and 'nature immersion' through location and atmosphere"
},
{
"persona": "Family Pet Owners Families in their 30s-40s creating memories with their pets in a safe, comfortable stay",
"age": {
"min_age": 33,
"max_age": 49
},
"favor_target": [
"Pet safety features (non-slip floors / fencing)",
"Spacious relaxation areas",
"Family activities like BBQ",
"Parking / travel convenience",
"Hygiene / cleanliness assurance"
],
"decision_trigger": "Spacious enough for families + reliable pet-friendly policies"
}
],
"selling_points": [
{
"english_category": "PET FRIENDLY",
"korean_category": "Pet-Friendly",
"description": "A complete retreat with your beloved pet",
"score": 95
},
{
"english_category": "HEALING",
"korean_category": "Healing",
"description": "A reset stay in Hongcheon's natural surroundings",
"score": 90
},
{
"english_category": "PRIVACY",
"korean_category": "Privacy",
"description": "A private retreat — relax without a care",
"score": 88
},
{
"english_category": "SHORT GETAWAY",
"korean_category": "Short Getaway",
"description": "A quick weekend escape to Hongcheon",
"score": 82
},
{
"english_category": "NIGHT MOOD",
"korean_category": "Night Mood",
"description": "An aesthetic ambiance that shines at night",
"score": 78
},
{
"english_category": "BBQ PARTY",
"korean_category": "BBQ Party",
"description": "A BBQ feast surrounded by nature",
"score": 75
},
{
"english_category": "PHOTO SPOT",
"korean_category": "Photo Spot",
"description": "The perfect spot for unforgettable pet photos",
"score": 73
}
],
"target_keywords": [
"Hongcheon pet-friendly stay",
"Gangwon-do pet pension",
"StayPet",
"Pet-cation",
"Travel with pets",
"Hongcheon aesthetic stay",
"Gangwon healing retreat",
"Forest view accommodation",
"Weekend trip recommendation",
"Private stay"
]
}
}

View File

@ -1,92 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>개인정보처리방침 - ADO2</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
h2 { font-size: 19px; margin-top: 40px; color: #111; }
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
ul { padding-left: 20px; }
.google-policy { background: #f0f7ff; border-left: 4px solid #4285f4; padding: 16px 20px; margin: 24px 0; border-radius: 4px; }
.google-policy p { color: #1a1a2e; margin: 0; }
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
</style>
</head>
<body>
<h1>개인정보처리방침 (Privacy Policy)</h1>
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 &nbsp;|&nbsp; <strong>최종 수정일:</strong> 2026년 5월 27일</p>
<p>㈜에이아이오투오(이하 "회사")는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")를 제공함에 있어 사용자의 개인정보를 중요시하며, 「개인정보 보호법」 등 관련 법령을 성실히 준수합니다.</p>
<h2>1. 수집하는 개인정보 항목 및 수집 방법</h2>
<p>회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집합니다.</p>
<ul>
<li><strong>카카오 로그인을 통한 수집:</strong> 이름, 이메일 주소, 프로필 사진 — 서비스 회원 가입 및 로그인에 사용</li>
<li><strong>Google 사용자 프로필 :</strong> YouTube 채널 연동 시 연결된 구글 계정의 이름 및 프로필 사진 확인 — 채널 소유자 식별 목적으로만 사용</li>
<li><strong>YouTube Data API :</strong> 채널 정보, 동영상 목록, 재생목록 등 YouTube 계정 데이터 (읽기 전용)</li>
<li><strong>YouTube Data API :</strong> AI가 생성한 콘텐츠를 YouTube에 업로드하기 위한 동영상 관리 권한</li>
<li><strong>YouTube Analytics API :</strong> 채널 및 동영상의 조회수, 시청 시간 등 성과 지표 데이터 (읽기 전용)</li>
<li><strong>서비스 이용 과정에서 자동 수집:</strong> 접속 IP, 쿠키, 서비스 이용 기록</li>
</ul>
<h2>2. Google 사용자 데이터의 수집 및 이용 목적</h2>
<p>회사가 접근하는 Google 사용자 데이터는 아래 목적으로만 사용되며, 명시된 목적 외에는 사용하지 않습니다.</p>
<ul>
<li><strong>Google 사용자 프로필:</strong> YouTube 채널 연동 과정에서 연결 대상 구글 계정을 식별하는 용도로만 사용하며, 서비스 로그인에는 사용되지 않습니다.</li>
<li><strong>YouTube 계정 데이터 (읽기):</strong> 기존 채널 정보·동영상 현황을 분석하여 AI 콘텐츠 전략 수립에 활용합니다.</li>
<li><strong>YouTube 동영상 업로드:</strong> AI가 생성한 영상을 사용자의 YouTube 채널에 업로드합니다. 업로드는 반드시 사용자의 명시적 요청에 의해서만 실행됩니다.</li>
<li><strong>YouTube 분석 데이터:</strong> 채널 성과 지표를 분석하여 AI 마케팅 전략 수립 및 콘텐츠 최적화에 활용합니다.</li>
</ul>
<div class="google-policy">
<p><strong>[Google API 서비스 사용자 데이터 정책 준수]</strong><br><br>
㈜에이아이오투오가 운영하는 ADO2 서비스가 Google API로부터 수신한 정보의 사용 및 타 앱으로의 전송은,
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank">Google API 서비스 사용자 데이터 정책</a>
제한적 사용(Limited Use) 요건을 포함한 모든 정책을 엄격히 준수합니다.<br><br>
특히, Google API로부터 수신한 데이터는 <strong>AI·ML 모델 학습에 사용되지 않으며</strong>, 사용자가 명시적으로 요청한 서비스 기능 제공 목적 외에는 사용·전송·공유되지 않습니다.</p>
</div>
<h2>3. Google 사용자 데이터의 제3자 공유</h2>
<p>회사는 Google API로부터 수신한 사용자 데이터를 아래 경우를 제외하고 어떠한 제3자에게도 판매하거나 공유하지 않습니다.</p>
<ul>
<li><strong>AI 콘텐츠 생성 처리:</strong> 서비스 제공에 필수적인 AI 생성 기능 수행을 위해 처리 서버로 전달될 수 있으며, 해당 처리는 서비스 제공 목적으로만 사용됩니다.</li>
<li><strong>법적 요구:</strong> 관련 법령에 의거한 수사기관 등의 적법한 요청이 있는 경우</li>
</ul>
<p>Google 사용자 데이터는 광고, 마케팅, 프로파일링 목적으로 사용되지 않습니다.</p>
<h2>4. Google 사용자 데이터의 저장 및 보안</h2>
<ul>
<li>Google OAuth 액세스 토큰 및 리프레시 토큰은 암호화된 상태로 보관됩니다.</li>
<li>Google API를 통해 읽어온 데이터는 서비스 기능 처리에 필요한 최소한의 시간 동안만 임시 보유하며, 처리 완료 후 삭제됩니다.</li>
<li>서버와의 모든 통신은 TLS(HTTPS)를 통해 암호화됩니다.</li>
<li>데이터 접근 권한은 서비스 운영에 필요한 최소 인원에게만 부여됩니다.</li>
</ul>
<h2>5. 개인정보의 보유 및 이용 기간 / 데이터 삭제</h2>
<p>원칙적으로 회원 탈퇴 또는 개인정보 수집·이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.</p>
<ul>
<li>전자상거래 관련 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)</li>
<li>접속 로그 기록: 3개월 (통신비밀보호법)</li>
<li>Google 연동 토큰: 연동 해제 또는 회원 탈퇴 즉시 삭제</li>
</ul>
<p><strong>데이터 삭제 요청 방법:</strong> 아래 이메일(<a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a>)로 요청하시면 <strong>영업일 기준 7일 이내</strong>에 처리 결과를 안내해 드립니다. Google 계정 연동 해제는 서비스 내 계정 설정에서 직접 처리하실 수 있으며, 해제 즉시 관련 토큰이 삭제됩니다.</p>
<h2>6. 정보주체의 권리 및 행사 방법</h2>
<p>사용자는 언제든지 자신의 개인정보에 대한 열람, 수정, 삭제, 처리 정지를 요청할 수 있습니다. 서비스 내 계정 설정에서 직접 처리하거나 아래 문의처로 연락해 주시기 바랍니다.</p>
<p>또한, <a href="https://myaccount.google.com/permissions" target="_blank">Google 계정 권한 관리 페이지</a>에서 ADO2 앱의 Google 데이터 접근 권한을 언제든지 직접 취소하실 수 있습니다.</p>
<h2>7. 개인정보 보호책임자 및 문의처</h2>
<p>개인정보 보호와 관련된 불만 처리 및 피해 구제에 관한 사항은 아래로 문의해 주시기 바랍니다.</p>
<ul>
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
</ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
</body>
</html>

View File

@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>서비스 약관 - ADO2</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
h2 { font-size: 19px; margin-top: 40px; color: #111; }
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
ul { padding-left: 20px; }
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
</style>
</head>
<body>
<h1>서비스 이용약관 (Terms of Service)</h1>
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 &nbsp;|&nbsp; <strong>최종 수정일:</strong> 2026년 5월 21일</p>
<h2>제 1 조 (목적)</h2>
<p>본 약관은 ㈜에이아이오투오(이하 "회사")가 제공하는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")의 이용과 관련하여, 회사와 이용자(이하 "회원") 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.</p>
<h2>제 2 조 (용어의 정의)</h2>
<ul>
<li>"서비스"라 함은 회사가 제공하는 ADO2 AI 마케팅 자동화 플랫폼 및 관련 제반 기능을 의미합니다.</li>
<li>"회원"이라 함은 본 약관에 동의하고 회사와 이용계약을 체결하여 서비스를 이용하는 자를 말합니다.</li>
<li>"콘텐츠"라 함은 서비스 내에서 AI가 생성하거나 회원이 등록하는 텍스트, 이미지, 음악, 영상 등 일체의 자료를 말합니다.</li>
</ul>
<h2>제 3 조 (약관의 효력 및 변경)</h2>
<p>회사는 본 약관의 내용을 서비스 화면에 게시하며, 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있습니다. 약관이 변경되는 경우 시행일 7일 전부터 공지합니다.</p>
<h2>제 4 조 (서비스의 제공 및 변경)</h2>
<p>회사는 AI 기반 마케팅 콘텐츠(가사, 이미지, 영상 등) 자동 생성, Google·YouTube 등 외부 플랫폼 연동, SNS 자동 배포 등의 서비스를 제공합니다. 운영상·기술상의 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.</p>
<h2>제 5 조 (회원의 의무)</h2>
<ul>
<li>타인의 Google 계정 등 외부 서비스 계정을 무단으로 도용하여 서비스를 이용해서는 안 됩니다.</li>
<li>스팸 발송, API 한도 고의 초과, 허위 정보 등록 등 비정상적인 방법으로 서비스를 이용해서는 안 됩니다.</li>
<li>회사 또는 제3자의 지식재산권을 침해해서는 안 됩니다.</li>
</ul>
<h2>제 6 조 (외부 API 연동 및 데이터 활용)</h2>
<p>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API 및 YouTube Analytics API를 사용하며, 이를 통해 수집·처리되는 데이터는 <a href="/privacy.html">개인정보처리방침</a>에 따라 관리됩니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.</p>
<p>YouTube API 서비스 이용과 관련하여 <a href="https://www.youtube.com/t/terms" target="_blank">YouTube 이용약관</a><a href="https://policies.google.com/privacy" target="_blank">Google 개인정보처리방침</a>이 함께 적용됩니다.</p>
<h2>제 7 조 (AI 생성 콘텐츠의 권리)</h2>
<p>서비스 내에서 AI가 생성한 콘텐츠에 대한 권리 관계는 관련 법령 및 회사의 별도 정책에 따릅니다. 회원이 직접 입력한 정보(매장 URL, 상호명 등)를 기반으로 생성된 콘텐츠에 대한 책임은 회원에게 있습니다.</p>
<h2>제 8 조 (책임 제한)</h2>
<p>회사는 천재지변, 외부 플랫폼(Google, YouTube, Naver 등)의 장애, 통신 장애 등 불가항력으로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</p>
<h2>제 9 조 (준거법 및 재판관할)</h2>
<p>본 약관과 관련된 분쟁은 대한민국 법을 준거법으로 하며, 소송은 회사의 소재지를 관할하는 법원에 제소합니다.</p>
<h2>문의처</h2>
<ul>
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
</ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
</body>
</html>

View File

@ -12,10 +12,7 @@ import GenerationFlow from './pages/Dashboard/GenerationFlow';
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess'; import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
import SocialConnectError from './pages/Social/SocialConnectError'; import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback'; import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage'; import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
import VideoDetailPage from './components/VideoDetailPage';
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest } from './utils/api';
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { CrawlingResponse } from './types/api'; import { CrawlingResponse } from './types/api';
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow'; type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
@ -99,27 +96,8 @@ const App: React.FC = () => {
parseSavedAnalysisData() parseSavedAnalysisData()
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
const [scrollProgress, setScrollProgress] = useState(0); const [scrollProgress, setScrollProgress] = useState(0);
const [isProcessingCallback, setIsProcessingCallback] = useState(false); const [isProcessingCallback, setIsProcessingCallback] = useState(false);
const tutorialVideoCheckedRef = useRef(false);
// generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off
useEffect(() => {
if (viewMode !== 'generation_flow') return;
if (tutorialVideoCheckedRef.current) return;
tutorialVideoCheckedRef.current = true;
const ENABLED_KEY = 'ado2_tutorial_enabled';
if (localStorage.getItem(ENABLED_KEY) !== null) return;
getVideosList(1, 1).then(response => {
const hasVideos = response.items.some(v => v.result_movie_url?.trim());
if (hasVideos) {
localStorage.setItem(ENABLED_KEY, 'false');
window.dispatchEvent(new Event('ado2-tutorial-auto-disable'));
}
}).catch(() => {});
}, [viewMode]);
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인) // 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
useEffect(() => { useEffect(() => {
@ -164,12 +142,6 @@ const App: React.FC = () => {
localStorage.removeItem('castad_wizard_step'); localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item'); localStorage.removeItem('castad_active_item');
} }
const redirectPath = sessionStorage.getItem('castad_login_redirect');
sessionStorage.removeItem('castad_login_redirect');
if (redirectPath && redirectPath !== '/') {
window.location.href = redirectPath;
return;
}
setInitialTab('새 프로젝트 만들기'); setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow'); setViewMode('generation_flow');
} catch (err) { } catch (err) {
@ -283,9 +255,7 @@ const App: React.FC = () => {
const handleStartAnalysis = async (url: string) => { const handleStartAnalysis = async (url: string) => {
if (!url.trim()) return; if (!url.trim()) return;
setAfterLoadTarget('analysis');
setViewMode('loading'); setViewMode('loading');
setIsAnalysisComplete(false);
setError(null); setError(null);
try { try {
@ -298,20 +268,18 @@ const App: React.FC = () => {
setAnalysisData(data); setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'url', value: url }); setViewMode('analysis');
setIsAnalysisComplete(true);
} catch (err) { } catch (err) {
console.error('Crawling failed:', err); console.error('Crawling failed:', err);
setError(t('app.analysisError')); const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
setError(errorMessage);
setViewMode('landing'); setViewMode('landing');
} }
}; };
// 업체명 자동완성으로 분석 시작 // 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => { const handleAutocomplete = async (request: AutocompleteRequest) => {
setAfterLoadTarget('analysis');
setViewMode('loading'); setViewMode('loading');
setIsAnalysisComplete(false);
setError(null); setError(null);
try { try {
@ -324,36 +292,11 @@ const App: React.FC = () => {
setAnalysisData(data); setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress }); setViewMode('analysis');
setIsAnalysisComplete(true);
} catch (err) { } catch (err) {
console.error('Autocomplete failed:', err); console.error('Autocomplete failed:', err);
setError(t('app.autocompleteError')); const errorMessage = err instanceof Error ? err.message : t('app.autocompleteGeneralError');
setViewMode('landing'); setError(errorMessage);
}
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string, category: string) => {
setAfterLoadTarget('generation_flow');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address, category);
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis failed:', err);
setError(t('app.autocompleteError'));
setViewMode('landing'); setViewMode('landing');
} }
}; };
@ -440,13 +383,6 @@ const App: React.FC = () => {
return <SocialConnectError />; return <SocialConnectError />;
} }
// 영상 상세 페이지 (/video/{id}) — 인증 게이트는 VideoDetailPage 내부에서 처리
const videoDetailMatch = pathname.match(/^\/video\/([^/]+)$/);
if (videoDetailMatch) {
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
}
// 카카오 콜백 처리 중 로딩 화면 표시 // 카카오 콜백 처리 중 로딩 화면 표시
if (isProcessingCallback) { if (isProcessingCallback) {
return ( return (
@ -459,12 +395,7 @@ const App: React.FC = () => {
} }
if (viewMode === 'loading') { if (viewMode === 'loading') {
return ( return <LoadingSection />;
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => setViewMode(afterLoadTarget)}
/>
);
} }
if (viewMode === 'analysis' && analysisData) { if (viewMode === 'analysis' && analysisData) {
@ -506,7 +437,7 @@ const App: React.FC = () => {
<HeroSection <HeroSection
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete} onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput} onTestData={handleTestData}
onNext={() => scrollToSection(1)} onNext={() => scrollToSection(1)}
error={error} error={error}
scrollProgress={scrollProgress} scrollProgress={scrollProgress}

View File

@ -1,150 +0,0 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import CitySelectModal, { REGIONS } from './CitySelectModal';
interface BusinessNameInputModalProps {
onClose: () => void;
onSubmit: (businessName: string, address: string, category: string) => void;
}
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
const { t } = useTranslation();
const [businessName, setBusinessName] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [detailAddress, setDetailAddress] = useState('');
const [category, setCategory] = useState('');
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
useEffect(() => {
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const handleCitySelect = (city: string) => {
const region = REGIONS.find(r => r.cities.includes(city));
// 특별시/광역시는 도 이름 없이 도시명만 사용
const prefix = region && region.label !== '특별시 / 광역시' ? `${region.label} ` : '';
setSelectedCity(`${prefix}${city}`);
};
const isValid = businessName.trim().length > 0 && selectedCity.length > 0 && detailAddress.trim().length > 0;
const handleSubmit = () => {
if (!isValid) return;
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
onSubmit(businessName.trim(), fullAddress, category.trim());
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isValid) handleSubmit();
};
// CitySelectModal의 selected prop용 — 도시명만 추출
const cityOnly = selectedCity.split(' ').pop() ?? '';
return createPortal(
<>
<div className="manual-modal-backdrop" onClick={onClose}>
<div className="manual-modal" onClick={e => e.stopPropagation()}>
<div className="manual-modal-header">
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
<button className="manual-modal-close" onClick={onClose}></button>
</div>
<p className="manual-modal-subtitle">{t('landing.hero.manualModalSubtitle')}</p>
<div className="manual-modal-body">
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelName')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderName')}
value={businessName}
onChange={e => setBusinessName(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={50}
autoFocus
/>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelRegion')}</label>
<button
type="button"
className={`manual-modal-city-btn ${selectedCity ? 'selected' : ''}`}
onClick={() => setIsCityModalOpen(true)}
>
<span>{selectedCity || t('landing.hero.manualPlaceholderRegion')}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelDetail')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderDetail')}
value={detailAddress}
onChange={e => setDetailAddress(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={100}
/>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelCategory')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderCategory')}
value={category}
onChange={e => setCategory(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={30}
/>
</div>
<div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="manual-modal-submit"
onClick={handleSubmit}
disabled={!isValid}
>
{t('landing.hero.manualAnalyzeButton')}
</button>
</div>
</div>
</div>
</div>
{isCityModalOpen && (
<CitySelectModal
selected={cityOnly}
onSelect={(city) => { handleCitySelect(city); setIsCityModalOpen(false); }}
onClose={() => setIsCityModalOpen(false)}
/>
)}
</>,
document.body
);
};
export default BusinessNameInputModal;

View File

@ -1,159 +0,0 @@
import React, { useState, useEffect } from 'react';
export const REGIONS: { label: string; cities: string[] }[] = [
{
label: '특별시 / 광역시',
cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'],
},
{
label: '경기도',
cities: [
'수원시', '성남시', '고양시', '용인시', '부천시', '안산시', '안양시', '남양주시',
'화성시', '평택시', '의정부시', '시흥시', '파주시', '김포시', '광주시', '광명시',
'군포시', '하남시', '오산시', '이천시', '안성시', '구리시', '양주시', '포천시',
'여주시', '동두천시', '과천시', '가평군', '양평군', '연천군',
],
},
{
label: '강원도',
cities: [
'춘천시', '원주시', '강릉시', '동해시', '태백시', '속초시', '삼척시',
'홍천군', '횡성군', '영월군', '평창군', '정선군', '철원군', '화천군',
'양구군', '인제군', '고성군', '양양군',
],
},
{
label: '충청북도',
cities: [
'청주시', '충주시', '제천시',
'보은군', '옥천군', '영동군', '증평군', '진천군', '괴산군', '음성군', '단양군',
],
},
{
label: '충청남도',
cities: [
'천안시', '공주시', '보령시', '아산시', '서산시', '논산시', '계룡시', '당진시',
'금산군', '부여군', '서천군', '청양군', '홍성군', '예산군', '태안군',
],
},
{
label: '전라북도',
cities: [
'전주시', '군산시', '익산시', '정읍시', '남원시', '김제시',
'완주군', '진안군', '무주군', '장수군', '임실군', '순창군', '고창군', '부안군',
],
},
{
label: '전라남도',
cities: [
'목포시', '여수시', '순천시', '나주시', '광양시',
'담양군', '곡성군', '구례군', '고흥군', '보성군', '화순군', '장흥군', '강진군',
'해남군', '영암군', '무안군', '함평군', '영광군', '장성군', '완도군', '진도군', '신안군',
],
},
{
label: '경상북도',
cities: [
'포항시', '경주시', '김천시', '안동시', '구미시', '영주시', '영천시', '상주시', '문경시', '경산시',
'의성군', '청송군', '영양군', '영덕군', '청도군', '고령군', '성주군', '칠곡군',
'예천군', '봉화군', '울진군', '울릉군',
],
},
{
label: '경상남도',
cities: [
'창원시', '진주시', '통영시', '사천시', '김해시', '밀양시', '거제시', '양산시',
'의령군', '함안군', '창녕군', '고성군', '남해군', '하동군', '산청군', '함양군', '거창군', '합천군',
],
},
{
label: '제주도',
cities: ['제주시', '서귀포시'],
},
];
interface CitySelectModalProps {
selected: string;
onSelect: (city: string) => void;
onClose: () => void;
}
const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, onClose }) => {
const [activeRegion, setActiveRegion] = useState<string | null>(
() => REGIONS.find(r => r.cities.includes(selected))?.label ?? null
);
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);
const activeEntry = REGIONS.find(r => r.label === activeRegion);
const cities = activeEntry?.cities ?? [];
const handleSelect = (value: string) => {
onSelect(value === selected ? '' : value);
onClose();
};
return (
<div className="city-modal-backdrop" onClick={onClose}>
<div className="city-modal" onClick={e => e.stopPropagation()}>
<div className="city-modal-header">
<span className="city-modal-title">
{activeRegion ? (
<>
<button className="city-modal-back" onClick={() => setActiveRegion(null)}></button>
{activeRegion}
</>
) : '지역 선택'}
</span>
<button className="city-modal-close" onClick={onClose}></button>
</div>
{activeRegion === null ? (
<div className="city-modal-grid">
{REGIONS.map(r => {
const isRegionSelected = selected === r.label;
const hasCitySelected = r.cities.includes(selected);
return (
<button
key={r.label}
className={`city-modal-region-item ${isRegionSelected || hasCitySelected ? 'has-selected' : ''}`}
onClick={() => setActiveRegion(r.label)}
>
<span>{r.label}</span>
{isRegionSelected && <span className="city-modal-region-badge"></span>}
{hasCitySelected && <span className="city-modal-region-badge">{selected}</span>}
<span className="city-modal-arrow"></span>
</button>
);
})}
</div>
) : (
<div className="city-modal-item-wrap">
{activeRegion !== '특별시 / 광역시' && (
<button
className={`city-modal-item city-modal-item-all ${selected === activeRegion ? 'active' : ''}`}
onClick={() => handleSelect(activeRegion!)}
>
</button>
)}
{cities.map(city => (
<button
key={city}
className={`city-modal-item ${selected === city ? 'active' : ''}`}
onClick={() => handleSelect(city)}
>
{city}
</button>
))}
</div>
)}
</div>
</div>
);
};
export default CitySelectModal;

View File

@ -1,228 +0,0 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { toggleVideoLike } from '../utils/api';
interface ContentCardSocialActionsProps {
videoId: number;
storeName: string;
region?: string;
commentCount: number;
initialLikeCount: number;
initialIsLiked?: boolean;
}
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
videoId,
storeName,
region,
commentCount,
initialLikeCount,
initialIsLiked = false,
}) => {
const { t } = useTranslation();
const [likeCount, setLikeCount] = useState(initialLikeCount);
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null);
const shareBtnRef = useRef<HTMLButtonElement>(null);
const shareMenuRef = useRef<HTMLDivElement>(null);
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shareUrl = `${window.location.origin}/video/${videoId}`;
useEffect(() => {
if (!shareMenuOpen) return;
const closeMenu = () => setShareMenuOpen(false);
const handleClickOutside = (e: MouseEvent) => {
if (
shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) &&
shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node)
) {
closeMenu();
}
};
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', closeMenu, true);
window.addEventListener('resize', closeMenu);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', closeMenu, true);
window.removeEventListener('resize', closeMenu);
};
}, [shareMenuOpen]);
// 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정
useLayoutEffect(() => {
if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return;
const margin = 8;
const menuRect = shareMenuRef.current.getBoundingClientRect();
let left = anchorRect.left;
if (left + menuRect.width > window.innerWidth - margin) {
left = window.innerWidth - menuRect.width - margin;
}
left = Math.max(margin, left);
let top = anchorRect.bottom + 6;
if (top + menuRect.height > window.innerHeight - margin) {
top = anchorRect.top - menuRect.height - 6;
}
top = Math.max(margin, top);
setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
}, [shareMenuOpen, anchorRect]);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
// clipboard API 미지원 환경에서는 무시
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleKakaoShare = () => {
const kakao = window.Kakao;
if (kakao?.Share) {
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: storeName || t('videoDetail.kakaoDefaultTitle'),
description: t('videoDetail.kakaoDescription', { region: region ?? '' }),
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
}
setShareMenuOpen(false);
};
const handleFacebookShare = () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleTwitterShare = () => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleLikeClick = (e: React.MouseEvent) => {
e.stopPropagation();
const prevLiked = isLiked;
const prevCount = likeCount;
setIsLiked(!prevLiked);
setLikeCount(prevLiked ? prevCount - 1 : prevCount + 1);
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
likeDebounceRef.current = setTimeout(async () => {
try {
const res = await toggleVideoLike(String(videoId));
setIsLiked(res.is_liked);
setLikeCount(res.like_count);
} catch (err) {
console.error('Failed to toggle like:', err);
setIsLiked(prevLiked);
setLikeCount(prevCount);
}
}, 500);
};
const handleShareBtnClick = (e: React.MouseEvent) => {
e.stopPropagation();
const rect = shareBtnRef.current?.getBoundingClientRect();
if (rect) {
// 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다
setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left });
setMenuPos({ top: rect.bottom + 6, left: rect.left });
}
setShareMenuOpen((v) => !v);
};
return (
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
<button
className={`content-card-like-btn ${isLiked ? 'liked' : ''}`}
onClick={handleLikeClick}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{likeCount}
</button>
<span className="content-card-comment">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{commentCount}
</span>
<button
ref={shareBtnRef}
className="content-card-share-btn"
onClick={handleShareBtnClick}
title={t('videoDetail.share')}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
{shareMenuOpen && menuPos && createPortal(
<div
ref={shareMenuRef}
className="video-detail-share-menu"
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left }}
>
<button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FEE500" />
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E" />
</svg>
{t('videoDetail.shareKakao')}
</button>
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
{t('videoDetail.shareFacebook')}
</button>
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
{t('videoDetail.shareTwitter')}
</button>
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
</button>
</div>,
document.body
)}
</div>
);
};
export default ContentCardSocialActions;

View File

@ -10,11 +10,6 @@ const Footer: React.FC = () => {
<div className="footer-left"> <div className="footer-left">
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" /> <img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
<p className="footer-copyright">Copyright O2O Inc. All rights reserved</p> <p className="footer-copyright">Copyright O2O Inc. All rights reserved</p>
<div className="footer-links">
<a href="https://ado2.o2osolution.ai/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a>
<span className="footer-link-divider">|</span>
<a href="https://ado2.o2osolution.ai/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
</div>
</div> </div>
<div className="footer-right"> <div className="footer-right">
<p className="footer-info">{t('footer.company')}</p> <p className="footer-info">{t('footer.company')}</p>

View File

@ -1,100 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl } from '../utils/api';
interface LoginPromptModalProps {
onClose: () => void;
}
const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
const { t } = useTranslation();
const handleLogin = async () => {
try {
sessionStorage.setItem('castad_login_redirect', window.location.pathname);
const response = await getKakaoLoginUrl();
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(255, 255, 255, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={onClose}
>
<div
style={{
background: '#01282A',
border: '1px solid rgba(155, 202, 204, 0.2)',
borderRadius: '16px',
padding: '40px 32px',
width: '360px',
maxWidth: '90vw',
textAlign: 'center',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
aria-label="닫기"
style={{
position: 'absolute',
top: '16px',
right: '16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#9BCACC',
padding: '4px',
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="4" y1="4" x2="16" y2="16"/>
<line x1="16" y1="4" x2="4" y2="16"/>
</svg>
</button>
<h2 style={{ color: '#FFFFFF', fontSize: '20px', fontWeight: 600, marginBottom: '12px' }}>
{t('loginPrompt.title')}
</h2>
<button
onClick={handleLogin}
style={{
width: '50%',
margin: '0 auto',
padding: '14px',
background: '#FEE500',
color: '#3C1E1E',
border: 'none',
borderRadius: '8px',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M10 2C5.582 2 2 4.91 2 8.5c0 2.26 1.37 4.25 3.44 5.44L4.6 17.1a.3.3 0 0 0 .44.33l4.03-2.67c.3.03.62.04.93.04 4.418 0 8-2.91 8-6.5S14.418 2 10 2z" fill="#3C1E1E"/>
</svg>
{t('loginPrompt.loginBtn')}
</button>
</div>
</div>
);
};
export default LoginPromptModal;

View File

@ -1,58 +0,0 @@
import React from 'react';
import { SearchHistoryItem } from './useSearchHistory';
interface SearchHistoryDropdownProps {
items: SearchHistoryItem[];
onSelect: (item: SearchHistoryItem) => void;
onDelete: (e: React.MouseEvent, value: string) => void;
className?: string;
itemClassName?: string;
titleClassName?: string;
addressClassName?: string;
}
const SearchHistoryDropdown: React.FC<SearchHistoryDropdownProps> = ({
items,
onSelect,
onDelete,
className = 'url-input-autocomplete-dropdown',
itemClassName = 'url-input-autocomplete-item',
titleClassName = 'url-input-autocomplete-title',
addressClassName = 'url-input-autocomplete-address',
}) => {
if (items.length === 0) return null;
return (
<div className={className}>
{items.map((item, index) => (
<div
key={index}
className={itemClassName}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<button
type="button"
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onMouseDown={(e) => { e.preventDefault(); onSelect(item); }}
>
<div className={titleClassName}>{item.value}</div>
{item.roadAddress && (
<div className={addressClassName}>{item.roadAddress}</div>
)}
</button>
<button
type="button"
onMouseDown={(e) => onDelete(e, item.value)}
style={{ color:'#737983', background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', opacity: 0.8, flexShrink: 0 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
))}
</div>
);
};
export default SearchHistoryDropdown;

View File

@ -1,56 +0,0 @@
import { useState, useEffect } from 'react';
export const SEARCH_HISTORY_KEY = 'castad_search_history';
export const SEARCH_HISTORY_MAX = 5;
export interface SearchHistoryItem {
type: 'url' | 'name';
value: string;
address?: string;
roadAddress?: string;
}
export const saveSearchHistory = (item: SearchHistoryItem) => {
try {
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
const history: SearchHistoryItem[] = saved ? JSON.parse(saved) : [];
const filtered = history.filter(h => h.value !== item.value);
const updated = [item, ...filtered].slice(0, SEARCH_HISTORY_MAX);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
} catch { /* ignore */ }
};
export const useSearchHistory = (searchType: 'url' | 'name') => {
const [history, setHistory] = useState<SearchHistoryItem[]>([]);
const [showHistory, setShowHistory] = useState(false);
useEffect(() => {
try {
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
if (saved) setHistory(JSON.parse(saved));
} catch { /* ignore */ }
}, []);
const filteredHistory = history.filter(h => h.type === searchType);
const deleteItem = (value: string) => {
const updated = history.filter(h => h.value !== value);
setHistory(updated);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
if (updated.filter(h => h.type === searchType).length === 0) {
setShowHistory(false);
}
};
const openHistory = (inputValue: string) => {
if (!inputValue && filteredHistory.length > 0) setShowHistory(true);
};
const closeHistory = () => setShowHistory(false);
const hideOnInput = (value: string) => {
if (value) setShowHistory(false);
};
return { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem };
};

View File

@ -1,353 +0,0 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
import { useSearchHistory } from './SearchHistory/useSearchHistory';
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from './BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual';
export type { SearchType };
const isValidUrl = (string: string): boolean => {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
// URL 형태로 판단되는 입력: 프로토콜/www. 시작 또는 도메인 형태(naver.me/xxx 등)
const isUrlLike = (value: string): boolean => {
const trimmed = value.trim();
return /^(https?:\/\/|www\.)/i.test(trimmed)
|| /^[a-z0-9-]+(\.[a-z0-9-]+)+([/?#]|$)/i.test(trimmed);
};
const sanitizeUrlInput = (value: string): string => {
return value.replace(/[^\x00-\x7F]/g, '');
};
// 텍스트 안에 포함된 첫 번째 URL을 추출 (없으면 null)
const extractUrl = (text: string): string | null => {
const match = text.match(/(https?:\/\/|www\.)[^\s]+/i);
return match ? match[0] : null;
};
interface SearchInputFormProps {
onAnalyze?: (value: string, type: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
onManualButtonClick?: () => void;
error?: string | null;
}
const SearchInputForm: React.FC<SearchInputFormProps> = ({
onAnalyze,
onAutocomplete,
onManualInput,
onManualButtonClick,
error: externalError,
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType] = useState<SearchType>('name');
const [isFocused, setIsFocused] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory('name');
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim()) {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
}
setIsAutocompleteLoading(true);
try {
const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0);
} catch {
setAutocompleteResults([]);
setShowAutocomplete(false);
} finally {
setIsAutocompleteLoading(false);
}
}, []);
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
const cleanTitle = cleanSearchTitle(item.title);
setInputValue(cleanTitle);
setSelectedItem({ ...item, title: cleanTitle });
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (showAutocomplete && autocompleteResults.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev => prev < autocompleteResults.length - 1 ? prev + 1 : 0);
return;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev => prev > 0 ? prev - 1 : autocompleteResults.length - 1);
return;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
}
return;
case 'Escape':
e.preventDefault();
setShowAutocomplete(false);
setHighlightedIndex(-1);
return;
}
}
if (e.key === 'Enter') {
e.preventDefault();
}
};
const handleStart = () => {
const trimmed = inputValue.trim();
if (!trimmed) {
setLocalError(t('landing.hero.errorInputRequired'));
return;
}
if (isValidUrl(trimmed)) {
setLocalError('');
onAnalyze?.(trimmed, 'url');
return;
}
setLocalError('');
if (selectedItem && onAutocomplete) {
onAutocomplete({
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
});
} else if (onAnalyze) {
onAnalyze(inputValue, searchType);
}
};
const applyInputChange = (value: string) => {
setInputValue(value);
setSelectedItem(null);
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
if (debounceRef.current) clearTimeout(debounceRef.current);
if (isValidUrl(value)) {
setAutocompleteResults([]);
setShowAutocomplete(false);
} else {
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
}
};
// 모바일 키보드 클립보드 추천 등 paste 이벤트 없이 여러 글자가 한 번에 들어오는 경우 처리
const handleChange = (value: string) => {
const isBulkInsert = value.length - inputValue.length > 1;
if (isBulkInsert) {
const extracted = extractUrl(value);
if (extracted && extracted !== value.trim()) {
applyInputChange(sanitizeUrlInput(extracted));
return;
}
if (isUrlLike(value)) {
const sanitized = sanitizeUrlInput(value);
if (sanitized !== value) {
applyInputChange(sanitized);
return;
}
}
}
applyInputChange(value);
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pastedText = e.clipboardData.getData('text');
// 붙여넣은 텍스트에 URL이 포함되어 있고 URL 외 다른 텍스트도 있으면 URL만 추출해서 입력창에 넣기
const extracted = extractUrl(pastedText);
if (extracted && extracted !== pastedText.trim()) {
e.preventDefault();
applyInputChange(sanitizeUrlInput(extracted));
return;
}
const input = e.currentTarget;
const start = input.selectionStart ?? inputValue.length;
const end = input.selectionEnd ?? inputValue.length;
const merged = inputValue.slice(0, start) + pastedText + inputValue.slice(end);
// 붙여넣은 후 완성될 전체 값이 URL 형태일 때만 비영문 문자 제거
if (!isUrlLike(merged)) return;
const sanitized = sanitizeUrlInput(merged);
if (sanitized === merged) return;
e.preventDefault();
applyInputChange(sanitized);
};
const handleManualButtonClick = () => {
if (onManualButtonClick) {
onManualButtonClick();
} else {
setIsManualModalOpen(true);
}
};
const error = externalError || localError;
return (
<div className="hero-form">
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
{/* 유형 라벨 */}
{/* <div className="hero-dropdown-container">
<div className="hero-dropdown-trigger">
<span className="hero-dropdown-main">
{t('landing.hero.searchTypeLabel')}
</span>
</div>
</div> */}
{/* 입력 필드 */}
<div className="hero-input-container" ref={autocompleteRef}>
<input
type="text"
value={inputValue}
onChange={(e) => handleChange(e.target.value)}
onPaste={handlePaste}
onFocus={() => {
setIsFocused(true);
if (autocompleteResults.length > 0) setShowAutocomplete(true);
openHistory(inputValue);
}}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onKeyDown={handleKeyDown}
placeholder={t('landing.hero.placeholderCombined')}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={(item) => {
closeHistory();
setInputValue(item.value);
if (item.type === 'name') {
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
}
}}
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
className="hero-autocomplete-dropdown"
itemClassName="hero-autocomplete-item"
titleClassName="hero-autocomplete-title"
addressClassName="hero-autocomplete-address"
/>
)}
{/* 자동완성 결과 */}
{showAutocomplete && (
<div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
) : (
autocompleteResults.map((item, index) => (
<button
key={index}
type="button"
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
onMouseDown={(e) => { e.preventDefault(); handleSelectAutocomplete(item); }}
onMouseEnter={() => setHighlightedIndex(index)}
>
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
{/* 입력 clear 버튼 */}
{inputValue && (
<button
type="button"
className="hero-input-clear"
onClick={() => {
setInputValue('');
setSelectedItem(null);
setLocalError('');
setAutocompleteResults([]);
setShowAutocomplete(false);
}}
>
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
</button>
)}
</div>
<span className="hero-input-hint">{t('landing.hero.guideCombined')}</span>
{error && <p className="hero-error">{error}</p>}
<button onClick={handleStart} className="hero-button">
{t('landing.hero.analyzeButton')}
</button>
<div className="hero-divider"><span></span></div>
<div className="hero-manual-card" onClick={handleManualButtonClick} role="button" tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleManualButtonClick()}>
<span className="hero-manual-card-title">{t('landing.hero.searchTypeManual')}</span>
<p className="hero-manual-button-desc">{t('landing.hero.manualButtonDesc')}</p>
</div>
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
{!onManualButtonClick && isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category) => {
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category);
}}
/>
)}
</div>
);
};
export default SearchInputForm;

View File

@ -37,13 +37,9 @@ interface SidebarProps {
onHome?: () => void; onHome?: () => void;
userInfo?: UserMeResponse | null; userInfo?: UserMeResponse | null;
onLogout?: () => void; onLogout?: () => void;
credits?: number | null;
tutorialAvailable?: boolean;
tutorialEnabled?: boolean;
onToggleTutorial?: () => void;
} }
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => { const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false); const [isMobileOpen, setIsMobileOpen] = useState(false);
@ -88,8 +84,8 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{ id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> }, { id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> }, { id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> }, { id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> }, { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
{ id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> }, { id: '콘텐츠 캘린더', label: '콘텐츠 캘린더', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> },
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> }, { id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
]; ];
@ -162,24 +158,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
</div> </div>
<div className="sidebar-footer"> <div className="sidebar-footer">
{/* 모바일 전용 튜토리얼 토글 */}
{tutorialAvailable && (
<button
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`}
onClick={() => onToggleTutorial?.()}
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4l3 3"/>
</svg>
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span>
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}>
{tutorialEnabled ? 'ON' : 'OFF'}
</span>
</button>
)}
<div className="sidebar-language-switch"> <div className="sidebar-language-switch">
<LanguageSwitch isCollapsed={isCollapsed} /> <LanguageSwitch isCollapsed={isCollapsed} />
</div> </div>
@ -202,9 +180,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{!isCollapsed && ( {!isCollapsed && (
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p> <p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
{credits !== null && credits !== undefined && (
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
)}
</div> </div>
)} )}
</div> </div>
@ -225,12 +200,12 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`} className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
title={t('sidebar.inquiry')} title="고객의견"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg> </svg>
{!isCollapsed && <span>{t('sidebar.inquiry')}</span>} {!isCollapsed && <span></span>}
</a> </a>
</div> </div>
</div> </div>

View File

@ -12,7 +12,6 @@ interface SocialPostingModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
video: VideoListItem | null; video: VideoListItem | null;
onGoToCalendar?: () => void;
} }
type PrivacyType = 'public' | 'unlisted' | 'private'; type PrivacyType = 'public' | 'unlisted' | 'private';
@ -117,8 +116,7 @@ const MiniCalendar: React.FC<{
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
isOpen, isOpen,
onClose, onClose,
video, video
onGoToCalendar,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const tutorial = useTutorial(); const tutorial = useTutorial();
@ -142,10 +140,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const channelDropdownRef = useRef<HTMLDivElement>(null); const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null); const privacyDropdownRef = useRef<HTMLDivElement>(null);
const hasBeenOpenedRef = useRef(false); const hasBeenOpenedRef = useRef(false);
const loadedForTaskIdRef = useRef<string | null>(null);
const loadedAtRef = useRef<number>(0);
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
const SEO_CACHE_TTL = 50 * 60 * 1000;
// Upload progress modal state // Upload progress modal state
const [showUploadProgress, setShowUploadProgress] = useState(false); const [showUploadProgress, setShowUploadProgress] = useState(false);
@ -156,8 +150,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 업로드 정보 (모달이 닫힌 후에도 유지) // 업로드 정보 (모달이 닫힌 후에도 유지)
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>(''); const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
const [uploadChannelName, setUploadChannelName] = useState<string>(''); const [uploadChannelName, setUploadChannelName] = useState<string>('');
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
const [uploadScheduledAt, setUploadScheduledAt] = useState<string | null>(null);
// 드롭다운 외부 클릭 시 닫기 // 드롭다운 외부 클릭 시 닫기
useEffect(() => { useEffect(() => {
@ -212,28 +204,11 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 소셜 계정 로드 // 소셜 계정 로드
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (isOpen) {
loadSocialAccounts();
const now = Date.now();
loadSocialAccounts();
const taskId = video?.task_id ?? null;
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) {
loadedForTaskIdRef.current = taskId;
loadedAtRef.current = now;
loadAutocomplete(); loadAutocomplete();
} else if (taskId) {
const cached = seoCache.current.get(taskId);
if (cached) {
setTitle(cached.title);
setDescription(cached.description);
setTags(cached.tags);
}
} }
}, [isOpen, video?.task_id]); }, [isOpen, video]);
const loadSocialAccounts = async () => { const loadSocialAccounts = async () => {
setIsLoadingAccounts(true); setIsLoadingAccounts(true);
@ -242,9 +217,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || []; const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
setSocialAccounts(activeAccounts); setSocialAccounts(activeAccounts);
if (activeAccounts.length > 0) { if (activeAccounts.length > 0) {
const lastUsed = localStorage.getItem('lastUsedSocialChannel'); setSelectedChannel(activeAccounts[0].platform_user_id);
const defaultAccount = activeAccounts.find(acc => acc.platform_user_id === lastUsed) || activeAccounts[0];
setSelectedChannel(defaultAccount.platform_user_id);
} }
} catch (error) { } catch (error) {
if (error instanceof TokenExpiredError) { if (error instanceof TokenExpiredError) {
@ -274,11 +247,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
if (autoSeoResponse.title) setTitle(autoSeoResponse.title); if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
if (autoSeoResponse.description) setDescription(autoSeoResponse.description); if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(',')); if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
seoCache.current.set(video.task_id, {
title: autoSeoResponse.title || '',
description: autoSeoResponse.description || '',
tags: autoSeoResponse.keywords?.join(',') || '',
});
} catch (error) { } catch (error) {
console.error('Failed to load autocomplete:', error); console.error('Failed to load autocomplete:', error);
// 실패해도 사용자에게 별도 알림 없이 조용히 처리 // 실패해도 사용자에게 별도 알림 없이 조용히 처리
@ -330,7 +298,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 업로드 정보 저장 (모달이 닫힌 후에도 유지) // 업로드 정보 저장 (모달이 닫힌 후에도 유지)
setUploadVideoTitle(title.trim()); setUploadVideoTitle(title.trim());
setUploadChannelName(selectedAcc.display_name); setUploadChannelName(selectedAcc.display_name);
setUploadIsScheduled(publishTime === 'schedule');
setShowUploadProgress(true); setShowUploadProgress(true);
try { try {
@ -368,19 +335,16 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
throw new Error(uploadResponse.message || t('social.uploadStartFailed')); throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
} }
localStorage.setItem('lastUsedSocialChannel', selectedAcc.platform_user_id);
if (publishTime === 'schedule') { if (publishTime === 'schedule') {
// 예약 업로드: 폴링 없이 바로 완료 처리 // 예약 업로드: 폴링 없이 바로 완료 처리
setUploadStatus('completed'); setUploadStatus('completed');
setUploadProgress(100); setUploadProgress(100);
setUploadScheduledAt(uploadResponse.scheduled_at ?? null);
onClose(); onClose();
resetForm(); resetForm();
} else { } else {
// 즉시 업로드: 완료될 때까지 폴링 // 즉시 업로드: 완료될 때까지 폴링
const result = await waitForUploadComplete( const result = await waitForUploadComplete(
uploadResponse.upload_id.toString(), uploadResponse.upload_id,
(status, progress) => { (status, progress) => {
setUploadStatus(status as UploadStatus); setUploadStatus(status as UploadStatus);
setUploadProgress(progress || 0); setUploadProgress(progress || 0);
@ -433,8 +397,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
setUploadErrorMessage(undefined); setUploadErrorMessage(undefined);
setUploadVideoTitle(''); setUploadVideoTitle('');
setUploadChannelName(''); setUploadChannelName('');
setUploadIsScheduled(false);
setUploadScheduledAt(null);
}; };
const handleClose = () => { const handleClose = () => {
@ -463,9 +425,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
channelName={uploadChannelName || selectedAccount?.display_name || ''} channelName={uploadChannelName || selectedAccount?.display_name || ''}
youtubeUrl={uploadYoutubeUrl} youtubeUrl={uploadYoutubeUrl}
errorMessage={uploadErrorMessage} errorMessage={uploadErrorMessage}
isScheduled={uploadIsScheduled} isScheduled={publishTime === 'schedule'}
scheduledAt={uploadScheduledAt}
onGoToCalendar={onGoToCalendar}
/> />
); );
@ -560,7 +520,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)} onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
> >
<div className="social-posting-channel-selected"> <div className="social-posting-channel-selected">
{selectedAccount ? ( {selectedAccount && (
<> <>
<img <img
src={getPlatformIcon(selectedAccount.platform)} src={getPlatformIcon(selectedAccount.platform)}
@ -569,8 +529,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
/> />
<span>{selectedAccount.display_name}</span> <span>{selectedAccount.display_name}</span>
</> </>
) : (
<span className="social-posting-channel-placeholder">{t('social.selectChannel')}</span>
)} )}
</div> </div>
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none"> <svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">

View File

@ -7,7 +7,6 @@ interface Rect {
left: number; left: number;
width: number; width: number;
height: number; height: number;
bottom: number;
} }
interface TooltipPos { interface TooltipPos {
@ -27,14 +26,10 @@ interface TutorialOverlayProps {
const PADDING = 8; const PADDING = 8;
function getTargetRect(selector: string): Rect | null { function getTargetRect(selector: string): Rect | null {
const els = Array.from(document.querySelectorAll(selector)); const el = document.querySelector(selector);
const el = els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0];
if (!el) return null; if (!el) return null;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom }; return { top: r.top, left: r.left, width: r.width, height: r.height };
} }
function getSpotlightRect( function getSpotlightRect(
@ -53,7 +48,6 @@ function getSpotlightRect(
return { return {
top, top,
bottom,
left, left,
width: Math.max(0, right - left), width: Math.max(0, right - left),
height: Math.max(0, bottom - top), height: Math.max(0, bottom - top),
@ -143,11 +137,7 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
}; };
const tryBind = (): boolean => { const tryBind = (): boolean => {
const els = Array.from(document.querySelectorAll(hint.targetSelector)); const el = document.querySelector(hint.targetSelector) as HTMLElement | null;
const el = (els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0]) as HTMLElement | null;
if (!el) return false; if (!el) return false;
bindToTarget(el); bindToTarget(el);
return true; return true;
@ -185,12 +175,6 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
if (!hint) return null; if (!hint) return null;
const isTargetVisible = targetRect
? targetRect.top + 60 < window.innerHeight && targetRect.bottom > 60
: true;
if (!isTargetVisible) return null;
const spotlightPadding = hint.spotlightPadding ?? PADDING; const spotlightPadding = hint.spotlightPadding ?? PADDING;
const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null; const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;

View File

@ -46,20 +46,28 @@ 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-input-wrapper', targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.field.title', titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.field.desc', descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
noSpotlight: true, noSpotlight: true,
variant: 'bubble', variant: 'bubble',
}, },
{ {
targetSelector: '.hero-manual-card-title', targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.manual.title', titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.manual.desc', descriptionKey: 'tutorial.landing.field.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
noSpotlight: true, noSpotlight: true,
variant: 'bubble', variant: 'bubble',
@ -68,13 +76,53 @@ export const tutorialSteps: TutorialStepDef[] = [
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', position: 'right',
clickToAdvance: true, clickToAdvance: false,
noSpotlight: true, noSpotlight: true,
variant: 'bubble', 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: [
@ -86,17 +134,17 @@ export const tutorialSteps: TutorialStepDef[] = [
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn', targetSelector: '.asset-upload-zone',
titleKey: 'tutorial.asset.upload.title', titleKey: 'tutorial.asset.upload.title',
descriptionKey: 'tutorial.asset.upload.desc', descriptionKey: 'tutorial.asset.upload.desc',
position: 'bottom', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.asset-ratio-section', targetSelector: '.asset-ratio-section',
titleKey: 'tutorial.asset.ratio.title', titleKey: 'tutorial.asset.ratio.title',
descriptionKey: 'tutorial.asset.ratio.desc', descriptionKey: 'tutorial.asset.ratio.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
@ -119,7 +167,7 @@ export const tutorialSteps: TutorialStepDef[] = [
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.language-grid', targetSelector: '.language-selector-wrapper',
titleKey: 'tutorial.sound.language.title', titleKey: 'tutorial.sound.language.title',
descriptionKey: 'tutorial.sound.language.desc', descriptionKey: 'tutorial.sound.language.desc',
position: 'top', position: 'top',
@ -129,7 +177,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.btn-generate-sound', targetSelector: '.btn-generate-sound',
titleKey: 'tutorial.sound.generate.title', titleKey: 'tutorial.sound.generate.title',
descriptionKey: 'tutorial.sound.generate.desc', descriptionKey: 'tutorial.sound.generate.desc',
position: 'top', position: 'right',
clickToAdvance: true, clickToAdvance: true,
}, },
], ],
@ -141,14 +189,14 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.lyrics-display', targetSelector: '.lyrics-display',
titleKey: 'tutorial.sound.lyrics.title', titleKey: 'tutorial.sound.lyrics.title',
descriptionKey: 'tutorial.sound.lyrics.desc', descriptionKey: 'tutorial.sound.lyrics.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.status-message-new', targetSelector: '.status-message-new',
titleKey: 'tutorial.sound.lyricsWait.title', titleKey: 'tutorial.sound.lyricsWait.title',
descriptionKey: 'tutorial.sound.lyricsWait.desc', descriptionKey: 'tutorial.sound.lyricsWait.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
}, },
], ],
@ -160,7 +208,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.audio-player', targetSelector: '.audio-player',
titleKey: 'tutorial.sound.audioPlayer.title', titleKey: 'tutorial.sound.audioPlayer.title',
descriptionKey: 'tutorial.sound.audioPlayer.desc', descriptionKey: 'tutorial.sound.audioPlayer.desc',
position: 'bottom', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
@ -220,21 +268,21 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.content-upload-btn', targetSelector: '.content-upload-btn',
titleKey: 'tutorial.ado2.download.title', titleKey: 'tutorial.ado2.download.title',
descriptionKey: 'tutorial.ado2.download.desc', descriptionKey: 'tutorial.ado2.download.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.content-delete-btn', targetSelector: '.content-delete-btn',
titleKey: 'tutorial.ado2.delete.title', titleKey: 'tutorial.ado2.delete.title',
descriptionKey: 'tutorial.ado2.delete.desc', descriptionKey: 'tutorial.ado2.delete.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.content-download-btn', targetSelector: '.content-download-btn',
titleKey: 'tutorial.ado2.upload.title', titleKey: 'tutorial.ado2.upload.title',
descriptionKey: 'tutorial.ado2.upload.desc', descriptionKey: 'tutorial.ado2.upload.desc',
position: 'top', position: 'right',
clickToAdvance: true, clickToAdvance: true,
}, },
], ],
@ -252,7 +300,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.comp2-video-section', targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.generating.title', titleKey: 'tutorial.completion.generating.title',
descriptionKey: 'tutorial.completion.generating.desc', descriptionKey: 'tutorial.completion.generating.desc',
position: 'top', position: 'right',
} }
] ]
}, },
@ -263,7 +311,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.comp2-video-section', targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.completion.title', titleKey: 'tutorial.completion.completion.title',
descriptionKey: 'tutorial.completion.completion.desc', descriptionKey: 'tutorial.completion.completion.desc',
position: 'top', position: 'right',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
@ -294,14 +342,14 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.social-posting-form', targetSelector: '.social-posting-form',
titleKey: 'tutorial.upload.required.title', titleKey: 'tutorial.upload.required.title',
descriptionKey: 'tutorial.upload.required.desc', descriptionKey: 'tutorial.upload.required.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.social-posting-radio-group', targetSelector: '.social-posting-radio-group',
titleKey: 'tutorial.upload.schedule.title', titleKey: 'tutorial.upload.schedule.title',
descriptionKey: 'tutorial.upload.schedule.desc', descriptionKey: 'tutorial.upload.schedule.desc',
position: 'top', position: 'left',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
@ -326,7 +374,7 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.yoy-chart-card', targetSelector: '.yoy-chart-card',
titleKey: 'tutorial.dashboard.chart.title', titleKey: 'tutorial.dashboard.chart.title',
descriptionKey: 'tutorial.dashboard.chart.desc', descriptionKey: 'tutorial.dashboard.chart.desc',
position: 'top', position: 'right',
}, },
{ {
targetSelector: '.tutorial-center-anchor', targetSelector: '.tutorial-center-anchor',
@ -345,7 +393,7 @@ export const tutorialSteps: TutorialStepDef[] = [
titleKey: 'tutorial.contentCalendar.grid.title', titleKey: 'tutorial.contentCalendar.grid.title',
descriptionKey: 'tutorial.contentCalendar.grid.desc', descriptionKey: 'tutorial.contentCalendar.grid.desc',
position: 'top', position: 'top',
clickToAdvance: false, clickToAdvance: true,
}, },
{ {
targetSelector: '.calendar-side-panel', targetSelector: '.calendar-side-panel',

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback } from 'react';
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps'; import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환 // 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
@ -19,7 +19,6 @@ function getGroupProgress(key: string, currentIndex: number): { groupTotal: numb
const SEEN_KEY = 'ado2_tutorial_seen'; const SEEN_KEY = 'ado2_tutorial_seen';
const PROGRESS_KEY = 'ado2_tutorial_progress'; const PROGRESS_KEY = 'ado2_tutorial_progress';
const ENABLED_KEY = 'ado2_tutorial_enabled';
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리 // 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
let globalSkip: (() => void) | null = null; let globalSkip: (() => void) | null = null;
@ -67,7 +66,6 @@ function clearProgress(key: string) {
interface UseTutorialReturn { interface UseTutorialReturn {
isActive: boolean; isActive: boolean;
isEnabled: boolean;
isRestartPopupVisible: boolean; isRestartPopupVisible: boolean;
currentHintIndex: number; currentHintIndex: number;
hints: TutorialHint[]; hints: TutorialHint[];
@ -77,7 +75,6 @@ interface UseTutorialReturn {
nextHint: () => void; nextHint: () => void;
prevHint: () => void; prevHint: () => void;
skipTutorial: () => void; skipTutorial: () => void;
toggleTutorial: (currentKey: string | null) => void;
showRestartPopup: (key: string) => void; showRestartPopup: (key: string) => void;
confirmRestart: () => void; confirmRestart: () => void;
cancelRestart: () => void; cancelRestart: () => void;
@ -86,7 +83,6 @@ interface UseTutorialReturn {
export function useTutorial(): UseTutorialReturn { export function useTutorial(): UseTutorialReturn {
const [isActive, setIsActive] = useState(false); const [isActive, setIsActive] = useState(false);
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false); const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null); const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
const [currentHintIndex, setCurrentHintIndex] = useState(0); const [currentHintIndex, setCurrentHintIndex] = useState(0);
@ -95,7 +91,6 @@ export function useTutorial(): UseTutorialReturn {
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined); const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => { const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
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 처리
@ -144,24 +139,6 @@ export function useTutorial(): UseTutorialReturn {
setCurrentHintIndex(0); setCurrentHintIndex(0);
}, [tutorialKey, currentHintIndex]); }, [tutorialKey, currentHintIndex]);
const toggleTutorial = useCallback((currentKey: string | null) => {
if (isEnabled) {
// off: 튜토리얼 중단 + 비활성화
setIsActive(false);
setIsEnabled(false);
localStorage.setItem(ENABLED_KEY, 'false');
} else {
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
localStorage.removeItem(SEEN_KEY);
localStorage.removeItem(PROGRESS_KEY);
localStorage.setItem(ENABLED_KEY, 'true');
setIsEnabled(true);
if (currentKey) {
startTutorial(currentKey, undefined, true);
}
}
}, [isEnabled, startTutorial]);
// 튜토리얼 다시 보기: 팝업 표시만 // 튜토리얼 다시 보기: 팝업 표시만
const showRestartPopup = useCallback((key: string) => { const showRestartPopup = useCallback((key: string) => {
setPendingRestartKey(key); setPendingRestartKey(key);
@ -189,19 +166,8 @@ export function useTutorial(): UseTutorialReturn {
return getSeenKeys().includes(key); return getSeenKeys().includes(key);
}, []); }, []);
// 영상 보유 사용자 자동 off 이벤트 수신
useEffect(() => {
const handler = () => {
setIsActive(false);
setIsEnabled(false);
};
window.addEventListener('ado2-tutorial-auto-disable', handler);
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
}, []);
return { return {
isActive, isActive,
isEnabled,
isRestartPopupVisible, isRestartPopupVisible,
currentHintIndex, currentHintIndex,
hints, hints,
@ -211,7 +177,6 @@ export function useTutorial(): UseTutorialReturn {
nextHint, nextHint,
prevHint, prevHint,
skipTutorial, skipTutorial,
toggleTutorial,
showRestartPopup, showRestartPopup,
confirmRestart, confirmRestart,
cancelRestart, cancelRestart,

View File

@ -14,8 +14,6 @@ interface UploadProgressModalProps {
youtubeUrl?: string; youtubeUrl?: string;
errorMessage?: string; errorMessage?: string;
isScheduled?: boolean; isScheduled?: boolean;
scheduledAt?: string | null;
onGoToCalendar?: () => void;
} }
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
@ -28,8 +26,6 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
youtubeUrl, youtubeUrl,
errorMessage, errorMessage,
isScheduled = false, isScheduled = false,
scheduledAt,
onGoToCalendar,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!isOpen) return null; if (!isOpen) return null;
@ -150,43 +146,14 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
{t('upload.viewOnYoutube')} {t('upload.viewOnYoutube')}
</a> </a>
)} )}
{/* Scheduled conflict notice */}
{status === 'completed' && isScheduled && scheduledAt && (
<div className="upload-progress-schedule-notice">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>
{t('upload.scheduleConflict', {
time: new Date(scheduledAt).toLocaleString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}),
})}
</span>
</div>
)}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="upload-progress-footer"> <div className="upload-progress-footer">
{canClose ? ( {canClose ? (
<> <button className="upload-progress-btn primary" onClick={onClose}>
<button className="upload-progress-btn primary" onClick={onClose}> {status === 'completed' ? t('upload.confirm') : t('upload.close')}
{status === 'completed' ? t('upload.confirm') : t('upload.close')} </button>
</button>
{status === 'completed' && onGoToCalendar && (
<span className="upload-progress-calendar-link" onClick={() => { onClose(); onGoToCalendar(); }}>
{t('upload.goToCalendar')}
</span>
)}
</>
) : ( ) : (
<p className="upload-progress-note">{t('upload.doNotClose')}</p> <p className="upload-progress-note">{t('upload.doNotClose')}</p>
)} )}

View File

@ -1,485 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
getVideoById,
getVideoComments,
postVideoComment,
deleteComment,
toggleVideoLike,
isLoggedIn,
} from '../utils/api';
import { VideoDetailItem, CommentItem } from '../types/api';
import LoginPromptModal from './LoginPromptModal';
interface VideoDetailContentProps {
videoId: string;
isModal?: boolean;
onClose?: () => void;
}
const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModal = false, onClose }) => {
const { t } = useTranslation();
const authed = isLoggedIn();
const [video, setVideo] = useState<VideoDetailItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [likeCount, setLikeCount] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [isLandscape, setIsLandscape] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [comments, setComments] = useState<CommentItem[]>([]);
const [commentsTotal, setCommentsTotal] = useState(0);
const [commentsPage, setCommentsPage] = useState(1);
const [commentsHasNext, setCommentsHasNext] = useState(false);
const [commentsLoading, setCommentsLoading] = useState(false);
const [commentInput, setCommentInput] = useState('');
const [commentSubmitting, setCommentSubmitting] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const [commentNickname, setCommentNickname] = useState('');
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
// 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
const handleChangeAvatar = useCallback(() => {
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
}, []);
const fetchComments = useCallback(async (page: number, append = false) => {
setCommentsLoading(true);
try {
const res = await getVideoComments(videoId, page, 20);
const sorted = [...res.items].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
setComments(prev => append ? [...prev, ...sorted] : sorted);
setCommentsTotal(res.total);
setCommentsHasNext(res.has_next);
setCommentsPage(page);
} catch (err) {
console.error('Failed to fetch comments:', err);
} finally {
setCommentsLoading(false);
}
}, [videoId]);
useEffect(() => {
const fetchVideo = async () => {
setLoading(true);
setError(null);
try {
const data = await getVideoById(videoId);
setVideo(data);
setLikeCount(data.like_count);
setIsLiked(data.is_liked_by_me);
} catch (err) {
console.error('Failed to fetch video:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
fetchVideo();
fetchComments(1);
}, [videoId, fetchComments]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
};
const formatCommentDate = (dateString: string) => {
const date = new Date(dateString);
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
};
const shareUrl = `${window.location.origin}/video/${videoId}`;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
// clipboard API 미지원 환경에서는 무시
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleKakaoShare = () => {
const kakao = window.Kakao;
if (kakao?.Share) {
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: video?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
description: t('videoDetail.kakaoDescription', { region: video?.region ?? '' }),
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
}
setShareMenuOpen(false);
};
const handleFacebookShare = () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleTwitterShare = () => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const shareMenuRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shareMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
setShareMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [shareMenuOpen]);
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLike = () => {
if (!authed) { setShowLoginModal(true); return; }
// 1. UI 즉시 업데이트 (Optimistic)
setIsLiked(prev => !prev);
setLikeCount(prev => isLiked ? prev - 1 : prev + 1);
// 2. 기존 debounce 타이머 취소 후 재설정
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
likeDebounceRef.current = setTimeout(async () => {
const prevLiked = isLiked;
const prevCount = likeCount;
try {
await toggleVideoLike(videoId);
} catch (err) {
// 3. 실패 시 롤백
console.error('Failed to toggle like:', err);
setIsLiked(prevLiked);
setLikeCount(prevCount);
}
}, 500);
};
const handleHeaderAction = () => {
if (!isModal && !authed) { setShowLoginModal(true); return; }
onClose?.();
};
const handleCommentFocus = () => {
if (!authed) setShowLoginModal(true);
};
const handleCommentSubmit = async () => {
if (!authed) { setShowLoginModal(true); return; }
if (!commentInput.trim() || commentSubmitting) return;
setCommentSubmitting(true);
try {
await postVideoComment(videoId, commentInput.trim(), commentNickname);
setCommentInput('');
if (commentTextareaRef.current) {
commentTextareaRef.current.style.height = 'auto';
}
setCommentNickname('');
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
await fetchComments(1);
} catch (err) {
console.error('Failed to post comment:', err);
} finally {
setCommentSubmitting(false);
}
};
const handleDeleteComment = async (commentId: number) => {
try {
await deleteComment(commentId);
await fetchComments(commentsPage);
} catch (err) {
console.error('Failed to delete comment:', err);
}
};
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>{t('videoDetail.deletedComment')}</span>;
return content;
};
return (
<div className={isModal ? 'video-detail-modal-content' : 'video-detail-page-content'}>
{/* 헤더 */}
<div className="video-detail-header">
{isModal ? (
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
) : (
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 4l-6 6 6 6"/>
</svg>
{t('sidebar.ado2Contents')}
</button>
)}
</div>
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
<p>{t('ado2Contents.loading')}</p>
</div>
) : error ? (
<div className="ado2-contents-error"><p>{error}</p></div>
) : video ? (
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<video
src={video.result_movie_url}
controls
autoPlay
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
/>
<div className="video-detail-info">
<h2 className="video-detail-store">{video.store_name}</h2>
<p className="video-detail-date">{formatDate(video.created_at)}</p>
{/* 좋아요 + 링크 복사 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className={`video-detail-like-btn ${isLiked ? 'liked' : ''}`}
onClick={handleLike}
disabled={false}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
{likeCount}
</button>
<div style={{ position: 'relative' }} ref={shareMenuRef}>
<button
className="video-detail-copy-btn"
onClick={() => setShareMenuOpen(v => !v)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
</button>
{shareMenuOpen && (
<div className="video-detail-share-menu">
{/* 카카오톡 */}
<button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FEE500"/>
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
</svg>
{t('videoDetail.shareKakao')}
</button>
{/* 페이스북 */}
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
</svg>
{t('videoDetail.shareFacebook')}
</button>
{/* X (트위터) */}
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
{t('videoDetail.shareTwitter')}
</button>
{/* URL 복사 */}
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
</button>
</div>
)}
</div>
</div>
{/* 댓글 섹션 */}
<div className="video-detail-comments">
<div className="video-detail-comments-header">
<h3 className="video-detail-comments-title">{t('videoDetail.commentsTitle')}</h3>
<span className="video-detail-comments-count">{commentsTotal}</span>
</div>
{/* 댓글 작성자 프로필 선택 */}
{authed && (
<div className="video-detail-comment-profile">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
alt={t('videoDetail.changeAvatarTitle')}
className="video-detail-comment-avatar"
onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }}
title={t('videoDetail.changeAvatarTitle')}
/>
<input
className="video-detail-nickname-input"
type="text"
placeholder={t('videoDetail.nicknamePlaceholder')}
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
/>
</div>
)}
<div className="video-detail-comment-input-wrap">
<textarea
ref={commentTextareaRef}
className="video-detail-comment-input"
placeholder={authed ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')}
maxLength={500}
rows={1}
value={commentInput}
onChange={(e) => {
setCommentInput(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onFocus={handleCommentFocus}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCommentSubmit();
}
}}
disabled={!authed}
/>
<button
className="video-detail-comment-submit"
onClick={handleCommentSubmit}
disabled={!authed || !commentInput.trim() || commentSubmitting}
>
{commentSubmitting ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')}
</button>
</div>
{comments.length === 0 && !commentsLoading ? (
<p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p>
) : (
<ul className="video-detail-comment-list">
{comments.map((c) => (
<li key={c.id} className="video-detail-comment-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
alt="avatar"
className="video-detail-comment-avatar"
/>
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{c.nickname || t('videoDetail.anonymous')}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(c.content, c.is_deleted)}
</p>
<div className="video-detail-comment-bottom">
<span className="video-detail-comment-date">{formatCommentDate(c.created_at)}</span>
{c.is_mine && !c.is_deleted && (
<button
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(c.id)}
>
{t('videoDetail.deleteComment')}
</button>
)}
</div>
{/* 대댓글 (읽기 전용) */}
{c.replies && c.replies.length > 0 && (
<ul className="video-detail-reply-list">
{c.replies.map((r) => (
<li key={r.id} className="video-detail-reply-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
alt="avatar"
className="video-detail-comment-avatar small"
/>
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{r.nickname || t('videoDetail.anonymous')}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(r.content, r.is_deleted)}
</p>
<div className="video-detail-comment-bottom">
<span className="video-detail-comment-date">{formatCommentDate(r.created_at)}</span>
{r.is_mine && !r.is_deleted && (
<button
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(r.id)}
>
{t('videoDetail.deleteComment')}
</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
)}
{commentsHasNext && (
<button
className="video-detail-comments-more"
onClick={() => fetchComments(commentsPage + 1, true)}
disabled={commentsLoading}
>
{commentsLoading ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')}
</button>
)}
</div>
</div>
</div>
) : null}
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}
</div>
);
};
export default VideoDetailContent;

View File

@ -1,56 +0,0 @@
import React, { useEffect } from 'react';
import VideoDetailContent from './VideoDetailContent';
interface VideoDetailModalProps {
videoId: string;
onClose: () => void;
}
const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose }) => {
useEffect(() => {
// 모달 열릴 때 배경 스크롤 잠금
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = prev;
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
zIndex: 2000,
display: 'flex',
overflowY: 'auto',
padding: '24px 16px',
}}
onClick={onClose}
>
<div
style={{
background: '#01282A',
borderRadius: '16px',
width: '100%',
maxWidth: '800px',
margin: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
</div>
</div>
);
};
export default VideoDetailModal;

View File

@ -1,21 +0,0 @@
import React from 'react';
import VideoDetailContent from './VideoDetailContent';
interface VideoDetailPageProps {
videoId: string;
}
const VideoDetailPage: React.FC<VideoDetailPageProps> = ({ videoId }) => {
const handleBack = () => {
localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
window.location.href = '/';
};
return (
<div className="video-detail-page">
<VideoDetailContent videoId={videoId} isModal={false} onClose={handleBack} />
</div>
);
};
export default VideoDetailPage;

View File

@ -1,53 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface WizardStepperProps {
currentStep: number; // 0~3, matching wizardStep in GenerationFlow
}
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep }) => {
const { t } = useTranslation();
const steps = [
t('wizardSteps.brandAnalysis'),
t('wizardSteps.asset'),
t('wizardSteps.sound'),
t('wizardSteps.video'),
];
return (
<div className="wizard-stepper">
{steps.map((label, index) => {
const isDone = index < currentStep;
const isCurrent = index === currentStep;
const stateClass = isDone ? ' done' : isCurrent ? ' current' : ' pending';
return (
<React.Fragment key={index}>
{/* 노드: 2rem 고정 너비. 라벨은 absolute로 노드 중심 아래에 붙음 */}
<div className={`wizard-step${stateClass}`}>
<div className="wizard-stepper-node">
{isDone ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<span>{index + 1}</span>
)}
</div>
<span className="wizard-stepper-label">{label}</span>
</div>
{/* 연결선: flex:1로 남은 공간을 균등하게 채움 */}
{index < steps.length - 1 && (
<div className={`wizard-step-line${isDone ? ' done' : ''}`} />
)}
</React.Fragment>
);
})}
</div>
);
};
export default WizardStepper;

View File

@ -2,20 +2,8 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './i18n'; import './i18n';
import './styles/index.css';
import App from './App'; import App from './App';
declare global {
interface Window {
Kakao: any;
}
}
const kakaoKey = import.meta.env.VITE_KAKAO_JS_KEY as string | undefined;
if (kakaoKey && window.Kakao && !window.Kakao.isInitialized()) {
window.Kakao.init(kakaoKey);
}
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
throw new Error("Could not find root element to mount to"); throw new Error("Could not find root element to mount to");

View File

@ -1,10 +1,4 @@
{ {
"wizardSteps": {
"brandAnalysis": "Analysis",
"asset": "Image",
"sound": "Sound",
"video": "Video"
},
"header": { "header": {
"loginFailedAlert": "Failed to get login URL. Please try again.", "loginFailedAlert": "Failed to get login URL. Please try again.",
"start": "Get Started", "start": "Get Started",
@ -18,14 +12,9 @@
"myContents": "My Contents", "myContents": "My Contents",
"myInfo": "My Info", "myInfo": "My Info",
"defaultUser": "User", "defaultUser": "User",
"credits": "Credits left: {{count}}",
"loggingOut": "Logging out...", "loggingOut": "Logging out...",
"logout": "Log Out", "logout": "Log Out",
"tutorialRestart": "Restart Tutorial", "tutorialRestart": "Restart Tutorial"
"tutorial": "Tutorial",
"tutorialOn": "Enable Tutorial",
"tutorialOff": "Disable Tutorial",
"inquiry": "Feedback"
}, },
"tutorial": { "tutorial": {
"skip": "Skip", "skip": "Skip",
@ -34,10 +23,17 @@
"finish": "Done", "finish": "Done",
"landing": { "landing": {
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." }, "intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." }, "dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." }, "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." } "button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
}, },
"analysis": {
"identity": { "title": "Brand Identity", "desc": "Check the core values and market positioning AI analyzed for your brand." },
"selling": { "title": "Key Selling Points", "desc": "See your brand's strengths." },
"persona": { "title": "Target Customer Types", "desc": "AI analyzed what kind of customers visit this brand." },
"keywords": { "title": "Recommended Keywords", "desc": "Keywords your customers are likely to search for." },
"generate": { "title": "Generate Content", "desc": "Create video content based on the analysis.\nClick to sign in with Kakao and continue." }
},
"asset": { "asset": {
"image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." }, "image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." },
"upload": { "title": "Add Images", "desc": "You can freely add more images." }, "upload": { "title": "Add Images", "desc": "You can freely add more images." },
@ -45,20 +41,14 @@
"next": { "title": "Next Step", "desc": "Proceed to the next step when ready." } "next": { "title": "Next Step", "desc": "Proceed to the next step when ready." }
}, },
"sound": { "sound": {
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." }, "genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand." },
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" }, "language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music." }, "generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music in your chosen genre and language." },
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." }, "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." }, "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." }
}, },
"completion": {
"contentInfo": { "title": "Content Info", "desc": "Check the file name, genre, resolution, and lyrics of the generated content." },
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
},
"myInfo": { "myInfo": {
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." }, "myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." }, "connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
@ -71,6 +61,12 @@
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." }, "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": {
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." },
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
},
"upload": { "upload": {
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please 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.\nPlease check them before uploading." }, "required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
@ -99,14 +95,12 @@
} }
}, },
"footer": { "footer": {
"company": "O2O Inc.", "company":"O2O Inc.",
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin", "businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
"headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea", "headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
"researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea", "researchCenter": "R&D : 13453 Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
"phone": "Tel : 070-4260-8310 | 010-2755-6463", "phone": "Tel : 070-4260-8310 | 010-2755-6463",
"email": "Email : o2oteam@o2o.kr", "email": "Email : o2oteam@o2o.kr"
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service"
}, },
"social": { "social": {
"title": "Social Media Posting", "title": "Social Media Posting",
@ -128,7 +122,7 @@
"privacyPrivate": "Private", "privacyPrivate": "Private",
"publishTimeLabel": "Publish Time", "publishTimeLabel": "Publish Time",
"publishNow": "Publish Now", "publishNow": "Publish Now",
"publishSchedule": "Schedule", "publishSchedule": "Schedule (Coming Soon)",
"footerNote": "", "footerNote": "",
"footerNoteLink": "", "footerNoteLink": "",
"cancel": "Cancel", "cancel": "Cancel",
@ -142,29 +136,7 @@
"uploadFailed": "Upload failed.", "uploadFailed": "Upload failed.",
"autoSeoTitle": "Auto-generating... (3060 sec)", "autoSeoTitle": "Auto-generating... (3060 sec)",
"autoSeoDescription": "Auto-generating... (3060 sec)", "autoSeoDescription": "Auto-generating... (3060 sec)",
"autoSeoTags": "Auto-generating... (3060 sec)", "autoSeoTags": "Auto-generating... (3060 sec)"
"youtubeCallback": {
"connecting": "Connecting YouTube...",
"pleaseWait": "Please wait a moment.",
"failed": "Connection Failed",
"errorAuthCancelled": "Google authentication was cancelled or failed.",
"errorNoCode": "Authorization code was not received.",
"errorProcessing": "An error occurred while processing the YouTube connection."
},
"connectError": {
"title": "{{platform}} Connection Failed",
"defaultError": "An unknown error occurred.",
"detail": "Please try again. If the problem persists, contact customer support.",
"retry": "Try Again"
},
"connectSuccess": {
"title": "{{name}} Connected",
"messageWithChannel": "{{platform}} account has been successfully connected.",
"messageWithoutChannel": "Account has been successfully connected.",
"verified": "Verified",
"countdown": "Redirecting in {{count}} seconds...",
"goNow": "Go Now"
}
}, },
"upload": { "upload": {
"title": "YouTube Upload", "title": "YouTube Upload",
@ -180,35 +152,23 @@
"viewOnYoutube": "View on YouTube", "viewOnYoutube": "View on YouTube",
"confirm": "OK", "confirm": "OK",
"close": "Close", "close": "Close",
"doNotClose": "Upload is in progress. Do not close this window.", "doNotClose": "Upload is in progress. Do not close this window."
"goToCalendar": "View in Calendar",
"scheduleConflict": "Schedule adjusted to {{time}} due to a conflict."
}, },
"landing": { "landing": {
"hero": { "hero": {
"searchTypeLabel": "Business Name | URL", "searchTypeBusinessName": "Business Name",
"placeholderCombined": "Enter a business name or Naver Maps/Place URL", "placeholderBusinessName": "Enter a business name",
"guideCombined": "Paste a Naver Maps share URL, or enter a business name to automatically retrieve information.", "guideUrl": "Search for a place on Naver Maps, tap Share,\nthen paste the URL here.",
"errorInputRequired": "Please enter a business name or URL.", "guideBusinessName": "Search by business name to retrieve information.",
"errorInvalidUrl": "Invalid URL format. (e.g., https://naver.me/abcdef)", "errorUrlRequired": "Please enter a URL.",
"errorNameRequired": "Please enter a business name.",
"errorInvalidUrl": "Invalid URL format. (e.g., https://example.com)",
"analyzeButton": "Brand Analysis", "analyzeButton": "Brand Analysis",
"scrollMore": "Scroll to see more", "scrollMore": "Scroll to see more",
"searching": "Searching...", "testDataLoading": "Loading...",
"searchTypeManual": "Not on Naver? Enter manually", "testData": "Test Data",
"manualButtonDesc": "Even businesses not listed on Naver Maps/Place\ncan get lyrics, music, and video production\njust by entering the name, region, and address.", "testDataLoadFailed": "Failed to load test data.",
"manualModalTitle": "Enter Business Info Directly", "searching": "Searching..."
"manualModalSubtitle": "Even businesses not on Naver can get lyrics, music, and video production by filling in the details below.",
"manualAnalyzeButton": "Get Started",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address",
"manualLabelCategory": "Category",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region",
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
}, },
"welcome": { "welcome": {
"title": "Welcome to ADO2.AI", "title": "Welcome to ADO2.AI",
@ -238,22 +198,8 @@
"guideBusinessName": "Search by business name to retrieve information.", "guideBusinessName": "Search by business name to retrieve information.",
"searchButton": "Search", "searchButton": "Search",
"searching": "Searching...", "searching": "Searching...",
"searchTypeManual": "Manual Input", "testDataLoading": "Loading...",
"errorSelectFromList": "Please select a business from the list.", "testData": "Test Data"
"errorUrlRequired": "Please enter a URL.",
"errorNameRequired": "Please enter a business name.",
"errorInvalidUrl": "Invalid URL format. (e.g. https://example.com)",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address",
"manualLabelCategory": "Category",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region",
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
}, },
"assetManagement": { "assetManagement": {
"title": "Brand Assets", "title": "Brand Assets",
@ -261,7 +207,7 @@
"imageAlt": "Image", "imageAlt": "Image",
"uploadBadge": "Uploaded", "uploadBadge": "Uploaded",
"imageUpload": "Image Upload", "imageUpload": "Image Upload",
"dragAndDrop": "Drag & drop or\nclick to upload", "dragAndDrop": "Drag and drop\nimages to upload",
"videoRatio": "Video Ratio", "videoRatio": "Video Ratio",
"minImages": "Min. 5 images", "minImages": "Min. 5 images",
"youtubeShorts": "YouTube Shorts", "youtubeShorts": "YouTube Shorts",
@ -269,7 +215,7 @@
"back": "Go Back", "back": "Go Back",
"loadMore": "Load more", "loadMore": "Load more",
"uploadFailed": "Image upload failed.", "uploadFailed": "Image upload failed.",
"uploading": "Uploading... (30 sec 1 min)", "uploading": "Uploading... (30 sec 2 min)",
"nextStep": "Next Step" "nextStep": "Next Step"
}, },
"soundStudio": { "soundStudio": {
@ -282,22 +228,19 @@
"genreLabel": "Select Genre", "genreLabel": "Select Genre",
"genreAuto": "Auto Select", "genreAuto": "Auto Select",
"genreBallad": "Ballad", "genreBallad": "Ballad",
"languageLabel": "Select Language", "languageLabel": "Language",
"languageKorean": "Korean", "languageKorean": "Korean",
"lyricsColumn": "Lyrics", "lyricsColumn": "Lyrics",
"lyricsHint": "Select the lyrics area to edit", "lyricsHint": "Select the lyrics area to edit",
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.", "lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
"lyricsPlaceholderBGM": "Background music is generated without lyrics.",
"generateButton": "Generate Sound", "generateButton": "Generate Sound",
"regenerateButton": "Regenerate",
"regenerateHint": "Press the regenerate button to create new lyrics and music.",
"generating": "Generating...", "generating": "Generating...",
"generateVideo": "Generate Video", "generateVideo": "Generate Video",
"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... (3060 sec)", "generatingLyrics": "Generating lyrics...\n(3060 sec)",
"generatingSong": "Generating music... (12 min)", "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.",
@ -308,10 +251,7 @@
"musicGenerationFailed": "Music generation failed.", "musicGenerationFailed": "Music generation failed.",
"multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.", "multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.",
"musicGenerationError": "An error occurred during music generation.", "musicGenerationError": "An error occurred during music generation.",
"songRegenerationError": "An error occurred during music regeneration.", "songRegenerationError": "An error occurred during music regeneration."
"creditsRemaining": "{{count}} left",
"creditsExhausted": "Not enough credits.",
"chargeCredits": "Top Up Credits"
}, },
"completion": { "completion": {
"back": "Go Back", "back": "Go Back",
@ -319,8 +259,6 @@
"titleError": "Video Generation Failed", "titleError": "Video Generation Failed",
"titleComplete": "Content Creation Complete", "titleComplete": "Content Creation Complete",
"imageAndVideo": "Images & Video", "imageAndVideo": "Images & Video",
"checkingSubtitle": "Checking subtitle generation status...",
"waitingSubtitle": "Generating subtitles...",
"requestingGeneration": "Requesting video generation...", "requestingGeneration": "Requesting video generation...",
"generatingVideo": "Generating video...", "generatingVideo": "Generating video...",
"processingAfterRefresh": "Processing video... (recovered after refresh)", "processingAfterRefresh": "Processing video... (recovered after refresh)",
@ -354,18 +292,7 @@
"videoNotReady": "The video is not ready yet.", "videoNotReady": "The video is not ready yet.",
"youtubeUploadMessage": "Uploading video to YouTube channel \"{{channelName}}\".\n\n(Upload feature coming soon)", "youtubeUploadMessage": "Uploading video to YouTube channel \"{{channelName}}\".\n\n(Upload feature coming soon)",
"deployComingSoon": "The deployment feature for the selected social channel is coming soon.", "deployComingSoon": "The deployment feature for the selected social channel is coming soon.",
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page.", "youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
"contentComplete": "Content Creation Complete",
"contentCompleteDesc": "Your optimized content has been created through AI analysis and editing",
"contentInfo": "File Name",
"genre": "Genre",
"resolution": "Resolution",
"lyrics": "Lyrics",
"sampleLyrics": "Loading lyrics...",
"noLyricsBGM": "Background music is generated without lyrics.",
"downloading": "Downloading...",
"download": "Download",
"uploadToSocial": "Upload"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@ -438,44 +365,13 @@
"stayIntroReel": "Stay Meomum Introduction Reel", "stayIntroReel": "Stay Meomum Introduction Reel",
"newYearEvent": "New Year Special Event", "newYearEvent": "New Year Special Event",
"nightTimelapse": "Pension Night View Timelapse" "nightTimelapse": "Pension Night View Timelapse"
}, }
"metricTotalViews": "Views",
"metricWatchTime": "Watch Time",
"metricAvgViewDuration": "Avg. Watch Duration",
"metricNewSubscribers": "New Subscribers",
"metricUploadedVideos": "Uploaded Videos",
"unitHours": "h",
"unitMinutes": "m",
"noDataInPeriod": "No data available for this period.",
"dataLoading": "Loading data...",
"noUploadsTitle": "No uploaded videos yet.",
"noUploadsDesc": "Upload videos via ADO2 to see real stats.",
"reconnectButton": "Reconnect →",
"connectButton": "Connect →",
"retryButton": "Retry →",
"selectAccount": "Select an account",
"modeAnnual": "Annual",
"modeMonthly": "Monthly",
"chartThisYear": "This Year",
"chartLastYear": "Last Year",
"chartThisMonth": "This Month",
"chartLastMonth": "Last Month",
"monthOverMonth": "Month-over-Month Growth",
"noAudienceData": "Not enough data to show real audience insights.",
"mockContentNotice": "The content shown below is sample data, not your actual content.",
"dataDelayTitle": "Data is being prepared",
"dataDelayDesc": "According to YouTube's policy, analytics data may take up to 48 hours to be reflected after a video is uploaded. Please check back later.",
"sampleHide": "Hide Sample",
"sampleShow": "Show Sample",
"recentVideosDesc": "Showing stats for the latest 30 videos uploaded via ADO2.",
"channelStatsDesc": "Showing stats for the selected channel.",
"youtubeApiFailed": "Failed to call YouTube Analytics API."
}, },
"myInfo": { "myInfo": {
"title": "My Info", "title": "My Info",
"tabBasic": "Basic Info", "tabBasic": "Basic Info",
"tabPayment": "Payment Info", "tabPayment": "Payment Info",
"tabBusiness": "Social Channel Management", "tabBusiness": "My Business & Social Channel Management",
"basicPlaceholder": "Basic information settings are coming soon.", "basicPlaceholder": "Basic information settings are coming soon.",
"paymentPlaceholder": "Payment information settings are coming soon.", "paymentPlaceholder": "Payment information settings are coming soon.",
"myBusiness": "My Business", "myBusiness": "My Business",
@ -490,22 +386,7 @@
"connected": "Connected", "connected": "Connected",
"disconnectAccount": "Disconnect", "disconnectAccount": "Disconnect",
"loadingAccounts": "Loading account information...", "loadingAccounts": "Loading account information...",
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page.", "youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
"creditsTitle": "Credit Balance",
"creditsLoading": "Loading...",
"creditsLabel": "Available Credits",
"creditsUnit": "credits",
"creditsDesc": "You can request a top-up from the admin if you run low on credits.",
"creditsChargeBtn": "Top Up",
"chargePopupTitle": "Credit Top-up Request",
"chargeAmountLabel": "Credits to Request",
"chargeNoteLabel": "Additional Notes",
"chargeNotePlaceholder": "Enter any message for the admin",
"chargeCancel": "Cancel",
"chargeSubmit": "Submit",
"chargeSubmitting": "Submitting...",
"chargeSuccess": "Your top-up request has been submitted!",
"chargeConfirm": "OK"
}, },
"ado2Contents": { "ado2Contents": {
"title": "ADO2 Contents", "title": "ADO2 Contents",
@ -524,14 +405,7 @@
"delete": "Delete", "delete": "Delete",
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"uploadToSocial": "Upload", "uploadToSocial": "Upload to social media"
"sortLatest": "Latest",
"sortOldest": "Oldest",
"sortLikes": "Most Liked",
"sortComments": "Most Commented",
"regionPlaceholder": "Select Region",
"searchPlaceholder": "Search by name",
"searchBtn": "Search"
}, },
"businessSettings": { "businessSettings": {
"title": "Business Settings", "title": "Business Settings",
@ -553,18 +427,14 @@
"marketPositioning": "Market Positioning", "marketPositioning": "Market Positioning",
"coreValue": "Core Value", "coreValue": "Core Value",
"categoryDefinition": "Category Definition", "categoryDefinition": "Category Definition",
"targetPersona": "Key Customer Type", "targetPersona": "Target Persona",
"ageSuffix": "years old", "ageSuffix": "years old",
"sellingPoints": "Key Selling Points", "sellingPoints": "Unique Selling Points (USP)",
"recommendedKeywords": "Recommended Target Keywords", "recommendedKeywords": "Recommended Target Keywords",
"generateContent": "Generate Content", "generateContent": "Generate Content",
"pageDescBefore": " reveals ", "pageDescBefore": " reveals ",
"pageDescAfter": "'s core strategy.", "pageDescAfter": "'s core strategy.",
"loadingTitle": "Analyzing Brand", "loadingTitle": "Analyzing Brand"
"loadingStep1": "Collecting brand information...",
"loadingStep2": "Analyzing collected data...",
"loadingStep3": "Deriving key strategies...",
"loadingStep4": "Organizing results..."
}, },
"common": { "common": {
"back": "Go Back", "back": "Go Back",
@ -576,74 +446,14 @@
"required": "*", "required": "*",
"unknown": "Unknown" "unknown": "Unknown"
}, },
"contentCalendar": {
"title": "Content Calendar",
"tabs": {
"all": "All",
"completed": "Done",
"scheduled": "Planned",
"failed": "Failed"
},
"status": {
"completed": "Done",
"scheduled": "Planned",
"failed": "Failed"
},
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
"yearMonth": "{{month}} {{year}}",
"monthDay": "{{month}} {{day}}",
"loading": "Loading...",
"noResults": "No recent results",
"noResultsDesc": "Upload your content to social channels",
"myContents": "My Contents",
"cancel": "Cancel",
"retry": "Retry",
"cancelConfirm": "Are you sure you want to cancel this reservation?",
"cancelFailed": "Failed to cancel.",
"retryFailed": "Failed to retry.",
"completedCount": "Done {{count}}",
"scheduledCount": "Sched {{count}}",
"failedCount": "Fail {{count}}"
},
"loginPrompt": {
"title": "Login Required",
"loginBtn": "Login"
},
"videoDetail": {
"dateFormat": "{{month}}/{{day}}/{{year}}",
"kakaoDefaultTitle": "ADO2 Video",
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
"kakaoButtonTitle": "Watch Video",
"deletedComment": "(This comment has been deleted.)",
"closeAriaLabel": "Close",
"share": "Share",
"copied": "Copied!",
"shareKakao": "KakaoTalk",
"shareFacebook": "Facebook",
"shareTwitter": "X (Twitter)",
"copyUrl": "Copy URL",
"commentsTitle": "Comments",
"changeAvatarTitle": "Click to change avatar",
"nicknamePlaceholder": "Author name",
"commentPlaceholder": "Write a comment...",
"commentLoginRequired": "Please log in to write a comment",
"commentSubmitting": "Submitting",
"commentSubmit": "Submit",
"noComments": "No comments yet.",
"anonymous": "Anonymous",
"deleteComment": "Delete",
"loadMoreComments": "Load more comments",
"loadingComments": "Loading..."
},
"app": { "app": {
"loginProcessing": "Processing login...", "loginProcessing": "Processing login...",
"loginFailed": "Login processing failed. Please try again.", "loginFailed": "Login processing failed. Please try again.",
"kakaoLoginFailed": "Kakao login failed. Please try again.", "kakaoLoginFailed": "Kakao login failed. Please try again.",
"loginUrlFailed": "Failed to get login URL. Please try again.", "loginUrlFailed": "Failed to get login URL. Please try again.",
"invalidUrl": "Invalid URL. Please enter a Naver Map URL.", "invalidUrl": "Invalid URL. Please enter a Naver Map URL.",
"analysisError": "No results found. Please check your input and try again.", "analysisError": "An error occurred during analysis. Please try again.",
"autocompleteError": "No results found. Please check your input and try again.", "autocompleteError": "Failed to retrieve business information.",
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.", "autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
"pageComingSoon": "{{page}} page is coming soon." "pageComingSoon": "{{page}} page is coming soon."
} }

View File

@ -1,10 +1,4 @@
{ {
"wizardSteps": {
"brandAnalysis": "분석",
"asset": "이미지",
"sound": "사운드",
"video": "영상"
},
"header": { "header": {
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.", "loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
"start": "시작하기", "start": "시작하기",
@ -18,14 +12,9 @@
"myContents": "내 콘텐츠", "myContents": "내 콘텐츠",
"myInfo": "내 정보", "myInfo": "내 정보",
"defaultUser": "사용자", "defaultUser": "사용자",
"credits": "보유 크레딧: {{count}}",
"loggingOut": "로그아웃 중...", "loggingOut": "로그아웃 중...",
"logout": "로그아웃", "logout": "로그아웃",
"tutorialRestart": "튜토리얼 다시 보기", "tutorialRestart": "튜토리얼 다시 보기"
"tutorial": "튜토리얼",
"tutorialOn": "튜토리얼 켜기",
"tutorialOff": "튜토리얼 끄기",
"inquiry": "고객의견"
}, },
"tutorial": { "tutorial": {
"skip": "건너뛰기", "skip": "건너뛰기",
@ -34,10 +23,17 @@
"finish": "완료", "finish": "완료",
"landing": { "landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." }, "intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." }, "dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." }, "field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
}, },
"analysis": {
"identity": { "title": "브랜드 정체성", "desc": "AI가 분석한 펜션의 핵심 가치와 시장 포지셔닝을 확인하세요." },
"selling": { "title": "주요 셀링 포인트", "desc": "펜션의 강점을 확인할 수 있어요." },
"persona": { "title": "주요 고객 유형", "desc": "어떤 고객이 이 펜션을 찾는지 분석했어요." },
"keywords": { "title": "추천 타겟 키워드", "desc": "고객이 검색할 가능성이 높은 키워드들이에요." },
"generate": { "title": "콘텐츠 생성", "desc": "분석 결과를 바탕으로 영상을 만들어 보세요.\n클릭하면 카카오 로그인으로 이동합니다." }
},
"asset": { "asset": {
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." }, "image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." }, "upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
@ -45,23 +41,17 @@
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." } "next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
}, },
"sound": { "sound": {
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요.", "note": "배경음악은 이후 오픈 예정입니다." }, "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." },
"language": { "title": "언어 선택", "desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" }, "language": { "title": "언어 선택", "desc": "사운드의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."}, "generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 선택한 장르와 언어로 가사와 음악을 생성해요."},
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." }, "lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." }, "lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." }, "audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." } "video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
}, },
"completion": {
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
},
"myInfo": { "myInfo": {
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." }, "myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." }, "connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." }, "connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." } "ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
}, },
@ -71,6 +61,12 @@
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." }, "delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." } "upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
}, },
"completion": {
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 제목,장르,규격,가사를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
},
"upload": { "upload": {
"seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." }, "seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." }, "required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
@ -101,12 +97,10 @@
"footer": { "footer": {
"company":"㈜에이아이오투오", "company":"㈜에이아이오투오",
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민", "businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호", "headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)", "researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
"phone": "전화 : 070-4260-8310 | 010-2755-6463", "phone": "전화 : 070-4260-8310 | 010-2755-6463",
"email": "이메일 : o2oteam@o2o.kr", "email": "이메일 : o2oteam@o2o.kr"
"privacyPolicy": "개인정보처리방침",
"termsOfService": "서비스 약관"
}, },
"social": { "social": {
"title": "소셜 미디어 포스팅", "title": "소셜 미디어 포스팅",
@ -142,29 +136,7 @@
"uploadFailed": "업로드에 실패했습니다.", "uploadFailed": "업로드에 실패했습니다.",
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)", "autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)", "autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)", "autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
"youtubeCallback": {
"connecting": "YouTube 연결 중...",
"pleaseWait": "잠시만 기다려주세요.",
"failed": "연결 실패",
"errorAuthCancelled": "Google 인증이 취소되었거나 실패했습니다.",
"errorNoCode": "인증 코드를 받지 못했습니다.",
"errorProcessing": "YouTube 연결 처리 중 오류가 발생했습니다."
},
"connectError": {
"title": "{{platform}} 연결 실패",
"defaultError": "알 수 없는 오류가 발생했습니다.",
"detail": "다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.",
"retry": "다시 시도"
},
"connectSuccess": {
"title": "{{name}} 연결 완료",
"messageWithChannel": "{{platform}} 계정이 성공적으로 연결되었습니다.",
"messageWithoutChannel": "계정이 성공적으로 연결되었습니다.",
"verified": "인증됨",
"countdown": "{{count}}초 후 자동으로 이동합니다...",
"goNow": "지금 이동"
}
}, },
"upload": { "upload": {
"title": "YouTube 업로드", "title": "YouTube 업로드",
@ -180,34 +152,23 @@
"viewOnYoutube": "YouTube에서 보기", "viewOnYoutube": "YouTube에서 보기",
"confirm": "확인", "confirm": "확인",
"close": "닫기", "close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.", "doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요."
"goToCalendar": "캘린더에서 확인", },
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
"landing": { "landing": {
"hero": { "hero": {
"searchTypeLabel": "업체명 | URL", "searchTypeBusinessName": "업체명",
"placeholderCombined": "업체명 또는 URL을 입력하세요.", "placeholderBusinessName": "업체명을 입력하세요.",
"guideCombined": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 정보를 가져옵니다.", "guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"errorInputRequired": "업체명 또는 URL을 입력해주세요.", "guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://naver.me/abcdef)", "errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"analyzeButton": "브랜드 분석", "analyzeButton": "브랜드 분석",
"scrollMore": "스크롤하여 더 보기", "scrollMore": "스크롤하여 더 보기",
"searching": "검색 중...", "testDataLoading": "로딩 중...",
"searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기", "testData": "테스트 데이터",
"manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.", "testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"manualModalTitle": "업체 정보 직접 입력", "searching": "검색 중..."
"manualModalSubtitle": "네이버에 등록되지 않은 업체도 아래 정보만 입력하면 가사·음악·영상 제작까지 할 수 있어요.",
"manualAnalyzeButton": "시작하기",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
}, },
"welcome": { "welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.", "title": "ADO2.AI에 오신 것을 환영합니다.",
@ -237,22 +198,8 @@
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.", "guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기", "searchButton": "검색하기",
"searching": "검색 중...", "searching": "검색 중...",
"searchTypeManual": "직접 입력", "testDataLoading": "로딩 중...",
"errorSelectFromList": "목록에서 업체를 선택해 주세요.", "testData": "테스트 데이터"
"errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
}, },
"assetManagement": { "assetManagement": {
"title": "브랜드 에셋", "title": "브랜드 에셋",
@ -260,7 +207,7 @@
"imageAlt": "이미지", "imageAlt": "이미지",
"uploadBadge": "업로드", "uploadBadge": "업로드",
"imageUpload": "이미지 업로드", "imageUpload": "이미지 업로드",
"dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드", "dragAndDrop": "이미지를 드래그하여\n업로드",
"videoRatio": "영상 비율", "videoRatio": "영상 비율",
"minImages": "최소 5장", "minImages": "최소 5장",
"youtubeShorts": "유튜브 쇼츠", "youtubeShorts": "유튜브 쇼츠",
@ -268,7 +215,7 @@
"back": "뒤로가기", "back": "뒤로가기",
"loadMore": "더보기", "loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.", "uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중 (30~60초 소요)", "uploading": "업로드 중 (30~2분 소요)",
"nextStep": "다음 단계" "nextStep": "다음 단계"
}, },
"soundStudio": { "soundStudio": {
@ -281,15 +228,12 @@
"genreLabel": "장르 선택", "genreLabel": "장르 선택",
"genreAuto": "자동 선택", "genreAuto": "자동 선택",
"genreBallad": "발라드", "genreBallad": "발라드",
"languageLabel": "언어 선택", "languageLabel": "언어",
"languageKorean": "한국어", "languageKorean": "한국어",
"lyricsColumn": "가사", "lyricsColumn": "가사",
"lyricsHint": "가사 영역을 선택해서 수정 가능해요", "lyricsHint": "가사 영역을 선택해서 수정 가능해요",
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.", "lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
"lyricsPlaceholderBGM": "배경음악은 가사 없이 생성됩니다.",
"generateButton": "사운드 생성", "generateButton": "사운드 생성",
"regenerateButton": "재생성 하기",
"regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.",
"generating": "생성 중...", "generating": "생성 중...",
"generateVideo": "영상 생성하기", "generateVideo": "영상 생성하기",
"videoGenerating": "영상 생성 중", "videoGenerating": "영상 생성 중",
@ -307,10 +251,7 @@
"musicGenerationFailed": "음악 생성에 실패했습니다.", "musicGenerationFailed": "음악 생성에 실패했습니다.",
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.", "multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.", "musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다.", "songRegenerationError": "음악 재생성 중 오류가 발생했습니다."
"creditsRemaining": "잔여 {{count}}",
"creditsExhausted": "크레딧이 부족합니다.",
"chargeCredits": "크레딧 충전하기"
}, },
"completion": { "completion": {
"back": "뒤로가기", "back": "뒤로가기",
@ -318,8 +259,6 @@
"titleError": "영상 생성 실패", "titleError": "영상 생성 실패",
"titleComplete": "콘텐츠 제작 완료", "titleComplete": "콘텐츠 제작 완료",
"imageAndVideo": "이미지 및 영상", "imageAndVideo": "이미지 및 영상",
"checkingSubtitle": "자막 생성 상태를 확인하고 있습니다...",
"waitingSubtitle": "자막을 생성하고 있습니다...",
"requestingGeneration": "영상 생성을 요청하고 있습니다...", "requestingGeneration": "영상 생성을 요청하고 있습니다...",
"generatingVideo": "영상을 생성하고 있습니다...", "generatingVideo": "영상을 생성하고 있습니다...",
"processingAfterRefresh": "영상을 처리하고 있습니다... (새로고침 후 복구됨)", "processingAfterRefresh": "영상을 처리하고 있습니다... (새로고침 후 복구됨)",
@ -353,18 +292,7 @@
"videoNotReady": "영상이 아직 준비되지 않았습니다.", "videoNotReady": "영상이 아직 준비되지 않았습니다.",
"youtubeUploadMessage": "YouTube 채널 \"{{channelName}}\"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)", "youtubeUploadMessage": "YouTube 채널 \"{{channelName}}\"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)",
"deployComingSoon": "선택한 소셜 채널에 배포 기능이 준비 중입니다.", "deployComingSoon": "선택한 소셜 채널에 배포 기능이 준비 중입니다.",
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.", "youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
"contentComplete": "콘텐츠 제작 완료",
"contentCompleteDesc": "AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다",
"contentInfo": "파일명",
"genre": "장르",
"resolution": "규격",
"lyrics": "가사",
"sampleLyrics": "가사를 불러오는 중...",
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
"downloading": "다운로드 중...",
"download": "다운로드",
"uploadToSocial": "업로드"
}, },
"dashboard": { "dashboard": {
"title": "대시보드", "title": "대시보드",
@ -437,44 +365,13 @@
"stayIntroReel": "스테이 머뭄 소개 릴스", "stayIntroReel": "스테이 머뭄 소개 릴스",
"newYearEvent": "신년 특가 이벤트 안내", "newYearEvent": "신년 특가 이벤트 안내",
"nightTimelapse": "펜션 야경 타임랩스" "nightTimelapse": "펜션 야경 타임랩스"
}, }
"metricTotalViews": "조회수",
"metricWatchTime": "시청시간",
"metricAvgViewDuration": "평균 시청시간",
"metricNewSubscribers": "신규 구독자",
"metricUploadedVideos": "업로드 영상",
"unitHours": "시간",
"unitMinutes": "분",
"noDataInPeriod": "이 기간에 데이터가 없습니다.",
"dataLoading": "데이터 로딩 중...",
"noUploadsTitle": "아직 업로드된 영상이 없습니다.",
"noUploadsDesc": "ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다.",
"reconnectButton": "재연동하러 가기 →",
"connectButton": "연동하러 가기 →",
"retryButton": "재시도 →",
"selectAccount": "계정을 선택하세요",
"modeAnnual": "연간",
"modeMonthly": "월간",
"chartThisYear": "올해",
"chartLastYear": "작년",
"chartThisMonth": "이번달",
"chartLastMonth": "지난달",
"monthOverMonth": "전월 대비 성장",
"noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
"mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
"dataDelayTitle": "데이터 준비 중",
"dataDelayDesc": "유튜브 정책에 따라 분석 데이터는 영상 업로드 후 최대 48시간이 지나야 반영됩니다. 나중에 다시 확인해 주세요.",
"sampleHide": "Sample 숨기기",
"sampleShow": "Sample 보기",
"recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",
"channelStatsDesc": "선택한 채널의 통계가 표시됩니다.",
"youtubeApiFailed": "YouTube Analytics API 호출에 실패했습니다."
}, },
"myInfo": { "myInfo": {
"title": "내 정보", "title": "내 정보",
"tabBasic": "기본 정보", "tabBasic": "기본 정보",
"tabPayment": "결제 정보", "tabPayment": "결제 정보",
"tabBusiness": "소셜 채널 관리", "tabBusiness": "내 비즈니스 & 소셜 채널 관리",
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.", "basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.", "paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
"myBusiness": "내 비즈니스", "myBusiness": "내 비즈니스",
@ -489,22 +386,7 @@
"connected": "연결됨", "connected": "연결됨",
"disconnectAccount": "연결 해제", "disconnectAccount": "연결 해제",
"loadingAccounts": "계정 정보를 불러오는 중...", "loadingAccounts": "계정 정보를 불러오는 중...",
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.", "youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
"creditsTitle": "크레딧 현황",
"creditsLoading": "로딩 중...",
"creditsLabel": "보유 크레딧",
"creditsUnit": "크레딧",
"creditsDesc": "크레딧이 부족하면 관리자에게 충전 요청할 수 있습니다.",
"creditsChargeBtn": "충전하기",
"chargePopupTitle": "크레딧 충전 요청",
"chargeAmountLabel": "충전 크레딧",
"chargeNoteLabel": "기타 내용",
"chargeNotePlaceholder": "관리자에게 전달할 내용을 입력해주세요",
"chargeCancel": "취소",
"chargeSubmit": "요청하기",
"chargeSubmitting": "요청 중...",
"chargeSuccess": "충전 요청이 완료 되었습니다!",
"chargeConfirm": "확인"
}, },
"ado2Contents": { "ado2Contents": {
"title": "ADO2 콘텐츠", "title": "ADO2 콘텐츠",
@ -523,14 +405,7 @@
"delete": "삭제", "delete": "삭제",
"previous": "이전", "previous": "이전",
"next": "다음", "next": "다음",
"uploadToSocial": "업로드", "uploadToSocial": "소셜 미디어에 업로드"
"sortLatest": "최신순",
"sortOldest": "오래된순",
"sortLikes": "좋아요 많은순",
"sortComments": "댓글 많은순",
"regionPlaceholder": "지역 선택",
"searchPlaceholder": "업체명 검색",
"searchBtn": "검색"
}, },
"businessSettings": { "businessSettings": {
"title": "비즈니스 설정", "title": "비즈니스 설정",
@ -559,11 +434,7 @@
"generateContent": "콘텐츠 생성", "generateContent": "콘텐츠 생성",
"pageDescBefore": "을 통해 도출된 ", "pageDescBefore": "을 통해 도출된 ",
"pageDescAfter": "의 핵심 전략입니다.", "pageDescAfter": "의 핵심 전략입니다.",
"loadingTitle": "브랜드 분석 중", "loadingTitle": "브랜드 분석 중"
"loadingStep1": "브랜드 정보를 수집하는 중...",
"loadingStep2": "수집한 데이터를 분석하는 중...",
"loadingStep3": "핵심 전략을 도출하는 중...",
"loadingStep4": "결과를 정리하는 중..."
}, },
"common": { "common": {
"back": "뒤로가기", "back": "뒤로가기",
@ -575,74 +446,14 @@
"required": "*", "required": "*",
"unknown": "알 수 없음" "unknown": "알 수 없음"
}, },
"contentCalendar": {
"title": "콘텐츠 캘린더",
"tabs": {
"all": "전체",
"completed": "완료",
"scheduled": "예약",
"failed": "실패"
},
"status": {
"completed": "완료",
"scheduled": "예약",
"failed": "실패"
},
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
"days": ["일","월","화","수","목","금","토"],
"yearMonth": "{{year}}년 {{month}}",
"monthDay": "{{month}} {{day}}일",
"loading": "불러오는 중...",
"noResults": "최근 결과 없음",
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
"myContents": "내 콘텐츠",
"cancel": "취소",
"retry": "재시도",
"cancelConfirm": "예약을 취소하시겠습니까?",
"cancelFailed": "취소에 실패했습니다.",
"retryFailed": "재시도에 실패했습니다.",
"completedCount": "완료 {{count}}",
"scheduledCount": "예약 {{count}}",
"failedCount": "실패 {{count}}"
},
"loginPrompt": {
"title": "로그인이 필요합니다.",
"loginBtn": "로그인"
},
"videoDetail": {
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
"kakaoDefaultTitle": "ADO2 영상",
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
"kakaoButtonTitle": "영상 보기",
"deletedComment": "(삭제된 댓글입니다.)",
"closeAriaLabel": "닫기",
"share": "공유하기",
"copied": "복사됨!",
"shareKakao": "카카오톡",
"shareFacebook": "페이스북",
"shareTwitter": "X (트위터)",
"copyUrl": "URL 복사",
"commentsTitle": "댓글",
"changeAvatarTitle": "클릭하여 아바타 변경",
"nicknamePlaceholder": "작성자 이름",
"commentPlaceholder": "댓글을 입력하세요...",
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
"commentSubmitting": "작성 중",
"commentSubmit": "작성",
"noComments": "아직 댓글이 없습니다.",
"anonymous": "익명",
"deleteComment": "삭제",
"loadMoreComments": "댓글 더 보기",
"loadingComments": "불러오는 중..."
},
"app": { "app": {
"loginProcessing": "로그인 처리 중...", "loginProcessing": "로그인 처리 중...",
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.", "loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
"kakaoLoginFailed": "카카오 로그인에 실패했습니다. 다시 시도해주세요.", "kakaoLoginFailed": "카카오 로그인에 실패했습니다. 다시 시도해주세요.",
"loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.", "loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
"invalidUrl": "유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.", "invalidUrl": "유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.",
"analysisError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.", "analysisError": "분석 중 오류가 발생했습니다. 다시 시도해주세요.",
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.", "autocompleteError": "업체 정보 조회에 실패했습니다.",
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.", "autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
"pageComingSoon": "{{page}} 페이지 준비 중입니다." "pageComingSoon": "{{page}} 페이지 준비 중입니다."
} }

View File

@ -1,3 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CrawlingResponse, TargetPersona } from '../../types/api'; import { CrawlingResponse, TargetPersona } from '../../types/api';

View File

@ -1,56 +1,15 @@
import React, { useEffect, useRef, useState } from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface LoadingSectionProps { const LoadingSection: React.FC = () => {
onComplete?: () => void;
isComplete?: boolean;
}
const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [progress, setProgress] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
if (intervalRef.current) clearInterval(intervalRef.current);
return 100;
}
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
return Math.min(prev + increment, 100);
});
}, 200);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
useEffect(() => {
if (!isComplete) return;
if (intervalRef.current) clearInterval(intervalRef.current);
setProgress(100);
const timer = setTimeout(() => {
onComplete?.();
}, 400);
return () => clearTimeout(timer);
}, [isComplete]);
const getStepMessage = (p: number) => {
if (p < 30) return t('analysis.loadingStep1');
if (p < 50) return t('analysis.loadingStep2');
if (p < 80) return t('analysis.loadingStep3');
return t('analysis.loadingStep4');
};
return ( return (
<div className="loading-container"> <div className="loading-container">
<div className="loading-content"> <div className="loading-content">
{/* Logo */} {/* Logo */}
<div className="loading-logo"> <div className="loading-logo">
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" /> <img src="/assets/images/ado2-loading-logo.svg" alt="ADO2" />
</div> </div>
{/* Loading Spinner and Text */} {/* Loading Spinner and Text */}
@ -64,17 +23,6 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
className="loading-spinner-icon" className="loading-spinner-icon"
/> />
</div> </div>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(progress)}%</span>
</div>
<p className="loading-step-message">{getStepMessage(progress)}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,128 +1,148 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getAllVideos, isLoggedIn } from '../../utils/api'; import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api'; import { VideoListItem } from '../../types/api';
import LoginPromptModal from '../../components/LoginPromptModal'; import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import CitySelectModal from '../../components/CitySelectModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
interface ADO2ContentsPageProps { interface ADO2ContentsPageProps {
onBack?: () => void; onBack: () => void;
} }
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => { const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const authed = isLoggedIn();
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const [videos, setVideos] = useState<VideoListItem[]>([]); const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(authed); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false); const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false); const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
const pageSize = 20; const pageSize = 12;
const [sortBy, setSortBy] = useState<'created_at' | 'like_count' | 'comment_count'>('created_at');
const [order, setOrder] = useState<'desc' | 'asc'>('desc');
const [searchInput, setSearchInput] = useState('');
const [storeName, setStoreName] = useState('');
const [region, setRegion] = useState('');
const [showCityModal, setShowCityModal] = useState(false);
useEffect(() => { useEffect(() => {
if (!authed) return;
fetchVideos(); fetchVideos();
}, [page, sortBy, order, storeName, region]); }, [page]);
const fetchVideos = async () => { const fetchVideos = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await getAllVideos(page, pageSize, sortBy, storeName, order, region); const response = await getVideosList(page, pageSize);
setVideos(response.items); console.log('[ADO2] API response:', response);
console.log('[ADO2] First video item:', response.items[0]);
// result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외)
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
setVideos(validVideos);
setTotal(response.total); setTotal(response.total);
setTotalPages(response.total_pages); setTotalPages(response.total_pages);
setHasNext(response.has_next); setHasNext(response.has_next);
setHasPrev(response.has_prev); setHasPrev(response.has_prev);
} catch (err) { } catch (err) {
console.error('Failed to fetch all videos:', err); console.error('Failed to fetch videos:', err);
setError(t('ado2Contents.loadFailed')); setError(t('ado2Contents.loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleCardClick = (videoId: number) => {
setSelectedVideoId(videoId);
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
return `${year}.${month}.${day}`; const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}${hours}:${minutes}`;
};
const formatTitle = (storeName: string, dateString: string) => {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
};
const handleDownload = async (videoUrl: string, storeName: string) => {
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${storeName}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download failed:', err);
alert(t('ado2Contents.downloadFailed'));
}
};
const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(videoId);
setDeleteModalOpen(true);
};
const handleUploadClick = (video: VideoListItem) => {
console.log('[ADO2] Upload clicked - video object:', video);
console.log('[ADO2] video.video_id:', video.video_id);
setUploadTargetVideo(video);
setUploadModalOpen(true);
};
const handleUploadModalClose = () => {
setUploadModalOpen(false);
setUploadTargetVideo(null);
};
const handleDeleteCancel = () => {
setDeleteModalOpen(false);
setDeleteTargetId(null);
};
const handleDeleteConfirm = async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
await deleteVideo(deleteTargetId);
// 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
// fetchVideos()를 호출하지 않음 - 서버 캐시 또는 동기화 지연으로 인해
// 삭제된 항목이 다시 나타날 수 있기 때문
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false);
setDeleteTargetId(null);
} catch (err) {
console.error('Delete failed:', err);
alert(t('ado2Contents.deleteFailed'));
} finally {
setIsDeleting(false);
}
}; };
return ( return (
<div className="ado2-contents-page"> <div className="ado2-contents-page">
{/* Header */}
<div className="ado2-contents-header"> <div className="ado2-contents-header">
<h1 className="ado2-contents-title">{t('sidebar.ado2Contents')}</h1> <h1 className="ado2-contents-title">{t('ado2Contents.title')}</h1>
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span> <span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
</div> </div>
<div className="ado2-contents-filters"> {/* Content Grid */}
<select
className="ado2-filter-select"
value={`${sortBy}__${order}`}
onChange={(e) => {
const [sb, ord] = e.target.value.split('__') as [typeof sortBy, typeof order];
setSortBy(sb);
setOrder(ord);
setPage(1);
}}
>
<option value="created_at__desc">{t('ado2Contents.sortLatest')}</option>
<option value="created_at__asc">{t('ado2Contents.sortOldest')}</option>
<option value="like_count__desc">{t('ado2Contents.sortLikes')}</option>
<option value="comment_count__desc">{t('ado2Contents.sortComments')}</option>
</select>
<button
type="button"
className={`ado2-region-pill ${region ? 'active' : ''}`}
onClick={() => setShowCityModal(true)}
>
{region || t('ado2Contents.regionPlaceholder')}
{region && (
<span className="ado2-region-clear" onClick={(e) => { e.stopPropagation(); setRegion(''); setPage(1); }}></span>
)}
</button>
<form
className="ado2-filter-search"
onSubmit={(e) => {
e.preventDefault();
setStoreName(searchInput);
setPage(1);
}}
>
<input
type="text"
className="ado2-filter-input"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder={t('ado2Contents.searchPlaceholder')}
/>
<button type="submit" className="ado2-filter-btn">
{t('ado2Contents.searchBtn')}
</button>
</form>
</div>
{loading ? ( {loading ? (
<div className="ado2-contents-loading"> <div className="ado2-contents-loading">
<div className="loading-spinner"></div> <div className="loading-spinner"></div>
@ -141,62 +161,89 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<> <>
<div className="ado2-contents-grid"> <div className="ado2-contents-grid">
{videos.map((video) => ( {videos.map((video) => (
<div <div key={video.task_id} className="ado2-content-card">
key={video.video_id} {/* Video Thumbnail */}
className="ado2-content-card" <div className="content-card-thumbnail">
style={{ cursor: 'pointer' }} {video.result_movie_url ? (
onClick={() => handleCardClick(video.video_id)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
>
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt={video.store_name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : video.result_movie_url ? (
<video <video
src={video.result_movie_url} src={video.result_movie_url}
className="content-video-preview" className="content-video-preview"
preload="metadata"
muted muted
playsInline playsInline
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/> />
) : ( ) : (
<div className="content-no-video" /> <div className="content-no-video">
)} <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
{/* 호버 오버레이 */} <rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<div className="ado2-gallery-overlay"> <line x1="7" y1="2" x2="7" y2="22"/>
<div className="ado2-gallery-play-btn"> <line x1="17" y1="2" x2="17" y2="22"/>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"> <line x1="2" y1="12" x2="22" y2="12"/>
<polygon points="5,3 19,12 5,21"/> <line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg> </svg>
</div> </div>
</div> )}
</div> </div>
{/* Card Info */}
<div className="content-card-info"> <div className="content-card-info">
<div className="content-card-text"> <div className="content-card-text">
<h3 className="content-card-title">{video.store_name}</h3> <h3 className="content-card-title">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> {formatTitle(video.store_name, video.created_at)}
<p className="content-card-date">{formatDate(video.created_at)}</p> </h3>
<ContentCardSocialActions <p className="content-card-date">
videoId={video.video_id} {formatDate(video.created_at)}
storeName={video.store_name} </p>
region={video.region} </div>
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0} {/* Action Buttons */}
initialIsLiked={video.is_liked_by_me} <div className="content-card-actions">
/> <button
</div> className="content-download-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
title={t('ado2Contents.download')}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
</button>
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video.video_id)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
{/* Pagination - 항상 표시 */}
<div className="ado2-contents-pagination"> <div className="ado2-contents-pagination">
<button <button
className="pagination-btn" className="pagination-btn"
@ -217,24 +264,38 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
</> </>
)} )}
{!authed && ( {/* 삭제 확인 모달 */}
<LoginPromptModal onClose={() => { window.location.href = '/'; }} /> {deleteModalOpen && (
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
<div className="delete-modal-actions">
<button
className="delete-modal-btn cancel"
onClick={handleDeleteCancel}
disabled={isDeleting}
>
{t('ado2Contents.cancel')}
</button>
<button
className="delete-modal-btn confirm"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
</button>
</div>
</div>
</div>
)} )}
{selectedVideoId !== null && ( {/* 소셜 미디어 업로드 모달 */}
<VideoDetailModal <SocialPostingModal
videoId={String(selectedVideoId)} isOpen={uploadModalOpen}
onClose={() => setSelectedVideoId(null)} onClose={handleUploadModalClose}
/> video={uploadTargetVideo}
)} />
{showCityModal && (
<CitySelectModal
selected={region}
onSelect={(city) => { setRegion(city); setPage(1); }}
onClose={() => setShowCityModal(false)}
/>
)}
</div> </div>
); );
}; };

View File

@ -26,7 +26,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical'); const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE); const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
@ -44,23 +43,15 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}; };
const getImageSrc = (item: ImageItem): string => { const getImageSrc = (item: ImageItem): string => {
return item.type === 'url' ? item.preview_url : item.preview; return item.type === 'url' ? item.url : item.preview;
}; };
const handleNextWithUpload = async () => { const handleNextWithUpload = async () => {
if (imageList.length === 0) return; if (imageList.length === 0) return;
setIsUploading(true); setIsUploading(true);
setUploadProgress(0);
setUploadError(null); setUploadError(null);
const interval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 99) return prev;
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
});
}, 200);
try { try {
const urlImages: ImageUrlItem[] = imageList const urlImages: ImageUrlItem[] = imageList
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url') .filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
@ -72,23 +63,13 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
if (urlImages.length > 0 || fileImages.length > 0) { if (urlImages.length > 0 || fileImages.length > 0) {
const response = await uploadImages(urlImages, fileImages); const response = await uploadImages(urlImages, fileImages);
clearInterval(interval);
setUploadProgress(100);
// 이미지 변경 시 이전 음악/영상 생성 결과 초기화 — 반드시 재생성하도록 강제
localStorage.removeItem('castad_song_generation');
localStorage.removeItem('castad_song_completion');
localStorage.removeItem('castad_song_task_id');
localStorage.removeItem('castad_video_generation');
localStorage.removeItem('castad_video_complete');
onNext(response.task_id); onNext(response.task_id);
} }
} catch (error) { } catch (error) {
clearInterval(interval);
console.error('Image upload failed:', error); console.error('Image upload failed:', error);
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed')); setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
} finally { } finally {
setIsUploading(false); setIsUploading(false);
setUploadProgress(0);
} }
}; };
@ -123,29 +104,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
return ( return (
<main className="asset-page"> <main className="asset-page">
{isUploading && (
<div className="asset-upload-overlay">
<div className="asset-upload-overlay-content">
<div className="loading-spinner">
<div className="loading-ring"></div>
<div className="loading-dot">
<div className="loading-dot-inner"></div>
</div>
</div>
<p className="comp2-loading-text">{t('assetManagement.uploading')}</p>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(uploadProgress)}%</span>
</div>
</div>
</div>
)}
{/* Fixed Header - 뒤로가기 버튼 */} {/* Fixed Header - 뒤로가기 버튼 */}
<div className="asset-sticky-header"> <div className="asset-sticky-header">
{onBack && ( {onBack && (
@ -187,7 +145,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<img <img
src={getImageSrc(item)} src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`} alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
referrerPolicy="no-referrer"
/> />
{item.type === 'file' && ( {item.type === 'file' && (
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div> <div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>

View File

@ -0,0 +1,21 @@
import React from 'react';
import Sidebar from '../../components/Sidebar';
import AssetManagementContent from './AssetManagementContent';
interface AssetManagementSectionProps {
onBack: () => void;
}
const AssetManagementSection: React.FC<AssetManagementSectionProps> = ({ onBack }) => {
return (
<div className="flex w-full h-screen bg-[#002224] text-white overflow-hidden">
<Sidebar />
<div className="flex-1 overflow-y-auto">
<AssetManagementContent onBack={onBack} onNext={() => {}} />
</div>
</div>
);
};
export default AssetManagementSection;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete } from '../../utils/api'; import { generateVideo, waitForVideoComplete } from '../../utils/api';
import SocialPostingModal from '../../components/SocialPostingModal'; import SocialPostingModal from '../../components/SocialPostingModal';
import { useTutorial } from '../../components/Tutorial/useTutorial'; import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
@ -12,7 +12,6 @@ interface CompletionContentProps {
songTaskId: string | null; songTaskId: string | null;
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void; onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
onVideoProgressChange?: (progress: number) => void; onVideoProgressChange?: (progress: number) => void;
onGoToCalendar?: () => void;
} }
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error'; type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
@ -33,8 +32,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
onBack, onBack,
songTaskId, songTaskId,
onVideoStatusChange, onVideoStatusChange,
onVideoProgressChange, onVideoProgressChange
onGoToCalendar,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle'); const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
@ -42,25 +40,21 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [renderProgress, setRenderProgress] = useState(0); const [renderProgress, setRenderProgress] = useState(0);
const [displayProgress, setDisplayProgress] = useState(0);
const hasStartedGeneration = useRef(false); const hasStartedGeneration = useRef(false);
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const tutorial = useTutorial(); const tutorial = useTutorial();
const tutorialIsActiveRef = useRef(tutorial.isActive);
tutorialIsActiveRef.current = tutorial.isActive;
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동) // 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
useEffect(() => { useEffect(() => {
const isComplete = videoStatus === 'complete'; const isComplete = videoStatus === 'complete';
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling'; const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
if (isProcessing && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) { if (isProcessing && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING); tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
} else if (isComplete && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) { } else if (isComplete && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION); tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
} }
}, [videoStatus]); }, [videoStatus, tutorial]);
// 소셜 미디어 포스팅 모달 // 소셜 미디어 포스팅 모달
const [showSocialModal, setShowSocialModal] = useState(false); const [showSocialModal, setShowSocialModal] = useState(false);
@ -89,31 +83,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
}, [renderProgress, onVideoProgressChange]); }, [renderProgress, onVideoProgressChange]);
useEffect(() => {
if (displayIntervalRef.current) clearInterval(displayIntervalRef.current);
if (renderProgress === 100) {
setDisplayProgress(100);
return;
}
// renderProgress에 도달한 후에도 99%까지 서서히 크리핑
const CREEP_MAX = 99;
displayIntervalRef.current = setInterval(() => {
setDisplayProgress(prev => {
const target = Math.max(prev, renderProgress);
if (prev >= CREEP_MAX) return prev;
const increment = prev < 70 ? 0.2 : prev < 90 ? 0.04 : 0.01;
return Math.min(prev + increment, Math.max(target, Math.min(prev + increment, CREEP_MAX)));
});
}, 100);
return () => {
if (displayIntervalRef.current) clearInterval(displayIntervalRef.current);
};
}, [renderProgress]);
const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null, dbId?: number) => { const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null, dbId?: number) => {
const data: SavedVideoState = { const data: SavedVideoState = {
videoTaskId, videoTaskId,
@ -174,19 +143,10 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
hasStartedGeneration.current = true; hasStartedGeneration.current = true;
setVideoStatus('generating'); setVideoStatus('generating');
setStatusMessage(t('completion.checkingSubtitle')); setStatusMessage(t('completion.requestingGeneration'));
setErrorMessage(null); setErrorMessage(null);
try { try {
// 자막 완료 여부 확인 후 미완료면 폴링
const subtitleStatus = await getSubtitleStatus(songTaskId);
if (subtitleStatus.status !== 'completed') {
setStatusMessage(t('completion.waitingSubtitle'));
await waitForSubtitleComplete(songTaskId);
}
setStatusMessage(t('completion.requestingGeneration'));
const savedRatio = localStorage.getItem('castad_video_ratio'); const savedRatio = localStorage.getItem('castad_video_ratio');
const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical'; const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical';
@ -294,7 +254,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoUrl(completeVideo.videoUrl); setVideoUrl(completeVideo.videoUrl);
if (completeVideo.videoDbId) setVideoDbId(completeVideo.videoDbId); if (completeVideo.videoDbId) setVideoDbId(completeVideo.videoDbId);
setVideoStatus('complete'); setVideoStatus('complete');
setShowComplete(true);
hasStartedGeneration.current = true; hasStartedGeneration.current = true;
return; return;
} }
@ -306,7 +265,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoUrl(savedState.videoUrl); setVideoUrl(savedState.videoUrl);
if (savedState.videoDbId) setVideoDbId(savedState.videoDbId); if (savedState.videoDbId) setVideoDbId(savedState.videoDbId);
setVideoStatus('complete'); setVideoStatus('complete');
setShowComplete(true);
hasStartedGeneration.current = true; hasStartedGeneration.current = true;
} else if (savedState.status === 'polling') { } else if (savedState.status === 'polling') {
setVideoStatus('polling'); setVideoStatus('polling');
@ -338,32 +296,15 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
}, []); }, []);
const [isDownloading, setIsDownloading] = useState(false); const handleDownload = () => {
if (videoUrl) {
const handleDownload = async () => {
if (!videoUrl || isDownloading) return;
setIsDownloading(true);
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = getFileName();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = videoUrl; link.href = videoUrl;
link.download = getFileName(); link.download = 'castad_video.mp4';
link.target = '_blank'; link.target = '_blank';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} finally {
setIsDownloading(false);
} }
}; };
@ -380,20 +321,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoStatus('idle'); setVideoStatus('idle');
setVideoUrl(null); setVideoUrl(null);
setErrorMessage(null); setErrorMessage(null);
setShowComplete(false);
clearStorage(); clearStorage();
startVideoGeneration(); startVideoGeneration();
}; };
const [showComplete, setShowComplete] = useState(false); const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
useEffect(() => {
if (displayProgress < 100) return;
const timer = setTimeout(() => setShowComplete(true), 500);
return () => clearTimeout(timer);
}, [displayProgress]);
const isLoading = videoStatus === 'generating' || videoStatus === 'polling' || (videoStatus === 'complete' && !showComplete);
// 비디오 해상도 계산 // 비디오 해상도 계산
const getVideoResolution = () => { const getVideoResolution = () => {
@ -409,18 +341,18 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return ( return (
<main className="comp2-page"> <main className="comp2-page">
{/* <div className="comp2-header"> <div className="comp2-header">
<button onClick={onBack} className="comp2-back-btn"> <button onClick={onBack} className="comp2-back-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
<span>{t('completion.back')}</span> <span>{t('completion.back')}</span>
</button> </button>
</div> */} </div>
<div className="comp2-title-row"> <div className="comp2-title-row">
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1> <h1 className="comp2-page-title">{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}</h1>
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</p> <p className="comp2-page-subtitle">{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}</p>
</div> </div>
<div className="comp2-container"> <div className="comp2-container">
@ -437,15 +369,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</div> </div>
</div> </div>
<p className="comp2-loading-text">{statusMessage}</p> <p className="comp2-loading-text">{statusMessage}</p>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${displayProgress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(displayProgress)}%</span>
</div>
</div> </div>
) : videoStatus === 'error' ? ( ) : videoStatus === 'error' ? (
<div className="comp2-video-error"> <div className="comp2-video-error">
@ -474,54 +397,27 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
{/* 오른쪽: 콘텐츠 정보 */} {/* 오른쪽: 콘텐츠 정보 */}
<div className="comp2-info-section"> <div className="comp2-info-section">
<div className="comp2-info-header"> <div className="comp2-info-header">
<span className="comp2-info-label">{t('completion.contentInfo')}</span> <span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '콘텐츠 정보' })}</span>
</div>
<div className="comp2-file-info">
<h3 className="comp2-filename">{getFileName()}</h3>
{/* <p className="comp2-filesize">19.6MB</p> */}
</div> </div>
<div className="comp2-info-content"> <div className="comp2-info-content">
<div className="comp2-file-info">
<h3 className="comp2-filename">{getFileName()}</h3>
{/* <p className="comp2-filesize">19.6MB</p> */}
</div>
<div className="comp2-meta-grid"> <div className="comp2-meta-grid">
<div className="comp2-meta-item"> <div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span> <span className="comp2-meta-label">{t('completion.genre', { defaultValue: '장르' })} : {songCompletionData?.genre || 'K-POP'}</span>
</div> </div>
<div className="comp2-meta-divider"></div> <div className="comp2-meta-divider"></div>
<div className="comp2-meta-item"> <div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.resolution')} : {getVideoResolution()}</span> <span className="comp2-meta-label">{t('completion.resolution', { defaultValue: '규격' })} : {getVideoResolution()}</span>
</div> </div>
<div className="comp2-meta-divider"></div> <div className="comp2-meta-divider"></div>
<div className="comp2-lyrics-section"> <div className="comp2-lyrics-section">
<span className="comp2-meta-label">{t('completion.lyrics')}</span> <span className="comp2-meta-label">{t('completion.lyrics', { defaultValue: '가사' })}</span>
{(() => { <p className="comp2-lyrics-text">
if (!songCompletionData) { {songCompletionData?.lyrics || t('completion.sampleLyrics', { defaultValue: '가사를 불러오는 중...' })}
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>; </p>
}
if (!songCompletionData.lyrics) {
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
}
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
const sections: { tag: string | null; lines: string[] }[] = [];
lines.forEach((line: string) => {
const tagMatch = line.trim().match(/^\[(.+)\]$/);
if (tagMatch) {
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
} else if (sections.length === 0) {
sections.push({ tag: null, lines: [line] });
} else {
sections[sections.length - 1].lines.push(line);
}
});
return (
<div className="comp2-lyrics-paragraphs">
{sections.filter(s => s.lines.length > 0).map((section, i) => (
<div key={i} className="comp2-lyrics-para-section">
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
</div>
))}
</div>
);
})()}
</div> </div>
</div> </div>
</div> </div>
@ -529,17 +425,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="comp2-buttons"> <div className="comp2-buttons">
<button <button
onClick={handleDownload} onClick={handleDownload}
disabled={videoStatus !== 'complete' || !videoUrl || isDownloading} disabled={videoStatus !== 'complete' || !videoUrl}
className="comp2-btn comp2-btn-secondary" className="comp2-btn comp2-btn-secondary"
> >
{isDownloading ? t('completion.downloading') : t('completion.download')} {t('completion.download', { defaultValue: '다운로드' })}
</button> </button>
<button <button
onClick={handleOpenSocialConnect} onClick={handleOpenSocialConnect}
disabled={videoStatus !== 'complete' || !videoDbId} disabled={videoStatus !== 'complete' || !videoDbId}
className="comp2-btn comp2-btn-primary" className="comp2-btn comp2-btn-primary"
> >
{t('completion.uploadToSocial')} {t('completion.uploadToSocial', { defaultValue: '소셜 채널 업로드' })}
</button> </button>
</div> </div>
</div> </div>
@ -550,7 +446,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<SocialPostingModal <SocialPostingModal
isOpen={showSocialModal} isOpen={showSocialModal}
onClose={handleCloseSocialConnect} onClose={handleCloseSocialConnect}
onGoToCalendar={onGoToCalendar}
video={videoUrl && videoDbId ? { video={videoUrl && videoDbId ? {
video_id: videoDbId, video_id: videoDbId,
store_name: songCompletionData?.businessName || '', store_name: songCompletionData?.businessName || '',
@ -558,8 +453,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
task_id: songTaskId || '', task_id: songTaskId || '',
result_movie_url: videoUrl, result_movie_url: videoUrl,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
like_count: 0,
comment_count: 0,
} : null} } : null}
/> />

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { UploadHistoryItem } from '../../types/api'; import { UploadHistoryItem } from '../../types/api';
import { getUploadHistory, cancelUpload, retryUpload } from '../../utils/api'; import { getUploadHistory, cancelUpload, retryUpload } from '../../utils/api';
@ -54,13 +53,13 @@ const statusColor = (status: UploadStatus) => {
} }
}; };
// 상태 라벨 (컴포넌트 내부에서 t()로 처리) // 상태 라벨
const statusLabelKey = (status: UploadStatus) => { const statusLabel = (status: UploadStatus) => {
switch (status) { switch (status) {
case 'completed': return 'contentCalendar.status.completed'; case 'completed': return '완료';
case 'scheduled': case 'scheduled':
case 'pending': return 'contentCalendar.status.scheduled'; case 'pending': return '예약';
case 'failed': return 'contentCalendar.status.failed'; case 'failed': return '실패';
default: return status; default: return status;
} }
}; };
@ -70,7 +69,6 @@ interface ContentCalendarContentProps {
} }
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => { const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => {
const { t } = useTranslation();
const today = new Date(); const today = new Date();
const [year, setYear] = useState(today.getFullYear()); const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth()); const [month, setMonth] = useState(today.getMonth());
@ -205,7 +203,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
// 취소 // 취소
const handleCancel = async (uploadId: number) => { const handleCancel = async (uploadId: number) => {
if (!window.confirm(t('contentCalendar.cancelConfirm'))) return; if (!window.confirm('예약을 취소하시겠습니까?')) return;
// 낙관적 업데이트: 먼저 UI에서 제거 // 낙관적 업데이트: 먼저 UI에서 제거
setAllItems(prev => prev.filter(i => i.upload_id !== uploadId)); setAllItems(prev => prev.filter(i => i.upload_id !== uploadId));
setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId)); setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId));
@ -214,7 +212,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
} catch { } catch {
// 실패 시 다시 불러오기 // 실패 시 다시 불러오기
refreshAll(); refreshAll();
alert(t('contentCalendar.cancelFailed')); alert('취소에 실패했습니다.');
} }
}; };
@ -224,7 +222,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
await retryUpload(uploadId); await retryUpload(uploadId);
refreshAll(); refreshAll();
} catch { } catch {
alert(t('contentCalendar.retryFailed')); alert('재시도에 실패했습니다.');
} }
}; };
@ -236,11 +234,10 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}; };
// 날짜 표시 // 날짜 표시 (M월 D)
const formatDateLabel = (key: string) => { const formatDateLabel = (key: string) => {
const [, m, d] = key.split('-'); const [, m, d] = key.split('-');
const months = t('contentCalendar.months', { returnObjects: true }) as string[]; return `${parseInt(m)}${parseInt(d)}`;
return t('contentCalendar.monthDay', { month: months[parseInt(m) - 1], day: parseInt(d) });
}; };
const buildCalendarDays = (): CalendarDay[] => { const buildCalendarDays = (): CalendarDay[] => {
@ -269,15 +266,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
}; };
const calendarDays = buildCalendarDays(); const calendarDays = buildCalendarDays();
const monthNames = t('contentCalendar.months', { returnObjects: true }) as string[]; const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
const dayLabels = t('contentCalendar.days', { returnObjects: true }) as string[]; const dayLabels = ['일','월','화','수','목','금','토'];
const tabs: TabType[] = ['전체','완료','예약','실패']; const tabs: TabType[] = ['전체','완료','예약','실패'];
const tabLabels: Record<TabType, string> = {
'전체': t('contentCalendar.tabs.all'),
'완료': t('contentCalendar.tabs.completed'),
'예약': t('contentCalendar.tabs.scheduled'),
'실패': t('contentCalendar.tabs.failed'),
};
// ── 캘린더 그리드 ────────────────────────────────────────────── // ── 캘린더 그리드 ──────────────────────────────────────────────
const renderCalendarGrid = () => { const renderCalendarGrid = () => {
@ -397,7 +388,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} /> <div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}> <span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.completedCount', { count: summary.completed })} {summary.completed}
</span> </span>
</div> </div>
)} )}
@ -405,7 +396,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} /> <div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}> <span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.scheduledCount', { count: summary.scheduled })} {summary.scheduled}
</span> </span>
</div> </div>
)} )}
@ -413,7 +404,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} /> <div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}> <span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.failedCount', { count: summary.failed })} {summary.failed}
</span> </span>
</div> </div>
)} )}
@ -441,7 +432,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap', letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap',
}} }}
> >
{tabLabels[tab]} {tab}
</button> </button>
))} ))}
</div> </div>
@ -451,9 +442,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
const renderStats = () => ( const renderStats = () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{[ {[
{ label: t('contentCalendar.tabs.scheduled'), value: totalStats.scheduled }, { label: '예약', value: totalStats.scheduled },
{ label: t('contentCalendar.tabs.completed'), value: totalStats.completed }, { label: '완료', value: totalStats.completed },
{ label: t('contentCalendar.tabs.failed'), value: totalStats.failed }, { label: '실패', value: totalStats.failed },
].map((item, idx) => ( ].map((item, idx) => (
<React.Fragment key={item.label}> <React.Fragment key={item.label}>
{idx > 0 && <div style={{ width: 0, height: 14, borderLeft: '1px solid #379599' }} />} {idx > 0 && <div style={{ width: 0, height: 14, borderLeft: '1px solid #379599' }} />}
@ -492,7 +483,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px',
margin: 0, whiteSpace: 'nowrap', margin: 0, whiteSpace: 'nowrap',
}}> }}>
{t('contentCalendar.yearMonth', { year, month: monthNames[month] })} {year} {monthNames[month]}
</p> </p>
<button <button
onClick={handleNextMonth} onClick={handleNextMonth}
@ -538,7 +529,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13, fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: statusColor(item.status as UploadStatus), lineHeight: 1, color: statusColor(item.status as UploadStatus), lineHeight: 1,
}}> }}>
{t(statusLabelKey(item.status as UploadStatus))} {statusLabel(item.status as UploadStatus)}
</span> </span>
<span style={{ <span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13, fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
@ -548,28 +539,19 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</span> </span>
</div> </div>
{/* 채널 아이콘 + 채널명 */} {/* 채널 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlatformIcon platform={item.platform} size={16} /> <PlatformIcon platform={item.platform} size={18} />
<span style={{ <span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#9bcacc', lineHeight: 1.4, color: '#e5f1f2', lineHeight: 1.4,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
}}> }}>
{item.platform_username || item.platform_user_id || item.channel_name} {item.title}
</span> </span>
</div> </div>
{/* 제목 */}
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#e5f1f2', lineHeight: 1.4,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
}}>
{item.title}
</span>
{/* 실패 메시지 */} {/* 실패 메시지 */}
{isFailed && item.error_message && ( {isFailed && item.error_message && (
<p style={{ <p style={{
@ -593,7 +575,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#9bcacc', cursor: 'pointer', color: '#9bcacc', cursor: 'pointer',
}} }}
> >
{t('contentCalendar.cancel')}
</button> </button>
)} )}
{isFailed && ( {isFailed && (
@ -606,7 +588,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#ffffff', cursor: 'pointer', color: '#ffffff', cursor: 'pointer',
}} }}
> >
{t('contentCalendar.retry')}
</button> </button>
)} )}
</div> </div>
@ -620,7 +602,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
if (panelLoading) { if (panelLoading) {
return ( return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span> <span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}> ...</span>
</div> </div>
); );
} }
@ -635,17 +617,17 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16, fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16,
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0, color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0,
}}> }}>
{t('contentCalendar.noResults')}
</p> </p>
<p style={{ <p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12, fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
color: '#9bcacc', letterSpacing: '-0.072px', lineHeight: 1, margin: 0, color: '#9bcacc', letterSpacing: '-0.072px', lineHeight: 1, margin: 0,
}}> }}>
{t('contentCalendar.noResultsDesc')}
</p> </p>
</div> </div>
<button <button
onClick={() => onNavigate?.(' 콘텐츠')} onClick={() => onNavigate?.('ADO2 콘텐츠')}
style={{ style={{
height: 34, padding: '0 10px', borderRadius: 8, border: 'none', height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
backgroundColor: '#a65eff', cursor: 'pointer', backgroundColor: '#a65eff', cursor: 'pointer',
@ -653,7 +635,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#ffffff', color: '#ffffff',
}} }}
> >
{t('contentCalendar.myContents')} ADO2
</button> </button>
</div> </div>
); );
@ -699,7 +681,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30, fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0, color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}> }}>
{t('contentCalendar.title')}
</p> </p>
</div> </div>
@ -717,7 +699,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</div> </div>
{/* 바텀시트 */} {/* 바텀시트 */}
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 10, display: 'flex', flexDirection: 'column' }}> <div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, display: 'flex', flexDirection: 'column' }}>
<div style={{ <div style={{
backgroundColor: '#01393b', backgroundColor: '#01393b',
borderTop: '1px solid #046266', borderTop: '1px solid #046266',
@ -764,7 +746,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30, fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0, color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}> }}>
{t('contentCalendar.title')}
</p> </p>
</div> </div>
@ -792,7 +774,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</div> </div>
{loading ? ( {loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span> <span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}> ...</span>
</div> </div>
) : renderCalendarGrid()} ) : renderCalendarGrid()}
</div> </div>

View File

@ -81,29 +81,29 @@ interface ConnectedAccount {
// ===================================================== // =====================================================
const MOCK_CONTENT_METRICS: ContentMetric[] = [ const MOCK_CONTENT_METRICS: ContentMetric[] = [
{ id: 'total-views', label: 'dashboard.metricTotalViews', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' }, { id: 'total-views', label: '조회수', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' },
{ id: 'total-watch-time', label: 'dashboard.metricWatchTime', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' }, { id: 'total-watch-time', label: '시청시간', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' },
{ id: 'avg-view-duration',label: 'dashboard.metricAvgViewDuration', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' }, { id: 'avg-view-duration',label: '평균 시청시간', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' },
{ id: 'new-subscribers', label: 'dashboard.metricNewSubscribers', value: 483, unit: 'count', trend: 50, trendDirection: 'up' }, { id: 'new-subscribers', label: '신규 구독자', value: 483, unit: 'count', trend: 50, trendDirection: 'up' },
{ id: 'likes', label: 'dashboard.metricLikes', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' }, { id: 'likes', label: '좋아요', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' },
{ id: 'comments', label: 'dashboard.metricComments', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' }, { id: 'comments', label: '댓글', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' },
{ id: 'shares', label: 'dashboard.metricShares', value: 840, unit: 'count', trend: 15, trendDirection: 'up' }, { id: 'shares', label: '공유', value: 840, unit: 'count', trend: 15, trendDirection: 'up' },
{ id: 'uploaded-videos', label: 'dashboard.metricUploadedVideos', value: 17, unit: 'count', trend: 4, trendDirection: 'up' }, { id: 'uploaded-videos', label: '업로드 영상', value: 17, unit: 'count', trend: 4, trendDirection: 'up' },
]; ];
const MOCK_MONTHLY_DATA: MonthlyData[] = [ const MOCK_MONTHLY_DATA: MonthlyData[] = [
{ month: 'dashboard.months.jan', thisYear: 18000, lastYear: 14500 }, { month: '1월', thisYear: 18000, lastYear: 14500 },
{ month: 'dashboard.months.feb', thisYear: 19500, lastYear: 15800 }, { month: '2월', thisYear: 19500, lastYear: 15800 },
{ month: 'dashboard.months.mar', thisYear: 21000, lastYear: 17200 }, { month: '3월', thisYear: 21000, lastYear: 17200 },
{ month: 'dashboard.months.apr', thisYear: 18500, lastYear: 16800 }, { month: '4월', thisYear: 18500, lastYear: 16800 },
{ month: 'dashboard.months.may', thisYear: 24000, lastYear: 19500 }, { month: '5월', thisYear: 24000, lastYear: 19500 },
{ month: 'dashboard.months.jun', thisYear: 27500, lastYear: 21000 }, { month: '6월', thisYear: 27500, lastYear: 21000 },
{ month: 'dashboard.months.jul', thisYear: 32000, lastYear: 23500 }, { month: '7월', thisYear: 32000, lastYear: 23500 },
{ month: 'dashboard.months.aug', thisYear: 29500, lastYear: 24800 }, { month: '8월', thisYear: 29500, lastYear: 24800 },
{ month: 'dashboard.months.sep', thisYear: 31000, lastYear: 26200 }, { month: '9월', thisYear: 31000, lastYear: 26200 },
{ month: 'dashboard.months.oct', thisYear: 28500, lastYear: 25500 }, { month: '10월', thisYear: 28500, lastYear: 25500 },
{ month: 'dashboard.months.nov', thisYear: 34000, lastYear: 27800 }, { month: '11월', thisYear: 34000, lastYear: 27800 },
{ month: 'dashboard.months.dec', thisYear: 38000, lastYear: 29500 }, { month: '12월', thisYear: 38000, lastYear: 29500 },
]; ];
const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => { const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => {
@ -201,16 +201,18 @@ const formatNumber = (num: number): string => {
return num.toString(); return num.toString();
}; };
const formatValue = (value: number, unit: string, unitHours: string, unitMinutes: string): string => { // unit에 따라 value를 표시용 문자열로 변환 (언어별 suffix는 호출부에서 처리)
if (unit === 'hours') return value.toFixed(1) + unitHours; const formatValue = (value: number, unit: string): string => {
if (unit === 'minutes') return Math.round(value) + unitMinutes; if (unit === 'hours') return value.toFixed(1) + '시간';
if (unit === 'minutes') return Math.round(value) + '분';
return formatNumber(Math.round(value)); return formatNumber(Math.round(value));
}; };
const formatTrend = (trend: number, unit: string, unitHours: string, unitMinutes: string): string => { // unit에 따라 trend 절댓값을 표시용 문자열로 변환
const formatTrend = (trend: number, unit: string): string => {
const abs = Math.abs(trend); const abs = Math.abs(trend);
if (unit === 'hours') return abs.toFixed(1) + unitHours; if (unit === 'hours') return abs.toFixed(1) + '시간';
if (unit === 'minutes') return Math.round(abs) + unitMinutes; if (unit === 'minutes') return Math.round(abs) + '분';
return formatNumber(Math.round(abs)); return formatNumber(Math.round(abs));
}; };
@ -237,22 +239,19 @@ const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => (
); );
const StatCard: React.FC<ContentMetric> = ({ label, value, unit, trend, trendDirection }) => { const StatCard: React.FC<ContentMetric> = ({ label, value, unit, trend, trendDirection }) => {
const { t } = useTranslation();
const unitHours = t('dashboard.unitHours');
const unitMinutes = t('dashboard.unitMinutes');
const isNeutral = trend === 0 || trendDirection === '-'; const isNeutral = trend === 0 || trendDirection === '-';
const isUp = trendDirection === 'up'; const isUp = trendDirection === 'up';
return ( return (
<div className="stat-card"> <div className="stat-card">
<span className="stat-label">{t(label)}</span> <span className="stat-label">{label}</span>
<h3 className="stat-value">{formatValue(value, unit, unitHours, unitMinutes)}</h3> <h3 className="stat-value">{formatValue(value, unit)}</h3>
<div className="stat-trend-wrapper"> <div className="stat-trend-wrapper">
{isNeutral ? ( {isNeutral ? (
<span className="stat-trend neutral"></span> <span className="stat-trend neutral"></span>
) : ( ) : (
<span className={`stat-trend ${isUp ? 'up' : 'down'}`}> <span className={`stat-trend ${isUp ? 'up' : 'down'}`}>
<TrendIcon direction={isUp ? 'up' : 'down'} /> <TrendIcon direction={isUp ? 'up' : 'down'} />
{isUp ? '+' : '-'}{formatTrend(trend, unit, unitHours, unitMinutes)} {isUp ? '+' : '-'}{formatTrend(trend, unit)}
</span> </span>
)} )}
</div> </div>
@ -277,12 +276,11 @@ const YearOverYearChart: React.FC<{
currentLabel: string; currentLabel: string;
previousLabel: string; previousLabel: string;
mode: 'day' | 'month'; mode: 'day' | 'month';
noDataLabel: string; }> = ({ data, currentLabel, previousLabel, mode }) => {
}> = ({ data, currentLabel, previousLabel, mode, noDataLabel }) => {
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', opacity: 0.4 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', opacity: 0.4 }}>
<span>{noDataLabel}</span> <span> .</span>
</div> </div>
); );
} }
@ -364,14 +362,14 @@ const YearOverYearChart: React.FC<{
// Icon Components // Icon Components
// ===================================================== // =====================================================
const YouTubeIcon: React.FC<{ className?: string; style?: React.CSSProperties }> = ({ className, style }) => ( const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} style={style} viewBox="0 0 24 24" fill="currentColor"> <svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" /> <path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg> </svg>
); );
const InstagramIcon: React.FC<{ className?: string; style?: React.CSSProperties }> = ({ className, style }) => ( const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} style={style} viewBox="0 0 24 24" fill="currentColor"> <svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" /> <path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" />
</svg> </svg>
); );
@ -575,7 +573,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
return; return;
} }
if (errorData.code === 'YOUTUBE_API_FAILED') { if (errorData.code === 'YOUTUBE_API_FAILED') {
setError({ code: 'YOUTUBE_API_FAILED', message: t('dashboard.youtubeApiFailed') }); setError({ code: 'YOUTUBE_API_FAILED', message: 'YouTube Analytics API 호출에 실패했습니다.' });
setDashboardData(null); setDashboardData(null);
return; return;
} }
@ -608,7 +606,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center h-screen"> <div className="flex justify-center items-center h-screen">
<div className="text-lg">{t('dashboard.dataLoading')}</div> <div className="text-lg">{t('데이터 로딩 중...')}</div>
</div> </div>
); );
} }
@ -616,13 +614,12 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
// hasUploads === false이고 error 없음: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너 // hasUploads === false이고 error 없음: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너
const isEmptyState = dashboardData?.hasUploads === false && !dashboardData?.error; const isEmptyState = dashboardData?.hasUploads === false && !dashboardData?.error;
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음
const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData || !!error;
// showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백 // showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백
const useReal = !showMockData && !isEmptyState; const useReal = !showMockData && !isEmptyState;
const hasRealContentMetrics = useReal && !!dashboardData?.contentMetrics?.some((m: ContentMetric) => m.value > 0); const contentMetrics = (useReal && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS;
const contentMetrics = hasRealContentMetrics ? dashboardData!.contentMetrics : MOCK_CONTENT_METRICS;
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음 5)실제 지표 없음
const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData || !!error || !hasRealContentMetrics;
const topContent = (useReal && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT; const topContent = (useReal && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT;
const hasRealAgeGroups = useReal && !!dashboardData?.audienceData?.ageGroups?.some(g => g.percentage > 0); const hasRealAgeGroups = useReal && !!dashboardData?.audienceData?.ageGroups?.some(g => g.percentage > 0);
const hasRealGender = useReal && ((dashboardData?.audienceData?.gender?.male ?? 0) + (dashboardData?.audienceData?.gender?.female ?? 0)) > 0; const hasRealGender = useReal && ((dashboardData?.audienceData?.gender?.male ?? 0) + (dashboardData?.audienceData?.gender?.female ?? 0)) > 0;
@ -634,15 +631,15 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
const chartData: ChartDataPoint[] = mode === 'month' const chartData: ChartDataPoint[] = mode === 'month'
? ((useReal && dashboardData?.monthlyData?.length) ? ((useReal && dashboardData?.monthlyData?.length)
? dashboardData.monthlyData.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear })) ? dashboardData.monthlyData.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear }))
: MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: t(d.month), current: d.thisYear, previous: d.lastYear }))) : MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear })))
: ((useReal && dashboardData?.dailyData?.length) : ((useReal && dashboardData?.dailyData?.length)
? dashboardData.dailyData.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod })) ? dashboardData.dailyData.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod }))
: MOCK_DAILY_DATA.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod }))); : MOCK_DAILY_DATA.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod })));
// mode별 차트 레이블 // mode별 차트 레이블
const chartCurrentLabel = mode === 'month' ? t('dashboard.chartThisYear') : t('dashboard.chartThisMonth'); const chartCurrentLabel = mode === 'month' ? '올해' : '이번달';
const chartPreviousLabel = mode === 'month' ? t('dashboard.chartLastYear') : t('dashboard.chartLastMonth'); const chartPreviousLabel = mode === 'month' ? '작년' : '지난달';
const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : t('dashboard.monthOverMonth'); const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : '전월 대비 성장';
const lastUpdated = new Date().toLocaleDateString('ko-KR', { const lastUpdated = new Date().toLocaleDateString('ko-KR', {
year: 'numeric', year: 'numeric',
@ -660,24 +657,8 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
<path d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" strokeLinecap="round" strokeLinejoin="round" /> <path d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
<div> <div>
<p style={{ color: '#a6ffea', fontSize: '20px', fontWeight: 600, marginBottom: '2px' }}>{t('dashboard.noUploadsTitle')}</p> <p style={{ color: '#a6ffea', fontSize: '20px', fontWeight: 600, marginBottom: '2px' }}> .</p>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '16px' }}>{t('dashboard.noUploadsDesc')}</p> <p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '16px' }}>ADO2 .</p>
</div>
</div>
</div>
)}
{/* 48시간 지연 안내 배너 */}
{dashboardData && !isEmptyState && !hasRealContentMetrics && !showMockData && (
<div className="mb-4 p-4 border-l-4 rounded" style={{ background: 'rgba(255,210,100,0.06)', borderColor: '#ffd264' }}>
<div className="flex items-center gap-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ffd264" strokeWidth="2" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div>
<p style={{ color: '#ffd264', fontSize: '16px', fontWeight: 600, marginBottom: '2px' }}>{t('dashboard.dataDelayTitle')}</p>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '14px' }}>{t('dashboard.dataDelayDesc')}</p>
</div> </div>
</div> </div>
</div> </div>
@ -698,7 +679,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
onClick={() => onNavigate?.('내 정보')} onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap" className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
> >
{t('dashboard.reconnectButton')}
</button> </button>
)} )}
{error.code === 'YOUTUBE_NOT_CONNECTED' && ( {error.code === 'YOUTUBE_NOT_CONNECTED' && (
@ -706,7 +687,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
onClick={() => onNavigate?.('내 정보')} onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap" className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
> >
{t('dashboard.connectButton')}
</button> </button>
)} )}
{(error.code === 'YOUTUBE_API_FAILED' || error.code === 'DASHBOARD_DATA_ERROR') && ( {(error.code === 'YOUTUBE_API_FAILED' || error.code === 'DASHBOARD_DATA_ERROR') && (
@ -714,7 +695,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
onClick={() => { setError(null); setRetryTrigger((n: number) => n + 1); }} onClick={() => { setError(null); setRetryTrigger((n: number) => n + 1); }}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap" className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
> >
{t('dashboard.retryButton')}
</button> </button>
)} )}
</div> </div>
@ -757,7 +738,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
</span> </span>
</> </>
) : ( ) : (
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}>{t('dashboard.selectAccount')}</span> <span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}> </span>
); );
})()} })()}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="2" style={{ flexShrink: 0, marginLeft: 'auto' }}> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="2" style={{ flexShrink: 0, marginLeft: 'auto' }}>
@ -819,20 +800,20 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.contentPerformance')}</h2> <h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.contentPerformance')}</h2>
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>{t('dashboard.recentVideosDesc')}</p> <p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>ADO2 30 .</p>
</div> </div>
<div className="mode-toggle"> <div className="mode-toggle">
<button <button
className={`mode-btn ${mode === 'month' ? 'active' : ''}`} className={`mode-btn ${mode === 'month' ? 'active' : ''}`}
onClick={() => setMode('month')} onClick={() => setMode('month')}
> >
{t('dashboard.modeAnnual')}
</button> </button>
<button <button
className={`mode-btn ${mode === 'day' ? 'active' : ''}`} className={`mode-btn ${mode === 'day' ? 'active' : ''}`}
onClick={() => setMode('day')} onClick={() => setMode('day')}
> >
{t('dashboard.modeMonthly')}
</button> </button>
</div> </div>
</div> </div>
@ -869,7 +850,6 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
currentLabel={chartCurrentLabel} currentLabel={chartCurrentLabel}
previousLabel={chartPreviousLabel} previousLabel={chartPreviousLabel}
mode={mode} mode={mode}
noDataLabel={t('dashboard.noDataInPeriod')}
/> />
</div> </div>
</div> </div>
@ -894,7 +874,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
<AnimatedSection delay={1000} className="audience-section"> <AnimatedSection delay={1000} className="audience-section">
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.audienceInsights')}</h2> <h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.audienceInsights')}</h2>
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>{t('dashboard.channelStatsDesc')}</p> <p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}> .</p>
</div> </div>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<div className="audience-cards" style={!hasRealAudienceData && !showMockData ? { filter: 'blur(4px)', pointerEvents: 'none', userSelect: 'none' } : {}}> <div className="audience-cards" style={!hasRealAudienceData && !showMockData ? { filter: 'blur(4px)', pointerEvents: 'none', userSelect: 'none' } : {}}>
@ -927,7 +907,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
padding: '20px 28px', padding: '20px 28px',
}}> }}>
<p style={{ color: 'rgba(255,255,255,0.85)', fontSize: '16px', fontWeight: 500, margin: 0, textAlign: 'center' }}> <p style={{ color: 'rgba(255,255,255,0.85)', fontSize: '16px', fontWeight: 500, margin: 0, textAlign: 'center' }}>
{t('dashboard.noAudienceData')} .
</p> </p>
</div> </div>
</div> </div>
@ -963,7 +943,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></> : <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></>
} }
</svg> </svg>
{showMockData ? t('dashboard.sampleHide') : t('dashboard.sampleShow')} {showMockData ? 'Sample 숨기기' : 'Sample 보기'}
</button> </button>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Sidebar from '../../components/Sidebar'; import Sidebar from '../../components/Sidebar';
import AssetManagementContent from './AssetManagementContent'; import AssetManagementContent from './AssetManagementContent';
@ -9,24 +9,21 @@ import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent'; import BusinessSettingsContent from './BusinessSettingsContent';
import UrlInputContent from './UrlInputContent'; import UrlInputContent from './UrlInputContent';
import ADO2ContentsPage from './ADO2ContentsPage'; import ADO2ContentsPage from './ADO2ContentsPage';
import MyContentsPage from './MyContentsPage';
import MyInfoContent from './MyInfoContent'; import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent'; import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection'; import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api'; import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api'; import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial'; import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
import WizardStepper from '../../components/WizardStepper';
const WIZARD_STEP_KEY = 'castad_wizard_step'; const WIZARD_STEP_KEY = 'castad_wizard_step';
const ACTIVE_ITEM_KEY = 'castad_active_item'; const ACTIVE_ITEM_KEY = 'castad_active_item';
const SONG_TASK_ID_KEY = 'castad_song_task_id'; const SONG_TASK_ID_KEY = 'castad_song_task_id';
const IMAGE_TASK_ID_KEY = 'castad_image_task_id'; const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
const ANALYSIS_DATA_KEY = 'castad_analysis_data'; const ANALYSIS_DATA_KEY = 'castad_analysis_data';
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용) // 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
const SONG_GENERATION_KEY = 'castad_song_generation'; const SONG_GENERATION_KEY = 'castad_song_generation';
@ -52,7 +49,7 @@ interface BusinessInfo {
interface GenerationFlowProps { interface GenerationFlowProps {
onHome: () => void; onHome: () => void;
initialActiveItem?: string; initialActiveItem?: string;
initialImageList?: ImageListItem[]; initialImageList?: string[];
businessInfo?: BusinessInfo; businessInfo?: BusinessInfo;
initialAnalysisData?: CrawlingResponse | null; initialAnalysisData?: CrawlingResponse | null;
} }
@ -117,35 +114,15 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const [videoGenerationProgress, setVideoGenerationProgress] = useState(0); const [videoGenerationProgress, setVideoGenerationProgress] = useState(0);
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData()); const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
const [analysisError, setAnalysisError] = useState<string | null>(null); const [analysisError, setAnalysisError] = useState<string | null>(null);
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null); const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
const [credits, setCredits] = useState<number | null>(null);
const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined);
const tutorial = useTutorial(); const tutorial = useTutorial();
const refreshCredits = useCallback(async () => { // 로그인 직후 사용자 정보 조회
try {
const { credits } = await getUserCredits();
setCredits(credits);
} catch (e) {
console.error('Failed to refresh credits:', e);
}
}, []);
// 사운드 스튜디오, 영상 완성 진입 시 크레딧 갱신
useEffect(() => {
if (wizardStep === 2 || wizardStep === 3) {
refreshCredits();
}
}, [wizardStep]);
// 로그인 직후 사용자 정보 + 크레딧 조회
useEffect(() => { useEffect(() => {
const fetchUserInfo = async () => { const fetchUserInfo = async () => {
try { try {
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]); const data = await getUserMe();
setUserInfo(data); setUserInfo(data);
setCredits(creditsData.credits);
} catch (error) { } catch (error) {
console.error('Failed to fetch user info:', error); console.error('Failed to fetch user info:', error);
} }
@ -169,9 +146,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// URL 이미지를 ImageItem 형태로 변환하여 초기화 // URL 이미지를 ImageItem 형태로 변환하여 초기화
const getInitialImageList = (): ImageItem[] => { const getInitialImageList = (): ImageItem[] => {
if (analysisData?.image_list && analysisData.image_list.length > 0) { if (analysisData?.image_list && analysisData.image_list.length > 0) {
return analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })); return analysisData.image_list.map(url => ({ type: 'url', url }));
} }
return initialImageList.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })); return initialImageList.map(url => ({ type: 'url', url }));
}; };
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList()); const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
@ -183,7 +160,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id); console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
if (analysisData?.image_list && analysisData.image_list.length > 0) { if (analysisData?.image_list && analysisData.image_list.length > 0) {
if (prevAnalysisMIdRef.current !== analysisData.m_id) { if (prevAnalysisMIdRef.current !== analysisData.m_id) {
setImageList(analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }))); setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
} }
} }
prevAnalysisMIdRef.current = analysisData?.m_id; prevAnalysisMIdRef.current = analysisData?.m_id;
@ -231,7 +208,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 업체명 자동완성으로 분석 시작 // 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => { const handleAutocomplete = async (request: AutocompleteRequest) => {
goToWizardStep(-1); // 로딩 상태로 goToWizardStep(-1); // 로딩 상태로
setIsAnalysisComplete(false);
setAnalysisError(null); setAnalysisError(null);
try { try {
@ -239,11 +215,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
console.log('[Autocomplete] Response m_id:', data.m_id); console.log('[Autocomplete] Response m_id:', data.m_id);
// 기본값 보장 // 기본값 보장
// if (data.marketing_analysis) { if (data.marketing_analysis) {
// data.marketing_analysis.tags = data.marketing_analysis.tags || []; data.marketing_analysis.tags = data.marketing_analysis.tags || [];
// data.marketing_analysis.facilities = data.marketing_analysis.facilities || []; data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
// data.marketing_analysis.report = data.marketing_analysis.report || ''; data.marketing_analysis.report = data.marketing_analysis.report || '';
// } }
if (data.processed_info) { if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음'; data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
data.processed_info.region = data.processed_info.region || ''; data.processed_info.region = data.processed_info.region || '';
@ -253,40 +229,25 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisData(data); setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress }); goToWizardStep(0); // 브랜드 분석 결과로
setIsAnalysisComplete(true);
} catch (err) { } catch (err) {
console.error('Autocomplete error:', err); console.error('Autocomplete error:', err);
setAnalysisError(t('app.autocompleteError')); setAnalysisError(err instanceof Error ? err.message : t('app.autocompleteError'));
goToWizardStep(-2); // URL 입력으로 돌아가기 goToWizardStep(-2); // URL 입력으로 돌아가기
} }
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출 // 테스트용 랜덤 m_id 생성 (99 ~ 300)
const handleManualInput = async (businessName: string, address: string, category: string) => { const generateRandomMId = (): number => {
goToWizardStep(-1); return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
setIsAnalysisComplete(false); };
setAnalysisError(null);
try { // 테스트 데이터로 브랜드 분석 페이지 이동
const data = await marketingAnalysis(businessName, address, category); const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, m_id: generateRandomMId(), _isTestData: true };
if (data.processed_info) { setAnalysisData(tagged);
data.processed_info.customer_name = data.processed_info.customer_name || businessName; localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
data.processed_info.region = data.processed_info.region || ''; goToWizardStep(0); // 브랜드 분석 결과로
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
}
data.image_list = data.image_list || [];
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis error:', err);
setAnalysisError(t('app.autocompleteError'));
goToWizardStep(-2);
}
}; };
// 언어 변경 시 테스트 데이터 다시 로드 // 언어 변경 시 테스트 데이터 다시 로드
@ -301,7 +262,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
.then(res => res.json()) .then(res => res.json())
.then((data: CrawlingResponse) => { .then((data: CrawlingResponse) => {
// 언어 변경 시에는 기존 m_id 유지 // 언어 변경 시에는 기존 m_id 유지
const tagged = { ...data, m_id: parsed.m_id, _isTestData: true }; const tagged = { ...data, m_id: parsed.m_id || generateRandomMId(), _isTestData: true };
setAnalysisData(tagged); setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged)); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
}) })
@ -314,7 +275,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
if (!url.trim()) return; if (!url.trim()) return;
goToWizardStep(-1); // 로딩 상태로 goToWizardStep(-1); // 로딩 상태로
setIsAnalysisComplete(false);
setAnalysisError(null); setAnalysisError(null);
try { try {
@ -322,11 +282,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
console.log('[Crawl] Response m_id:', data.m_id); console.log('[Crawl] Response m_id:', data.m_id);
// 기본값 보장 // 기본값 보장
// if (data.marketing_analysis) { if (data.marketing_analysis) {
// data.marketing_analysis.tags = data.marketing_analysis.tags || []; data.marketing_analysis.tags = data.marketing_analysis.tags || [];
// data.marketing_analysis.facilities = data.marketing_analysis.facilities || []; data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
// data.marketing_analysis.report = data.marketing_analysis.report || ''; data.marketing_analysis.report = data.marketing_analysis.report || '';
// } }
if (data.processed_info) { if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음'; data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
data.processed_info.region = data.processed_info.region || ''; data.processed_info.region = data.processed_info.region || '';
@ -336,11 +296,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisData(data); setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'url', value: url }); goToWizardStep(0); // 브랜드 분석 결과로
setIsAnalysisComplete(true);
} catch (err) { } catch (err) {
console.error('Crawling failed:', err); console.error('Crawling failed:', err);
setAnalysisError(t('app.analysisError')); const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
setAnalysisError(errorMessage);
goToWizardStep(-2); // URL 입력으로 돌아가기 goToWizardStep(-2); // URL 입력으로 돌아가기
} }
}; };
@ -380,7 +340,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
if (tutorial.isActive) tutorial.skipTutorial(); 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 === ' 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) { } else if (activeItem === 'ADO2 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS); tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
} else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) { } else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) {
tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD); tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD);
@ -389,15 +349,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
} }
}, [activeItem]); }, [activeItem]);
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
const handleGoToPayment = () => {
setMyInfoInitialTab('payment');
setActiveItem('내 정보');
};
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화 // 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
const handleNavigate = (item: string) => { const handleNavigate = (item: string) => {
setMyInfoInitialTab(undefined);
if (item === '새 프로젝트 만들기') { if (item === '새 프로젝트 만들기') {
// 기존 프로젝트 데이터 초기화 // 기존 프로젝트 데이터 초기화
clearAllProjectStorage(); clearAllProjectStorage();
@ -414,7 +367,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisError(null); setAnalysisError(null);
} }
setActiveItem(item); setActiveItem(item);
refreshCredits();
}; };
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링 // 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
@ -426,18 +378,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<UrlInputContent <UrlInputContent
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete} onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput} onTestData={handleTestData}
error={analysisError} error={analysisError}
/> />
); );
case -1: case -1:
// 로딩 단계 // 로딩 단계
return ( return <LoadingSection />;
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => goToWizardStep(0)}
/>
);
case 0: case 0:
// 브랜드 분석 결과 단계 // 브랜드 분석 결과 단계
if (!analysisData) { if (!analysisData) {
@ -483,10 +430,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
businessInfo={currentBusinessInfo} businessInfo={currentBusinessInfo}
imageTaskId={imageTaskId} imageTaskId={imageTaskId}
mId={analysisData?.m_id ?? 0} mId={analysisData?.m_id ?? 0}
industry={analysisData?.industry ?? ''}
videoGenerationStatus={videoGenerationStatus} videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress} videoGenerationProgress={videoGenerationProgress}
onGoToPayment={handleGoToPayment}
onStatusChange={(status: string) => { onStatusChange={(status: string) => {
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) { if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400); setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
@ -511,7 +456,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
songTaskId={songTaskId} songTaskId={songTaskId}
onVideoStatusChange={setVideoGenerationStatus} onVideoStatusChange={setVideoGenerationStatus}
onVideoProgressChange={setVideoGenerationProgress} onVideoProgressChange={setVideoGenerationProgress}
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
/> />
); );
default: default:
@ -526,35 +470,22 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case '비즈니스 설정': case '비즈니스 설정':
return <BusinessSettingsContent />; return <BusinessSettingsContent />;
case 'ADO2 콘텐츠': case 'ADO2 콘텐츠':
return <ADO2ContentsPage />;
case '내 콘텐츠':
return ( return (
<MyContentsPage <ADO2ContentsPage
onBack={() => setActiveItem('새 프로젝트 만들기')} onBack={() => setActiveItem('새 프로젝트 만들기')}
onNavigate={handleNavigate}
/> />
); );
case '콘텐츠 캘린더': case '콘텐츠 캘린더':
return <ContentCalendarContent onNavigate={handleNavigate} />; return <ContentCalendarContent onNavigate={handleNavigate} />;
case '내 정보': case '내 정보':
return <MyInfoContent initialTab={myInfoInitialTab} />; return <MyInfoContent />;
case '새 프로젝트 만들기': case '새 프로젝트 만들기':
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시 // 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
if (wizardStep === -1 || wizardStep === -2) { if (wizardStep === 0 || wizardStep === -1) {
return renderWizardContent(); return renderWizardContent();
} }
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
if (wizardStep === 0) {
return (
<>
<WizardStepper currentStep={wizardStep} />
{renderWizardContent()}
</>
);
}
return ( return (
<div className="wizard-page-container"> <div className="wizard-page-container">
<WizardStepper currentStep={wizardStep} />
{renderWizardContent()} {renderWizardContent()}
</div> </div>
); );
@ -573,12 +504,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 브랜드 분석(0)일 때는 전체 페이지 스크롤 // 브랜드 분석(0)일 때는 전체 페이지 스크롤
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0; const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보 // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis; const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null) // 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
const getCurrentTutorialKey = (): string | null => { const getCurrentTutorialKey = (): string | null => {
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO; if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
if (activeItem === ' 콘텐츠') 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 === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
if (activeItem === '새 프로젝트 만들기') { if (activeItem === '새 프로젝트 만들기') {
@ -588,22 +519,23 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return null; return null;
}; };
const handleTutorialRestart = () => {
tutorial.showRestartPopup(getCurrentTutorialKey()!);
};
const tutorialUI = ( const tutorialUI = (
<> <>
{getCurrentTutorialKey() && ( {getCurrentTutorialKey() && (
<button <button
className={`tutorial-toggle-fab ${tutorial.isEnabled ? 'active' : ''}`} className="tutorial-restart-fab"
onClick={() => tutorial.toggleTutorial(getCurrentTutorialKey())} onClick={handleTutorialRestart}
title={tutorial.isEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')} title={t('sidebar.tutorialRestart')}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<path d="M12 8v4l3 3"/> <path d="M12 8v4l3 3"/>
</svg> </svg>
<span>{t('sidebar.tutorial')}</span> <span>{t('sidebar.tutorialRestart')}</span>
<span className={`tutorial-toggle-badge ${tutorial.isEnabled ? 'on' : 'off'}`}>
{tutorial.isEnabled ? 'ON' : 'OFF'}
</span>
</button> </button>
)} )}
{tutorial.isActive && ( {tutorial.isActive && (
@ -630,7 +562,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return ( return (
<div className="analysis-page-wrapper"> <div className="analysis-page-wrapper">
{showSidebar && ( {showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} /> <Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
)} )}
<main className="analysis-page-main"> <main className="analysis-page-main">
{renderContent()} {renderContent()}
@ -643,10 +575,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return ( return (
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}> <div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
{showSidebar && ( {showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} /> <Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
)} )}
{tutorialUI} {tutorialUI}
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}> <div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
{renderContent()} {renderContent()}
</div> </div>
</div> </div>

View File

@ -1,398 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const isPlayingRef = useRef(false);
const pendingPlayRef = useRef(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
video.preload = 'auto';
} else {
video.preload = 'none';
}
},
{ threshold: 0.1 }
);
observer.observe(video);
return () => observer.disconnect();
}, []);
const safePlay = useCallback((video: HTMLVideoElement) => {
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise
.then(() => { isPlayingRef.current = true; })
.catch((error) => {
if (error.name !== 'AbortError') console.error(error);
});
}
}, []);
const handleCanPlay = useCallback(() => {
if (!pendingPlayRef.current || !videoRef.current) return;
pendingPlayRef.current = false;
safePlay(videoRef.current);
}, [safePlay]);
const handleMouseEnter = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.readyState >= 3) {
safePlay(video);
} else {
pendingPlayRef.current = true;
}
}, [safePlay]);
const handleMouseLeave = useCallback(() => {
const video = videoRef.current;
if (!video) return;
pendingPlayRef.current = false;
if (isPlayingRef.current) {
video.pause();
video.currentTime = 0;
isPlayingRef.current = false;
}
}, []);
return (
<video
ref={videoRef}
src={src}
className={className}
muted
playsInline
preload="none"
onCanPlay={handleCanPlay}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
};
interface MyContentsPageProps {
onBack?: () => void;
onNavigate?: (item: string) => void;
}
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const pageSize = 12;
useEffect(() => {
fetchVideos();
}, [page]);
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
}
}, []);
const fetchVideos = async () => {
setLoading(true);
setError(null);
try {
const response = await getVideosList(page, pageSize);
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
setVideos(validVideos);
setTotal(response.total);
setTotalPages(response.total_pages);
setHasNext(response.has_next);
setHasPrev(response.has_prev);
} catch (err) {
console.error('Failed to fetch videos:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}${hours}:${minutes}`;
};
const formatTitle = (storeName: string, dateString: string) => {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
};
const handleDownload = async (videoUrl: string, storeName: string) => {
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${storeName}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download failed:', err);
alert(t('ado2Contents.downloadFailed'));
}
};
const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(videoId);
setDeleteModalOpen(true);
};
const handleUploadClick = (video: VideoListItem) => {
setUploadTargetVideo(video);
setUploadModalOpen(true);
};
const handleUploadModalClose = () => {
setUploadModalOpen(false);
setUploadTargetVideo(null);
};
const handleDeleteCancel = () => {
setDeleteModalOpen(false);
setDeleteTargetId(null);
};
const handleDeleteConfirm = async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
await deleteVideo(deleteTargetId);
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false);
setDeleteTargetId(null);
} catch (err) {
console.error('Delete failed:', err);
alert(t('ado2Contents.deleteFailed'));
} finally {
setIsDeleting(false);
}
};
return (
<div className="ado2-contents-page">
{/* Header */}
<div className="ado2-contents-header">
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
</div>
{/* Content Grid */}
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
<p>{t('ado2Contents.loading')}</p>
</div>
) : error ? (
<div className="ado2-contents-error">
<p>{error}</p>
<button onClick={fetchVideos} className="retry-btn">{t('ado2Contents.retry')}</button>
</div>
) : videos.length === 0 ? (
<div className="ado2-contents-empty">
<p>{t('ado2Contents.noContent')}</p>
</div>
) : (
<>
<div className="ado2-contents-grid">
{videos.map((video) => (
<div key={video.task_id} className="ado2-content-card">
{/* Video Thumbnail */}
<div
className="content-card-thumbnail"
style={{ cursor: 'pointer' }}
onClick={() => setSelectedVideoId(video.video_id)}
>
{video.result_movie_url ? (
<VideoPreviewCard
src={video.result_movie_url}
className="content-video-preview"
/>
) : (
<div className="content-no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg>
</div>
)}
</div>
{/* Card Info */}
<div className="content-card-info">
<div className="content-card-text">
<h3 className="content-card-title">
{formatTitle(video.store_name, video.created_at)}
</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p className="content-card-date">
{formatDate(video.created_at)}
</p>
<ContentCardSocialActions
videoId={video.video_id}
storeName={video.store_name}
region={video.region}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
</div>
</div>
{/* Action Buttons */}
<div className="content-card-actions">
<button
className="content-download-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
title={t('ado2Contents.download')}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
</button>
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video.video_id)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="ado2-contents-pagination">
<button
className="pagination-btn"
onClick={() => setPage(p => p - 1)}
disabled={!hasPrev}
>
{t('ado2Contents.previous')}
</button>
<span className="pagination-info">{page} / {totalPages}</span>
<button
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={!hasNext}
>
{t('ado2Contents.next')}
</button>
</div>
</>
)}
{/* 삭제 확인 모달 */}
{deleteModalOpen && (
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
<div className="delete-modal-actions">
<button
className="delete-modal-btn cancel"
onClick={handleDeleteCancel}
disabled={isDeleting}
>
{t('ado2Contents.cancel')}
</button>
<button
className="delete-modal-btn confirm"
onClick={handleDeleteConfirm}
disabled={isDeleting}
>
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
</button>
</div>
</div>
</div>
)}
{selectedVideoId !== null && (
<VideoDetailModal
videoId={String(selectedVideoId)}
onClose={() => setSelectedVideoId(null)}
/>
)}
{/* 소셜 미디어 업로드 모달 */}
<SocialPostingModal
isOpen={uploadModalOpen}
onClose={handleUploadModalClose}
video={uploadTargetVideo}
onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
/>
</div>
);
};
export default MyContentsPage;

View File

@ -1,53 +1,18 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api'; import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api';
import { SocialAccount } from '../../types/api'; import { SocialAccount } from '../../types/api';
type TabType = 'basic' | 'payment' | 'business'; type TabType = 'basic' | 'payment' | 'business';
interface MyInfoContentProps { const MyInfoContent: React.FC = () => {
initialTab?: TabType;
}
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business'); const [activeTab, setActiveTab] = useState<TabType>('business');
const [businessUrl, setBusinessUrl] = useState(''); const [businessUrl, setBusinessUrl] = useState('');
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]); const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
const [isConnecting, setIsConnecting] = useState<string | null>(null); const [isConnecting, setIsConnecting] = useState<string | null>(null);
const [credits, setCredits] = useState<number | null>(null);
const [isLoadingCredits, setIsLoadingCredits] = useState(false);
const [showChargePopup, setShowChargePopup] = useState(false);
const [chargeAmount, setChargeAmount] = useState('');
const [chargeNote, setChargeNote] = useState('');
const [isRequesting, setIsRequesting] = useState(false);
const [chargeSuccess, setChargeSuccess] = useState(false);
// initialTab 변경 시 탭 업데이트
useEffect(() => {
if (initialTab) setActiveTab(initialTab);
}, [initialTab]);
// 크레딧 로드
useEffect(() => {
if (activeTab === 'payment') {
loadCredits();
}
}, [activeTab]);
const loadCredits = async () => {
setIsLoadingCredits(true);
try {
const data = await getUserCredits();
setCredits(data.credits);
} catch (error) {
console.error('Failed to load credits:', error);
} finally {
setIsLoadingCredits(false);
}
};
// 소셜 계정 목록 로드 // 소셜 계정 목록 로드
useEffect(() => { useEffect(() => {
@ -113,80 +78,12 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0; const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
const tabs = [ const tabs = [
// { id: 'basic' as TabType, label: t('myInfo.tabBasic') }, { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') }, { id: 'payment' as TabType, label: t('myInfo.tabPayment') },
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') }, { id: 'business' as TabType, label: t('myInfo.tabBusiness') },
]; ];
return ( return (
<>
{showChargePopup && (
<div className="myinfo-popup-overlay" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>
<div className="myinfo-popup" onClick={e => e.stopPropagation()}>
{chargeSuccess ? (
<>
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
<button className="myinfo-popup-close" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeConfirm')}</button>
</>
) : (
<>
<h3 className="myinfo-popup-title">{t('myInfo.chargePopupTitle')}</h3>
<div className="myinfo-popup-field">
<label className="myinfo-popup-label">{t('myInfo.chargeAmountLabel')}</label>
<div className="myinfo-popup-counter">
<button
className="myinfo-popup-counter-btn"
onClick={() => setChargeAmount(v => String(Math.max(1, Number(v || 0) - 1)))}
></button>
<input
type="number"
className="myinfo-popup-input myinfo-popup-input--center"
placeholder="0"
value={chargeAmount}
onChange={e => setChargeAmount(e.target.value.replace(/[^0-9]/g, ''))}
min={1}
/>
<button
className="myinfo-popup-counter-btn"
onClick={() => setChargeAmount(v => String(Number(v || 0) + 1))}
>+</button>
</div>
</div>
<div className="myinfo-popup-field">
<label className="myinfo-popup-label">{t('myInfo.chargeNoteLabel')}</label>
<textarea
className="myinfo-popup-textarea"
placeholder={t('myInfo.chargeNotePlaceholder')}
value={chargeNote}
onChange={e => setChargeNote(e.target.value)}
rows={3}
/>
</div>
<div className="myinfo-popup-actions">
<button className="myinfo-popup-cancel" onClick={() => { setShowChargePopup(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeCancel')}</button>
<button
className="myinfo-popup-close"
disabled={!chargeAmount || isRequesting}
onClick={async () => {
setIsRequesting(true);
try {
await requestCreditCharge(Number(chargeAmount), chargeNote);
} catch (e) {
console.error('Charge request failed:', e);
} finally {
setIsRequesting(false);
setChargeSuccess(true);
}
}}
>
{isRequesting ? t('myInfo.chargeSubmitting') : t('myInfo.chargeSubmit')}
</button>
</div>
</>
)}
</div>
</div>
)}
<main className="myinfo-page"> <main className="myinfo-page">
<h1 className="myinfo-title">{t('myInfo.title')}</h1> <h1 className="myinfo-title">{t('myInfo.title')}</h1>
@ -205,42 +102,21 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
{/* 탭 컨텐츠 */} {/* 탭 컨텐츠 */}
<div className="myinfo-content"> <div className="myinfo-content">
{/* {activeTab === 'basic' && ( {activeTab === 'basic' && (
<div className="myinfo-section"> <div className="myinfo-section">
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p> <p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
</div> </div>
)} */} )}
{activeTab === 'payment' && ( {activeTab === 'payment' && (
<div className="myinfo-section"> <div className="myinfo-section">
<h2 className="myinfo-section-title">{t('myInfo.creditsTitle')}</h2> <p className="myinfo-placeholder">{t('myInfo.paymentPlaceholder')}</p>
<div className="myinfo-credits-card">
{isLoadingCredits ? (
<p className="myinfo-placeholder">{t('myInfo.creditsLoading')}</p>
) : (
<>
<div className="myinfo-credits-row">
<span className="myinfo-credits-label">{t('myInfo.creditsLabel')}</span>
<span className="myinfo-credits-value">{credits !== null ? credits.toLocaleString() : '-'} {t('myInfo.creditsUnit')}</span>
</div>
<div className="myinfo-credits-row">
<p className="myinfo-credits-desc">{t('myInfo.creditsDesc')}</p>
<button
className="myinfo-credits-charge-btn"
onClick={() => setShowChargePopup(true)}
>
{t('myInfo.creditsChargeBtn')}
</button>
</div>
</>
)}
</div>
</div> </div>
)} )}
{activeTab === 'business' && ( {activeTab === 'business' && (
<> <>
{/* {/* 내 비즈니스 섹션 */}
<div className="myinfo-section"> <div className="myinfo-section">
<h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2> <h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2>
<div className="myinfo-business-card"> <div className="myinfo-business-card">
@ -261,7 +137,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
</button> </button>
</div> </div>
</div> </div>
</div> */} </div>
{/* 소셜 채널 섹션 */} {/* 소셜 채널 섹션 */}
<div className="myinfo-section"> <div className="myinfo-section">
@ -374,7 +250,6 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
)} )}
</div> </div>
</main> </main>
</>
); );
}; };

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'; 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';
@ -16,70 +16,15 @@ interface SoundStudioContentProps {
businessInfo?: BusinessInfo; businessInfo?: BusinessInfo;
imageTaskId: string | null; imageTaskId: string | null;
mId: number; mId: number;
industry?: string;
onStatusChange?: (status: string) => void; onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error'; videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number; videoGenerationProgress?: number;
onGoToPayment?: () => void;
} }
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error'; type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
const MAX_RETRY_COUNT = 3; const MAX_RETRY_COUNT = 3;
// 생성 완료된 곡 상태를 새로고침 후에도 복원하기 위한 키
// GenerationFlow의 clearAllProjectStorage가 이 키를 포함해 정리함
const SONG_GENERATION_KEY = 'castad_song_generation';
const GENRE_VALUES = ['kpop', 'pop', 'ballad', 'hip-hop', 'rnb', 'edm', 'jazz', 'rock'];
const genreMap: Record<string, string> = {
'자동 선택': '', // 런타임에 랜덤 결정
'K-POP': 'kpop',
'POP': 'pop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
const getEffectiveGenre = (selectedGenre: string): string => {
if (selectedGenre === '자동 선택') {
return GENRE_VALUES[Math.floor(Math.random() * GENRE_VALUES.length)];
}
return genreMap[selectedGenre] || 'pop';
};
// 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환
const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => {
const displayMap: Record<string, string> = {
kpop: 'K-POP',
pop: 'POP',
ballad: t('soundStudio.genreBallad'),
'hip-hop': 'Hip-Hop',
rnb: 'R&B',
edm: 'EDM',
jazz: 'JAZZ',
rock: 'ROCK',
};
return displayMap[genreValue] || genreValue;
};
const sanitizeError = (message: string, fallback: string): string => {
if (
message.includes('Traceback') ||
message.includes('File "/') ||
message.includes('httpx.') ||
message.includes('httpcore.') ||
message.length > 200
) {
return fallback;
}
return message;
};
const LANGUAGE_FLAGS: Record<string, string> = { const LANGUAGE_FLAGS: Record<string, string> = {
'한국어': '🇰🇷', '한국어': '🇰🇷',
'English': '🇺🇸', 'English': '🇺🇸',
@ -95,18 +40,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo, businessInfo,
imageTaskId, imageTaskId,
mId, mId,
industry = '',
onStatusChange, onStatusChange,
videoGenerationStatus = 'idle', videoGenerationStatus = 'idle',
videoGenerationProgress = 0, videoGenerationProgress = 0
onGoToPayment,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedType, setSelectedType] = useState('보컬'); const [selectedType, setSelectedType] = useState('보컬');
const [selectedLang, setSelectedLang] = useState('한국어'); const [selectedLang, setSelectedLang] = useState('한국어');
const [selectedGenre, setSelectedGenre] = useState('자동 선택'); const [selectedGenre, setSelectedGenre] = useState('자동 선택');
const [usedGenre, setUsedGenre] = useState('');
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<GenerationStatus>('idle'); const [status, setStatus] = useState<GenerationStatus>('idle');
const [lyrics, setLyrics] = useState(''); const [lyrics, setLyrics] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [audioUrl, setAudioUrl] = useState<string | null>(null);
@ -117,41 +60,40 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
const [songTaskId, setSongTaskId] = useState<string | null>(null); const [songTaskId, setSongTaskId] = useState<string | null>(null);
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const languageDropdownRef = useRef<HTMLDivElement>(null);
// 완료 데이터를 localStorage에 저장 // 완료 데이터를 localStorage에 저장
const saveSongCompletionData = () => { const saveSongCompletionData = () => {
const completionData = { const completionData = {
businessName: businessInfo?.customer_name || '알 수 없음', businessName: businessInfo?.customer_name || '알 수 없음',
// '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장 genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre,
genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre,
lyrics: lyrics, lyrics: lyrics,
timestamp: Date.now(), timestamp: Date.now(),
}; };
localStorage.setItem('castad_song_completion', JSON.stringify(completionData)); localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
}; };
// 새로고침 후 생성 완료된 곡 상태 복원
useEffect(() => {
const saved = localStorage.getItem(SONG_GENERATION_KEY);
if (!saved) return;
try {
const parsed = JSON.parse(saved);
if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return;
// 저장된 URL로 복원 (public Blob URL이라 만료되지 않음)
// 백엔드에 /song/download 엔드포인트가 없으므로 재조회하지 않는다.
setSongTaskId(parsed.songTaskId);
setAudioUrl(parsed.audioUrl);
setLyrics(parsed.lyrics ?? '');
setStatus('complete');
} catch {
// 파싱 실패 시 무시
}
}, []); // 마운트 1회만 실행
// Close language dropdown when clicking outside // Close language dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (languageDropdownRef.current && !languageDropdownRef.current.contains(event.target as Node)) {
setIsLanguageDropdownOpen(false);
}
};
if (isLanguageDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isLanguageDropdownOpen]);
// Auto-navigate to next page when video generation is complete // Auto-navigate to next page when video generation is complete
useEffect(() => { useEffect(() => {
if (videoGenerationStatus === 'complete' && songTaskId) { if (videoGenerationStatus === 'complete' && songTaskId) {
@ -190,18 +132,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setStatusMessage(''); setStatusMessage('');
setRetryCount(0); setRetryCount(0);
// 새로고침 후에도 복원할 수 있도록 완료된 곡 상태를 저장
try {
localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({
audioUrl: statusResponse.song_result_url,
songTaskId: taskId,
lyrics: currentLyrics,
status: 'complete',
}));
} catch {
// localStorage 쓰기 실패는 무시
}
} catch (error) { } catch (error) {
console.error('Polling failed:', error); console.error('Polling failed:', error);
@ -218,10 +148,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} }
} else { } else {
setStatus('error'); setStatus('error');
setErrorMessage(sanitizeError( setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
error instanceof Error ? error.message : '',
t('soundStudio.musicGenerationError')
));
setRetryCount(0); setRetryCount(0);
} }
} }
@ -232,14 +159,21 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try { try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const genreMap: Record<string, string> = {
const isInstrumental = selectedType === '배경음악'; '자동 선택': 'pop',
const effectiveGenre = getEffectiveGenre(selectedGenre); 'K-POP': 'kpop',
setUsedGenre(effectiveGenre); '발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
const songResponse = await generateSong(imageTaskId, { const songResponse = await generateSong(imageTaskId, {
genre: effectiveGenre, genre: genreMap[selectedGenre] || 'pop',
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }), language,
lyrics: currentLyrics,
}); });
if (!songResponse.success) { if (!songResponse.success) {
@ -251,14 +185,46 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} catch (error) { } catch (error) {
console.error('Song regeneration failed:', error); console.error('Song regeneration failed:', error);
setStatus('error'); setStatus('error');
setErrorMessage(sanitizeError( setErrorMessage(error instanceof Error ? error.message : t('soundStudio.songRegenerationError'));
error instanceof Error ? error.message : '',
t('soundStudio.songRegenerationError')
));
setRetryCount(0); setRetryCount(0);
} }
}; };
const handleMove = (clientX: number) => {
if (!progressBarRef.current || !audioRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
const newTime = (newProgress / 100) * duration;
audioRef.current.currentTime = newTime;
setProgress(newProgress);
setCurrentTime(newTime);
};
const onMouseDown = (e: React.MouseEvent) => {
if (!audioUrl) return;
setIsDragging(true);
handleMove(e.clientX);
};
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (isDragging) handleMove(e.clientX);
};
const onMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [isDragging]);
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
@ -318,9 +284,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
return; return;
} }
// 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거
localStorage.removeItem(SONG_GENERATION_KEY);
setStatus('generating_lyric'); setStatus('generating_lyric');
setErrorMessage(null); setErrorMessage(null);
setStatusMessage(t('soundStudio.generatingLyrics')); setStatusMessage(t('soundStudio.generatingLyrics'));
@ -330,10 +293,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try { try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const isInstrumental = selectedType === '배경음악';
// 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다.
// '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다.
const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre];
// 1. 가사 생성 요청 // 1. 가사 생성 요청
console.log('[SoundStudio] Sending m_id:', mId); console.log('[SoundStudio] Sending m_id:', mId);
@ -344,55 +303,53 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
m_id: mId, m_id: mId,
region: businessInfo.region, region: businessInfo.region,
task_id: imageTaskId, task_id: imageTaskId,
industry,
orientation, orientation,
...(isInstrumental && { instrumental: true }),
...(manualGenre && { genre: manualGenre }),
}); });
if (!lyricResponse.success || !lyricResponse.task_id) { if (!lyricResponse.success || !lyricResponse.task_id) {
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed')); throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
} }
let songLyrics: string | undefined; // 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다. setStatusMessage(t('soundStudio.generatingLyrics'));
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다. const lyricDetailResponse = await waitForLyricComplete(
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre); lyricResponse.task_id,
(status: string) => {
if (!isInstrumental) { if (status === 'processing') {
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회 setStatusMessage(t('soundStudio.generatingLyrics'));
setStatusMessage(t('soundStudio.generatingLyrics'));
const lyricDetailResponse = await waitForLyricComplete(
lyricResponse.task_id,
(status: string) => {
if (status === 'processing') {
setStatusMessage(t('soundStudio.generatingLyrics'));
}
} }
);
if (!lyricDetailResponse.lyric_result) {
throw new Error(t('soundStudio.lyricNotReceived'));
} }
);
// "I'm sorry" 체크 if (!lyricDetailResponse.lyric_result) {
if (lyricDetailResponse.lyric_result.includes("I'm sorry")) { throw new Error(t('soundStudio.lyricNotReceived'));
throw new Error(t('soundStudio.lyricGenerationError'));
}
setLyrics(lyricDetailResponse.lyric_result);
songLyrics = lyricDetailResponse.lyric_result;
// 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용
effectiveGenre = lyricDetailResponse.genre || effectiveGenre;
} }
// "I'm sorry" 체크
if (lyricDetailResponse.lyric_result.includes("I'm sorry")) {
throw new Error(t('soundStudio.lyricGenerationError'));
}
setLyrics(lyricDetailResponse.lyric_result);
setStatus('generating_song'); setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong')); setStatusMessage(t('soundStudio.generatingSong'));
setUsedGenre(effectiveGenre);
const genreMap: Record<string, string> = {
'자동 선택': 'pop',
'K-POP': 'kpop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
const songResponse = await generateSong(imageTaskId, { const songResponse = await generateSong(imageTaskId, {
genre: effectiveGenre, genre: genreMap[selectedGenre] || 'pop',
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }), language,
lyrics: lyricDetailResponse.lyric_result,
}); });
if (!songResponse.success) { if (!songResponse.success) {
@ -411,15 +368,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setStatus('polling'); setStatus('polling');
setStatusMessage(t('soundStudio.generatingSong')); setStatusMessage(t('soundStudio.generatingSong'));
await resumePolling(songResponse.task_id, songResponse.song_id, songLyrics ?? '', 0); await resumePolling(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 0);
} catch (error) { } catch (error) {
console.error('Music generation failed:', error); console.error('Music generation failed:', error);
setStatus('error'); setStatus('error');
setErrorMessage(sanitizeError( setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
error instanceof Error ? error.message : '',
t('soundStudio.musicGenerationError')
));
setRetryCount(0); setRetryCount(0);
} }
}; };
@ -437,24 +391,6 @@ 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';
// 드래그로 인한 잦은 리렌더에서도 가사 파싱(정규식/배열 처리)이 매번 재실행되지 않도록 메모이제이션
const lyricsSections = useMemo(() => {
if (!lyrics) return [];
const lines = lyrics.split('\n').filter(l => l.trim());
const sections: { tag: string | null; lines: string[] }[] = [];
lines.forEach(line => {
const tagMatch = line.trim().match(/^\[(.+)\]$/);
if (tagMatch) {
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
} else if (sections.length === 0) {
sections.push({ tag: null, lines: [line] });
} else {
sections[sections.length - 1].lines.push(line);
}
});
return sections.filter(s => s.lines.length > 0);
}, [lyrics]);
// status 변경 시 부모에 알림 // status 변경 시 부모에 알림
useEffect(() => { useEffect(() => {
onStatusChange?.(status); onStatusChange?.(status);
@ -464,14 +400,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<> <>
<main className="sound-studio-page"> <main className="sound-studio-page">
{audioUrl && ( {audioUrl && (
<audio <audio ref={audioRef} src={audioUrl} preload="metadata" />
ref={audioRef}
src={audioUrl}
preload="metadata"
onLoadedMetadata={() => {
if (audioRef.current) setDuration(audioRef.current.duration);
}}
/>
)} )}
{/* Header */} {/* Header */}
@ -499,15 +428,14 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<label className="input-label">{t('soundStudio.soundTypeLabel')}</label> <label className="input-label">{t('soundStudio.soundTypeLabel')}</label>
<div className="sound-type-grid"> <div className="sound-type-grid">
{[ {[
{ key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false }, { key: '보컬', label: t('soundStudio.soundTypeVocal') },
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: false }, { key: '배경음악', label: t('soundStudio.soundTypeBGM') },
].map(({ key, label, disabled }) => ( ].map(({ key, label }) => (
<button <button
key={key} key={key}
onClick={() => !isGenerating && !disabled && setSelectedType(key)} onClick={() => !isGenerating && setSelectedType(key)}
disabled={isGenerating || disabled} disabled={isGenerating}
className={`sound-type-btn ${selectedType === key ? 'active' : ''}`} className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
title={disabled ? t('soundStudio.comingSoon', { defaultValue: '이후 오픈 예정입니다' }) : undefined}
> >
{label} {label}
</button> </button>
@ -548,7 +476,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
))} ))}
</div> </div>
<div className="genre-row"> <div className="genre-row">
{['JAZZ', 'ROCK', 'POP'].map(genre => ( {['JAZZ', 'ROCK'].map(genre => (
<button <button
key={genre} key={genre}
onClick={() => setSelectedGenre(genre)} onClick={() => setSelectedGenre(genre)}
@ -558,6 +486,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{genre} {genre}
</button> </button>
))} ))}
<div className="genre-btn-placeholder"></div>
</div> </div>
</div> </div>
</div> </div>
@ -565,31 +494,39 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Language Selection */} {/* Language Selection */}
<div className="sound-studio-section"> <div className="sound-studio-section">
<label className="input-label">{t('soundStudio.languageLabel')}</label> <label className="input-label">{t('soundStudio.languageLabel')}</label>
<div className="genre-grid language-grid"> <div className="language-selector-wrapper" ref={languageDropdownRef}>
<div className="genre-row"> <button
{['한국어', 'English', '中文'].map((lang) => ( onClick={() => setIsLanguageDropdownOpen(!isLanguageDropdownOpen)}
<button disabled={isGenerating}
key={lang} className="language-selector"
onClick={() => !isGenerating && setSelectedLang(lang)} >
disabled={isGenerating} <div className="language-display">
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`} <span className="language-flag">{LANGUAGE_FLAGS[selectedLang]}</span>
> <span className="language-name">{selectedLang}</span>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang} </div>
</button> <img
))} src="/assets/images/icon-dropdown.svg"
</div> alt=""
<div className="genre-row"> className={`language-dropdown-icon ${isLanguageDropdownOpen ? 'open' : ''}`}
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => ( />
<button </button>
key={lang} {isLanguageDropdownOpen && (
onClick={() => !isGenerating && setSelectedLang(lang)} <div className="language-dropdown-menu">
disabled={isGenerating} {Object.keys(LANGUAGE_MAP).map((lang) => (
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`} <button
> key={lang}
<span>{LANGUAGE_FLAGS[lang]}</span> {lang} onClick={() => {
</button> setSelectedLang(lang);
))} setIsLanguageDropdownOpen(false);
</div> }}
className={`language-dropdown-item ${selectedLang === lang ? 'active' : ''}`}
>
<span className="language-flag">{LANGUAGE_FLAGS[lang]}</span>
<span className="language-name">{lang}</span>
</button>
))}
</div>
)}
</div> </div>
</div> </div>
@ -602,16 +539,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</svg> </svg>
{statusMessage} {statusMessage}
</div> </div>
) : status === 'complete' ? (
<>
<button
onClick={handleRegenerate}
className="btn-generate-sound"
>
{t('soundStudio.regenerateButton')}
</button>
<p className="regenerate-hint">{t('soundStudio.regenerateHint')}</p>
</>
) : ( ) : (
<button <button
onClick={handleGenerateMusic} onClick={handleGenerateMusic}
@ -622,30 +549,18 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</button> </button>
)} )}
{errorMessage && (() => { {errorMessage && (
const isCreditsError = errorMessage.includes('credit'); <div className="error-message-new">
return ( {errorMessage}
<div className="error-message-new"> </div>
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage} )}
{isCreditsError && (
<a
href=""
className="charge-credits-link"
onClick={e => { e.preventDefault(); onGoToPayment?.(); }}
>
{t('soundStudio.chargeCredits')}
</a>
)}
</div>
);
})()}
</div> </div>
{/* Right Column - Lyrics */} {/* Right Column - Lyrics */}
<div className="lyrics-column"> <div className="lyrics-column">
<div className="lyrics-header"> <div className="lyrics-header">
<h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3> <h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3>
{/* <p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p> */} <p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p>
</div> </div>
{/* Audio Player */} {/* Audio Player */}
@ -667,8 +582,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
)} )}
</button> </button>
{/* 진행 표시 전용 — Azure Blob 익명 응답에 Accept-Ranges가 없어 seek가 불안정하므로 드래그 미지원 */}
<div <div
ref={progressBarRef}
onMouseDown={onMouseDown}
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`} className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
> >
<div <div
@ -685,17 +601,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Lyrics Display */} {/* Lyrics Display */}
<div className="lyrics-display"> <div className="lyrics-display">
{lyrics ? ( {lyrics ? (
<div className="lyrics-paragraphs"> <textarea
{lyricsSections.map((section, i) => ( value={lyrics}
<div key={i} className="lyrics-section"> onChange={(e) => setLyrics(e.target.value)}
{section.tag && <span className="lyrics-tag">{section.tag}</span>} className="lyrics-textarea"
<p className="lyrics-paragraph">{section.lines.join('\n')}</p> placeholder={t('soundStudio.lyricsPlaceholder')}
</div> />
))}
</div>
) : ( ) : (
<div className="lyrics-placeholder"> <div className="lyrics-placeholder">
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')} {t('soundStudio.lyricsPlaceholder')}
</div> </div>
)} )}
</div> </div>

View File

@ -1,29 +1,300 @@
import React from 'react';
import { AutocompleteRequest } from '../../utils/api'; import React, { useState, useRef, useCallback } from 'react';
import SearchInputForm, { SearchType } from '../../components/SearchInputForm'; import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
import { CrawlingResponse } from '../../types/api';
type SearchType = 'url' | 'name';
// 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
interface UrlInputContentProps { interface UrlInputContentProps {
onAnalyze: (value: string, type?: SearchType) => void; onAnalyze: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void; onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string) => void; onTestData?: (data: CrawlingResponse) => void;
error: string | null; error: string | null;
} }
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => { const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [isLoadingTest, setIsLoadingTest] = useState(false);
// 테스트 데이터 로드 핸들러
const handleTestData = async () => {
if (!onTestData) return;
setIsLoadingTest(true);
try {
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
const response = await fetch(jsonFile);
const data: CrawlingResponse = await response.json();
onTestData(data);
} catch (err) {
console.error('테스트 데이터 로드 실패:', err);
} finally {
setIsLoadingTest(false);
}
};
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') },
];
const getPlaceholder = () => {
return searchType === 'url'
? 'https://naver.me/abcdef'
: t('urlInput.placeholderBusinessName');
};
const getGuideText = () => {
return searchType === 'url'
? t('urlInput.guideUrl')
: t('urlInput.guideBusinessName');
};
// 업체명 검색 시 자동완성 (디바운스 적용)
const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim() || searchType !== 'name') {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
}
setIsAutocompleteLoading(true);
try {
const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0);
} catch (error) {
console.error('자동완성 검색 오류:', error);
setAutocompleteResults([]);
setShowAutocomplete(false);
} finally {
setIsAutocompleteLoading(false);
}
}, [searchType]);
// 자동완성 항목 선택 - 업체 정보 저장
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
setSelectedItem(item); // 선택된 업체 정보 저장
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
};
// 키보드 네비게이션 핸들러
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showAutocomplete || autocompleteResults.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev =>
prev < autocompleteResults.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : autocompleteResults.length - 1
);
break;
case 'Enter':
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
e.preventDefault();
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
}
break;
case 'Escape':
setShowAutocomplete(false);
setHighlightedIndex(-1);
break;
}
};
// 붙여넣기 시 URL만 추출
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
}
};
// 폼 제출 처리
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim()) return;
if (searchType === 'name' && selectedItem && onAutocomplete) {
// 업체명 검색인 경우 autocomplete API 호출
const request: AutocompleteRequest = {
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
};
onAutocomplete(request);
} else {
// URL 검색인 경우 기존 로직
onAnalyze(inputValue.trim(), searchType);
}
};
return ( return (
<div className="url-input-container"> <div className="url-input-container">
<div className="url-input-content"> <div className="url-input-content">
{/* 로고 */}
<div className="url-input-logo"> <div className="url-input-logo">
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" /> <img src="/assets/images/ado2-logo.svg" alt="ADO2" />
</div> </div>
<SearchInputForm {/* URL 입력 폼 */}
onAnalyze={onAnalyze} <form onSubmit={handleSubmit} className="url-input-form">
onAutocomplete={onAutocomplete} <div className="url-input-wrapper">
onManualInput={onManualInput} {/* 드롭다운 */}
error={error} <div className="url-input-dropdown-container">
/> <button
type="button"
className="url-input-dropdown-trigger"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`url-input-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="url-input-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
setSearchType(option.value);
setIsDropdownOpen(false);
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
{/* 입력 필드 */}
<div className="url-input-field-container" ref={autocompleteRef}>
<input
type="text"
value={inputValue}
onChange={(e) => {
let value = e.target.value;
// URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...")
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) {
value = urlMatch[0];
}
}
setInputValue(value);
// 업체명 검색일 때 자동완성 검색 (디바운스)
if (searchType === 'name') {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleAutocompleteSearch(value);
}, 300);
}
}}
onPaste={handlePaste}
onFocus={() => {
if (searchType === 'name' && autocompleteResults.length > 0) {
setShowAutocomplete(true);
}
}}
placeholder={getPlaceholder()}
className="url-input-field"
/>
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="url-input-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="url-input-autocomplete-loading">{t('urlInput.searching')}</div>
) : (
autocompleteResults.map((item, index) => (
<button
key={index}
type="button"
className="url-input-autocomplete-item"
onMouseDown={(e) => {
e.preventDefault();
handleSelectAutocomplete(item);
}}
>
<div className="url-input-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="url-input-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
</div>
{/* 안내 텍스트 */}
<p className="url-input-guide">
{getGuideText()}
</p>
{/* 에러 메시지 */}
{error && (
<p className="url-input-error">{error}</p>
)}
{/* 검색 버튼 */}
<button type="submit" className="url-input-button">
{t('landing.hero.analyzeButton')}
</button>
</form>
</div> </div>
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
{isTestPage && onTestData && (
<button
onClick={handleTestData}
disabled={isLoadingTest}
className="test-data-button"
>
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
</button>
)}
</div> </div>
); );
}; };

View File

@ -11,9 +11,9 @@ const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// YouTube Shorts 영상 ID들 // YouTube Shorts 영상 ID들
const videos = [ const videos = [
{ id: 1, videoId: 'M3iuPZ59X1I' }, { id: 1, videoId: 'OZJ8X4P82OA' },
{ id: 2, videoId: 'JxWQxELDHSs' }, { id: 2, videoId: 'hNzMO21O40c' },
{ id: 3, videoId: 'c2ZdwhaB7S4' }, { id: 3, videoId: 'dM8_d6Aud68' },
]; ];
return ( return (

View File

@ -1,14 +1,34 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AutocompleteRequest, isLoggedIn } from '../../utils/api'; import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
import { CrawlingResponse } from '../../types/api';
import { useTutorial } from '../../components/Tutorial/useTutorial'; import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay'; import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
import LoginPromptModal from '../../components/LoginPromptModal'; type SearchType = 'url' | 'name';
import SearchInputForm from '../../components/SearchInputForm';
import { SearchType } from '../../components/SearchInputForm'; // 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onTestData?: (data: CrawlingResponse) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number; // 0 ~ 1 (스크롤 진행률)
}
const isValidUrl = (string: string): boolean => {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
// Orb configuration with movement zones to prevent overlap // Orb configuration with movement zones to prevent overlap
interface OrbConfig { interface OrbConfig {
@ -17,36 +37,64 @@ interface OrbConfig {
initialX: number; initialX: number;
initialY: number; initialY: number;
color: string; color: string;
// Movement bounds for each orb
minX: number; minX: number;
maxX: number; maxX: number;
minY: number; minY: number;
maxY: number; maxY: number;
} }
// 6 orbs distributed in a 3x2 grid pattern with overlapping zones for smooth movement
const orbConfigs: OrbConfig[] = [ const orbConfigs: OrbConfig[] = [
// Top-left zone
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 }, { id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
// Top-right zone
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 }, { id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
// Middle-left zone
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 }, { id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
// Middle-right zone
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 }, { id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
// Bottom-left zone
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 }, { id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
// Bottom-right zone
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 }, { id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
]; ];
interface HeroSectionProps { const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
onAnalyze?: (value: string, type?: SearchType) => void; const { t, i18n } = useTranslation();
onAutocomplete?: (data: AutocompleteRequest) => void; const [inputValue, setInputValue] = useState('');
onManualInput?: (businessName: string, address: string, category: string) => void; const [searchType, setSearchType] = useState<SearchType>('name');
onNext?: () => void; const [isDropdownOpen, setIsDropdownOpen] = useState(false);
error?: string | null; const [localError, setLocalError] = useState('');
scrollProgress?: number; const [isLoadingTest, setIsLoadingTest] = useState(false);
}
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => { // 테스트 데이터 로드 핸들러
const { t } = useTranslation(); const handleTestData = async () => {
const [isManualModalOpen, setIsManualModalOpen] = useState(false); if (!onTestData) return;
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false); setIsLoadingTest(true);
try {
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
const response = await fetch(jsonFile);
const data: CrawlingResponse = await response.json();
onTestData(data);
} catch (error) {
console.error('테스트 데이터 로드 실패:', error);
setLocalError(t('landing.hero.testDataLoadFailed'));
} finally {
setIsLoadingTest(false);
}
};
const [isFocused, setIsFocused] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]); const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]); const animationRefs = useRef<number[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tutorial = useTutorial(); const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작 // 첫 방문 시 랜딩 튜토리얼 시작
@ -59,12 +107,104 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
} }
}, []); }, []);
// Orb 랜덤 이동 애니메이션 const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
];
// 드롭다운 외부 클릭 감지
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 업체명 검색 시 자동완성 (디바운스 적용)
const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim() || searchType !== 'name') {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
}
setIsAutocompleteLoading(true);
try {
const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0);
} catch (error) {
console.error('자동완성 검색 오류:', error);
setAutocompleteResults([]);
setShowAutocomplete(false);
} finally {
setIsAutocompleteLoading(false);
}
}, [searchType]);
// 자동완성 항목 선택 - 업체 정보 저장
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
setSelectedItem(item); // 선택된 업체 정보 저장
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
// LANDING_NAME 튜토리얼 진행 중이면 다음 힌트로 이동
if (tutorial.isActive && tutorial.tutorialKey === TUTORIAL_KEYS.LANDING_NAME) {
tutorial.nextHint();
}
};
// 키보드 네비게이션 핸들러
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 자동완성이 표시될 때만 키보드 네비게이션 활성화
if (showAutocomplete && autocompleteResults.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev =>
prev < autocompleteResults.length - 1 ? prev + 1 : 0
);
return;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : autocompleteResults.length - 1
);
return;
case 'Enter':
e.preventDefault(); // 항상 Enter 기본 동작 방지
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
}
return;
case 'Escape':
e.preventDefault();
setShowAutocomplete(false);
setHighlightedIndex(-1);
return;
}
}
// 업체명 검색 모드에서 Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
if (e.key === 'Enter' && searchType === 'name') {
e.preventDefault();
}
};
// Random movement for orbs
useEffect(() => { useEffect(() => {
const moveOrb = (orb: HTMLDivElement, index: number) => { const moveOrb = (orb: HTMLDivElement, index: number) => {
const config = orbConfigs[index]; const config = orbConfigs[index];
let currentX = config.initialX; let currentX = config.initialX;
let currentY = config.initialY; let currentY = config.initialY;
// 초기 타겟은 현재 위치와 동일하게 설정 (순간이동 방지)
let targetX = currentX; let targetX = currentX;
let targetY = currentY; let targetY = currentY;
let scale = 1; let scale = 1;
@ -72,13 +212,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
let isFirstMove = true; let isFirstMove = true;
const generateNewTarget = () => { const generateNewTarget = () => {
// Move within the orb's designated zone
const rangeX = config.maxX - config.minX; const rangeX = config.maxX - config.minX;
const rangeY = config.maxY - config.minY; const rangeY = config.maxY - config.minY;
if (isFirstMove) { if (isFirstMove) {
// 첫 이동은 현재 위치에서 가까운 곳으로 (자연스러운 시작)
const smallRangeX = rangeX * 0.3; const smallRangeX = rangeX * 0.3;
const smallRangeY = rangeY * 0.3; const smallRangeY = rangeY * 0.3;
targetX = currentX + (Math.random() - 0.5) * smallRangeX; targetX = currentX + (Math.random() - 0.5) * smallRangeX;
targetY = currentY + (Math.random() - 0.5) * smallRangeY; targetY = currentY + (Math.random() - 0.5) * smallRangeY;
// 범위 내로 클램핑
targetX = Math.max(config.minX, Math.min(config.maxX, targetX)); targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
targetY = Math.max(config.minY, Math.min(config.maxY, targetY)); targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
isFirstMove = false; isFirstMove = false;
@ -86,38 +230,94 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
targetX = config.minX + Math.random() * rangeX; targetX = config.minX + Math.random() * rangeX;
targetY = config.minY + Math.random() * rangeY; targetY = config.minY + Math.random() * rangeY;
} }
targetScale = 0.9 + Math.random() * 0.2; targetScale = 0.9 + Math.random() * 0.2; // 0.9 to 1.1 (더 작은 범위)
}; };
const animate = () => { const animate = () => {
// Slow speed - 일정한 속도로 부드럽게
const speed = 0.003; const speed = 0.003;
currentX += (targetX - currentX) * speed; currentX += (targetX - currentX) * speed;
currentY += (targetY - currentY) * speed; currentY += (targetY - currentY) * speed;
scale += (targetScale - scale) * speed; scale += (targetScale - scale) * speed;
// Apply transform
orb.style.left = `${currentX}%`; orb.style.left = `${currentX}%`;
orb.style.top = `${currentY}%`; orb.style.top = `${currentY}%`;
orb.style.transform = `scale(${scale})`; orb.style.transform = `scale(${scale})`;
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
if (distance < 1) generateNewTarget(); // Generate new target when close enough
const distance = Math.sqrt(
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)
);
if (distance < 1) {
generateNewTarget();
}
animationRefs.current[index] = requestAnimationFrame(animate); animationRefs.current[index] = requestAnimationFrame(animate);
}; };
// 첫 타겟 생성 후 애니메이션 시작
generateNewTarget(); generateNewTarget();
animate(); animate();
}; };
// 모든 공이 동시에 자연스럽게 시작 (딜레이 없이)
orbRefs.current.forEach((orb, index) => { orbRefs.current.forEach((orb, index) => {
if (orb) moveOrb(orb, index); if (orb) {
moveOrb(orb, index);
}
}); });
// Cleanup
return () => { return () => {
animationRefs.current.forEach(id => cancelAnimationFrame(id)); animationRefs.current.forEach(id => cancelAnimationFrame(id));
}; };
}, []); }, []);
const error = externalError || localError;
const getPlaceholder = () => {
return searchType === 'url'
? 'https://naver.me/abcdef'
: t('landing.hero.placeholderBusinessName');
};
const getGuideText = () => {
return searchType === 'url'
? t('landing.hero.guideUrl')
: t('landing.hero.guideBusinessName');
};
const handleStart = () => {
if (!inputValue.trim()) {
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
return;
}
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
setLocalError(t('landing.hero.errorInvalidUrl'));
return;
}
setLocalError('');
if (searchType === 'name' && selectedItem && onAutocomplete) {
// 업체명 검색인 경우 autocomplete API 호출
const request: AutocompleteRequest = {
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
};
onAutocomplete(request);
} else if (onAnalyze) {
// URL 검색인 경우 기존 로직
onAnalyze(inputValue, searchType);
}
};
return ( return (
<div className="hero-section"> <div className="hero-section">
{/* Animated background orbs */} {/* Animated background orbs - 스크롤에 따라 빠르게 사라짐 */}
<div <div
className="hero-bg-orbs" className="hero-bg-orbs"
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }} style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
@ -139,38 +339,189 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
</div> </div>
<div className="hero-content"> <div className="hero-content">
{/* Logo Image */}
<img <img
src="/assets/images/ADO2_with Slogan_white.svg" src="/assets/images/ado2-logo.svg"
alt="ADO2" alt="ADO2"
className="hero-logo" className="hero-logo"
/> />
<SearchInputForm
onAnalyze={onAnalyze} {/* Input Form */}
onAutocomplete={onAutocomplete} <div className="hero-form">
onManualInput={onManualInput} <div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
onManualButtonClick={() => { {/* 드롭다운 */}
if (isLoggedIn()) { <div className="hero-dropdown-container" ref={dropdownRef}>
setIsManualModalOpen(true); <button
} else { type="button"
setIsLoginPromptOpen(true); className="hero-dropdown-trigger"
} onClick={() => setIsDropdownOpen(!isDropdownOpen)}
}} >
error={externalError || null} <span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
/> <svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="hero-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
setSearchType(option.value);
setIsDropdownOpen(false);
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
<div className="hero-input-container" ref={autocompleteRef}>
<input
type="text"
value={inputValue}
onChange={(e) => {
let value = e.target.value;
// URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...")
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) {
value = urlMatch[0];
}
}
setInputValue(value);
setHighlightedIndex(-1); // 입력 시 하이라이트 초기화
if (localError) setLocalError('');
// 업체명 검색일 때 자동완성 검색 (디바운스)
if (searchType === 'name') {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleAutocompleteSearch(value);
}, 300);
}
}}
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
if (localError) setLocalError('');
}
}}
onFocus={() => {
setIsFocused(true);
if (searchType === 'name' && autocompleteResults.length > 0) {
setShowAutocomplete(true);
}
}}
onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
) : (
autocompleteResults.map((item, index) => (
<button
key={index}
type="button"
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelectAutocomplete(item);
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
{inputValue && (
<button
type="button"
className="hero-input-clear"
onClick={() => {
setInputValue('');
setLocalError('');
setAutocompleteResults([]);
setShowAutocomplete(false);
}}
>
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
</button>
)}
</div>
<span className="hero-input-hint">{getGuideText()}</span>
{error && (
<p className="hero-error">{error}</p>
)}
<button onClick={handleStart} className="hero-button">
{t('landing.hero.analyzeButton')}
</button>
</div>
</div> </div>
{/* Footer Indicator */} {/* Footer Indicator */}
<button onClick={onNext} className="scroll-indicator"> <button onClick={onNext} className="scroll-indicator">
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span> <span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
<div className="scroll-indicator-icon"> <div className="scroll-indicator-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"> <svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7 10l5 5 5-5" /> <path d="M7 10l5 5 5-5" />
</svg> </svg>
</div> </div>
</button> </button>
{tutorial.isActive && !isManualModalOpen && ( {/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
{isTestPage && onTestData && (
<button
onClick={handleTestData}
disabled={isLoadingTest}
className="test-data-button"
>
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
</button>
)}
{tutorial.isActive && (
<TutorialOverlay <TutorialOverlay
hints={tutorial.hints} hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex} currentIndex={tutorial.currentHintIndex}
@ -180,21 +531,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
groupProgress={tutorial.groupProgress} groupProgress={tutorial.groupProgress}
/> />
)} )}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category);
}}
/>
)}
{isLoginPromptOpen && (
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
)}
</div> </div>
); );
}; };

View File

@ -77,7 +77,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
<div className="login-content"> <div className="login-content">
{/* Logo */} {/* Logo */}
<div className="login-logo"> <div className="login-logo">
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" /> <img src="/assets/images/ado2-login-logo.svg" alt="ADO2" />
</div> </div>
{/* Error Message */} {/* Error Message */}

View File

@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
const SocialConnectError: React.FC = () => { const SocialConnectError: React.FC = () => {
const { t } = useTranslation(); // URL 파라미터 파싱
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const platform = searchParams.get('platform') || 'unknown'; const platform = searchParams.get('platform') || 'unknown';
const errorMessage = searchParams.get('error') || t('social.connectError.defaultError'); const errorMessage = searchParams.get('error') || '알 수 없는 오류가 발생했습니다.';
const platformName = platform === 'youtube' ? 'YouTube' : const platformName = platform === 'youtube' ? 'YouTube' :
platform === 'instagram' ? 'Instagram' : platform === 'instagram' ? 'Instagram' :
@ -27,19 +25,19 @@ const SocialConnectError: React.FC = () => {
<line x1="9" y1="9" x2="15" y2="15" /> <line x1="9" y1="9" x2="15" y2="15" />
</svg> </svg>
</div> </div>
<h1 className="social-connect-title">{t('social.connectError.title', { platform: platformName })}</h1> <h1 className="social-connect-title">{platformName} </h1>
<p className="social-connect-message"> <p className="social-connect-message">
{errorMessage} {errorMessage}
</p> </p>
<p className="social-connect-detail"> <p className="social-connect-detail">
{t('social.connectError.detail')} . .
</p> </p>
<div className="social-connect-actions"> <div className="social-connect-actions">
<button <button
onClick={navigateToHome} onClick={navigateToHome}
className="social-connect-button" className="social-connect-button"
> >
{t('social.connectError.retry')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
// 연결된 소셜 계정 정보를 저장하는 localStorage 키
const SOCIAL_CONNECTED_KEY = 'castad_social_connected'; const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
interface ConnectedSocialAccount { interface ConnectedSocialAccount {
@ -13,9 +13,9 @@ interface ConnectedSocialAccount {
} }
const SocialConnectSuccess: React.FC = () => { const SocialConnectSuccess: React.FC = () => {
const { t } = useTranslation();
const [countdown, setCountdown] = useState(3); const [countdown, setCountdown] = useState(3);
// URL 파라미터 파싱
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const platform = searchParams.get('platform') || 'unknown'; const platform = searchParams.get('platform') || 'unknown';
const accountId = searchParams.get('account_id') || ''; const accountId = searchParams.get('account_id') || '';
@ -26,6 +26,7 @@ const SocialConnectSuccess: React.FC = () => {
platform === 'instagram' ? 'Instagram' : platform === 'instagram' ? 'Instagram' :
platform === 'facebook' ? 'Facebook' : platform; platform === 'facebook' ? 'Facebook' : platform;
// 연결된 계정 정보를 localStorage에 저장
useEffect(() => { useEffect(() => {
if (platform && accountId) { if (platform && accountId) {
const connectedAccount: ConnectedSocialAccount = { const connectedAccount: ConnectedSocialAccount = {
@ -41,6 +42,7 @@ const SocialConnectSuccess: React.FC = () => {
}, [platform, accountId, channelName, profileImage]); }, [platform, accountId, channelName, profileImage]);
const navigateToHome = () => { const navigateToHome = () => {
// URL을 루트로 변경하고 페이지 새로고침하여 generation_flow로 이동
window.location.href = '/'; window.location.href = '/';
}; };
@ -62,6 +64,7 @@ const SocialConnectSuccess: React.FC = () => {
return ( return (
<div className="social-connect-page"> <div className="social-connect-page">
<div className="social-connect-card success"> <div className="social-connect-card success">
{/* 프로필 이미지가 있으면 표시 */}
{profileImage ? ( {profileImage ? (
<img <img
src={profileImage} src={profileImage}
@ -78,13 +81,15 @@ const SocialConnectSuccess: React.FC = () => {
)} )}
<h1 className="social-connect-title"> <h1 className="social-connect-title">
{t('social.connectSuccess.title', { name: channelName || platformName })} {channelName || platformName}
</h1> </h1>
<p className="social-connect-message"> <p className="social-connect-message">
{channelName {channelName ? (
? t('social.connectSuccess.messageWithChannel', { platform: platformName }) <>{platformName} .</>
: t('social.connectSuccess.messageWithoutChannel')} ) : (
<> .</>
)}
</p> </p>
{channelName && ( {channelName && (
@ -93,20 +98,20 @@ const SocialConnectSuccess: React.FC = () => {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
{t('social.connectSuccess.verified')}
</span> </span>
</div> </div>
)} )}
<p className="social-connect-countdown"> <p className="social-connect-countdown">
{t('social.connectSuccess.countdown', { count: countdown })} {countdown} ...
</p> </p>
<button <button
onClick={navigateToHome} onClick={navigateToHome}
className="social-connect-button" className="social-connect-button"
> >
{t('social.connectSuccess.goNow')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,11 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
const YouTubeOAuthCallback: React.FC = () => { const YouTubeOAuthCallback: React.FC = () => {
const { t } = useTranslation();
const [status, setStatus] = useState<'processing' | 'error'>('processing'); const [status, setStatus] = useState<'processing' | 'error'>('processing');
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
@ -20,7 +18,7 @@ const YouTubeOAuthCallback: React.FC = () => {
if (error) { if (error) {
console.error('[YouTube OAuth] Error from Google:', error); console.error('[YouTube OAuth] Error from Google:', error);
setStatus('error'); setStatus('error');
setErrorMessage(t('social.youtubeCallback.errorAuthCancelled')); setErrorMessage('Google 인증이 취소되었거나 실패했습니다.');
setTimeout(() => { setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error); window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
}, 1500); }, 1500);
@ -30,7 +28,7 @@ const YouTubeOAuthCallback: React.FC = () => {
if (!code) { if (!code) {
console.error('[YouTube OAuth] No code received'); console.error('[YouTube OAuth] No code received');
setStatus('error'); setStatus('error');
setErrorMessage(t('social.youtubeCallback.errorNoCode')); setErrorMessage('인증 코드를 받지 못했습니다.');
setTimeout(() => { setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=no_code'; window.location.href = '/social/connect/error?platform=youtube&error=no_code';
}, 1500); }, 1500);
@ -48,7 +46,7 @@ const YouTubeOAuthCallback: React.FC = () => {
} catch (err) { } catch (err) {
console.error('[YouTube OAuth] Failed to process callback:', err); console.error('[YouTube OAuth] Failed to process callback:', err);
setStatus('error'); setStatus('error');
setErrorMessage(t('social.youtubeCallback.errorProcessing')); setErrorMessage('YouTube 연결 처리 중 오류가 발생했습니다.');
setTimeout(() => { setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed'; window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
}, 1500); }, 1500);
@ -68,8 +66,8 @@ const YouTubeOAuthCallback: React.FC = () => {
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" /> <circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
</svg> </svg>
</div> </div>
<h1 className="social-connect-title">{t('social.youtubeCallback.connecting')}</h1> <h1 className="social-connect-title">YouTube ...</h1>
<p className="social-connect-message">{t('social.youtubeCallback.pleaseWait')}</p> <p className="social-connect-message"> .</p>
</> </>
) : ( ) : (
<> <>
@ -80,7 +78,7 @@ const YouTubeOAuthCallback: React.FC = () => {
<line x1="9" y1="9" x2="15" y2="15" /> <line x1="9" y1="9" x2="15" y2="15" />
</svg> </svg>
</div> </div>
<h1 className="social-connect-title">{t('social.youtubeCallback.failed')}</h1> <h1 className="social-connect-title"> </h1>
<p className="social-connect-message">{errorMessage}</p> <p className="social-connect-message">{errorMessage}</p>
</> </>
)} )}

View File

@ -1,947 +0,0 @@
/* =====================================================
Layout Components
===================================================== */
/* Landing Page Container - Free Scroll */
.landing-container {
width: 100%;
height: 100dvh;
overflow-y: scroll;
overflow-x: hidden;
background-color: var(--color-bg-darker);
}
.landing-section {
background: var(--Color-teal-800, #002224);
min-height: 100dvh;
width: 100%;
}
/* Page Container */
.page-container {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--spacing-page);
overflow: hidden;
background-color: #002224;
position: relative;
}
@media (min-width: 768px) {
.page-container {
padding: var(--spacing-page-md);
}
}
/* Page Container - Full Height with Background */
.page-container-full {
width: 100%;
min-height: 100dvh;
color: var(--color-text-white);
display: flex;
flex-direction: column;
padding: var(--spacing-page);
background-color: var(--color-bg-dark);
}
@media (min-width: 768px) {
.page-container-full {
padding: var(--spacing-page-md);
}
}
@media (min-width: 1024px) {
.page-container-full {
padding: 2.5rem;
}
}
/* Wizard Page Container - 단계별 페이지 전환용 */
.wizard-page-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--color-bg-darker);
overflow-y: auto;
}
/* Main Content Area */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 72rem;
margin-left: auto;
margin-right: auto;
width: 100%;
min-height: 0;
overflow: hidden;
}
@media (min-width: 1024px) {
.main-content {
flex-direction: row;
}
}
/* Dashboard Layout */
.dashboard-layout {
display: flex;
width: 100%;
min-height: 100dvh;
background-color: var(--color-bg-darker);
color: var(--color-text-white);
}
.dashboard-content {
flex: 1;
min-height: 0;
position: relative;
overflow-y: auto;
padding-left: 0;
}
/* =====================================================
Card Components
===================================================== */
/* Base Card */
.card {
background-color: var(--color-bg-card);
border-radius: var(--radius-3xl);
padding: var(--spacing-page);
border: 1px solid var(--color-border-white-5);
box-shadow: var(--shadow-xl);
}
@media (min-width: 768px) {
.card {
padding: var(--spacing-page-md);
}
}
/* Card Variants */
.card-flex {
display: flex;
flex-direction: column;
}
.card-flex-1 {
flex: 1;
}
.card-flex-2 {
flex: 2;
}
.card-overflow-hidden {
overflow: hidden;
}
/* Card Inner (Darker Background) */
.card-inner {
background-color: rgba(18, 26, 29, 0.5);
border-radius: var(--radius-2xl);
border: 1px solid var(--color-border-white-5);
padding: 1rem;
}
/* =====================================================
Header Components
===================================================== */
/* Page Header */
.page-header {
text-align: center;
margin-bottom: 2rem;
flex-shrink: 0;
}
/* Page Title */
.page-title {
font-size: var(--text-3xl);
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: -0.025em;
}
@media (min-width: 768px) {
.page-title {
font-size: var(--text-4xl);
}
}
/* Page Subtitle */
.page-subtitle {
color: var(--color-text-gray-400);
font-size: var(--text-base);
}
@media (min-width: 768px) {
.page-subtitle {
font-size: var(--text-lg);
}
}
/* Section Title (Mint color uppercase) */
.section-title {
color: #94FBE0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.006em;
margin-bottom: 0;
flex-shrink: 0;
}
/* Section Title Purple */
.section-title-purple {
color: #AE72F9;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.006em;
margin-bottom: 0;
display: block;
}
/* =====================================================
Button Components
===================================================== */
/* Primary Button (Purple) */
.btn-primary {
background-color: var(--color-purple);
color: var(--color-text-white);
font-weight: 700;
padding: 1rem 4rem;
border-radius: var(--radius-full);
transition: all var(--transition-normal);
transform: scale(1);
box-shadow: var(--shadow-purple);
font-size: var(--text-lg);
border: none;
cursor: pointer;
}
.btn-primary:hover {
background-color: var(--color-purple-hover);
}
.btn-primary:active {
transform: scale(0.95);
}
.btn-primary:disabled {
background-color: #4b5563;
color: var(--color-text-gray-400);
cursor: not-allowed;
box-shadow: none;
}
/* Secondary Button (Mint) */
.btn-secondary {
background-color: var(--color-mint);
color: var(--color-bg-dark);
font-weight: 700;
padding: 1rem;
border-radius: var(--radius-xl);
transition: all var(--transition-normal);
transform: scale(1);
font-size: var(--text-base);
border: none;
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-secondary:hover {
background-color: var(--color-mint-hover);
}
.btn-secondary:active {
transform: scale(0.98);
}
.btn-secondary:disabled {
background-color: rgba(166, 255, 234, 0.5);
color: rgba(18, 26, 29, 0.5);
cursor: not-allowed;
}
/* Outline Button */
.btn-outline {
background-color: transparent;
border: 1px solid var(--color-border-gray-600);
color: var(--color-text-white);
font-weight: 700;
padding: 1rem 4rem;
border-radius: var(--radius-full);
transition: all var(--transition-normal);
font-size: var(--text-base);
cursor: pointer;
}
.btn-outline:hover {
border-color: var(--color-text-gray-500);
}
/* Back Button */
.btn-back {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
border-radius: var(--radius-full);
border: 1px solid var(--color-border-gray-600);
background-color: transparent;
color: var(--color-text-gray-300);
font-size: var(--text-base);
transition: background-color var(--transition-normal);
cursor: pointer;
}
.btn-back:hover {
background-color: rgba(31, 41, 55, 1);
}
/* Regenerate Button (Mint Outline) */
.btn-regenerate {
margin-top: 1rem;
width: 100%;
padding: 0.75rem;
background-color: var(--color-mint-20);
color: var(--color-mint);
border: 1px solid var(--color-mint-30);
font-weight: 700;
border-radius: var(--radius-xl);
transition: background-color var(--transition-normal);
font-size: var(--text-base);
flex-shrink: 0;
cursor: pointer;
}
.btn-regenerate:hover {
background-color: var(--color-mint-30);
}
.btn-regenerate:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Selection Button */
.btn-select {
padding: 0.75rem;
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-gray-700);
background-color: rgba(18, 26, 29, 0.4);
color: var(--color-text-gray-400);
font-size: var(--text-sm);
font-weight: 700;
transition: all var(--transition-normal);
cursor: pointer;
}
.btn-select:hover {
border-color: var(--color-border-gray-600);
}
.btn-select.active {
border-color: var(--color-mint);
background-color: var(--color-bg-dark);
color: var(--color-mint);
}
.btn-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* =====================================================
Form Components
===================================================== */
/* Select Input */
.select-input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: var(--radius-xl);
background-color: var(--color-bg-dark);
border: 1px solid var(--color-border-gray-700);
font-size: var(--text-base);
color: var(--color-text-gray-300);
}
.select-input:focus {
outline: none;
border-color: var(--color-mint);
}
.select-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Text Input */
.text-input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: var(--radius-xl);
background-color: var(--color-bg-dark);
border: 1px solid var(--color-border-gray-700);
font-size: var(--text-base);
color: var(--color-text-gray-300);
}
.text-input:focus {
outline: none;
border-color: var(--color-mint);
}
/* Textarea - Lyrics */
.textarea-lyrics {
flex: 1;
overflow-y: auto;
font-size: var(--text-sm);
color: var(--color-text-gray-400);
background-color: transparent;
resize: none;
border: none;
padding-right: 0.5rem;
line-height: 1.625;
min-height: 0;
}
.textarea-lyrics:focus {
outline: none;
color: var(--color-text-gray-300);
}
/* =====================================================
Navigation Components
===================================================== */
/* Back Button Container */
.back-button-container {
width: 100%;
max-width: 1040px;
display: flex;
justify-content: flex-start;
margin-bottom: 1.5rem;
flex-shrink: 0;
}
/* Bottom Button Container */
.bottom-button-container {
position: fixed;
bottom: 32px;
left: 0;
right: 0;
display: flex;
justify-content: center;
z-index: 30;
pointer-events: none;
}
.bottom-button-container > * {
pointer-events: all;
}
@media (min-width: 768px) {
.bottom-button-container {
left: 240px;
}
}
/* =====================================================
Sidebar Components
===================================================== */
/* Sidebar Container */
.sidebar {
position: fixed;
height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--color-bg-dark);
border-right: 1px solid var(--color-border-white-5);
transition: all var(--transition-slow);
z-index: 50;
}
@media (min-width: 768px) {
.sidebar {
position: sticky;
top: 0;
flex-shrink: 0;
}
}
.sidebar.expanded {
width: 15rem;
}
.sidebar.collapsed {
width: 5rem;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.sidebar.mobile-closed {
transform: translateX(-100%);
}
@media (min-width: 768px) {
.sidebar.mobile-closed {
transform: translateX(0);
}
}
/* Sidebar Header */
.sidebar-header {
padding: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-header.collapsed {
flex-direction: column;
gap: 1rem;
}
/* Sidebar Logo */
.sidebar-logo {
font-family: 'Playfair Display', serif;
font-style: italic;
font-size: var(--text-2xl);
font-weight: 700;
letter-spacing: -0.025em;
color: var(--color-text-white);
cursor: pointer;
transition: color var(--transition-normal);
}
.sidebar-logo:hover {
color: var(--color-mint);
}
/* Sidebar Menu */
.sidebar-menu {
flex: 1;
padding: 0 0.75rem;
margin-top: 1rem;
overflow-y: auto;
}
/* Sidebar Menu Item */
.sidebar-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-xl);
transition: all var(--transition-normal);
cursor: pointer;
margin-bottom: 0.25rem;
position: relative;
overflow: hidden;
}
.sidebar-item.collapsed {
justify-content: center;
width: 3rem;
height: 3rem;
margin-left: auto;
margin-right: auto;
padding: 0;
}
.sidebar-item.active {
background-color: var(--color-mint);
color: var(--color-bg-dark);
}
.sidebar-item:not(.active) {
color: var(--color-text-gray-400);
}
.sidebar-item:not(.active):not(.disabled):hover {
background-color: rgba(255, 255, 255, 0.05);
color: var(--color-text-white);
}
.sidebar-item.disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.sidebar-item-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: color var(--transition-normal);
}
.sidebar-item.active .sidebar-item-icon {
color: var(--color-bg-dark);
}
.sidebar-item-label {
font-size: 16px;
font-weight: 700;
white-space: nowrap;
}
/* Sidebar Footer */
.sidebar-footer {
padding: 1rem;
margin-top: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Credit Card */
.credit-card {
background-color: var(--color-bg-card);
border-radius: var(--radius-2xl);
padding: 1rem;
border: 1px solid var(--color-border-white-5);
}
.credit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.credit-label {
color: var(--color-text-gray-400);
font-size: var(--text-sm);
font-weight: 500;
}
.credit-value {
color: var(--color-text-white);
font-size: var(--text-sm);
font-weight: 700;
}
.credit-bar {
width: 100%;
height: 0.375rem;
background-color: #1f2937;
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: 0.75rem;
}
.credit-bar-fill {
height: 100%;
background-color: var(--color-mint);
}
.credit-upgrade-btn {
width: 100%;
padding: 0.625rem;
background-color: var(--color-bg-dark);
color: var(--color-text-white);
font-size: var(--text-sm);
font-weight: 700;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-gray-700);
transition: background-color var(--transition-normal);
cursor: pointer;
}
.credit-upgrade-btn:hover {
background-color: #1f2937;
}
/* Profile Section */
.profile-section {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
.profile-section.collapsed {
flex-direction: column;
}
.profile-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-full);
border: 1px solid var(--color-border-gray-700);
object-fit: cover;
}
.profile-avatar-default {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bg-gray-700);
color: var(--color-text-gray-400);
}
.profile-name {
color: var(--color-text-white);
font-size: var(--text-sm);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-credits {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-weight: 500;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Sidebar Language Switch */
.sidebar-language-switch {
padding: 0 1rem 0.75rem;
display: flex;
justify-content: flex-start;
}
/* Toggle container */
.lang-toggle {
position: relative;
display: flex;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 2px;
width: 100%;
max-width: 120px;
}
.lang-toggle-option {
flex: 1;
position: relative;
z-index: 1;
padding: 4px 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.4);
background: none;
border: none;
cursor: pointer;
transition: color 0.3s ease;
text-align: center;
}
.lang-toggle-option.active {
color: #ffffff;
}
.lang-toggle-option:hover:not(.active) {
color: rgba(255, 255, 255, 0.6);
}
/* Sliding indicator */
.lang-toggle-slider {
position: absolute;
top: 2px;
bottom: 2px;
width: calc(50% - 2px);
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.lang-toggle-slider.left {
transform: translateX(2px);
}
.lang-toggle-slider.right {
transform: translateX(calc(100% + 2px));
}
/* Collapsed state - simple button */
.lang-toggle-collapsed {
padding: 4px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.lang-toggle-collapsed:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.15);
}
/* Sidebar Footer Actions */
.sidebar-footer-actions {
display: flex;
flex-direction: column;
}
.sidebar-footer-actions .logout-btn {
width: 100%;
}
.sidebar-inquiry-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
color: var(--color-text-gray-400);
text-decoration: none;
transition: color var(--transition-normal);
white-space: nowrap;
font-size: var(--text-sm);
font-weight: 700;
flex-shrink: 0;
}
.sidebar-inquiry-btn:hover {
color: var(--color-text-white);
}
.sidebar-inquiry-btn.collapsed {
justify-content: center;
padding: 0.75rem;
}
/* Logout Button */
.logout-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
color: var(--color-text-gray-400);
background: none;
border: none;
transition: color var(--transition-normal);
cursor: pointer;
}
.logout-btn:hover {
color: var(--color-text-white);
}
.logout-btn.collapsed {
justify-content: center;
}
.logout-btn-label {
font-size: var(--text-sm);
font-weight: 700;
}
/* 모바일 전용 사이드바 튜토리얼 토글 */
.sidebar-tutorial-btn {
display: none;
}
@media (max-width: 767px) {
.sidebar-tutorial-btn {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
margin-top: 0.5rem;
padding: 0.75rem;
border-radius: var(--radius-lg);
color: var(--color-text-gray-400);
border: none;
background: none;
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
transition: color var(--transition-normal), background-color var(--transition-normal);
}
.sidebar-tutorial-btn:hover {
color: var(--color-text-white);
background-color: rgba(255, 255, 255, 0.05);
}
.sidebar-tutorial-btn.active {
color: var(--color-mint);
}
.sidebar-tutorial-label {
text-align: left;
}
.sidebar-tutorial-badge {
font-size: 11px;
font-weight: 700;
}
.sidebar-tutorial-badge.on {
color: var(--color-mint);
}
.sidebar-tutorial-badge.off {
color: var(--color-text-gray-400);
}
}
/* Mobile Menu Button */
.mobile-menu-btn {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 40;
padding: 0.625rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-white-10);
color: var(--color-text-gray-400);
cursor: pointer;
}
.mobile-menu-btn:hover {
color: var(--color-text-white);
}
@media (min-width: 768px) {
.mobile-menu-btn {
display: none;
}
}
/* Mobile Overlay */
.mobile-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
}
@media (min-width: 768px) {
.mobile-overlay {
display: none;
}
}

View File

@ -1,503 +0,0 @@
/* =====================================================
Business Settings Components
===================================================== */
/* Settings Container */
.settings-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.75rem;
background-color: var(--color-bg-dark);
color: var(--color-text-white);
overflow: hidden;
}
@media (min-width: 640px) {
.settings-container {
padding: 1rem;
}
}
@media (min-width: 768px) {
.settings-container {
padding: 1.5rem;
}
}
/* Settings Header */
.settings-header {
text-align: center;
margin-bottom: 1rem;
max-width: 42rem;
margin-left: 2.5rem;
}
@media (min-width: 640px) {
.settings-header {
margin-bottom: 1.5rem;
}
}
@media (min-width: 768px) {
.settings-header {
margin-bottom: 2rem;
margin-left: 0;
}
}
.settings-title {
font-size: var(--text-lg);
font-weight: 700;
margin-bottom: 0.25rem;
}
@media (min-width: 640px) {
.settings-title {
font-size: var(--text-xl);
margin-bottom: 0.5rem;
}
}
@media (min-width: 768px) {
.settings-title {
font-size: 1.5rem;
}
}
@media (min-width: 1024px) {
.settings-title {
font-size: var(--text-3xl);
}
}
.settings-description {
color: var(--color-text-gray-400);
font-size: 9px;
font-weight: 300;
line-height: 1.625;
opacity: 0.8;
}
@media (min-width: 640px) {
.settings-description {
font-size: 10px;
}
}
@media (min-width: 768px) {
.settings-description {
font-size: var(--text-xs);
}
}
/* Settings Card */
.settings-card {
width: 100%;
max-width: 36rem;
}
.settings-card-inner {
background-color: var(--color-bg-card);
border-radius: var(--radius-xl);
padding: 0.75rem;
border: 1px solid var(--color-border-white-5);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
@media (min-width: 640px) {
.settings-card-inner {
padding: 1rem;
border-radius: var(--radius-2xl);
}
}
@media (min-width: 768px) {
.settings-card-inner {
padding: 1.5rem;
border-radius: var(--radius-3xl);
}
}
.settings-card-title {
color: var(--color-mint);
font-size: 9px;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
@media (min-width: 640px) {
.settings-card-title {
font-size: 10px;
margin-bottom: 0.75rem;
}
}
@media (min-width: 768px) {
.settings-card-title {
font-size: var(--text-xs);
margin-bottom: 1rem;
}
}
/* Social Items */
.social-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (min-width: 640px) {
.social-items {
gap: 0.75rem;
}
}
.social-item {
background-color: var(--color-bg-dark);
padding: 0.5rem;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-white-5);
display: flex;
align-items: center;
justify-content: space-between;
transition: border-color var(--transition-normal);
}
@media (min-width: 640px) {
.social-item {
padding: 0.75rem;
border-radius: var(--radius-xl);
}
}
.social-item:hover {
border-color: var(--color-border-white-10);
}
.social-item-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 640px) {
.social-item-left {
gap: 0.75rem;
}
}
.social-item-icon {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 640px) {
.social-item-icon {
width: 2rem;
height: 2rem;
}
}
.social-item-icon-inner {
width: 0.75rem;
height: 0.75rem;
}
@media (min-width: 640px) {
.social-item-icon-inner {
width: 1rem;
height: 1rem;
}
}
.social-item-name {
font-size: 10px;
font-weight: 700;
color: var(--color-text-gray-300);
}
@media (min-width: 640px) {
.social-item-name {
font-size: var(--text-xs);
}
}
.social-item-connect {
color: var(--color-mint);
font-size: 9px;
font-weight: 700;
background: none;
border: none;
cursor: pointer;
}
@media (min-width: 640px) {
.social-item-connect {
font-size: 10px;
}
}
.social-item-connect:hover {
text-decoration: underline;
}
/* =====================================================
Footer Component
===================================================== */
/* Landing Footer */
.landing-footer {
width: 100%;
background: var(--Color-teal-800, #002224);
padding: 40px 1.5rem;
}
@media (min-width: 768px) {
.landing-footer {
padding: 48px 5rem;
}
}
@media (min-width: 1024px) {
.landing-footer {
padding: 60px 100px;
}
}
.footer-content {
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 1144px;
margin: 0 auto;
}
@media (min-width: 768px) {
.footer-content {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 4rem;
}
}
.footer-left {
min-width: 262px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.footer-logo {
height: 24px;
width: auto;
object-fit: contain;
}
.footer-copyright {
font-size: 14px;
font-weight: 400;
color: #379599;
letter-spacing: -0.006em;
line-height: 1.19;
text-align: center;
}
.footer-links {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.footer-link {
font-size: 13px;
color: #379599;
text-decoration: none;
opacity: 0.8;
}
.footer-link:hover {
opacity: 1;
text-decoration: underline;
}
.footer-link-divider {
font-size: 13px;
color: #379599;
opacity: 0.5;
}
.footer-right {
max-width: 600px;
display: flex;
flex-direction: column;
gap: 4px;
}
.footer-info {
font-size: 12px;
font-weight: 400;
color: #CEE5E6;
letter-spacing: -0.006em;
line-height: 1.19;
}
@media (min-width: 768px) {
.footer-info {
font-size: 14px;
}
}
/* =====================================================
Header Component
===================================================== */
/* Landing Header */
.landing-header {
width: calc(100% - 2rem);
max-width: 1280px;
padding: 12px 12px 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10100;
background: rgba(0, 34, 36, 0.3);
border: 1px solid rgba(229, 241, 242, 0.2);
border-radius: 999px;
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
@media (min-width: 768px) {
.landing-header {
width: calc(100% - 10rem);
}
}
.header-logo {
display: flex;
align-items: center;
height: 16px;
}
.header-logo img {
height: 100%;
width: auto;
object-fit: contain;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-actions .lang-toggle {
width: auto;
max-width: none;
padding: 2px;
flex-shrink: 0;
}
.header-actions .lang-toggle-option {
padding: 4px 10px;
white-space: nowrap;
}
.header-login-btn {
padding: 10px 24px;
border-radius: 999px;
background-color: #94FBE0;
color: #000000;
font-weight: 600;
font-size: 14px;
border: none;
cursor: pointer;
transition: all var(--transition-normal);
letter-spacing: -0.006em;
line-height: 1.19;
}
.header-login-btn:hover {
background-color: #7fe8cc;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(148, 251, 224, 0.3);
}
.header-login-btn:active {
transform: scale(0.95);
}
.header-start-btn {
padding: 10px 24px;
border-radius: 999px;
background-color: #AE72F9;
color: #FFFFFF;
font-weight: 600;
font-size: 14px;
border: none;
cursor: pointer;
transition: all var(--transition-normal);
letter-spacing: -0.006em;
line-height: 1.19;
}
.header-start-btn:hover {
background-color: #9B5DE5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(174, 114, 249, 0.3);
}
.header-start-btn:active {
transform: scale(0.95);
}
.header-avatar {
width: 2rem;
height: 2rem;
border-radius: var(--radius-full);
overflow: hidden;
border: 1px solid var(--color-border-gray-700);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
@media (min-width: 640px) {
.header-avatar {
width: 2.5rem;
height: 2.5rem;
}
}
.header-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* =====================================================
Content Safe Area (for Landing Pages)
===================================================== */
.content-safe-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
/* CASTAD Design System - 도메인별 CSS 파일 매니페스트 */
@import './tokens.css';
@import './base-components.css';
@import './media-components.css';
@import './generation-flow.css';
@import './landing.css';
@import './dashboard.css';
@import './business-settings.css';
@import './studio-assets.css';
@import './contents-social.css';
@import './modals-overlays.css';

File diff suppressed because it is too large Load Diff

View File

@ -1,593 +0,0 @@
/* =====================================================
Player Components
===================================================== */
/* Player Bar */
.player-bar {
background-color: rgba(0, 0, 0, 0.4);
border-radius: var(--radius-full);
padding: 0.75rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
/* Play Button */
.play-btn {
color: var(--color-mint);
flex-shrink: 0;
transition: transform var(--transition-normal);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.play-btn:hover {
transform: scale(1.1);
}
.play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Progress Bar */
.progress-bar-container {
flex: 1;
height: 0.375rem;
background-color: #1f2937;
border-radius: var(--radius-full);
position: relative;
cursor: pointer;
}
.progress-bar-container:disabled,
.progress-bar-container.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.progress-bar-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: var(--color-mint);
border-radius: var(--radius-full);
}
.progress-bar-thumb {
position: absolute;
top: 50%;
transform: translateY(-50%) translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.progress-bar-thumb-glow {
position: absolute;
width: 1rem;
height: 1rem;
background-color: var(--color-mint);
border-radius: var(--radius-full);
opacity: 0.2;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.progress-bar-thumb-dot {
width: 0.75rem;
height: 0.75rem;
background-color: var(--color-mint);
border-radius: var(--radius-full);
box-shadow: var(--shadow-mint-glow);
border: 1px solid rgba(18, 26, 29, 0.2);
}
/* Time Display */
.time-display {
font-size: var(--text-sm);
color: var(--color-text-gray-500);
font-family: monospace;
text-align: right;
white-space: nowrap;
}
/* =====================================================
Image Grid Components
===================================================== */
/* Image Grid */
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.image-grid-4 {
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
/* Image Item */
.image-item {
aspect-ratio: 1;
background-color: rgba(18, 26, 29, 0.5);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-white-5);
position: relative;
overflow: hidden;
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-item-badge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-purple-80);
border-radius: 0.25rem;
font-size: var(--text-xs);
color: var(--color-text-white);
font-weight: 500;
}
.image-item-remove {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1.75rem;
height: 1.75rem;
background-color: rgba(0, 0, 0, 0.6);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
border: none;
cursor: pointer;
transition: all var(--transition-normal);
}
.image-item-remove:hover {
background-color: rgba(239, 68, 68, 0.8);
color: var(--color-text-white);
}
/* =====================================================
Upload Components
===================================================== */
/* Upload Zone */
.upload-zone {
flex: 1;
border: 2px dashed var(--color-border-gray-600);
border-radius: var(--radius-2xl);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
text-align: center;
cursor: pointer;
transition: border-color var(--transition-normal);
}
.upload-zone:hover {
border-color: rgba(166, 255, 234, 0.5);
}
.upload-zone-icon {
margin-bottom: 1rem;
color: var(--color-text-gray-500);
transition: color var(--transition-normal);
}
.upload-zone:hover .upload-zone-icon {
color: var(--color-mint);
}
.upload-zone-title {
color: var(--color-text-gray-300);
font-size: var(--text-base);
font-weight: 500;
line-height: 1.625;
margin-bottom: 0.5rem;
}
.upload-zone-subtitle {
color: var(--color-text-gray-500);
font-size: var(--text-sm);
}
/* =====================================================
Tag Components
===================================================== */
/* Tag */
.tag {
padding: 0.625rem 1.25rem;
background-color: var(--color-bg-card-inner);
border-radius: var(--radius-full);
font-size: var(--text-base);
color: #e5e7eb;
border: 1px solid var(--color-border-white-10);
}
/* Tag with Dot */
.tag-dot {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--text-sm);
color: var(--color-text-gray-400);
}
.tag-dot::before {
content: '';
width: 0.375rem;
height: 0.375rem;
border-radius: var(--radius-full);
background-color: var(--color-mint);
}
/* =====================================================
Social Card Components
===================================================== */
/* Social Card */
.social-card {
display: flex;
align-items: center;
gap: 1rem;
background-color: var(--color-bg-dark);
padding: 1rem;
border-radius: var(--radius-2xl);
border: 1px solid var(--color-border-white-5);
transition: all var(--transition-normal);
cursor: pointer;
}
.social-card:hover {
border-color: var(--color-border-white-10);
}
.social-card.selected {
border-color: var(--color-mint);
box-shadow: 0 0 15px rgba(166, 255, 234, 0.15);
background-color: var(--color-bg-card);
}
.social-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: transform var(--transition-slow);
}
.social-icon-inner {
width: 1.5rem;
height: 1.5rem;
color: var(--color-text-white);
}
.social-check {
position: absolute;
top: -0.25rem;
right: -0.25rem;
width: 1.25rem;
height: 1.25rem;
background-color: var(--color-mint);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-bg-dark);
border: 2px solid var(--color-bg-dark);
}
.social-name {
font-size: var(--text-base);
font-weight: 700;
transition: color var(--transition-normal);
}
.social-card.selected .social-name {
color: var(--color-text-white);
}
.social-card:not(.selected) .social-name {
color: var(--color-text-gray-300);
}
.social-email {
margin-left: auto;
font-size: var(--text-sm);
color: var(--color-purple);
}
/* =====================================================
Video Preview Components
===================================================== */
/* Video Container */
.video-container {
flex: 1;
background-color: #000;
border-radius: var(--radius-2xl);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.video-pattern {
position: absolute;
inset: 0;
opacity: 0.1;
background-image:
linear-gradient(45deg, var(--color-bg-dark) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-bg-dark) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-bg-dark) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-bg-dark) 75%);
background-size: 40px 40px;
background-position: 0 0, 0 20px, 20px -20px, -20px 0px;
}
.video-controls {
position: absolute;
bottom: 1rem;
left: 1rem;
right: 1rem;
z-index: 10;
}
.video-controls-inner {
display: flex;
align-items: center;
gap: 0.75rem;
}
.video-play-btn {
color: var(--color-text-white);
background: none;
border: none;
cursor: pointer;
transition: color var(--transition-normal);
}
.video-play-btn:hover {
color: var(--color-mint);
}
.video-progress {
flex: 1;
height: 0.375rem;
background-color: rgba(255, 255, 255, 0.2);
border-radius: var(--radius-full);
overflow: hidden;
}
.video-progress-fill {
height: 100%;
background-color: var(--color-mint);
}
/* Video Player */
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
}
/* Video Loading State */
.video-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
/* Video Error State */
.video-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
.video-error svg {
width: 4rem;
height: 4rem;
color: #ef4444;
margin-bottom: 1rem;
}
/* Social Card Disabled State */
.social-card.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* =====================================================
Tags Container
===================================================== */
.tags-container {
margin-top: 1rem;
background-color: rgba(18, 26, 29, 0.6);
border-radius: var(--radius-xl);
padding: 0.75rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
border: 1px solid var(--color-border-white-5);
flex-shrink: 0;
}
/* =====================================================
Status Messages
===================================================== */
/* Error Message */
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-xl);
color: #f87171;
font-size: var(--text-sm);
flex-shrink: 0;
}
/* Status Message */
.status-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: var(--color-mint-10);
border: 1px solid var(--color-mint-30);
border-radius: var(--radius-xl);
color: var(--color-mint);
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* =====================================================
Utility Classes
===================================================== */
/* Flex utilities */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.flex-2 {
flex: 2;
}
.shrink-0 {
flex-shrink: 0;
}
.min-h-0 {
min-height: 0;
}
/* Gap utilities */
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
/* Margin utilities */
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; }
/* Text utilities */
.text-center { text-align: center; }
.text-right { text-align: right; }
.font-bold { font-weight: 700; }
.font-medium { font-weight: 500; }
/* Overflow utilities */
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
/* Width utilities */
.w-full { width: 100%; }
/* Animation */
@keyframes pulse {
0%, 100% { opacity: 0.2; }
50% { opacity: 0.4; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Grid utilities */
.grid-cols-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
/* Space utilities */
.space-y-4 > * + * {
margin-top: 1rem;
}
.space-y-5 > * + * {
margin-top: 1.25rem;
}
/* Min width */
.min-w-280 {
min-width: 280px;
}
/* Scrollbar utility */
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,74 +0,0 @@
/* =====================================================
CASTAD Design System - CSS Variables & Component Classes
===================================================== */
/* =====================================================
CSS Variables - Design Tokens
===================================================== */
:root {
/* Primary Colors */
--color-mint: #a6ffea;
--color-mint-hover: #8affda;
--color-mint-glow: rgba(166, 255, 234, 0.6);
--color-mint-10: rgba(166, 255, 234, 0.1);
--color-mint-20: rgba(166, 255, 234, 0.2);
--color-mint-30: rgba(166, 255, 234, 0.3);
--color-mint-50: rgba(166, 255, 234, 0.5);
--color-purple: #a682ff;
--color-purple-hover: #9570f0;
--color-purple-glow: rgba(166, 130, 255, 0.2);
--color-purple-80: rgba(166, 130, 255, 0.8);
/* Background Colors - Teal-600 based */
--color-bg-dark: #002224;
--color-bg-darker: #001a1c;
--color-bg-card: #003538;
--color-bg-card-inner: #004548;
/* Text Colors */
--color-text-white: #ffffff;
--color-text-gray-300: #d1d5db;
--color-text-gray-400: #9ca3af;
--color-text-gray-500: #6b7280;
/* Border Colors */
--color-border-white-5: rgba(255, 255, 255, 0.05);
--color-border-white-10: rgba(255, 255, 255, 0.1);
--color-border-gray-600: #4b5563;
--color-border-gray-700: #374151;
/* Shadows */
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-purple: 0 10px 15px -3px rgba(166, 130, 255, 0.2);
--shadow-mint-glow: 0 0 10px rgba(166, 255, 234, 0.6);
/* Spacing */
--spacing-page: 1.5rem;
--spacing-page-md: 2rem;
/* Border Radius */
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--radius-full: 9999px;
/* Font Sizes */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
/* Transitions */
--transition-fast: 150ms;
--transition-normal: 200ms;
--transition-slow: 300ms;
}

View File

@ -32,21 +32,14 @@ export interface MarketingAnalysis {
target_keywords: string[]; target_keywords: string[];
} }
export interface ImageListItem {
preview: string; // 미리보기용 CDN 리사이즈 URL
original: string; // 업로드용 원본 URL
}
export interface CrawlingResponse { export interface CrawlingResponse {
image_list: ImageListItem[] | null; image_list: string[];
image_count: number; image_count: number;
m_id: number; m_id: number;
industry: string;
processed_info: { processed_info: {
customer_name: string; customer_name: string;
region: string; region: string;
detail_region_info: string; detail_region_info: string;
industry: string;
}; };
marketing_analysis: MarketingAnalysis; marketing_analysis: MarketingAnalysis;
} }
@ -54,8 +47,7 @@ export interface CrawlingResponse {
// URL 이미지 (서버에서 가져온 이미지) // URL 이미지 (서버에서 가져온 이미지)
export interface UrlImage { export interface UrlImage {
type: 'url'; type: 'url';
url: string; // 업로드용 원본 URL url: string;
preview_url: string; // 미리보기용 CDN 리사이즈 URL
} }
// 업로드된 파일 이미지 // 업로드된 파일 이미지
@ -75,10 +67,7 @@ export interface LyricGenerateRequest {
m_id: number; m_id: number;
region: string; region: string;
task_id: string; task_id: string;
industry?: string;
orientation?: 'vertical' | 'horizontal'; orientation?: 'vertical' | 'horizontal';
instrumental?: boolean;
genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천)
} }
// 가사 생성 응답 // 가사 생성 응답
@ -108,16 +97,14 @@ export interface LyricDetailResponse {
status: string; status: string;
lyric_prompt: string; lyric_prompt: string;
lyric_result: string; lyric_result: string;
genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값)
created_at: string; created_at: string;
} }
// 노래 생성 요청 // 노래 생성 요청
export interface SongGenerateRequest { export interface SongGenerateRequest {
genre: string; genre: string;
language?: string; language: string;
lyrics?: string; lyrics: string;
instrumental?: boolean;
} }
// 노래 생성 응답 // 노래 생성 응답
@ -154,13 +141,6 @@ export interface SongDownloadResponse {
} }
// 영상 생성 응답 // 영상 생성 응답
// 자막 상태 확인 응답
export interface SubtitleStatusResponse {
task_id: string;
status: string; // 'pending' | 'processing' | 'completed' | 'failed' | 'error'
message: string;
}
export interface VideoGenerateResponse { export interface VideoGenerateResponse {
success: boolean; success: boolean;
task_id: string; task_id: string;
@ -274,33 +254,14 @@ export interface UserMeResponse {
created_at: string; created_at: string;
} }
export interface UserCreditsResponse { // 비디오 목록 아이템
credits: number;
}
// 비디오 목록 아이템 (갤러리용)
export interface VideoListItem { export interface VideoListItem {
video_id: number; video_id: number;
store_name: string; store_name: string;
region: string; region: string;
task_id: string; task_id: string;
result_movie_url: string; result_movie_url: string;
thumbnail_url?: string;
created_at: string; created_at: string;
like_count: number;
comment_count: number;
is_liked_by_me?: boolean;
}
// 비디오 상세 아이템
export interface VideoDetailItem {
video_id: number;
result_movie_url: string;
store_name: string;
region: string;
created_at: string;
like_count: number;
is_liked_by_me: boolean;
} }
// 비디오 목록 응답 // 비디오 목록 응답
@ -314,45 +275,6 @@ export interface VideosListResponse {
has_prev: boolean; has_prev: boolean;
} }
// 댓글 대댓글
export interface CommentReply {
id: number;
nickname: string;
content: string | null;
is_deleted: boolean;
is_mine: boolean;
created_at: string;
}
// 댓글 아이템
export interface CommentItem {
id: number;
nickname: string;
content: string | null;
is_deleted: boolean;
is_mine: boolean;
created_at: string;
replies: CommentReply[];
}
// 댓글 목록 응답
export interface CommentsResponse {
items: CommentItem[];
total: number;
page: number;
page_size: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
// 좋아요 토글 응답
export interface LikeToggleResponse {
video_id: number;
is_liked: boolean;
like_count: number;
}
// ============================================ // ============================================
// Social OAuth Types // Social OAuth Types
// ============================================ // ============================================
@ -423,11 +345,9 @@ export interface SocialUploadRequest {
// 소셜 업로드 응답 // 소셜 업로드 응답
export interface SocialUploadResponse { export interface SocialUploadResponse {
success: boolean; success: boolean;
upload_id: number; upload_id: string;
platform: string; status: 'pending' | 'uploading' | 'completed' | 'failed';
status: string;
message: string; message: string;
scheduled_at?: string | null; // 예약 업로드 충돌 시 실제 예약 시간 반환
} }
// 소셜 업로드 상태 조회 응답 // 소셜 업로드 상태 조회 응답
@ -455,8 +375,6 @@ export interface UploadHistoryItem {
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled'; status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
title: string; title: string;
channel_name: string; channel_name: string;
platform_user_id: string | null;
platform_username: string | null;
scheduled_at: string | null; scheduled_at: string | null;
uploaded_at: string | null; uploaded_at: string | null;
created_at: string; created_at: string;

View File

@ -8,7 +8,6 @@ import {
SongGenerateResponse, SongGenerateResponse,
SongStatusResponse, SongStatusResponse,
SongDownloadResponse, SongDownloadResponse,
SubtitleStatusResponse,
VideoGenerateResponse, VideoGenerateResponse,
VideoStatusResponse, VideoStatusResponse,
VideoDownloadResponse, VideoDownloadResponse,
@ -29,11 +28,6 @@ import {
TokenExpiredErrorResponse, TokenExpiredErrorResponse,
YTAutoSeoRequest, YTAutoSeoRequest,
YTAutoSeoResponse, YTAutoSeoResponse,
UserCreditsResponse,
VideoDetailItem,
CommentsResponse,
CommentItem,
LikeToggleResponse,
} from '../types/api'; } from '../types/api';
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
@ -167,8 +161,6 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
}); });
if (!response.ok) { if (!response.ok) {
const errorBody = await response.json().catch(() => null);
console.error('[generateSong] 422 detail:', JSON.stringify(errorBody));
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
@ -250,52 +242,6 @@ export async function waitForSongComplete(
return poll(); return poll();
} }
// 자막 상태 확인 API
export async function getSubtitleStatus(taskId: string): Promise<SubtitleStatusResponse> {
const response = await authenticatedFetch(`${API_URL}/lyric/subtitle/status/${taskId}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 자막 완료까지 폴링 (5초 간격, 10분 타임아웃)
const SUBTITLE_POLL_INTERVAL = 5000;
const SUBTITLE_POLL_TIMEOUT = 5 * 60 * 1000;
export async function waitForSubtitleComplete(
taskId: string,
onStatusChange?: (status: string) => void
): Promise<SubtitleStatusResponse> {
const startTime = Date.now();
const poll = async (): Promise<SubtitleStatusResponse> => {
if (Date.now() - startTime > SUBTITLE_POLL_TIMEOUT) {
throw new Error('TIMEOUT');
}
const response = await getSubtitleStatus(taskId);
onStatusChange?.(response.status);
if (response.status === 'completed') {
return response;
}
if (response.status === 'failed' || response.status === 'error') {
throw new Error(response.message || '자막 생성에 실패했습니다.');
}
await new Promise(resolve => setTimeout(resolve, SUBTITLE_POLL_INTERVAL));
return poll();
};
return poll();
}
// 영상 생성 API // 영상 생성 API
export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> { export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> {
const response = await authenticatedFetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, { const response = await authenticatedFetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, {
@ -377,99 +323,6 @@ export async function deleteVideo(videoId: number): Promise<void> {
} }
} }
// 전체 사용자 영상 목록 조회 API (ADO2 콘텐츠 갤러리용)
export async function getAllVideos(
page: number = 1,
pageSize: number = 20,
sortBy: 'created_at' | 'like_count' | 'comment_count' = 'created_at',
storeName: string = '',
order: 'desc' | 'asc' = 'desc',
region: string = '',
): Promise<VideosListResponse> {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
sort_by: sortBy,
order,
});
if (storeName.trim()) params.set('store_name', storeName.trim());
if (region.trim()) params.set('region', region.trim());
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 단일 영상 상세 조회 API
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 목록 조회
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 작성
export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise<CommentItem> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 삭제
export async function deleteComment(commentId: number): Promise<void> {
const response = await authenticatedFetch(`${API_URL}/comment/${commentId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// 좋아요 토글
export async function toggleVideoLike(videoId: string): Promise<LikeToggleResponse> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 이미지 업로드 API (multipart/form-data) // 이미지 업로드 API (multipart/form-data)
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음) // 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000; const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;
@ -623,20 +476,14 @@ export async function authenticatedFetch(
let response = await fetch(url, { ...options, headers }); let response = await fetch(url, { ...options, headers });
// 401 에러 시 에러 코드에 따라 처리 // 401 에러 시 토큰 갱신 시도
if (response.status === 401) { if (response.status === 401) {
const errorBody = await response.json().catch(() => null);
const errorCode = errorBody?.detail?.code;
if (errorCode !== 'TOKEN_EXPIRED') {
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
redirectToLogin();
throw new Error(errorCode ?? 'Unauthorized');
}
try { try {
// 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지)
// refreshPromise가 존재하면 재사용, 없으면 새로 생성
if (!refreshPromise) { if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => { refreshPromise = refreshAccessToken().finally(() => {
// 성공/실패 상관없이 Promise 초기화 (다음 갱신을 위해)
refreshPromise = null; refreshPromise = null;
}); });
} }
@ -653,6 +500,7 @@ export async function authenticatedFetch(
response = await fetch(url, { ...options, headers: newHeaders }); response = await fetch(url, { ...options, headers: newHeaders });
} catch (refreshError) { } catch (refreshError) {
console.error('Token refresh failed:', refreshError); console.error('Token refresh failed:', refreshError);
// 토큰 갱신 실패 시 로그인 페이지로 리다이렉트
redirectToLogin(); redirectToLogin();
throw refreshError; throw refreshError;
} }
@ -803,32 +651,6 @@ export async function getUserMe(): Promise<UserMeResponse> {
return response.json(); return response.json();
} }
// 사용자 크레딧 조회
export async function getUserCredits(): Promise<UserCreditsResponse> {
const response = await authenticatedFetch(`${API_URL}/user/auth/me/credits`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 크레딧 충전 요청
export async function requestCreditCharge(amount: number, note: string): Promise<void> {
const response = await authenticatedFetch(`${API_URL}/user/credits/charge-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requested_amount: amount, message: note }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// 로그인 여부 확인 // 로그인 여부 확인
export function isLoggedIn(): boolean { export function isLoggedIn(): boolean {
return !!getAccessToken(); return !!getAccessToken();
@ -844,16 +666,6 @@ export interface AccommodationSearchItem {
title: string; title: string;
} }
// 네이버 검색 API는 title에 <b> 태그와 &amp; 같은 HTML 엔티티를 함께 내려주므로
// 태그 제거 후 엔티티까지 디코딩해야 "&amp;" 같은 원문이 그대로 노출되지 않는다.
// textarea는 DOM에 붙이지 않고 content model상 스크립트를 실행하지 않으므로 엔티티 디코딩 용도로 안전하다.
export const cleanSearchTitle = (title: string): string => {
const withoutTags = title.replace(/<[^>]*>/g, '');
const textarea = document.createElement('textarea');
textarea.innerHTML = withoutTags;
return textarea.value;
};
export interface AccommodationSearchResponse { export interface AccommodationSearchResponse {
count: number; count: number;
items: AccommodationSearchItem[]; items: AccommodationSearchItem[];
@ -910,37 +722,6 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
} }
} }
// 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise<CrawlingResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
try {
const response = await authenticatedFetch(`${API_URL}/marketing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ store_name: storeName, address, category }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('요청 시간이 초과되었습니다. 다시 시도해주세요.');
}
throw error;
}
}
// ============================================ // ============================================
// Social OAuth TOKEN_EXPIRED 처리 // Social OAuth TOKEN_EXPIRED 처리
// ============================================ // ============================================