Compare commits

..

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

74 changed files with 14829 additions and 15700 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, '.'),
}
}
};
});

12320
index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,24 +11,10 @@
<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:url" content="https://demo.castad.net/" />
<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_48.svg" sizes="48x48">
<!-- Meta Pixel Code -->
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '1572679738203392');
fbq('track', 'PageView');
</script>
<!-- End Meta Pixel Code -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@ -176,12 +162,9 @@
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-[#121a1d]">
<!-- Meta Pixel noscript fallback (head의 noscript에는 img가 허용되지 않아 body에 위치) -->
<noscript><img height="1" width="1" style="display:none"
src="https://www.facebook.com/tr?id=1572679738203392&ev=PageView&noscript=1"
/></noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 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

@ -84,7 +84,7 @@
<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>
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
</ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>

View File

@ -41,7 +41,7 @@
</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>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API(<code>youtube.readonly</code>, <code>youtube.upload</code>) 및 YouTube Analytics API(<code>yt-analytics.readonly</code>)를 사용하며, 이를 통해 수집·처리되는 데이터는 <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>
@ -58,7 +58,7 @@
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
</ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>

View File

@ -14,7 +14,8 @@ import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
import VideoDetailPage from './components/VideoDetailPage';
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
import LoginPromptModal from './components/LoginPromptModal';
import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { CrawlingResponse } from './types/api';
@ -100,32 +101,11 @@ const App: React.FC = () => {
);
const [error, setError] = useState<string | null>(null);
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
const [scrollProgress, setScrollProgress] = useState(0);
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 파라미터 확인)
useEffect(() => {
// 광고 유입 UTM 파라미터 캡처 (콜백 처리로 URL이 정리되기 전에 최우선 실행)
storeUtmFromUrl();
const urlParams = new URLSearchParams(window.location.search);
const accessToken = urlParams.get('access_token');
const refreshToken = urlParams.get('refresh_token');
@ -139,21 +119,17 @@ const App: React.FC = () => {
// 백엔드에서 토큰을 URL 파라미터로 전달한 경우
if (accessToken && refreshToken && !isProcessingCallback) {
setIsProcessingCallback(true);
handleTokenCallback(accessToken, refreshToken, urlParams.get('is_new_user') === 'true');
handleTokenCallback(accessToken, refreshToken);
}
// 기존 code 방식 (Redirect URI가 프론트엔드인 경우) - Social OAuth는 제외
else if (code && !isProcessingCallback && !isSocialOAuthCallback) {
setIsProcessingCallback(true);
handleKakaoCallback(code);
}
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
else if (viewMode === 'landing') {
trackViewContent();
}
}, []);
// 백엔드에서 토큰을 URL 파라미터로 전달받은 경우 처리
const handleTokenCallback = async (accessToken: string, refreshToken: string, isNewUser: boolean = false) => {
const handleTokenCallback = (accessToken: string, refreshToken: string) => {
try {
// 토큰 저장
saveTokens(accessToken, refreshToken);
@ -162,15 +138,8 @@ const App: React.FC = () => {
const url = new URL(window.location.href);
url.searchParams.delete('access_token');
url.searchParams.delete('refresh_token');
url.searchParams.delete('is_new_user');
window.history.replaceState({}, document.title, url.pathname);
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
// (서버가 계정당 최초 1회 판정 + UTM 저장, 실패해도 로그인 흐름에 영향 없음)
if (isNewUser) {
await trackCompleteRegistration();
}
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
if (savedData) {
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
@ -198,12 +167,6 @@ const App: React.FC = () => {
try {
const response = await kakaoCallback(code);
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
// (페이지 이동 전에 완료해야 요청이 유실되지 않음)
if (response.is_new_user) {
await trackCompleteRegistration();
}
// 로그인 성공 - 서버에서 받은 redirect_url로 이동
window.location.href = response.redirect_url;
} catch (err) {
@ -303,7 +266,6 @@ const App: React.FC = () => {
const handleStartAnalysis = async (url: string) => {
if (!url.trim()) return;
setAfterLoadTarget('analysis');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
@ -329,7 +291,6 @@ const App: React.FC = () => {
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
setAfterLoadTarget('analysis');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
@ -353,31 +314,6 @@ const App: React.FC = () => {
}
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string, category: string, officialSiteUrl?: string) => {
setAfterLoadTarget('generation_flow');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address, category, officialSiteUrl);
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');
}
};
// 테스트 데이터로 브랜드 분석 페이지 이동
const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true };
@ -482,7 +418,7 @@ const App: React.FC = () => {
return (
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => setViewMode(afterLoadTarget)}
onComplete={() => setViewMode('analysis')}
/>
);
}
@ -526,7 +462,7 @@ const App: React.FC = () => {
<HeroSection
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
onNext={() => scrollToSection(1)}
error={error}
scrollProgress={scrollProgress}

View File

@ -1,169 +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, officialSiteUrl: 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 [officialSiteUrl, setOfficialSiteUrl] = 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()}`;
// 프로토콜 없이 입력하면 https:// 를 붙여서 전달
const trimmedUrl = officialSiteUrl.trim();
const normalizedUrl = trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)
? `https://${trimmedUrl}`
: trimmedUrl;
onSubmit(businessName.trim(), fullAddress, category.trim(), normalizedUrl);
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-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelSiteUrl')}</label>
<input
type="url"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderSiteUrl')}
value={officialSiteUrl}
onChange={e => setOfficialSiteUrl(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={2048}
/>
</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,6 +1,6 @@
import React, { useState, useEffect } from 'react';
export const REGIONS: { label: string; cities: string[] }[] = [
const REGIONS: { label: string; cities: string[] }[] = [
{
label: '특별시 / 광역시',
cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'],
@ -88,11 +88,10 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
return () => { document.body.style.overflow = ''; };
}, []);
const activeEntry = REGIONS.find(r => r.label === activeRegion);
const cities = activeEntry?.cities ?? [];
const cities = REGIONS.find(r => r.label === activeRegion)?.cities ?? [];
const handleSelect = (value: string) => {
onSelect(value === selected ? '' : value);
const handleCityClick = (city: string) => {
onSelect(city === selected ? '' : city);
onClose();
};
@ -113,38 +112,25 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
{activeRegion === null ? (
<div className="city-modal-grid">
{REGIONS.map(r => {
const isRegionSelected = selected === r.label;
const hasCitySelected = r.cities.includes(selected);
return (
{REGIONS.map(r => (
<button
key={r.label}
className={`city-modal-region-item ${isRegionSelected || hasCitySelected ? 'has-selected' : ''}`}
className={`city-modal-region-item ${r.cities.includes(selected) ? '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>}
{r.cities.includes(selected) && <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)}
onClick={() => handleCityClick(city)}
>
{city}
</button>

View File

@ -1,107 +0,0 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { API_URL, toggleVideoLike } from '../utils/api';
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
interface ContentCardSocialActionsProps {
videoId: number;
storeName: string;
region?: string;
title?: string | null;
commentCount: number;
initialLikeCount: number;
initialIsLiked?: boolean;
}
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
videoId,
storeName,
title,
commentCount,
initialLikeCount,
initialIsLiked = false,
}) => {
const { t } = useTranslation();
const [likeCount, setLikeCount] = useState(initialLikeCount);
const [isLiked, setIsLiked] = useState(initialIsLiked);
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shareUrl = buildVideoShareUrl(API_URL, videoId);
const shareTitle = title || storeName || t('videoDetail.kakaoDefaultTitle');
const handleShareBtnClick = async (e: React.MouseEvent) => {
e.stopPropagation();
const handled = await tryNativeShare({
title: shareTitle,
url: shareUrl,
});
if (handled) {
return;
}
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
// clipboard API 미지원 환경에서는 무시
}
};
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);
};
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
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>
</div>
);
};
export default ContentCardSocialActions;

View File

@ -11,9 +11,9 @@ const Footer: React.FC = () => {
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
<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>
<a href="https://demo.castad.net/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>
<a href="https://demo.castad.net/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
</div>
</div>
<div className="footer-right">

View File

@ -71,25 +71,17 @@ const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
<button
onClick={handleLogin}
style={{
width: '50%',
margin: '0 auto',
width: '100%',
padding: '14px',
background: '#FEE500',
color: '#3C1E1E',
background: '#1A8F93',
color: '#FFFFFF',
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>

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, officialSiteUrl?: 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, officialSiteUrl) => {
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category, officialSiteUrl);
}}
/>
)}
</div>
);
};
export default SearchInputForm;

View File

@ -38,12 +38,9 @@ interface SidebarProps {
userInfo?: UserMeResponse | null;
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, credits }) => {
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
@ -162,24 +159,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
</div>
<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">
<LanguageSwitch isCollapsed={isCollapsed} />
</div>

View File

@ -142,7 +142,10 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null);
const hasBeenOpenedRef = useRef(false);
const loadedForVideoIdRef = useRef<number | null>(null);
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
const [showUploadProgress, setShowUploadProgress] = useState(false);
@ -154,7 +157,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
const [uploadChannelName, setUploadChannelName] = useState<string>('');
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
const [uploadScheduledAt, setUploadScheduledAt] = useState<string | null>(null);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
@ -209,30 +211,28 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 소셜 계정 로드
useEffect(() => {
if (!isOpen) {
loadedForVideoIdRef.current = null;
return;
}
if (!isOpen) return;
const now = Date.now();
loadSocialAccounts();
const videoId = video?.video_id ?? null;
if (!videoId || videoId === loadedForVideoIdRef.current) {
return;
}
loadedForVideoIdRef.current = videoId;
if (video?.title) {
setTitle(video.title);
setDescription(video.description || '');
setTags((video.hashtags || []).join(','));
setIsLoadingAutoDescription(false);
return;
}
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();
}, [isOpen, video?.video_id, video?.title, video?.description, video?.hashtags]);
} else if (taskId) {
const cached = seoCache.current.get(taskId);
if (cached) {
setTitle(cached.title);
setDescription(cached.description);
setTags(cached.tags);
}
}
}, [isOpen, video?.task_id]);
const loadSocialAccounts = async () => {
setIsLoadingAccounts(true);
@ -258,20 +258,29 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
};
const loadAutocomplete = async () => {
if (!video?.video_id) return;
if (!video?.task_id) return;
setIsLoadingAutoDescription(true);
try {
const requestPayload = {
video_id: video.video_id,
task_id : video.task_id,
};
// Call autoSEO API
console.log('[Upload] Request payload:', requestPayload);
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
// 각 필드가 있을 때만 덮어씌움 (기존 값 보호)
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
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) {
console.error('Failed to load autocomplete:', error);
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
} finally {
setIsLoadingAutoDescription(false);
}
@ -364,13 +373,12 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 예약 업로드: 폴링 없이 바로 완료 처리
setUploadStatus('completed');
setUploadProgress(100);
setUploadScheduledAt(uploadResponse.scheduled_at ?? null);
onClose();
resetForm();
} else {
// 즉시 업로드: 완료될 때까지 폴링
const result = await waitForUploadComplete(
uploadResponse.upload_id.toString(),
uploadResponse.upload_id,
(status, progress) => {
setUploadStatus(status as UploadStatus);
setUploadProgress(progress || 0);
@ -424,7 +432,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
setUploadVideoTitle('');
setUploadChannelName('');
setUploadIsScheduled(false);
setUploadScheduledAt(null);
};
const handleClose = () => {
@ -454,7 +461,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
youtubeUrl={uploadYoutubeUrl}
errorMessage={uploadErrorMessage}
isScheduled={uploadIsScheduled}
scheduledAt={uploadScheduledAt}
onGoToCalendar={onGoToCalendar}
/>
);

View File

@ -143,11 +143,7 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
};
const tryBind = (): boolean => {
const els = Array.from(document.querySelectorAll(hint.targetSelector));
const el = (els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0]) as HTMLElement | null;
const el = document.querySelector(hint.targetSelector) as HTMLElement | null;
if (!el) return false;
bindToTarget(el);
return true;

View File

@ -47,18 +47,18 @@ export const tutorialSteps: TutorialStepDef[] = [
key: TUTORIAL_KEYS.LANDING,
hints: [
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc',
targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-manual-card-title',
titleKey: 'tutorial.landing.manual.title',
descriptionKey: 'tutorial.landing.manual.desc',
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
@ -294,14 +294,14 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.social-posting-form',
titleKey: 'tutorial.upload.required.title',
descriptionKey: 'tutorial.upload.required.desc',
position: 'top',
position: 'left',
clickToAdvance: false,
},
{
targetSelector: '.social-posting-radio-group',
titleKey: 'tutorial.upload.schedule.title',
descriptionKey: 'tutorial.upload.schedule.desc',
position: 'top',
position: 'left',
clickToAdvance: false,
},
{

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';
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
@ -189,16 +189,6 @@ export function useTutorial(): UseTutorialReturn {
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 {
isActive,
isEnabled,

View File

@ -14,7 +14,6 @@ interface UploadProgressModalProps {
youtubeUrl?: string;
errorMessage?: string;
isScheduled?: boolean;
scheduledAt?: string | null;
onGoToCalendar?: () => void;
}
@ -28,7 +27,6 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
youtubeUrl,
errorMessage,
isScheduled = false,
scheduledAt,
onGoToCalendar,
}) => {
const { t } = useTranslation();
@ -150,28 +148,6 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
{t('upload.viewOnYoutube')}
</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>
{/* Footer */}

View File

@ -7,11 +7,8 @@ import {
deleteComment,
toggleVideoLike,
isLoggedIn,
getUserMe,
API_URL,
} from '../utils/api';
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
import { VideoDetailItem, CommentItem } from '../types/api';
import LoginPromptModal from './LoginPromptModal';
interface VideoDetailContentProps {
@ -32,8 +29,8 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const [isLiked, setIsLiked] = useState(false);
const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [isLandscape, setIsLandscape] = useState(false);
const [showSiteOverlay, setShowSiteOverlay] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [comments, setComments] = useState<CommentItem[]>([]);
@ -45,7 +42,16 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const [commentSubmitting, setCommentSubmitting] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const [currentUser, setCurrentUser] = useState<UserMeResponse | null>(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);
@ -69,7 +75,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const fetchVideo = async () => {
setLoading(true);
setError(null);
setShowSiteOverlay(false);
try {
const data = await getVideoById(videoId);
setVideo(data);
@ -87,16 +92,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
fetchComments(1);
}, [videoId, fetchComments]);
useEffect(() => {
if (!authed) return;
getUserMe().then(setCurrentUser).catch((err) => {
console.error('Failed to fetch current user:', err);
});
}, [authed]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`;
};
const formatCommentDate = (dateString: string) => {
@ -104,8 +102,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
};
const shareUrl = buildVideoShareUrl(API_URL, videoId);
const shareTitle = video?.title || video?.store_name || t('videoDetail.kakaoDefaultTitle');
const shareUrl = `${window.location.origin}/video/${videoId}`;
const handleCopyLink = async () => {
try {
@ -117,17 +114,50 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
setTimeout(() => setCopied(false), 2000);
};
const handleShareButtonClick = async () => {
const handled = await tryNativeShare({
title: shareTitle,
url: shareUrl,
const handleKakaoShare = () => {
const kakao = window.Kakao;
if (kakao?.Share) {
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: video?.store_name ?? 'ADO2 영상',
description: `${video?.region ?? ''} · ADO2 AI 마케팅 영상`,
imageUrl: 'https://demo.castad.net/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: '영상 보기', link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
if (handled) {
return;
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
}
await 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 = () => {
@ -167,11 +197,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
if (!commentInput.trim() || commentSubmitting) return;
setCommentSubmitting(true);
try {
await postVideoComment(videoId, commentInput.trim());
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);
@ -190,7 +222,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
};
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>{t('videoDetail.deletedComment')}</span>;
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>(삭제된 댓글입니다.)</span>;
return content;
};
@ -199,7 +231,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
{/* 헤더 */}
<div className="video-detail-header">
{isModal ? (
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label="닫기">
<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>
@ -223,7 +255,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<div className="ado2-contents-error"><p>{error}</p></div>
) : video ? (
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<div className="video-detail-player-wrap">
<video
src={video.result_movie_url}
controls
@ -235,34 +266,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
onTimeUpdate={(e) => {
if (!video.official_site_url) return;
const v = e.currentTarget;
// 영상 종료 3초 전부터 공식 홈페이지 오버레이 노출
setShowSiteOverlay(
Number.isFinite(v.duration) && v.duration - v.currentTime <= 3
);
}}
/>
{video.official_site_url && showSiteOverlay && (
<a
href={video.official_site_url}
target="_blank"
rel="noopener noreferrer"
className="video-site-overlay"
>
<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="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span className="video-site-overlay-text">
<strong>{video.store_name}</strong>
{t('videoDetail.siteOverlayLabel')}
</span>
</a>
)}
</div>
<div className="video-detail-info">
<h2 className="video-detail-store">{video.store_name}</h2>
@ -280,37 +284,79 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
</svg>
{likeCount}
</button>
<div style={{ position: 'relative' }} ref={shareMenuRef}>
<button
className="video-detail-copy-btn"
onClick={handleShareButtonClick}
title={t('videoDetail.share')}
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')}
{copied ? '복사됨!' : '공유하기'}
</button>
{shareMenuOpen && (
<div className="video-detail-share-menu">
{/* 카카오톡 */}
<button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#FEE500">
<path d="M12 3C6.477 3 2 6.477 2 10.8c0 2.736 1.582 5.14 3.978 6.592-.175.598-.63 2.178-.723 2.514-.113.412.151.406.318.295.13-.087 2.07-1.403 2.909-1.97.487.068.986.104 1.518.104 5.523 0 10-3.477 10-7.8S17.523 3 12 3z"/>
</svg>
카카오톡
</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>
페이스북
</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>
X (트위터)
</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 ? '복사됨!' : 'URL 복사'}
</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>
<h3 className="video-detail-comments-title">댓글</h3>
<span className="video-detail-comments-count">{commentsTotal}</span>
</div>
{/* 댓글 작성자 프로필 (카카오 로그인 정보) */}
{authed && currentUser && (
{/* 댓글 작성자 프로필 선택 */}
{authed && (
<div className="video-detail-comment-profile">
{currentUser.profile_image_url && (
<img
src={currentUser.profile_image_url}
alt={currentUser.nickname}
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
alt="아바타 변경"
className="video-detail-comment-avatar"
onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }}
title="클릭하여 아바타 변경"
/>
<input
className="video-detail-nickname-input"
type="text"
placeholder="작성자 이름"
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
/>
)}
<span className="video-detail-comment-nickname">{currentUser.nickname}</span>
</div>
)}
@ -318,7 +364,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<textarea
ref={commentTextareaRef}
className="video-detail-comment-input"
placeholder={authed ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')}
placeholder={authed ? '댓글을 입력하세요...' : '로그인 후 댓글을 작성할 수 있습니다'}
maxLength={500}
rows={1}
value={commentInput}
@ -341,26 +387,24 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
onClick={handleCommentSubmit}
disabled={!authed || !commentInput.trim() || commentSubmitting}
>
{commentSubmitting ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')}
{commentSubmitting ? '작성 중' : '작성'}
</button>
</div>
{comments.length === 0 && !commentsLoading ? (
<p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p>
<p className="video-detail-comments-empty">아직 댓글이 없습니다.</p>
) : (
<ul className="video-detail-comment-list">
{comments.map((c) => (
<li key={c.id} className="video-detail-comment-item">
{c.profile_image_url && (
<img
src={c.profile_image_url}
alt={c.nickname || t('videoDetail.anonymous')}
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')}
{c.nickname || '익명'}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(c.content, c.is_deleted)}
@ -372,7 +416,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(c.id)}
>
{t('videoDetail.deleteComment')}
삭제
</button>
)}
</div>
@ -382,16 +426,14 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<ul className="video-detail-reply-list">
{c.replies.map((r) => (
<li key={r.id} className="video-detail-reply-item">
{r.profile_image_url && (
<img
src={r.profile_image_url}
alt={r.nickname || t('videoDetail.anonymous')}
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')}
{r.nickname || '익명'}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(r.content, r.is_deleted)}
@ -403,7 +445,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(r.id)}
>
{t('videoDetail.deleteComment')}
삭제
</button>
)}
</div>
@ -424,7 +466,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
onClick={() => fetchComments(commentsPage + 1, true)}
disabled={commentsLoading}
>
{commentsLoading ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')}
{commentsLoading ? '불러오는 중...' : '댓글 더 보기'}
</button>
)}
</div>

View File

@ -41,7 +41,7 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
background: '#01282A',
borderRadius: '16px',
width: '100%',
maxWidth: '800px',
maxWidth: '900px',
margin: 'auto',
position: 'relative',
}}

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,7 +2,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './i18n';
import './styles/index.css';
import App from './App';
declare global {

View File

@ -1,10 +1,4 @@
{
"wizardSteps": {
"brandAnalysis": "Analysis",
"asset": "Image",
"sound": "Sound",
"video": "Video"
},
"header": {
"loginFailedAlert": "Failed to get login URL. Please try again.",
"start": "Get Started",
@ -34,8 +28,8 @@
"finish": "Done",
"landing": {
"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." },
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
},
"asset": {
@ -47,18 +41,12 @@
"sound": {
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
"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." },
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
},
"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": { "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." },
@ -71,6 +59,12 @@
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
},
"completion": {
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." },
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
},
"upload": {
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
@ -101,8 +95,8 @@
"footer": {
"company":"O2O Inc.",
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
"headquarters": "HQ : 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",
"headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, 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",
"email": "Email : o2oteam@o2o.kr",
"privacyPolicy": "Privacy Policy",
@ -128,7 +122,7 @@
"privacyPrivate": "Private",
"publishTimeLabel": "Publish Time",
"publishNow": "Publish Now",
"publishSchedule": "Schedule",
"publishSchedule": "Schedule (Coming Soon)",
"footerNote": "",
"footerNoteLink": "",
"cancel": "Cancel",
@ -142,29 +136,7 @@
"uploadFailed": "Upload failed.",
"autoSeoTitle": "Auto-generating... (30–60 sec)",
"autoSeoDescription": "Auto-generating... (30–60 sec)",
"autoSeoTags": "Auto-generating... (30–60 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"
}
"autoSeoTags": "Auto-generating... (30–60 sec)"
},
"upload": {
"title": "YouTube Upload",
@ -181,36 +153,23 @@
"confirm": "OK",
"close": "Close",
"doNotClose": "Upload is in progress. Do not close this window.",
"goToCalendar": "View in Calendar",
"scheduleConflict": "Schedule adjusted to {{time}} due to a conflict."
"goToCalendar": "View in Calendar"
},
"landing": {
"hero": {
"searchTypeLabel": "Business Name | URL",
"placeholderCombined": "Enter a business name or Naver Maps/Place URL",
"guideCombined": "Paste a Naver Maps share URL, or enter a business name to automatically retrieve information.",
"errorInputRequired": "Please enter a business name or URL.",
"errorInvalidUrl": "Invalid URL format. (e.g., https://naver.me/abcdef)",
"searchTypeBusinessName": "Business Name",
"placeholderBusinessName": "Enter a business name",
"guideUrl": "Search for a place on Naver Maps, tap Share,\nthen paste the URL here.",
"guideBusinessName": "Search by business name to retrieve information.",
"errorUrlRequired": "Please enter a URL.",
"errorNameRequired": "Please enter a business name.",
"errorInvalidUrl": "Invalid URL format. (e.g., https://example.com)",
"analyzeButton": "Brand Analysis",
"scrollMore": "Scroll to see more",
"searching": "Searching...",
"searchTypeManual": "Not on Naver? Enter manually",
"manualButtonDesc": "Even businesses not listed on Naver Maps/Place\ncan get lyrics, music, and video production\njust by entering the name, region, and address.",
"manualModalTitle": "Enter Business Info Directly",
"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)",
"manualLabelSiteUrl": "Website link (optional)",
"manualPlaceholderSiteUrl": "Enter the official website URL (e.g. https://example.com)"
"testDataLoading": "Loading...",
"testData": "Test Data",
"testDataLoadFailed": "Failed to load test data.",
"searching": "Searching..."
},
"welcome": {
"title": "Welcome to ADO2.AI",
@ -240,44 +199,24 @@
"guideBusinessName": "Search by business name to retrieve information.",
"searchButton": "Search",
"searching": "Searching...",
"searchTypeManual": "Manual Input",
"errorSelectFromList": "Please select a business from the list.",
"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)"
"testDataLoading": "Loading...",
"testData": "Test Data"
},
"assetManagement": {
"title": "Brand Assets",
"selectedImages": "Selected Images",
"imageAlt": "Image",
"uploadBadge": "Uploaded",
"previewUnavailable": "No preview",
"imageUpload": "Image Upload",
"dragAndDrop": "Drag & drop or\nclick to upload",
"videoRatio": "Video Ratio",
"minImages": "Recommended 30+ images",
"minImages": "Min. 5 images",
"youtubeShorts": "YouTube Shorts",
"youtubeVideo": "YouTube Video",
"back": "Go Back",
"loadMore": "Load more",
"uploadFailed": "Image upload failed.",
"uploadErrorTitle": "Image Upload Error",
"uploadErrorConfirm": "OK",
"duplicateSkippedTitle": "Duplicate Images Skipped",
"duplicateSkippedMessage": "Skipped {{count}} image(s) that were already added.",
"preparingPreviews": "Preparing previews ({{done}} / {{total}})",
"uploading": "Uploading... (30 sec – 1 min)",
"uploading": "Uploading... (30 sec – 2 min)",
"nextStep": "Next Step"
},
"soundStudio": {
@ -290,7 +229,7 @@
"genreLabel": "Select Genre",
"genreAuto": "Auto Select",
"genreBallad": "Ballad",
"languageLabel": "Select Language",
"languageLabel": "Language",
"languageKorean": "Korean",
"lyricsColumn": "Lyrics",
"lyricsHint": "Select the lyrics area to edit",
@ -304,8 +243,8 @@
"videoGenerating": "Generating Video",
"noBusinessInfo": "No business information. Please try again.",
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
"generatingLyrics": "Generating lyrics... (30–60 sec)",
"generatingSong": "Generating music... (1–2 min)",
"generatingLyrics": "Generating lyrics...\n(30–60 sec)",
"generatingSong": "Generating music...\n(1–2 min)",
"songQueued": "Song generation queued...",
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
"lyricGenerationFailed": "Lyrics generation request failed.",
@ -319,7 +258,7 @@
"songRegenerationError": "An error occurred during music regeneration.",
"creditsRemaining": "{{count}} left",
"creditsExhausted": "Not enough credits.",
"chargeCredits": "Top Up Credits"
"chargeCredits": "Purchase credits"
},
"completion": {
"back": "Go Back",
@ -373,7 +312,7 @@
"noLyricsBGM": "Background music is generated without lyrics.",
"downloading": "Downloading...",
"download": "Download",
"uploadToSocial": "Upload"
"uploadToSocial": "Upload to Social"
},
"dashboard": {
"title": "Dashboard",
@ -471,8 +410,8 @@
"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": "No data to display",
"dataDelayDesc": "There is no analytics data to display. YouTube may take up to 48 hours to reflect data after upload, and statistics are also not collected if the video was uploaded as private or has been deleted from YouTube.",
"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.",
@ -483,7 +422,7 @@
"title": "My Info",
"tabBasic": "Basic Info",
"tabPayment": "Payment Info",
"tabBusiness": "Social Channel Management",
"tabBusiness": "My Business & Social Channel Management",
"basicPlaceholder": "Basic information settings are coming soon.",
"paymentPlaceholder": "Payment information settings are coming soon.",
"myBusiness": "My Business",
@ -532,7 +471,7 @@
"delete": "Delete",
"previous": "Previous",
"next": "Next",
"uploadToSocial": "Upload",
"uploadToSocial": "Upload to social media",
"sortLatest": "Latest",
"sortOldest": "Oldest",
"sortLikes": "Most Liked",
@ -561,9 +500,9 @@
"marketPositioning": "Market Positioning",
"coreValue": "Core Value",
"categoryDefinition": "Category Definition",
"targetPersona": "Key Customer Type",
"targetPersona": "Target Persona",
"ageSuffix": "years old",
"sellingPoints": "Key Selling Points",
"sellingPoints": "Unique Selling Points (USP)",
"recommendedKeywords": "Recommended Target Keywords",
"generateContent": "Generate Content",
"pageDescBefore": " reveals ",
@ -604,7 +543,7 @@
"loading": "Loading...",
"noResults": "No recent results",
"noResultsDesc": "Upload your content to social channels",
"myContents": "My Contents",
"ado2Contents": "ADO2 Contents",
"cancel": "Cancel",
"retry": "Retry",
"cancelConfirm": "Are you sure you want to cancel this reservation?",
@ -616,31 +555,7 @@
},
"loginPrompt": {
"title": "Login Required",
"loginBtn": "Login"
},
"videoDetail": {
"dateFormat": "{{month}}/{{day}}/{{year}}",
"kakaoDefaultTitle": "ADO2 Video",
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
"deletedComment": "(This comment has been deleted.)",
"closeAriaLabel": "Close",
"share": "Share",
"copied": "Copied!",
"siteOverlayLabel": "View more",
"shareKakao": "KakaoTalk",
"shareFacebook": "Facebook",
"shareTwitter": "X (Twitter)",
"copyUrl": "Copy URL",
"commentsTitle": "Comments",
"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..."
"loginBtn": "Login with Kakao"
},
"app": {
"loginProcessing": "Processing login...",

View File

@ -1,10 +1,4 @@
{
"wizardSteps": {
"brandAnalysis": "분석",
"asset": "이미지",
"sound": "사운드",
"video": "영상"
},
"header": {
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
"start": "시작하기",
@ -34,8 +28,8 @@
"finish": "완료",
"landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
},
"asset": {
@ -61,7 +55,7 @@
},
"myInfo": {
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." },
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
},
@ -101,8 +95,8 @@
"footer": {
"company":"㈜에이아이오투오",
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
"headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
"phone": "전화 : 070-4260-8310 | 010-2755-6463",
"email": "이메일 : o2oteam@o2o.kr",
"privacyPolicy": "개인정보처리방침",
@ -142,29 +136,7 @@
"uploadFailed": "업로드에 실패했습니다.",
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoDescription": "자동으로 작성중입니다. (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": "지금 이동"
}
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
},
"upload": {
"title": "YouTube 업로드",
@ -181,35 +153,23 @@
"confirm": "확인",
"close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
"goToCalendar": "캘린더에서 확인",
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
"goToCalendar": "캘린더에서 확인"
},
"landing": {
"hero": {
"searchTypeLabel": "업체명 | URL",
"placeholderCombined": "업체명 또는 URL을 입력하세요.",
"guideCombined": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 정보를 가져옵니다.",
"errorInputRequired": "업체명 또는 URL을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://naver.me/abcdef)",
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"analyzeButton": "브랜드 분석",
"scrollMore": "스크롤하여 더 보기",
"searching": "검색 중...",
"searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기",
"manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.",
"manualModalTitle": "업체 정보 직접 입력",
"manualModalSubtitle": "네이버에 등록되지 않은 업체도 아래 정보만 입력하면 가사·음악·영상 제작까지 할 수 있어요.",
"manualAnalyzeButton": "시작하기",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)",
"manualLabelSiteUrl": "홈페이지 링크 (선택)",
"manualPlaceholderSiteUrl": "공식 홈페이지 주소를 입력하세요. (예: https://example.com)"
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"searching": "검색 중..."
},
"welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.",
@ -239,44 +199,24 @@
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기",
"searching": "검색 중...",
"searchTypeManual": "직접 입력",
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
"errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터"
},
"assetManagement": {
"title": "브랜드 에셋",
"selectedImages": "선택된 이미지",
"imageAlt": "이미지",
"uploadBadge": "업로드",
"previewUnavailable": "미리보기 없음",
"imageUpload": "이미지 업로드",
"dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드",
"videoRatio": "영상 비율",
"minImages": "권장 30장 이상",
"minImages": "최소 5장",
"youtubeShorts": "유튜브 쇼츠",
"youtubeVideo": "유튜브 일반",
"back": "뒤로가기",
"loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.",
"uploadErrorTitle": "이미지 업로드 초과",
"uploadErrorConfirm": "확인",
"duplicateSkippedTitle": "중복 이미지 제외",
"duplicateSkippedMessage": "이미 추가된 이미지 {{count}}장을 제외했습니다.",
"preparingPreviews": "미리보기 준비 중 ({{done}} / {{total}})",
"uploading": "업로드 중 (30~60초 소요)",
"uploading": "업로드 중 (30초~2분 소요)",
"nextStep": "다음 단계"
},
"soundStudio": {
@ -372,7 +312,7 @@
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
"downloading": "다운로드 중...",
"download": "다운로드",
"uploadToSocial": "업로드"
"uploadToSocial": "소셜 채널 업로드"
},
"dashboard": {
"title": "대시보드",
@ -470,8 +410,8 @@
"monthOverMonth": "전월 대비 성장",
"noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
"mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
"dataDelayTitle": "표시할 데이터 없음",
"dataDelayDesc": "표시할 분석 데이터가 없습니다. 유튜브 정책상 업로드 후 최대 48시간이 지나야 데이터가 반영되며, 영상을 비공개로 업로드했거나 유튜브에서 삭제한 경우에도 통계가 집계되지 않습니다.",
"dataDelayTitle": "데이터 준비 중",
"dataDelayDesc": "유튜브 정책에 따라 분석 데이터는 영상 업로드 후 최대 48시간이 지나야 반영됩니다. 나중에 다시 확인해 주세요.",
"sampleHide": "Sample 숨기기",
"sampleShow": "Sample 보기",
"recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",
@ -482,7 +422,7 @@
"title": "내 정보",
"tabBasic": "기본 정보",
"tabPayment": "결제 정보",
"tabBusiness": "소셜 채널 관리",
"tabBusiness": "내 비즈니스 & 소셜 채널 관리",
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
"myBusiness": "내 비즈니스",
@ -531,7 +471,7 @@
"delete": "삭제",
"previous": "이전",
"next": "다음",
"uploadToSocial": "업로드",
"uploadToSocial": "소셜 미디어에 업로드",
"sortLatest": "최신순",
"sortOldest": "오래된순",
"sortLikes": "좋아요 많은순",
@ -603,7 +543,7 @@
"loading": "불러오는 중...",
"noResults": "최근 결과 없음",
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
"myContents": "내 콘텐츠",
"ado2Contents": "ADO2 콘텐츠",
"cancel": "취소",
"retry": "재시도",
"cancelConfirm": "예약을 취소하시겠습니까?",
@ -614,32 +554,8 @@
"failedCount": "실패 {{count}}"
},
"loginPrompt": {
"title": "로그인이 필요합니다.",
"loginBtn": "로그인"
},
"videoDetail": {
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
"kakaoDefaultTitle": "ADO2 영상",
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
"deletedComment": "(삭제된 댓글입니다.)",
"closeAriaLabel": "닫기",
"share": "공유하기",
"copied": "복사됨!",
"siteOverlayLabel": "자세히 보기",
"shareKakao": "카카오톡",
"shareFacebook": "페이스북",
"shareTwitter": "X (트위터)",
"copyUrl": "URL 복사",
"commentsTitle": "댓글",
"commentPlaceholder": "댓글을 입력하세요...",
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
"commentSubmitting": "작성 중",
"commentSubmit": "작성",
"noComments": "아직 댓글이 없습니다.",
"anonymous": "익명",
"deleteComment": "삭제",
"loadMoreComments": "댓글 더 보기",
"loadingComments": "불러오는 중..."
"title": "로그인이 필요합니다",
"loginBtn": "카카오로 로그인"
},
"app": {
"loginProcessing": "로그인 처리 중...",

View File

@ -22,7 +22,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
return Math.min(prev + increment, 100);
});
}, 200);
}, 100);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
@ -50,7 +50,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
<div className="loading-content">
{/* 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>
{/* Loading Spinner and Text */}

View File

@ -5,7 +5,6 @@ import { VideoListItem } from '../../types/api';
import LoginPromptModal from '../../components/LoginPromptModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import CitySelectModal from '../../components/CitySelectModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
interface ADO2ContentsPageProps {
onBack?: () => void;
@ -151,15 +150,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
>
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
{video.poster_url ? (
<img
src={video.poster_url}
alt={video.store_name}
loading="lazy"
decoding="async"
className="content-video-preview"
/>
) : video.thumbnail_url ? (
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt={video.store_name}
@ -190,15 +181,16 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<h3 className="content-card-title">{video.store_name}</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}
title={video.title}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
<span className="content-card-like">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" 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>
{video.like_count ?? 0}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
<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>
{video.comment_count ?? 0}
</span>
</div>
</div>
</div>

View File

@ -3,19 +3,13 @@ import React, { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ImageItem, ImageUrlItem } from '../../types/api';
import { uploadImages } from '../../utils/api';
import { isImageInputFile } from '../../utils/imageCompression.ts';
import { MAX_IMAGES_PER_UPLOAD_TASK } from '../../utils/imageUpload.ts';
import { splitDuplicateFiles } from '../../utils/imageDedup.ts';
interface AssetManagementContentProps {
onNext: (imageTaskId: string) => void;
onBack?: () => void;
imageList: ImageItem[];
onRemoveImage: (index: number) => void;
onAddImages: (
files: File[],
onProgress: (done: number, total: number) => void
) => Promise<void>;
onAddImages: (files: File[]) => void;
}
type VideoRatio = 'vertical' | 'horizontal';
@ -32,12 +26,9 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// 업로드 실패와 중복 제외 안내가 같은 다이얼로그 마크업을 공유한다.
const [dialog, setDialog] = useState<{ title: string; message: string } | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
const [thumbnailProgress, setThumbnailProgress] = useState<{ done: number; total: number } | null>(null);
useEffect(() => {
const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio;
@ -46,42 +37,20 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}
}, []);
useEffect(() => {
if (!dialog) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setDialog(null);
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [dialog]);
const handleVideoRatioChange = (ratio: VideoRatio) => {
setVideoRatio(ratio);
localStorage.setItem('castad_video_ratio', ratio);
};
const getImageSrc = (item: ImageItem): string | null => {
return item.type === 'url' ? item.preview_url : item.preview;
const getImageSrc = (item: ImageItem): string => {
return item.type === 'url' ? item.url : item.preview;
};
const handleNextWithUpload = async () => {
if (imageList.length === 0) return;
setIsUploading(true);
setUploadProgress(0);
setDialog(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);
setUploadError(null);
try {
const urlImages: ImageUrlItem[] = imageList
@ -94,47 +63,13 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
if (urlImages.length > 0 || fileImages.length > 0) {
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);
}
} catch (error) {
clearInterval(interval);
console.error('Image upload failed:', error);
setDialog({
title: t('assetManagement.uploadErrorTitle'),
message: error instanceof Error ? error.message : t('assetManagement.uploadFailed'),
});
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
const runAddImages = async (files: File[]) => {
const { newFiles, duplicateCount } = splitDuplicateFiles(imageList, files);
if (newFiles.length > 0) {
setThumbnailProgress({ done: 0, total: newFiles.length });
try {
await onAddImages(newFiles, (done, total) => setThumbnailProgress({ done, total }));
} finally {
setThumbnailProgress(null);
}
}
// 썸네일 생성 오버레이에 가려지지 않도록 작업이 끝난 뒤에 안내한다.
if (duplicateCount > 0) {
setDialog({
title: t('assetManagement.duplicateSkippedTitle'),
message: t('assetManagement.duplicateSkippedMessage', { count: duplicateCount }),
});
}
};
@ -146,8 +81,10 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files).filter(isImageInputFile);
if (files.length > 0) void runAddImages(files);
const files = Array.from(e.dataTransfer.files).filter((file: File) =>
file.type.startsWith('image/')
);
if (files.length > 0) onAddImages(files);
};
const handleFileSelect = () => {
@ -157,7 +94,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
void runAddImages(Array.from(files));
onAddImages(Array.from(files));
e.target.value = '';
}
};
@ -167,79 +104,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
return (
<main className="asset-page">
{thumbnailProgress && (
<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.preparingPreviews', {
done: thumbnailProgress.done,
total: thumbnailProgress.total,
})}
</p>
</div>
</div>
)}
{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>
)}
{dialog && (
<div
className="asset-upload-error-overlay"
onClick={() => setDialog(null)}
>
<div
className="asset-upload-error-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="asset-upload-error-title"
aria-describedby="asset-upload-error-message"
onClick={(event) => event.stopPropagation()}
>
<h2 id="asset-upload-error-title" className="asset-upload-error-title">
{dialog.title}
</h2>
<p id="asset-upload-error-message" className="asset-upload-error-message">
{dialog.message}
</p>
<button
type="button"
className="asset-upload-error-confirm"
onClick={() => setDialog(null)}
autoFocus
>
{t('assetManagement.uploadErrorConfirm')}
</button>
</div>
</div>
)}
{/* Fixed Header - 뒤로가기 버튼 */}
<div className="asset-sticky-header">
{onBack && (
@ -263,13 +127,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<div className="asset-section-header-left">
<h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
<span className="asset-section-subtitle">{t('assetManagement.minImages')}</span>
<span
className={`asset-section-count${
imageList.length > MAX_IMAGES_PER_UPLOAD_TASK ? ' asset-section-count-over' : ''
}`}
>
{imageList.length} / {MAX_IMAGES_PER_UPLOAD_TASK}
</span>
</div>
<button onClick={handleFileSelect} className="asset-mobile-upload-btn">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@ -283,31 +140,12 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<div className="asset-image-list">
{visibleImages.length > 0 && (
<div className="asset-image-grid">
{visibleImages.map((item, i) => {
const src = getImageSrc(item);
const fileName = item.type === 'file' ? item.file.name : '';
return (
{visibleImages.map((item, i) => (
<div key={i} className="asset-image-item">
{src ? (
<img
src={src}
src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
referrerPolicy="no-referrer"
loading="lazy"
decoding="async"
/>
) : (
<div className="asset-image-placeholder" title={fileName}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<span className="asset-image-placeholder-name">
{fileName || t('assetManagement.previewUnavailable')}
</span>
</div>
)}
{item.type === 'file' && (
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
)}
@ -321,8 +159,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
</svg>
</button>
</div>
);
})}
))}
</div>
)}
</div>
@ -388,6 +225,9 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
{/* Fixed Footer - 다음 단계 버튼 */}
<div className="asset-sticky-footer">
{uploadError && (
<p className="text-red-500 text-sm mb-2">{uploadError}</p>
)}
<button
onClick={handleNextWithUpload}
disabled={imageList.length === 0 || isUploading}
@ -400,7 +240,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<input
ref={fileInputRef}
type="file"
accept="image/*,.heic,.heif"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"

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 { useTranslation } from 'react-i18next';
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api';
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete } from '../../utils/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
@ -266,9 +266,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoStatus('complete');
setStatusMessage('');
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
// Meta FirstVideoCreated 전환 이벤트 발화 (서버가 계정당 최초 1회 판정, 실패해도 무시)
trackFirstVideoCreated();
} else {
throw new Error(t('completion.videoUrlMissing'));
}
@ -412,14 +409,14 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return (
<main className="comp2-page">
{/* <div className="comp2-header">
<div className="comp2-header">
<button onClick={onBack} className="comp2-back-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
<span>{t('completion.back')}</span>
</button>
</div> */}
</div>
<div className="comp2-title-row">
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
@ -479,11 +476,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="comp2-info-header">
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
</div>
<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-info-content">
<div className="comp2-meta-grid">
<div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
@ -503,22 +500,19 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
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);
}
});
const outro = lines.slice(-1);
const body = lines.slice(0, -1);
const half = Math.ceil(body.length / 2);
const sections = [
{ tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro },
].filter(s => s.lines.length > 0);
return (
<div className="comp2-lyrics-paragraphs">
{sections.filter(s => s.lines.length > 0).map((section, i) => (
{sections.map((section, i) => (
<div key={i} className="comp2-lyrics-para-section">
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
<span className="comp2-lyrics-tag">{section.tag}</span>
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
</div>
))}
@ -563,7 +557,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
created_at: new Date().toISOString(),
like_count: 0,
comment_count: 0,
is_liked_by_me: false,
} : null}
/>

View File

@ -645,7 +645,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</p>
</div>
<button
onClick={() => onNavigate?.('내 콘텐츠')}
onClick={() => onNavigate?.('ADO2 콘텐츠')}
style={{
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
backgroundColor: '#a65eff', cursor: 'pointer',
@ -653,7 +653,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#ffffff',
}}
>
{t('contentCalendar.myContents')}
{t('contentCalendar.ado2Contents')}
</button>
</div>
);
@ -717,7 +717,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</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={{
backgroundColor: '#01393b',
borderTop: '1px solid #046266',

View File

@ -14,13 +14,11 @@ import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
import WizardStepper from '../../components/WizardStepper';
import { createPreviewThumbnail } from '../../utils/imageCompression.ts';
const WIZARD_STEP_KEY = 'castad_wizard_step';
const ACTIVE_ITEM_KEY = 'castad_active_item';
@ -29,15 +27,6 @@ const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
/** 파일 이미지의 썸네일 objectURL 을 일괄 해제한다. preview 가 null 인 항목은 건너뛴다. */
const revokeAllPreviews = (items: ImageItem[]) => {
items.forEach(item => {
if (item.type === 'file' && item.preview) {
URL.revokeObjectURL(item.preview);
}
});
};
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
const SONG_GENERATION_KEY = 'castad_song_generation';
const VIDEO_GENERATION_KEY = 'castad_video_generation';
@ -62,7 +51,7 @@ interface BusinessInfo {
interface GenerationFlowProps {
onHome: () => void;
initialActiveItem?: string;
initialImageList?: ImageListItem[];
initialImageList?: string[];
businessInfo?: BusinessInfo;
initialAnalysisData?: CrawlingResponse | null;
}
@ -179,21 +168,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// URL 이미지를 ImageItem 형태로 변환하여 초기화
const getInitialImageList = (): ImageItem[] => {
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 imageListRef = useRef<ImageItem[]>(imageList);
imageListRef.current = imageList;
// 의존성 배열에 imageList 를 넣으면 이미지를 추가할 때마다 cleanup 이 돌아
// 방금 만든 썸네일이 즉시 revoke 된다. 반드시 빈 배열이어야 한다.
useEffect(() => {
return () => revokeAllPreviews(imageListRef.current);
}, []);
const prevAnalysisMIdRef = useRef<number | null | undefined>(analysisData?.m_id);
// analysisData 변경 시 m_id가 바뀐 경우(새로운 분석)에만 imageList 업데이트
@ -202,9 +182,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
if (analysisData?.image_list && analysisData.image_list.length > 0) {
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
// 목록을 통째로 교체하므로 기존 파일 썸네일을 먼저 해제한다.
revokeAllPreviews(imageListRef.current);
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;
@ -213,35 +191,21 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const handleRemoveImage = (index: number) => {
setImageList(prev => {
const item = prev[index];
// 파일 이미지인 경우 썸네일 메모리 해제 (생성 실패 시 preview 는 null)
if (item.type === 'file' && item.preview) {
// 파일 이미지인 경우 메모리 해제
if (item.type === 'file') {
URL.revokeObjectURL(item.preview);
}
return prev.filter((_, i) => i !== index);
});
};
const handleAddImages = async (
files: File[],
onProgress: (done: number, total: number) => void
) => {
const total = files.length;
const newImages: ImageItem[] = [];
onProgress(0, total);
// 반드시 순차 처리한다. Promise.all 로 동시에 돌리면 원본 크기 비트맵이
// 한꺼번에 메모리에 올라가, 이 작업이 없애려던 문제가 그대로 재현된다.
for (const file of files) {
const preview = await createPreviewThumbnail(file);
newImages.push({ type: 'file', file, preview });
onProgress(newImages.length, total);
// 진행률이 실제로 화면에 그려지도록 매 장마다 렌더링 기회를 넘긴다.
await new Promise(resolve => setTimeout(resolve, 0));
}
// 장마다 setState 하면 그리드가 N 번 재조정된다. 전부 끝난 뒤 한 번만 반영한다.
// 새로 추가된 이미지를 배열 앞에 둔다 (최신 이미지가 상단에 표시)
const handleAddImages = (files: File[]) => {
const newImages: ImageItem[] = files.map(file => ({
type: 'file',
file,
preview: URL.createObjectURL(file),
}));
// 새로 업로드된 이미지를 배열 앞에 추가 (최신 이미지가 상단에 표시)
setImageList(prev => [...newImages, ...prev]);
};
@ -253,7 +217,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setSongTaskId(null);
setImageTaskId(null);
setAnalysisData(null);
revokeAllPreviews(imageListRef.current);
setImageList([]);
onHome();
};
@ -298,31 +261,17 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string, category: string, officialSiteUrl?: string) => {
goToWizardStep(-1);
setIsAnalysisComplete(false);
setAnalysisError(null);
// 테스트용 랜덤 m_id 생성 (99 ~ 300)
const generateRandomMId = (): number => {
return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
};
try {
const data = await marketingAnalysis(businessName, address, category, officialSiteUrl);
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
data.processed_info.region = data.processed_info.region || '';
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);
}
// 테스트 데이터로 브랜드 분석 페이지 이동
const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, m_id: generateRandomMId(), _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
goToWizardStep(0); // 브랜드 분석 결과로
};
// 언어 변경 시 테스트 데이터 다시 로드
@ -337,7 +286,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
.then(res => res.json())
.then((data: CrawlingResponse) => {
// 언어 변경 시에는 기존 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);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
})
@ -462,7 +411,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<UrlInputContent
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
error={analysisError}
/>
);
@ -519,7 +468,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
businessInfo={currentBusinessInfo}
imageTaskId={imageTaskId}
mId={analysisData?.m_id ?? 0}
industry={analysisData?.industry ?? ''}
videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress}
onGoToPayment={handleGoToPayment}
@ -575,22 +523,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case '내 정보':
return <MyInfoContent initialTab={myInfoInitialTab} />;
case '새 프로젝트 만들기':
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
if (wizardStep === -1 || wizardStep === -2) {
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
if (wizardStep === 0 || wizardStep === -1) {
return renderWizardContent();
}
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
if (wizardStep === 0) {
return (
<>
<WizardStepper currentStep={wizardStep} />
{renderWizardContent()}
</>
);
}
return (
<div className="wizard-page-container">
<WizardStepper currentStep={wizardStep} />
{renderWizardContent()}
</div>
);
@ -666,7 +604,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return (
<div className="analysis-page-wrapper">
{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} credits={credits} />
)}
<main className="analysis-page-main">
{renderContent()}
@ -679,10 +617,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return (
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
{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} credits={credits} />
)}
{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()}
</div>
</div>

View File

@ -5,7 +5,6 @@ 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';
@ -248,15 +247,7 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
style={{ cursor: 'pointer' }}
onClick={() => setSelectedVideoId(video.video_id)}
>
{video.poster_url ? (
<img
src={video.poster_url}
alt={video.store_name}
loading="lazy"
decoding="async"
className="content-video-preview"
/>
) : video.result_movie_url ? (
{video.result_movie_url ? (
<VideoPreviewCard
src={video.result_movie_url}
className="content-video-preview"
@ -287,15 +278,16 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
<p className="content-card-date">
{formatDate(video.created_at)}
</p>
<ContentCardSocialActions
videoId={video.video_id}
storeName={video.store_name}
region={video.region}
title={video.title}
commentCount={video.comment_count ?? 0}
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
<span className="content-card-like">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" 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>
{video.like_count ?? 0}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
<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>
{video.comment_count ?? 0}
</span>
</div>
</div>

View File

@ -113,7 +113,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
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: 'business' as TabType, label: t('myInfo.tabBusiness') },
];
@ -205,11 +205,11 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
{/* 탭 컨텐츠 */}
<div className="myinfo-content">
{/* {activeTab === 'basic' && (
{activeTab === 'basic' && (
<div className="myinfo-section">
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
</div>
)} */}
)}
{activeTab === 'payment' && (
<div className="myinfo-section">
@ -240,7 +240,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
{activeTab === 'business' && (
<>
{/* 내 비즈니스 섹션
{/* 내 비즈니스 섹션 */}
<div className="myinfo-section">
<h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2>
<div className="myinfo-business-card">
@ -261,7 +261,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
</button>
</div>
</div>
</div> */}
</div>
{/* 소셜 채널 섹션 */}
<div className="myinfo-section">

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 { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
import { LANGUAGE_MAP } from '../../types/api';
@ -16,7 +16,6 @@ interface SoundStudioContentProps {
businessInfo?: BusinessInfo;
imageTaskId: string | null;
mId: number;
industry?: string;
onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number;
@ -27,59 +26,6 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
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> = {
'한국어': '🇰🇷',
'English': '🇺🇸',
@ -95,7 +41,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo,
imageTaskId,
mId,
industry = '',
onStatusChange,
videoGenerationStatus = 'idle',
videoGenerationProgress = 0,
@ -105,8 +50,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [selectedType, setSelectedType] = useState('보컬');
const [selectedLang, setSelectedLang] = useState('한국어');
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
const [usedGenre, setUsedGenre] = useState('');
const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<GenerationStatus>('idle');
const [lyrics, setLyrics] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null);
@ -117,40 +62,20 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0);
const [songTaskId, setSongTaskId] = useState<string | null>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// 완료 데이터를 localStorage에 저장
const saveSongCompletionData = () => {
const completionData = {
businessName: businessInfo?.customer_name || '알 수 없음',
// '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장
genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre,
genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre,
lyrics: lyrics,
timestamp: Date.now(),
};
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
// Auto-navigate to next page when video generation is complete
useEffect(() => {
@ -190,18 +115,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setStatusMessage('');
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) {
console.error('Polling failed:', error);
@ -218,10 +131,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
}
} else {
setStatus('error');
setErrorMessage(sanitizeError(
error instanceof Error ? error.message : '',
t('soundStudio.musicGenerationError')
));
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
setRetryCount(0);
}
}
@ -232,13 +142,20 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const genreMap: Record<string, string> = {
'자동 선택': 'pop',
'K-POP': 'kpop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
const isInstrumental = selectedType === '배경음악';
const effectiveGenre = getEffectiveGenre(selectedGenre);
setUsedGenre(effectiveGenre);
const songResponse = await generateSong(imageTaskId, {
genre: effectiveGenre,
genre: genreMap[selectedGenre] || 'pop',
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
});
@ -251,14 +168,46 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} catch (error) {
console.error('Song regeneration failed:', error);
setStatus('error');
setErrorMessage(sanitizeError(
error instanceof Error ? error.message : '',
t('soundStudio.songRegenerationError')
));
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.songRegenerationError'));
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(() => {
const audio = audioRef.current;
if (!audio) return;
@ -318,9 +267,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
return;
}
// 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거
localStorage.removeItem(SONG_GENERATION_KEY);
setStatus('generating_lyric');
setErrorMessage(null);
setStatusMessage(t('soundStudio.generatingLyrics'));
@ -331,9 +277,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const isInstrumental = selectedType === '배경음악';
// 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다.
// '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다.
const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre];
// 1. 가사 생성 요청
console.log('[SoundStudio] Sending m_id:', mId);
@ -344,20 +287,26 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
m_id: mId,
region: businessInfo.region,
task_id: imageTaskId,
industry,
orientation,
...(isInstrumental && { instrumental: true }),
...(manualGenre && { genre: manualGenre }),
});
if (!lyricResponse.success || !lyricResponse.task_id) {
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
}
const genreMap: Record<string, string> = {
'자동 선택': 'pop',
'K-POP': 'kpop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
let songLyrics: string | undefined;
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다.
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다.
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre);
if (!isInstrumental) {
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
@ -382,16 +331,13 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setLyrics(lyricDetailResponse.lyric_result);
songLyrics = lyricDetailResponse.lyric_result;
// 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용
effectiveGenre = lyricDetailResponse.genre || effectiveGenre;
}
setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong'));
setUsedGenre(effectiveGenre);
const songResponse = await generateSong(imageTaskId, {
genre: effectiveGenre,
genre: genreMap[selectedGenre] || 'pop',
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
});
@ -416,10 +362,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} catch (error) {
console.error('Music generation failed:', error);
setStatus('error');
setErrorMessage(sanitizeError(
error instanceof Error ? error.message : '',
t('soundStudio.musicGenerationError')
));
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
setRetryCount(0);
}
};
@ -437,24 +380,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
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 변경 시 부모에 알림
useEffect(() => {
onStatusChange?.(status);
@ -548,7 +473,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
))}
</div>
<div className="genre-row">
{['JAZZ', 'ROCK', 'POP'].map(genre => (
{['JAZZ', 'ROCK'].map(genre => (
<button
key={genre}
onClick={() => setSelectedGenre(genre)}
@ -558,6 +483,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{genre}
</button>
))}
<div className="genre-btn-placeholder"></div>
</div>
</div>
</div>
@ -570,8 +496,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{['한국어', 'English', '中文'].map((lang) => (
<button
key={lang}
onClick={() => !isGenerating && setSelectedLang(lang)}
disabled={isGenerating}
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
disabled={isGenerating || selectedType === '배경음악'}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
@ -582,8 +508,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
<button
key={lang}
onClick={() => !isGenerating && setSelectedLang(lang)}
disabled={isGenerating}
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
disabled={isGenerating || selectedType === '배경음악'}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
@ -667,8 +593,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
)}
</button>
{/* 진행 표시 전용 — Azure Blob 익명 응답에 Accept-Ranges가 없어 seek가 불안정하므로 드래그 미지원 */}
<div
ref={progressBarRef}
onMouseDown={onMouseDown}
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
>
<div
@ -684,16 +611,27 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Lyrics Display */}
<div className="lyrics-display">
{lyrics ? (
{lyrics ? (() => {
const lines = lyrics.split('\n').filter(l => l.trim());
const outro = lines.slice(-1);
const body = lines.slice(0, -1);
const half = Math.ceil(body.length / 2);
const sections = [
{ tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro },
].filter(s => s.lines.length > 0);
return (
<div className="lyrics-paragraphs">
{lyricsSections.map((section, i) => (
{sections.map((section, i) => (
<div key={i} className="lyrics-section">
{section.tag && <span className="lyrics-tag">{section.tag}</span>}
<span className="lyrics-tag">{section.tag}</span>
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
</div>
))}
</div>
) : (
);
})() : (
<div className="lyrics-placeholder">
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
</div>

View File

@ -1,29 +1,334 @@
import React from 'react';
import { AutocompleteRequest } from '../../utils/api';
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
import React, { useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
import { CrawlingResponse } from '../../types/api';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
type SearchType = 'url' | 'name';
// 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
interface UrlInputContentProps {
onAnalyze: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
onTestData?: (data: CrawlingResponse) => void;
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 { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType);
const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
setSelectedItem({
title: item.value,
address: item.address || '',
roadAddress: item.roadAddress || '',
});
}
};
// 테스트 데이터 로드 핸들러
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 (e.key === 'Enter') {
e.preventDefault();
return;
}
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 '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();
};
const handleAnalyzeClick = () => {
if (!inputValue.trim()) return;
if (searchType === 'name') {
if (selectedItem && onAutocomplete) {
const request: AutocompleteRequest = {
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
};
onAutocomplete(request);
}
// selectedItem 없으면 아무것도 하지 않음 (드롭다운에서 선택 필요)
} else {
// URL 검색인 경우 기존 로직
onAnalyze(inputValue.trim(), searchType);
}
};
return (
<div className="url-input-container">
<div className="url-input-content">
{/* 로고 */}
<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>
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
error={error}
/>
{/* URL 입력 폼 */}
<form onSubmit={handleSubmit} className="url-input-form">
<div className="url-input-wrapper">
{/* 드롭다운 */}
<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);
hideOnInput(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);
}
openHistory(inputValue);
}}
onBlur={() => setTimeout(() => closeHistory(), 150)}
placeholder={getPlaceholder()}
className="url-input-field"
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={handleSelectHistory}
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
/>
)}
{/* 자동완성 결과 */}
{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="button" onClick={handleAnalyzeClick} className="url-input-button">
{t('landing.hero.analyzeButton')}
</button>
</form>
</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>
);
};

View File

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

View File

@ -1,14 +1,36 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
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 { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
import LoginPromptModal from '../../components/LoginPromptModal';
import SearchInputForm from '../../components/SearchInputForm';
import { SearchType } from '../../components/SearchInputForm';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
type SearchType = 'url' | 'name';
// 환경변수에서 테스트 모드 확인
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
interface OrbConfig {
@ -17,36 +39,65 @@ interface OrbConfig {
initialX: number;
initialY: number;
color: string;
// Movement bounds for each orb
minX: number;
maxX: number;
minY: number;
maxY: number;
}
// 6 orbs distributed in a 3x2 grid pattern with overlapping zones for smooth movement
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 },
// 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 },
// 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 },
// 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 },
// 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 },
// 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 },
];
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string, officialSiteUrl?: string) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number;
}
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false);
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
const { t } = useTranslation();
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [isLoginPromptOpen, setIsLoginPromptOpen] = 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 (error) {
console.error('테스트 데이터 로드 실패:', error);
setLocalError(t('landing.hero.testDataLoadFailed'));
} finally {
setIsLoadingTest(false);
}
};
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType);
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 animationRefs = useRef<number[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작
@ -59,12 +110,100 @@ 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);
};
// 키보드 네비게이션 핸들러
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') {
e.preventDefault();
}
};
// Random movement for orbs
useEffect(() => {
const moveOrb = (orb: HTMLDivElement, index: number) => {
const config = orbConfigs[index];
let currentX = config.initialX;
let currentY = config.initialY;
// 초기 타겟은 현재 위치와 동일하게 설정 (순간이동 방지)
let targetX = currentX;
let targetY = currentY;
let scale = 1;
@ -72,13 +211,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
let isFirstMove = true;
const generateNewTarget = () => {
// Move within the orb's designated zone
const rangeX = config.maxX - config.minX;
const rangeY = config.maxY - config.minY;
if (isFirstMove) {
// 첫 이동은 현재 위치에서 가까운 곳으로 (자연스러운 시작)
const smallRangeX = rangeX * 0.3;
const smallRangeY = rangeY * 0.3;
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
// 범위 내로 클램핑
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
isFirstMove = false;
@ -86,38 +229,94 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
targetX = config.minX + Math.random() * rangeX;
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 = () => {
// Slow speed - 일정한 속도로 부드럽게
const speed = 0.003;
currentX += (targetX - currentX) * speed;
currentY += (targetY - currentY) * speed;
scale += (targetScale - scale) * speed;
// Apply transform
orb.style.left = `${currentX}%`;
orb.style.top = `${currentY}%`;
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);
};
// 첫 타겟 생성 후 애니메이션 시작
generateNewTarget();
animate();
};
// 모든 공이 동시에 자연스럽게 시작 (딜레이 없이)
orbRefs.current.forEach((orb, index) => {
if (orb) moveOrb(orb, index);
if (orb) {
moveOrb(orb, index);
}
});
// Cleanup
return () => {
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 (
<div className="hero-section">
{/* Animated background orbs */}
{/* Animated background orbs - 스크롤에 따라 빠르게 사라짐 */}
<div
className="hero-bg-orbs"
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
@ -139,38 +338,214 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
</div>
<div className="hero-content">
{/* Logo Image */}
<img
src="/assets/images/ADO2_with Slogan_white.svg"
src="/assets/images/ado2-logo.svg"
alt="ADO2"
className="hero-logo"
/>
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
onManualButtonClick={() => {
if (isLoggedIn()) {
setIsManualModalOpen(true);
} else {
setIsLoginPromptOpen(true);
{/* Input Form */}
<div className="hero-form">
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
{/* 드롭다운 */}
<div className="hero-dropdown-container" ref={dropdownRef}>
<button
type="button"
className="hero-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={`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);
hideOnInput(value);
if (localError) setLocalError('');
// 업체명 검색일 때 자동완성 검색 (디바운스)
if (searchType === 'name') {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleAutocompleteSearch(value);
}, 300);
}
}}
error={externalError || null}
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);
}
openHistory(inputValue);
}}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onKeyDown={handleKeyDown}
placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={(item) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
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 && 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>
{/* Footer Indicator */}
<button onClick={onNext} className="scroll-indicator">
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
<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" />
</svg>
</div>
</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
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
@ -180,21 +555,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
groupProgress={tutorial.groupProgress}
/>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category, officialSiteUrl) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category, officialSiteUrl);
}}
/>
)}
{isLoginPromptOpen && (
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
)}
</div>
);
};

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api';
import { getKakaoLoginUrl, kakaoCallback } from '../../utils/api';
interface LoginSectionProps {
onBack: () => void;
@ -28,13 +28,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
setError(null);
try {
const response = await kakaoCallback(code);
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
// (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해)
if (response.is_new_user) {
await trackCompleteRegistration();
}
await kakaoCallback(code);
// URL에서 code 파라미터 제거
const url = new URL(window.location.href);
@ -83,7 +77,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
<div className="login-content">
{/* 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>
{/* Error Message */}

View File

@ -1,13 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
const SocialConnectError: React.FC = () => {
const { t } = useTranslation();
// URL 파라미터 파싱
const searchParams = new URLSearchParams(window.location.search);
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' :
platform === 'instagram' ? 'Instagram' :
@ -27,19 +25,19 @@ const SocialConnectError: React.FC = () => {
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</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">
{errorMessage}
</p>
<p className="social-connect-detail">
{t('social.connectError.detail')}
다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.
</p>
<div className="social-connect-actions">
<button
onClick={navigateToHome}
className="social-connect-button"
>
{t('social.connectError.retry')}
다시 시도
</button>
</div>
</div>

View File

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

View File

@ -1,11 +1,9 @@
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 YouTubeOAuthCallback: React.FC = () => {
const { t } = useTranslation();
const [status, setStatus] = useState<'processing' | 'error'>('processing');
const [errorMessage, setErrorMessage] = useState<string>('');
@ -20,7 +18,7 @@ const YouTubeOAuthCallback: React.FC = () => {
if (error) {
console.error('[YouTube OAuth] Error from Google:', error);
setStatus('error');
setErrorMessage(t('social.youtubeCallback.errorAuthCancelled'));
setErrorMessage('Google 인증이 취소되었거나 실패했습니다.');
setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
}, 1500);
@ -30,7 +28,7 @@ const YouTubeOAuthCallback: React.FC = () => {
if (!code) {
console.error('[YouTube OAuth] No code received');
setStatus('error');
setErrorMessage(t('social.youtubeCallback.errorNoCode'));
setErrorMessage('인증 코드를 받지 못했습니다.');
setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=no_code';
}, 1500);
@ -48,7 +46,7 @@ const YouTubeOAuthCallback: React.FC = () => {
} catch (err) {
console.error('[YouTube OAuth] Failed to process callback:', err);
setStatus('error');
setErrorMessage(t('social.youtubeCallback.errorProcessing'));
setErrorMessage('YouTube 연결 처리 중 오류가 발생했습니다.');
setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
}, 1500);
@ -68,8 +66,8 @@ const YouTubeOAuthCallback: React.FC = () => {
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
</svg>
</div>
<h1 className="social-connect-title">{t('social.youtubeCallback.connecting')}</h1>
<p className="social-connect-message">{t('social.youtubeCallback.pleaseWait')}</p>
<h1 className="social-connect-title">YouTube 연결 중...</h1>
<p className="social-connect-message">잠시만 기다려주세요.</p>
</>
) : (
<>
@ -80,7 +78,7 @@ const YouTubeOAuthCallback: React.FC = () => {
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</div>
<h1 className="social-connect-title">{t('social.youtubeCallback.failed')}</h1>
<h1 className="social-connect-title">연결 실패</h1>
<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[];
}
export interface ImageListItem {
preview: string; // 미리보기용 CDN 리사이즈 URL
original: string; // 업로드용 원본 URL
}
export interface CrawlingResponse {
image_list: ImageListItem[] | null;
image_list: string[];
image_count: number;
m_id: number;
industry: string;
processed_info: {
customer_name: string;
region: string;
detail_region_info: string;
industry: string;
};
marketing_analysis: MarketingAnalysis;
}
@ -54,15 +47,14 @@ export interface CrawlingResponse {
// URL 이미지 (서버에서 가져온 이미지)
export interface UrlImage {
type: 'url';
url: string; // 업로드용 원본 URL
preview_url: string; // 미리보기용 CDN 리사이즈 URL
url: string;
}
// 업로드된 파일 이미지
export interface FileImage {
type: 'file';
file: File; // 업로드용 원본. 썸네일로 대체하지 않는다.
preview: string | null; // 미리보기용 축소 썸네일 objectURL. 생성 실패 시 null.
file: File;
preview: string; // createObjectURL로 생성된 미리보기 URL
}
export type ImageItem = UrlImage | FileImage;
@ -75,10 +67,8 @@ export interface LyricGenerateRequest {
m_id: number;
region: string;
task_id: string;
industry?: string;
orientation?: 'vertical' | 'horizontal';
instrumental?: boolean;
genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천)
}
// 가사 생성 응답
@ -108,7 +98,6 @@ export interface LyricDetailResponse {
status: string;
lyric_prompt: string;
lyric_result: string;
genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값)
created_at: string;
}
@ -233,13 +222,22 @@ export interface KakaoLoginUrlResponse {
auth_url: string;
}
// 카카오 콜백 응답 (JWT 토큰) — 백엔드 LoginResponse와 동일 구조
// 카카오 콜백 사용자 정보
export interface KakaoCallbackUser {
id: number;
nickname: string;
email: string | null;
profile_image_url: string | null;
is_new_user: boolean;
}
// 카카오 콜백 응답 (JWT 토큰)
export interface KakaoCallbackResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
is_new_user: boolean;
user: KakaoCallbackUser;
redirect_url: string;
}
@ -276,28 +274,19 @@ export interface VideoListItem {
region: string;
task_id: string;
result_movie_url: string;
poster_url?: string | null;
thumbnail_url?: string;
title?: string | null;
description?: string | null;
hashtags?: string[] | null;
official_site_url?: string | null;
created_at: string;
like_count: number;
comment_count: number;
is_liked_by_me: boolean;
is_liked_by_me?: boolean;
}
// 비디오 상세 아이템
export interface VideoDetailItem {
video_id: number;
result_movie_url: string;
poster_url?: string | null;
store_name: string;
region: string;
title?: string | null;
description?: string | null;
official_site_url?: string | null;
created_at: string;
like_count: number;
is_liked_by_me: boolean;
@ -318,7 +307,6 @@ export interface VideosListResponse {
export interface CommentReply {
id: number;
nickname: string;
profile_image_url: string | null;
content: string | null;
is_deleted: boolean;
is_mine: boolean;
@ -329,7 +317,6 @@ export interface CommentReply {
export interface CommentItem {
id: number;
nickname: string;
profile_image_url: string | null;
content: string | null;
is_deleted: boolean;
is_mine: boolean;
@ -401,7 +388,7 @@ export interface SocialDisconnectResponse {
// 유튜브 SEO Description 자동완성 요청
export interface YTAutoSeoRequest {
video_id: number;
task_id: string; // 아카이브의 비디오 ID
}
// 유튜브 SEO Description 자동완성 응답
@ -425,11 +412,9 @@ export interface SocialUploadRequest {
// 소셜 업로드 응답
export interface SocialUploadResponse {
success: boolean;
upload_id: number;
platform: string;
status: string;
upload_id: string;
status: 'pending' | 'uploading' | 'completed' | 'failed';
message: string;
scheduled_at?: string | null; // 예약 업로드 충돌 시 실제 예약 시간 반환
}
// 소셜 업로드 상태 조회 응답

View File

@ -35,7 +35,6 @@ import {
CommentItem,
LikeToggleResponse,
} from '../types/api';
import { uploadImagesSequentially } from './imageUpload.ts';
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
console.log('[API] API_URL:', API_URL);
@ -310,183 +309,6 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h
return response.json();
}
// ============================================================
// Meta 전환 추적
// ============================================================
// Meta 픽셀이 심는 1st-party 쿠키 값 읽기 (_fbc/_fbp)
function getCookieValue(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
// URL의 fbclid로 조립해둔 fbc 폴백 값을 보관하는 localStorage 키
// UTM과 분리해 두어야 가입 완료 후 UTM을 정리해도 이후 이벤트에서 계속 쓸 수 있다.
const FBC_STORAGE_KEY = 'castad_fbc';
// Meta 클릭 ID(fbc) 조회
// 픽셀이 심은 _fbc 쿠키를 우선 사용하고, 없으면 랜딩 시 fbclid로 조립해둔 값을 쓴다.
// 애드블로커·쿠키 정리 등으로 쿠키가 유실돼도 광고 클릭 기여를 잃지 않기 위한 폴백이다.
function getFbc(): string | null {
const cookie = getCookieValue('_fbc');
if (cookie) return cookie;
try {
return localStorage.getItem(FBC_STORAGE_KEY);
} catch {
return null;
}
}
// 첫 영상 생성 완료(FirstVideoCreated) 전환 이벤트 발화 API
// 서버가 first_video_created_at null 여부로 계정당 최초 1회를 판정하고,
// fired=true일 때만 브라우저 픽셀(fbq)을 동일 event_id로 발화해 중복제거를 보장한다.
export async function trackFirstVideoCreated(): Promise<void> {
try {
const response = await authenticatedFetch(`${API_URL}/tracking/meta/first-video-created`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fbc: getFbc(),
fbp: getCookieValue('_fbp'),
event_source_url: window.location.href,
}),
});
if (!response.ok) return;
const data: { fired: boolean; event_id: string | null } = await response.json();
const fbq = (window as unknown as { fbq?: (...args: unknown[]) => void }).fbq;
if (data.fired && data.event_id && typeof fbq === 'function') {
// 서버 CAPI 이벤트와 동일한 event_id로 발화 → Meta가 중복제거
fbq('trackCustom', 'FirstVideoCreated', {}, { eventID: data.event_id });
}
} catch (error) {
// 전환 추적 실패는 서비스 기능에 영향을 주지 않도록 무시
console.error('[Tracking] FirstVideoCreated failed:', error);
}
}
// UTM 파라미터 localStorage 키 (first-touch 보존)
const UTM_STORAGE_KEY = 'castad_utm';
const UTM_PARAM_NAMES = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
// 이벤트 중복제거용 UUID 생성
// crypto.randomUUID는 보안 컨텍스트(https/localhost) 전용이라 미지원 환경 폴백 포함
function generateEventId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// fbq 전역 함수 안전 접근
function getFbq(): ((...args: unknown[]) => void) | null {
const fbq = (window as unknown as { fbq?: (...args: unknown[]) => void }).fbq;
return typeof fbq === 'function' ? fbq : null;
}
// 랜딩 진입 URL에서 광고 유입 정보(UTM, fbclid)를 localStorage에 보관한다.
export function storeUtmFromUrl(): void {
try {
const params = new URLSearchParams(window.location.search);
// UTM: 이미 저장된 값이 있으면 덮어쓰지 않아 최초 유입 경로(first-touch)를 보존
if (!localStorage.getItem(UTM_STORAGE_KEY)) {
const utm: Record<string, string> = {};
for (const name of UTM_PARAM_NAMES) {
const value = params.get(name);
if (value) utm[name] = value;
}
if (Object.keys(utm).length > 0) {
localStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utm));
}
}
// fbclid: 광고 클릭 기여는 최신 클릭이 가져가므로 새 값이 오면 덮어쓴다
// (Meta 픽셀의 _fbc 쿠키도 동일하게 last-touch로 동작)
// 형식: fb.{subdomainIndex}.{creationTime(ms)}.{fbclid}
const fbclid = params.get('fbclid');
if (fbclid) {
localStorage.setItem(FBC_STORAGE_KEY, `fb.1.${Date.now()}.${fbclid}`);
}
} catch (error) {
console.error('[Tracking] storeUtmFromUrl failed:', error);
}
}
// 랜딩 진입(ViewContent) 이벤트 발화 — 비로그인 시점이므로 브라우저 픽셀 단독
export function trackViewContent(): void {
try {
const fbq = getFbq();
if (fbq) {
fbq('track', 'ViewContent', {}, { eventID: generateEventId() });
console.log('[Tracking] ViewContent fired');
} else {
console.warn('[Tracking] ViewContent skipped: fbq not loaded');
}
} catch (error) {
const detail = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack}` : String(error);
console.error('[Tracking] ViewContent failed:', detail);
}
}
// 회원가입 완료(CompleteRegistration) 전환 이벤트 발화 API
// 서버가 registration_tracked_at null 여부로 계정당 최초 1회를 판정하고 UTM을 함께 기록한다.
// fired=true일 때만 브라우저 픽셀(fbq)을 동일 event_id로 발화해 중복제거를 보장한다.
export async function trackCompleteRegistration(): Promise<void> {
try {
let utm: Record<string, string> = {};
try {
utm = JSON.parse(localStorage.getItem(UTM_STORAGE_KEY) ?? '{}');
} catch {
utm = {};
}
const response = await authenticatedFetch(`${API_URL}/tracking/meta/complete-registration`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fbc: getFbc(),
fbp: getCookieValue('_fbp'),
event_source_url: window.location.href,
utm_source: utm.utm_source ?? null,
utm_medium: utm.utm_medium ?? null,
utm_campaign: utm.utm_campaign ?? null,
utm_content: utm.utm_content ?? null,
utm_term: utm.utm_term ?? null,
}),
});
if (!response.ok) return;
const data: { fired: boolean; event_id: string | null } = await response.json();
if (data.fired && data.event_id) {
const fbq = getFbq();
if (fbq) {
// 서버 CAPI 이벤트와 동일한 event_id로 발화 → Meta가 중복제거
fbq('track', 'CompleteRegistration', {}, { eventID: data.event_id });
}
// 가입 어트리뷰션 저장 완료 → 보관하던 UTM 제거
localStorage.removeItem(UTM_STORAGE_KEY);
}
} catch (error) {
// 전환 추적 실패는 서비스 기능에 영향을 주지 않도록 무시
console.error('[Tracking] CompleteRegistration failed:', error);
}
}
// 영상 상태 확인 API
export async function getVideoStatus(taskId: string): Promise<VideoStatusResponse> {
const response = await authenticatedFetch(`${API_URL}/video/status/${taskId}`, {
@ -609,12 +431,12 @@ export async function getVideoComments(videoId: string, page: number = 1, pageSi
return response.json();
}
// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
export async function postVideoComment(videoId: string, content: string, parentId?: number): Promise<CommentItem> {
// 댓글 작성
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, parent_id: parentId ?? null }),
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
});
if (!response.ok) {
@ -656,10 +478,18 @@ export async function uploadImages(
imageUrls: ImageUrlItem[],
files: File[]
): Promise<ImageUploadResponse> {
return uploadImagesSequentially(imageUrls, files, postImageUpload);
const formData = new FormData();
// URL 이미지들을 images_json으로 전달
if (imageUrls.length > 0) {
formData.append('images_json', JSON.stringify(imageUrls));
}
async function postImageUpload(formData: FormData): Promise<ImageUploadResponse> {
// 파일들을 files로 전달
files.forEach((file) => {
formData.append('files', file);
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT);
@ -670,21 +500,19 @@ async function postImageUpload(formData: FormData): Promise<ImageUploadResponse>
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 413) {
throw new Error('이미지 파일이 너무 커서 업로드할 수 없습니다. 더 작은 이미지를 선택해주세요.');
}
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;
} finally {
clearTimeout(timeoutId);
}
}
@ -936,27 +764,17 @@ function clearAllLocalData() {
// 로그아웃
export async function logout(): Promise<void> {
const refreshToken = getRefreshToken();
try {
if (refreshToken) {
const response = await authenticatedFetch(`${API_URL}/user/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
// 응답과 관계없이 로컬 데이터 전체 삭제
clearAllLocalData();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
} finally {
// 응답과 관계없이 로컬 데이터 전체 삭제
clearAllLocalData();
}
}
// 모든 기기에서 로그아웃
export async function logoutAll(): Promise<void> {
@ -1026,16 +844,6 @@ export interface AccommodationSearchItem {
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 {
count: number;
items: AccommodationSearchItem[];
@ -1092,42 +900,6 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
}
}
// 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string, category = '', officialSiteUrl?: string): 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,
official_site_url: officialSiteUrl?.trim() || null,
}),
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 처리
// ============================================

View File

@ -1,305 +0,0 @@
export const MAX_UPLOAD_IMAGE_BYTES = 2 * 1024 * 1024;
export const MAX_UPLOAD_IMAGE_DIMENSION = 2048;
export const MAX_FALLBACK_UPLOAD_IMAGE_BYTES = 15 * 1024 * 1024;
const OUTPUT_MIME_TYPE = 'image/jpeg';
const OUTPUT_EXTENSION = 'jpg';
const BACKEND_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif']);
const BACKEND_IMAGE_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
'image/heif',
'image/heic-sequence',
'image/heif-sequence',
]);
interface ImageDimensions {
width: number;
height: number;
}
interface DecodedImage extends ImageDimensions {
source: CanvasImageSource;
dispose: () => void;
}
interface CompressionAttempt {
scale: number;
quality: number;
}
const COMPRESSION_ATTEMPTS: CompressionAttempt[] = [
{ scale: 1, quality: 0.86 },
{ scale: 1, quality: 0.74 },
{ scale: 1, quality: 0.62 },
{ scale: 1, quality: 0.5 },
{ scale: 0.85, quality: 0.7 },
{ scale: 0.7, quality: 0.65 },
{ scale: 0.55, quality: 0.6 },
{ scale: 0.4, quality: 0.55 },
];
export function calculateTargetDimensions(
width: number,
height: number,
maxDimension = MAX_UPLOAD_IMAGE_DIMENSION
): ImageDimensions {
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
!Number.isFinite(maxDimension) ||
width <= 0 ||
height <= 0 ||
maxDimension <= 0
) {
throw new Error('Image dimensions must be positive finite numbers.');
}
const scale = Math.min(1, maxDimension / Math.max(width, height));
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}
export function shouldCompressImage(
fileSize: number,
dimensions: ImageDimensions,
maxBytes = MAX_UPLOAD_IMAGE_BYTES,
maxDimension = MAX_UPLOAD_IMAGE_DIMENSION
): boolean {
return (
fileSize > maxBytes ||
dimensions.width > maxDimension ||
dimensions.height > maxDimension
);
}
export function buildCompressedFileName(fileName: string): string {
const extensionIndex = fileName.lastIndexOf('.');
const baseName = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName;
return `${baseName || 'image'}.${OUTPUT_EXTENSION}`;
}
function getFileExtension(fileName: string): string {
const extensionIndex = fileName.lastIndexOf('.');
return extensionIndex >= 0 ? fileName.slice(extensionIndex + 1).toLowerCase() : '';
}
export function isImageInputFile(file: Pick<File, 'name' | 'type'>): boolean {
return file.type.startsWith('image/') || BACKEND_IMAGE_EXTENSIONS.has(getFileExtension(file.name));
}
export function canUploadOriginalImage(file: Pick<File, 'name' | 'type'>): boolean {
const hasAllowedExtension = BACKEND_IMAGE_EXTENSIONS.has(getFileExtension(file.name));
const hasAllowedMimeType = file.type === '' || BACKEND_IMAGE_MIME_TYPES.has(file.type.toLowerCase());
return hasAllowedExtension && hasAllowedMimeType;
}
export function canUploadOriginalAfterCompressionFailure(
fileSize: number,
maxBytes = MAX_FALLBACK_UPLOAD_IMAGE_BYTES
): boolean {
return fileSize <= maxBytes;
}
function useOriginalOrThrow(file: File, cause: unknown): File {
if (!canUploadOriginalImage(file)) {
throw new Error(
`${file.name} 파일을 지원하는 이미지 형식으로 변환할 수 없습니다. ` +
'JPEG, PNG 또는 WebP로 변환한 뒤 다시 시도해주세요.',
{ cause }
);
}
if (!canUploadOriginalAfterCompressionFailure(file.size)) {
throw new Error(
`${file.name} 파일은 15 MB보다 크고 브라우저에서 크기를 줄일 수 없습니다. ` +
'JPEG나 PNG로 변환하거나 더 작은 이미지를 선택해주세요.',
{ cause }
);
}
console.warn(`Image compression was unavailable for ${file.name}; uploading the original.`, cause);
return file;
}
function decodeWithImageElement(file: File): Promise<DecodedImage> {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
resolve({
source: image,
width: image.naturalWidth,
height: image.naturalHeight,
dispose: () => URL.revokeObjectURL(objectUrl),
});
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error(`Unable to decode image: ${file.name}`));
};
image.src = objectUrl;
});
}
async function decodeImage(file: File): Promise<DecodedImage> {
if (typeof createImageBitmap === 'function') {
const bitmap = await createImageBitmap(file);
return {
source: bitmap,
width: bitmap.width,
height: bitmap.height,
dispose: () => bitmap.close(),
};
}
return decodeWithImageElement(file);
}
function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('The browser could not compress the image.'));
}
},
OUTPUT_MIME_TYPE,
quality
);
});
}
export async function compressImageForUpload(file: File): Promise<File> {
if (!isImageInputFile(file)) {
throw new Error(`지원하지 않는 이미지 파일입니다: ${file.name}`);
}
let decoded: DecodedImage;
try {
decoded = await decodeImage(file);
} catch (error) {
return useOriginalOrThrow(file, error);
}
try {
if (
canUploadOriginalImage(file) &&
!shouldCompressImage(file.size, {
width: decoded.width,
height: decoded.height,
})
) {
return file;
}
const baseDimensions = calculateTargetDimensions(decoded.width, decoded.height);
let smallestBlob: Blob | null = null;
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('The browser does not support image compression.');
}
for (const attempt of COMPRESSION_ATTEMPTS) {
canvas.width = Math.max(1, Math.round(baseDimensions.width * attempt.scale));
canvas.height = Math.max(1, Math.round(baseDimensions.height * attempt.scale));
// JPEG has no alpha channel. A white background avoids transparent pixels
// becoming black when PNG/WebP images are converted.
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(decoded.source, 0, 0, canvas.width, canvas.height);
const blob = await canvasToBlob(canvas, attempt.quality);
if (!smallestBlob || blob.size < smallestBlob.size) {
smallestBlob = blob;
}
if (blob.size <= MAX_UPLOAD_IMAGE_BYTES) {
return new File([blob], buildCompressedFileName(file.name), {
type: blob.type || OUTPUT_MIME_TYPE,
lastModified: file.lastModified,
});
}
}
} catch (error) {
return useOriginalOrThrow(file, error);
}
const measuredSize = smallestBlob?.size ?? file.size;
throw new Error(
`${file.name} 파일을 ${Math.round(MAX_UPLOAD_IMAGE_BYTES / (1024 * 1024))} MB 이하로 ` +
`압축할 수 없습니다(압축 결과: ${Math.ceil(measuredSize / (1024 * 1024))} MB).`
);
} finally {
decoded.dispose();
}
}
export const MAX_PREVIEW_DIMENSION = 400;
export const PREVIEW_QUALITY = 0.7;
/**
* 그리드 미리보기 전용 축소 썸네일을 만든다.
*
* 업로드에는 원본 File 을 그대로 쓰고, 여기서 만든 objectURL 은 <img> 에만 쓰인다.
* 핵심은 finally 의 dispose() 로, createImageBitmap 경로에서 원본 비트맵을 즉시
* 해제해 메모리에 원본 크기 비트맵이 남지 않게 한다.
*
* 실패해도 예외를 던지지 않고 null 을 돌려준다. 호출부가 자리표시자를 그린다.
*/
export async function createPreviewThumbnail(file: File): Promise<string | null> {
if (!isImageInputFile(file)) {
return null;
}
let decoded: DecodedImage;
try {
decoded = await decodeImage(file);
} catch (error) {
console.warn(`미리보기 썸네일 디코딩 실패: ${file.name}`, error);
return null;
}
try {
const { width, height } = calculateTargetDimensions(
decoded.width,
decoded.height,
MAX_PREVIEW_DIMENSION
);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
// JPEG 에는 알파 채널이 없다. 투명 픽셀이 검게 나오지 않도록 흰 배경을 깐다.
context.fillStyle = '#ffffff';
context.fillRect(0, 0, width, height);
context.drawImage(decoded.source, 0, 0, width, height);
const blob = await canvasToBlob(canvas, PREVIEW_QUALITY);
return URL.createObjectURL(blob);
} catch (error) {
console.warn(`미리보기 썸네일 생성 실패: ${file.name}`, error);
return null;
} finally {
decoded.dispose();
}
}

