Compare commits

..

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

61 changed files with 14256 additions and 13760 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, '.'),
}
}
};
});

12585
index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@
<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">
@ -162,6 +162,7 @@
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-[#121a1d]">
<div id="root"></div>

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

@ -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

@ -334,14 +334,14 @@ const App: React.FC = () => {
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string, category: string) => {
const handleManualInput = async (businessName: string, address: string) => {
setAfterLoadTarget('generation_flow');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address, category);
const data = await marketingAnalysis(businessName, address);
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
@ -507,6 +507,7 @@ const App: React.FC = () => {
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
onNext={() => scrollToSection(1)}
error={error}
scrollProgress={scrollProgress}

View File

@ -5,7 +5,7 @@ import CitySelectModal, { REGIONS } from './CitySelectModal';
interface BusinessNameInputModalProps {
onClose: () => void;
onSubmit: (businessName: string, address: string, category: string) => void;
onSubmit: (businessName: string, address: string) => void;
}
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
@ -13,7 +13,6 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const [businessName, setBusinessName] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [detailAddress, setDetailAddress] = useState('');
const [category, setCategory] = useState('');
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
useEffect(() => {
@ -42,7 +41,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const handleSubmit = () => {
if (!isValid) return;
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
onSubmit(businessName.trim(), fullAddress, category.trim());
onSubmit(businessName.trim(), fullAddress);
onClose();
};
@ -61,7 +60,6 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
<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">
@ -105,19 +103,6 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
/>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelCategory')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderCategory')}
value={category}
onChange={e => setCategory(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={30}
/>
</div>
<div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')}
@ -128,7 +113,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
onClick={handleSubmit}
disabled={!isValid}
>
{t('landing.hero.manualAnalyzeButton')}
{t('landing.hero.analyzeButton')}
</button>
</div>
</div>

View File

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

View File

@ -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

@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
import { useSearchHistory } from './SearchHistory/useSearchHistory';
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from './BusinessNameInputModal';
@ -18,27 +18,10 @@ const isValidUrl = (string: string): boolean => {
}
};
// URL 형태로 판단되는 입력: 프로토콜/www. 시작 또는 도메인 형태(naver.me/xxx 등)
const isUrlLike = (value: string): boolean => {
const trimmed = value.trim();
return /^(https?:\/\/|www\.)/i.test(trimmed)
|| /^[a-z0-9-]+(\.[a-z0-9-]+)+([/?#]|$)/i.test(trimmed);
};
const sanitizeUrlInput = (value: string): string => {
return value.replace(/[^\x00-\x7F]/g, '');
};
// 텍스트 안에 포함된 첫 번째 URL을 추출 (없으면 null)
const extractUrl = (text: string): string | null => {
const match = text.match(/(https?:\/\/|www\.)[^\s]+/i);
return match ? match[0] : null;
};
interface SearchInputFormProps {
onAnalyze?: (value: string, type: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
onManualInput?: (businessName: string, address: string) => void;
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
onManualButtonClick?: () => void;
error?: string | null;
@ -53,7 +36,8 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType] = useState<SearchType>('name');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState('');
@ -63,12 +47,16 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory('name');
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
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);
}
@ -77,8 +65,19 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
];
const getPlaceholder = () =>
searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName');
const getGuideText = () =>
searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName');
const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim()) {
if (!query.trim() || searchType !== 'name') {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
@ -94,12 +93,11 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
} finally {
setIsAutocompleteLoading(false);
}
}, []);
}, [searchType]);
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
const cleanTitle = cleanSearchTitle(item.title);
setInputValue(cleanTitle);
setSelectedItem({ ...item, title: cleanTitle });
setInputValue(item.title.replace(/<[^>]*>/g, ''));
setSelectedItem(item);
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
@ -135,20 +133,18 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
};
const handleStart = () => {
const trimmed = inputValue.trim();
if (!trimmed) {
setLocalError(t('landing.hero.errorInputRequired'));
if (!inputValue.trim()) {
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
return;
}
if (isValidUrl(trimmed)) {
setLocalError('');
onAnalyze?.(trimmed, 'url');
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
setLocalError(t('landing.hero.errorInvalidUrl'));
return;
}
setLocalError('');
if (selectedItem && onAutocomplete) {
if (searchType === 'name' && selectedItem && onAutocomplete) {
onAutocomplete({
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
@ -159,67 +155,6 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
}
};
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();
@ -233,30 +168,84 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
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 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> */}
{/* 입력 필드 */}
<div className="hero-input-container" ref={autocompleteRef}>
<input
type="text"
value={inputValue}
onChange={(e) => handleChange(e.target.value)}
onPaste={handlePaste}
onChange={(e) => {
let value = e.target.value;
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
}
setInputValue(value);
setSelectedItem(null);
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
if (searchType === 'name') {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
}
}}
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
if (localError) setLocalError('');
}
}}
onFocus={() => {
setIsFocused(true);
if (autocompleteResults.length > 0) setShowAutocomplete(true);
if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true);
openHistory(inputValue);
}}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onKeyDown={handleKeyDown}
placeholder={t('landing.hero.placeholderCombined')}
placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
@ -266,8 +255,12 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
items={filteredHistory}
onSelect={(item) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
if (item.type === 'name') {
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
}
}}
@ -280,7 +273,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
)}
{/* 자동완성 결과 */}
{showAutocomplete && (
{showAutocomplete && searchType === 'name' && (
<div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
@ -320,7 +313,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
)}
</div>
<span className="hero-input-hint">{t('landing.hero.guideCombined')}</span>
<span className="hero-input-hint">{getGuideText()}</span>
{error && <p className="hero-error">{error}</p>}
@ -328,21 +321,17 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
{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>
<button type="button" className="hero-manual-button" onClick={handleManualButtonClick}>
{t('landing.hero.searchTypeManual')}
</button>
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
{!onManualButtonClick && isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category) => {
onSubmit={(businessName, address) => {
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category);
onManualInput?.(businessName, address);
}}
/>
)}

View File

@ -157,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(() => {
@ -374,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);
@ -434,7 +432,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
setUploadVideoTitle('');
setUploadChannelName('');
setUploadIsScheduled(false);
setUploadScheduledAt(null);
};
const handleClose = () => {
@ -464,7 +461,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
youtubeUrl={uploadYoutubeUrl}
errorMessage={uploadErrorMessage}
isScheduled={uploadIsScheduled}
scheduledAt={uploadScheduledAt}
onGoToCalendar={onGoToCalendar}
/>
);

View File

@ -46,6 +46,15 @@ export const tutorialSteps: TutorialStepDef[] = [
{
key: TUTORIAL_KEYS.LANDING,
hints: [
{
targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title',
@ -56,10 +65,10 @@ export const tutorialSteps: TutorialStepDef[] = [
variant: 'bubble',
},
{
targetSelector: '.hero-manual-card-title',
targetSelector: '.hero-manual-button',
titleKey: 'tutorial.landing.manual.title',
descriptionKey: 'tutorial.landing.manual.desc',
position: 'top',
position: 'bottom',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',

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

@ -94,7 +94,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
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) => {
@ -120,12 +120,12 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: video?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
description: t('videoDetail.kakaoDescription', { region: video?.region ?? '' }),
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
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: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
buttons: [{ title: '영상 보기', link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
@ -222,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;
};
@ -231,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>
@ -293,7 +293,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<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">
@ -303,21 +303,21 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<rect width="20" height="20" rx="4" fill="#FEE500"/>
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
</svg>
{t('videoDetail.shareKakao')}
</button>
{/* 페이스북 */}
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
</svg>
{t('videoDetail.shareFacebook')}
</button>
{/* X (트위터) */}
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
{t('videoDetail.shareTwitter')}
X ()
</button>
{/* URL 복사 */}
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
@ -325,7 +325,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
{copied ? '복사됨!' : 'URL 복사'}
</button>
</div>
)}
@ -335,7 +335,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
{/* 댓글 섹션 */}
<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>
@ -344,16 +344,16 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<div className="video-detail-comment-profile">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
alt={t('videoDetail.changeAvatarTitle')}
alt="아바타 변경"
className="video-detail-comment-avatar"
onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }}
title={t('videoDetail.changeAvatarTitle')}
title="클릭하여 아바타 변경"
/>
<input
className="video-detail-nickname-input"
type="text"
placeholder={t('videoDetail.nicknamePlaceholder')}
placeholder="작성자 이름"
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
@ -365,7 +365,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}
@ -388,12 +388,12 @@ 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) => (
@ -405,7 +405,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
/>
<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)}
@ -417,7 +417,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(c.id)}
>
{t('videoDetail.deleteComment')}
</button>
)}
</div>
@ -434,7 +434,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
/>
<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)}
@ -446,7 +446,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(r.id)}
>
{t('videoDetail.deleteComment')}
</button>
)}
</div>
@ -467,7 +467,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,7 +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." },
"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." },
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
},
@ -47,18 +42,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 +60,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." },
@ -128,7 +123,7 @@
"privacyPrivate": "Private",
"publishTimeLabel": "Publish Time",
"publishNow": "Publish Now",
"publishSchedule": "Schedule",
"publishSchedule": "Schedule (Coming Soon)",
"footerNote": "",
"footerNoteLink": "",
"cancel": "Cancel",
@ -142,29 +137,7 @@
"uploadFailed": "Upload failed.",
"autoSeoTitle": "Auto-generating... (3060 sec)",
"autoSeoDescription": "Auto-generating... (3060 sec)",
"autoSeoTags": "Auto-generating... (3060 sec)",
"youtubeCallback": {
"connecting": "Connecting YouTube...",
"pleaseWait": "Please wait a moment.",
"failed": "Connection Failed",
"errorAuthCancelled": "Google authentication was cancelled or failed.",
"errorNoCode": "Authorization code was not received.",
"errorProcessing": "An error occurred while processing the YouTube connection."
},
"connectError": {
"title": "{{platform}} Connection Failed",
"defaultError": "An unknown error occurred.",
"detail": "Please try again. If the problem persists, contact customer support.",
"retry": "Try Again"
},
"connectSuccess": {
"title": "{{name}} Connected",
"messageWithChannel": "{{platform}} account has been successfully connected.",
"messageWithoutChannel": "Account has been successfully connected.",
"verified": "Verified",
"countdown": "Redirecting in {{count}} seconds...",
"goNow": "Go Now"
}
"autoSeoTags": "Auto-generating... (3060 sec)"
},
"upload": {
"title": "YouTube Upload",
@ -181,34 +154,33 @@
"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",
"testDataLoading": "Loading...",
"testData": "Test Data",
"testDataLoadFailed": "Failed to load test data.",
"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",
"searchTypeManual": "Manual Input",
"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)"
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
},
"welcome": {
"title": "Welcome to ADO2.AI",
@ -238,6 +210,8 @@
"guideBusinessName": "Search by business name to retrieve information.",
"searchButton": "Search",
"searching": "Searching...",
"testDataLoading": "Loading...",
"testData": "Test Data",
"searchTypeManual": "Manual Input",
"errorSelectFromList": "Please select a business from the list.",
"errorUrlRequired": "Please enter a URL.",
@ -248,12 +222,10 @@
"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)"
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
},
"assetManagement": {
"title": "Brand Assets",
@ -269,7 +241,7 @@
"back": "Go Back",
"loadMore": "Load more",
"uploadFailed": "Image upload failed.",
"uploading": "Uploading... (30 sec 1 min)",
"uploading": "Uploading... (30 sec 2 min)",
"nextStep": "Next Step"
},
"soundStudio": {
@ -282,7 +254,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",
@ -296,8 +268,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... (3060 sec)",
"generatingSong": "Generating music... (12 min)",
"generatingLyrics": "Generating lyrics...\n(3060 sec)",
"generatingSong": "Generating music...\n(12 min)",
"songQueued": "Song generation queued...",
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
"lyricGenerationFailed": "Lyrics generation request failed.",
@ -311,7 +283,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",
@ -365,7 +337,7 @@
"noLyricsBGM": "Background music is generated without lyrics.",
"downloading": "Downloading...",
"download": "Download",
"uploadToSocial": "Upload"
"uploadToSocial": "Upload to Social"
},
"dashboard": {
"title": "Dashboard",
@ -524,7 +496,7 @@
"delete": "Delete",
"previous": "Previous",
"next": "Next",
"uploadToSocial": "Upload",
"uploadToSocial": "Upload to social media",
"sortLatest": "Latest",
"sortOldest": "Oldest",
"sortLikes": "Most Liked",
@ -553,9 +525,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 ",
@ -596,7 +568,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?",
@ -608,33 +580,7 @@
},
"loginPrompt": {
"title": "Login Required",
"loginBtn": "Login"
},
"videoDetail": {
"dateFormat": "{{month}}/{{day}}/{{year}}",
"kakaoDefaultTitle": "ADO2 Video",
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
"kakaoButtonTitle": "Watch Video",
"deletedComment": "(This comment has been deleted.)",
"closeAriaLabel": "Close",
"share": "Share",
"copied": "Copied!",
"shareKakao": "KakaoTalk",
"shareFacebook": "Facebook",
"shareTwitter": "X (Twitter)",
"copyUrl": "Copy URL",
"commentsTitle": "Comments",
"changeAvatarTitle": "Click to change avatar",
"nicknamePlaceholder": "Author name",
"commentPlaceholder": "Write a comment...",
"commentLoginRequired": "Please log in to write a comment",
"commentSubmitting": "Submitting",
"commentSubmit": "Submit",
"noComments": "No comments yet.",
"anonymous": "Anonymous",
"deleteComment": "Delete",
"loadMoreComments": "Load more comments",
"loadingComments": "Loading..."
"loginBtn": "Login with Kakao"
},
"app": {
"loginProcessing": "Processing login...",

View File

@ -1,10 +1,4 @@
{
"wizardSteps": {
"brandAnalysis": "분석",
"asset": "이미지",
"sound": "사운드",
"video": "영상"
},
"header": {
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
"start": "시작하기",
@ -34,7 +28,8 @@
"finish": "완료",
"landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
},
@ -142,29 +137,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,33 +154,33 @@
"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": "스크롤하여 더 보기",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"searching": "검색 중...",
"searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기",
"manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.",
"manualModalTitle": "업체 정보 직접 입력",
"manualModalSubtitle": "네이버에 등록되지 않은 업체도 아래 정보만 입력하면 가사·음악·영상 제작까지 할 수 있어요.",
"manualAnalyzeButton": "시작하기",
"searchTypeManual": "직접 입력",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
},
"welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.",
@ -237,6 +210,8 @@
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기",
"searching": "검색 중...",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"searchTypeManual": "직접 입력",
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
"errorUrlRequired": "URL을 입력해주세요.",
@ -247,12 +222,10 @@
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
},
"assetManagement": {
"title": "브랜드 에셋",
@ -268,7 +241,7 @@
"back": "뒤로가기",
"loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중 (30~60초 소요)",
"uploading": "업로드 중 (30~2분 소요)",
"nextStep": "다음 단계"
},
"soundStudio": {
@ -364,7 +337,7 @@
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
"downloading": "다운로드 중...",
"download": "다운로드",
"uploadToSocial": "업로드"
"uploadToSocial": "소셜 채널 업로드"
},
"dashboard": {
"title": "대시보드",
@ -523,7 +496,7 @@
"delete": "삭제",
"previous": "이전",
"next": "다음",
"uploadToSocial": "업로드",
"uploadToSocial": "소셜 미디어에 업로드",
"sortLatest": "최신순",
"sortOldest": "오래된순",
"sortLikes": "좋아요 많은순",
@ -595,7 +568,7 @@
"loading": "불러오는 중...",
"noResults": "최근 결과 없음",
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
"myContents": "내 콘텐츠",
"ado2Contents": "ADO2 콘텐츠",
"cancel": "취소",
"retry": "재시도",
"cancelConfirm": "예약을 취소하시겠습니까?",
@ -609,32 +582,6 @@
"title": "로그인이 필요합니다.",
"loginBtn": "로그인"
},
"videoDetail": {
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
"kakaoDefaultTitle": "ADO2 영상",
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
"kakaoButtonTitle": "영상 보기",
"deletedComment": "(삭제된 댓글입니다.)",
"closeAriaLabel": "닫기",
"share": "공유하기",
"copied": "복사됨!",
"shareKakao": "카카오톡",
"shareFacebook": "페이스북",
"shareTwitter": "X (트위터)",
"copyUrl": "URL 복사",
"commentsTitle": "댓글",
"changeAvatarTitle": "클릭하여 아바타 변경",
"nicknamePlaceholder": "작성자 이름",
"commentPlaceholder": "댓글을 입력하세요...",
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
"commentSubmitting": "작성 중",
"commentSubmit": "작성",
"noComments": "아직 댓글이 없습니다.",
"anonymous": "익명",
"deleteComment": "삭제",
"loadMoreComments": "댓글 더 보기",
"loadingComments": "불러오는 중..."
},
"app": {
"loginProcessing": "로그인 처리 중...",
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",

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);
};

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;
@ -182,14 +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}
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

@ -26,7 +26,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
@ -44,23 +43,15 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
};
const getImageSrc = (item: ImageItem): string => {
return item.type === 'url' ? item.preview_url : item.preview;
return item.type === 'url' ? item.url : item.preview;
};
const handleNextWithUpload = async () => {
if (imageList.length === 0) return;
setIsUploading(true);
setUploadProgress(0);
setUploadError(null);
const interval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 99) return prev;
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
});
}, 200);
try {
const urlImages: ImageUrlItem[] = imageList
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
@ -72,23 +63,13 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
if (urlImages.length > 0 || fileImages.length > 0) {
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);
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
@ -123,29 +104,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
return (
<main className="asset-page">
{isUploading && (
<div className="asset-upload-overlay">
<div className="asset-upload-overlay-content">
<div className="loading-spinner">
<div className="loading-ring"></div>
<div className="loading-dot">
<div className="loading-dot-inner"></div>
</div>
</div>
<p className="comp2-loading-text">{t('assetManagement.uploading')}</p>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(uploadProgress)}%</span>
</div>
</div>
</div>
)}
{/* Fixed Header - 뒤로가기 버튼 */}
<div className="asset-sticky-header">
{onBack && (
@ -187,7 +145,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<img
src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
referrerPolicy="no-referrer"
/>
{item.type === 'file' && (
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>

View File

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

View File

@ -409,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>
@ -476,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>
@ -500,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>
))}

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,12 +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 { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, marketingAnalysis, 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';
const WIZARD_STEP_KEY = 'castad_wizard_step';
const ACTIVE_ITEM_KEY = 'castad_active_item';
@ -52,7 +51,7 @@ interface BusinessInfo {
interface GenerationFlowProps {
onHome: () => void;
initialActiveItem?: string;
initialImageList?: ImageListItem[];
initialImageList?: string[];
businessInfo?: BusinessInfo;
initialAnalysisData?: CrawlingResponse | null;
}
@ -169,9 +168,9 @@ 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());
@ -183,7 +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) {
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;
@ -263,13 +262,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string, category: string) => {
const handleManualInput = async (businessName: string, address: string) => {
goToWizardStep(-1);
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
const data = await marketingAnalysis(businessName, address, category);
const data = await marketingAnalysis(businessName, address);
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
@ -289,6 +288,19 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}
};
// 테스트용 랜덤 m_id 생성 (99 ~ 300)
const generateRandomMId = (): number => {
return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
};
// 테스트 데이터로 브랜드 분석 페이지 이동
const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, m_id: generateRandomMId(), _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
goToWizardStep(0); // 브랜드 분석 결과로
};
// 언어 변경 시 테스트 데이터 다시 로드
useEffect(() => {
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
@ -301,7 +313,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));
})
@ -427,6 +439,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
error={analysisError}
/>
);
@ -483,7 +496,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
businessInfo={currentBusinessInfo}
imageTaskId={imageTaskId}
mId={analysisData?.m_id ?? 0}
industry={analysisData?.industry ?? ''}
videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress}
onGoToPayment={handleGoToPayment}
@ -539,22 +551,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>
);
@ -646,7 +648,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
)}
{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';
@ -279,14 +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}
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