View File

@ -1,49 +0,0 @@
import type { ImageItem } from '../types/api';
/**
* 같은 파일을 가리키는지 판별하기 위한 키.
*
* 브라우저는 동일한 파일에 대해 name/size/lastModified 를 항상 동일하게 주므로,
* 내용 해시를 계산하지 않고도 비용 없이 중복을 걸러낼 수 있다.
*/
export function getFileIdentity(file: Pick<File, 'name' | 'size' | 'lastModified'>): string {
return `${file.name}|${file.size}|${file.lastModified}`;
}
export interface FileDedupResult {
/** 목록에 새로 넣어야 할 파일 */
newFiles: File[];
/** 중복이라 제외된 파일 수 */
duplicateCount: number;
}
/**
* 이미 목록에 있는 파일, 그리고 이번 배치 안에서 서로 겹치는 파일을 걸러낸다.
*
* url 타입 이미지(크롤링으로 가져온 원격 이미지)는 로컬 파일과 비교할 수단이
* 없으므로 비교 대상에서 제외한다.
*/
export function splitDuplicateFiles(existing: ImageItem[], incoming: File[]): FileDedupResult {
const seen = new Set<string>();
for (const item of existing) {
if (item.type === 'file') {
seen.add(getFileIdentity(item.file));
}
}
const newFiles: File[] = [];
let duplicateCount = 0;
for (const file of incoming) {
const identity = getFileIdentity(file);
if (seen.has(identity)) {
duplicateCount += 1;
continue;
}
seen.add(identity);
newFiles.push(file);
}
return { newFiles, duplicateCount };
}

View File

@ -1,83 +0,0 @@
import type { ImageUploadResponse, ImageUrlItem } from '../types/api';
import { compressImageForUpload } from './imageCompression.ts';
export const MAX_IMAGES_PER_UPLOAD_TASK = 100;
export type ImageUploadRequest = (formData: FormData) => Promise<ImageUploadResponse>;
export type ImageCompressor = (file: File) => Promise<File>;
interface ImageUploadFormOptions {
imageUrls?: ImageUrlItem[];
file?: File;
taskId?: string;
finalize?: boolean;
}
export function createImageUploadFormData({
imageUrls = [],
file,
taskId,
finalize,
}: ImageUploadFormOptions): FormData {
const formData = new FormData();
if (imageUrls.length > 0) {
formData.append('images_json', JSON.stringify(imageUrls));
}
if (taskId) {
formData.append('task_id', taskId);
}
if (finalize !== undefined) {
formData.append('finalize', String(finalize));
}
if (file) {
formData.append('files', file);
}
return formData;
}
export async function uploadImagesSequentially(
imageUrls: ImageUrlItem[],
files: File[],
sendRequest: ImageUploadRequest,
compressFile: ImageCompressor = compressImageForUpload
): Promise<ImageUploadResponse> {
const totalImageCount = imageUrls.length + files.length;
if (totalImageCount > MAX_IMAGES_PER_UPLOAD_TASK) {
throw new Error(`이미지는 최대 ${MAX_IMAGES_PER_UPLOAD_TASK}장까지 업로드할 수 있습니다.`);
}
if (files.length === 0) {
return sendRequest(createImageUploadFormData({ imageUrls }));
}
let taskId: string | undefined;
let finalResponse: ImageUploadResponse | undefined;
for (let index = 0; index < files.length; index += 1) {
// Compress and upload one source at a time. This prevents both decoded
// image buffers and multipart request bodies from accumulating in memory.
const compressedFile = await compressFile(files[index]);
const isFirstRequest = index === 0;
const isLastRequest = index === files.length - 1;
const formData = createImageUploadFormData({
imageUrls: isFirstRequest ? imageUrls : [],
file: compressedFile,
taskId,
finalize: isLastRequest,
});
finalResponse = await sendRequest(formData);
if (!finalResponse.task_id) {
throw new Error('이미지 업로드 응답에 작업 ID가 없습니다.');
}
if (taskId && finalResponse.task_id !== taskId) {
throw new Error('이미지 업로드 작업 ID가 요청 사이에 변경되었습니다. 다시 시도해주세요.');
}
taskId = finalResponse.task_id;
}
// files.length > 0 guarantees that the loop produced a response.
return finalResponse as ImageUploadResponse;
}

View File

@ -1,29 +0,0 @@
/**
* 기기의 네이티브 공유 시트를 열고 요청을 처리했는지 반환합니다.
*
* 긴 `text`를 넣으면 카카오 등이 URL을 미리보기가 아니라 본문 텍스트로만 보냅니다.
* 제목·설명은 OG 페이지(`og:title`, `og:description`)에 두고, 여기에는 url(과 짧은 title)만 넘깁니다.
*
* 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주합니다.
*/
export async function tryNativeShare(data: ShareData): Promise<boolean> {
if (typeof navigator.share !== 'function') {
return false;
}
try {
await navigator.share(data);
return true;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return true;
}
return false;
}
}
/** API 서버가 제공하는 영상별 Open Graph 공유 페이지 URL을 만듭니다. */
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`;
}