@ -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,46 +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') ||
@ -95,7 +54,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo,
imageTaskId,
mId,
industry = '',
onStatusChange,
videoGenerationStatus = 'idle',
videoGenerationProgress = 0,
@ -105,8 +63,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 +75,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 +128,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);
@ -232,13 +158,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 }),
});
@ -259,6 +192,41 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
}
};
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 +286,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
return;
}
// 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거
localStorage.removeItem(SONG_GENERATION_KEY);
setStatus('generating_lyric');
setErrorMessage(null);
setStatusMessage(t('soundStudio.generatingLyrics'));
@ -331,9 +296,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 +306,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 +350,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 }),
});
@ -437,24 +402,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 +495,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 +505,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{genre}
</button>
))}
<div className="genre-btn-placeholder"></div>
</div>
</div>
</div>
@ -667,8 +615,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 +633,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,18 +1,43 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest } from '../../utils/api';
import { CrawlingResponse } from '../../types/api';
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
// 환경변수에서 테스트 모드 확인
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) => void;
onManualInput?: (businessName: string, address: 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, onManualInput, onTestData, error }) => {
const { t, i18n } = useTranslation();
const [isLoadingTest, setIsLoadingTest] = useState(false);
const handleTestData = async () => {
if (!onTestData) return;
setIsLoadingTest(true);
try {
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
const response = await fetch(jsonFile);
const data: CrawlingResponse = await response.json();
onTestData(data);
} catch (err) {
console.error('테스트 데이터 로드 실패:', err);
} finally {
setIsLoadingTest(false);
}
};
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" />
</div>
@ -24,6 +49,17 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
error={error}
/>
</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

@ -2,6 +2,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest, isLoggedIn } 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';
@ -10,6 +11,9 @@ import LoginPromptModal from '../../components/LoginPromptModal';
import SearchInputForm from '../../components/SearchInputForm';
import { SearchType } from '../../components/SearchInputForm';
// 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
// Orb configuration with movement zones to prevent overlap
interface OrbConfig {
id: string;
@ -35,16 +39,19 @@ const orbConfigs: OrbConfig[] = [
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
onManualInput?: (businessName: string, address: string) => void;
onTestData?: (data: CrawlingResponse) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number;
}
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
const { t } = useTranslation();
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation();
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
const [testError, setTestError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]);
const tutorial = useTutorial();
@ -59,6 +66,22 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
}
}, []);
// 테스트 데이터 로드 핸들러
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 {
setTestError(t('landing.hero.testDataLoadFailed'));
} finally {
setIsLoadingTest(false);
}
};
// Orb 랜덤 이동 애니메이션
useEffect(() => {
const moveOrb = (orb: HTMLDivElement, index: number) => {
@ -156,7 +179,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setIsLoginPromptOpen(true);
}
}}
error={externalError || null}
error={externalError || testError || null}
/>
</div>
@ -170,6 +193,13 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
</div>
</button>
{/* 테스트 버튼 */}
{isTestPage && onTestData && (
<button onClick={handleTestData} disabled={isLoadingTest} className="test-data-button">
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
</button>
)}
{tutorial.isActive && !isManualModalOpen && (
<TutorialOverlay
hints={tutorial.hints}
@ -184,10 +214,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category) => {
onSubmit={(businessName, address) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category);
onManualInput?.(businessName, address);
}}
/>
)}

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,8 +47,7 @@ export interface CrawlingResponse {
// URL 이미지 (서버에서 가져온 이미지)
export interface UrlImage {
type: 'url';
url: string; // 업로드용 원본 URL
preview_url: string; // 미리보기용 CDN 리사이즈 URL
url: string;
}
// 업로드된 파일 이미지
@ -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;
}
@ -423,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

@ -844,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[];
@ -911,7 +901,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
}
// 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise<CrawlingResponse> {
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
@ -921,7 +911,7 @@ export async function marketingAnalysis(storeName: string, address: string, cate
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ store_name: storeName, address, category }),
body: JSON.stringify({ store_name: storeName, address }),
signal: controller.signal,
});