인디케이터 추가 및 css코드 분리

main
김성경 2026-06-19 16:03:06 +09:00
parent 429c8d4708
commit 888ac4fa5e
45 changed files with 13009 additions and 14040 deletions

View File

@ -1,24 +0,0 @@
# 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

@ -1,233 +0,0 @@
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

@ -1,20 +0,0 @@
<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

@ -1,159 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ADO2 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

@ -1,15 +0,0 @@
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

@ -1,5 +0,0 @@
{
"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

@ -1,22 +0,0 @@
{
"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

@ -1,29 +0,0 @@
{
"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

@ -1,30 +0,0 @@
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

@ -1,23 +0,0 @@
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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,244 +0,0 @@
{
"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

@ -1,244 +0,0 @@
{
"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

@ -60,6 +60,7 @@ 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">
@ -113,7 +114,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
onClick={handleSubmit}
disabled={!isValid}
>
{t('landing.hero.analyzeButton')}
{t('landing.hero.manualAnalyzeButton')}
</button>
</div>
</div>

View File

@ -36,8 +36,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [searchType] = useState<SearchType>('name');
const [isFocused, setIsFocused] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState('');
@ -47,16 +46,12 @@ 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(searchType === 'manual' ? 'name' : searchType);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory('name');
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);
}
@ -65,19 +60,8 @@ 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() || searchType !== 'name') {
if (!query.trim()) {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
@ -93,7 +77,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
} finally {
setIsAutocompleteLoading(false);
}
}, [searchType]);
}, []);
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, ''));
@ -133,18 +117,20 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
};
const handleStart = () => {
if (!inputValue.trim()) {
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
const trimmed = inputValue.trim();
if (!trimmed) {
setLocalError(t('landing.hero.errorInputRequired'));
return;
}
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
setLocalError(t('landing.hero.errorInvalidUrl'));
if (isValidUrl(trimmed)) {
setLocalError('');
onAnalyze?.(trimmed, 'url');
return;
}
setLocalError('');
if (searchType === 'name' && selectedItem && onAutocomplete) {
if (selectedItem && onAutocomplete) {
onAutocomplete({
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
@ -168,44 +154,14 @@ 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" 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 className="hero-dropdown-container">
<div className="hero-dropdown-trigger">
<span className="hero-dropdown-main">
{t('landing.hero.searchTypeLabel')}
</span>
</div>
</div> */}
{/* 입력 필드 */}
<div className="hero-input-container" ref={autocompleteRef}>
@ -213,39 +169,28 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
type="text"
value={inputValue}
onChange={(e) => {
let value = e.target.value;
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
}
const value = e.target.value;
setInputValue(value);
setSelectedItem(null);
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
if (searchType === 'name') {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (isValidUrl(value)) {
setAutocompleteResults([]);
setShowAutocomplete(false);
} else {
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
}
}}
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
if (localError) setLocalError('');
}
}}
onFocus={() => {
setIsFocused(true);
if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true);
if (autocompleteResults.length > 0) setShowAutocomplete(true);
openHistory(inputValue);
}}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onKeyDown={handleKeyDown}
placeholder={getPlaceholder()}
placeholder={t('landing.hero.placeholderCombined')}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
@ -255,12 +200,8 @@ 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 || '' });
}
}}
@ -273,7 +214,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
)}
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
{showAutocomplete && (
<div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
@ -313,7 +254,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
)}
</div>
<span className="hero-input-hint">{getGuideText()}</span>
<span className="hero-input-hint">{t('landing.hero.guideCombined')}</span>
{error && <p className="hero-error">{error}</p>}
@ -321,9 +262,13 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
{t('landing.hero.analyzeButton')}
</button>
<button type="button" className="hero-manual-button" onClick={handleManualButtonClick}>
{t('landing.hero.searchTypeManual')}
</button>
<div className="hero-divider"><span></span></div>
<div className="hero-manual-card" onClick={handleManualButtonClick} role="button" tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleManualButtonClick()}>
<span className="hero-manual-card-title">{t('landing.hero.searchTypeManual')}</span>
<p className="hero-manual-button-desc">{t('landing.hero.manualButtonDesc')}</p>
</div>
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
{!onManualButtonClick && isManualModalOpen && (

View File

@ -46,15 +46,6 @@ 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',
@ -65,10 +56,10 @@ export const tutorialSteps: TutorialStepDef[] = [
variant: 'bubble',
},
{
targetSelector: '.hero-manual-button',
targetSelector: '.hero-manual-card-title',
titleKey: 'tutorial.landing.manual.title',
descriptionKey: 'tutorial.landing.manual.desc',
position: 'bottom',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',

View File

@ -0,0 +1,53 @@
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,6 +2,7 @@
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,4 +1,10 @@
{
"wizardSteps": {
"brandAnalysis": "Analysis",
"asset": "Image",
"sound": "Sound",
"video": "Video"
},
"header": {
"loginFailedAlert": "Failed to get login URL. Please try again.",
"start": "Get Started",
@ -28,8 +34,7 @@
"finish": "Done",
"landing": {
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." },
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
},
@ -42,12 +47,18 @@
"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 in your chosen genre and language." },
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music." },
"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 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." }
},
"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." },
@ -60,12 +71,6 @@
"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." },
@ -94,7 +99,7 @@
}
},
"footer": {
"company":"O2O Inc.",
"company": "O2O Inc.",
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
"headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
"researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
@ -123,7 +128,7 @@
"privacyPrivate": "Private",
"publishTimeLabel": "Publish Time",
"publishNow": "Publish Now",
"publishSchedule": "Schedule (Coming Soon)",
"publishSchedule": "Schedule",
"footerNote": "",
"footerNoteLink": "",
"cancel": "Cancel",
@ -137,7 +142,29 @@
"uploadFailed": "Upload failed.",
"autoSeoTitle": "Auto-generating... (3060 sec)",
"autoSeoDescription": "Auto-generating... (3060 sec)",
"autoSeoTags": "Auto-generating... (3060 sec)"
"autoSeoTags": "Auto-generating... (3060 sec)",
"youtubeCallback": {
"connecting": "Connecting YouTube...",
"pleaseWait": "Please wait a moment.",
"failed": "Connection Failed",
"errorAuthCancelled": "Google authentication was cancelled or failed.",
"errorNoCode": "Authorization code was not received.",
"errorProcessing": "An error occurred while processing the YouTube connection."
},
"connectError": {
"title": "{{platform}} Connection Failed",
"defaultError": "An unknown error occurred.",
"detail": "Please try again. If the problem persists, contact customer support.",
"retry": "Try Again"
},
"connectSuccess": {
"title": "{{name}} Connected",
"messageWithChannel": "{{platform}} account has been successfully connected.",
"messageWithoutChannel": "Account has been successfully connected.",
"verified": "Verified",
"countdown": "Redirecting in {{count}} seconds...",
"goNow": "Go Now"
}
},
"upload": {
"title": "YouTube Upload",
@ -155,24 +182,23 @@
"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." },
"scheduleConflict": "Schedule adjusted to {{time}} due to a conflict."
},
"landing": {
"hero": {
"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)",
"searchTypeLabel": "Business Name | URL",
"placeholderCombined": "Enter a business name or Naver Maps/Place URL",
"guideCombined": "Enter a business name, or paste a Naver Maps share URL (naver.me/...) to automatically retrieve information.",
"errorInputRequired": "Please enter a business name or URL.",
"errorInvalidUrl": "Invalid URL format. (e.g., https://naver.me/abcdef)",
"analyzeButton": "Brand Analysis",
"scrollMore": "Scroll to see more",
"testDataLoading": "Loading...",
"testData": "Test Data",
"testDataLoadFailed": "Failed to load test data.",
"searching": "Searching...",
"searchTypeManual": "Manual Input",
"manualModalTitle": "Enter Business Info",
"searchTypeManual": "Not on Naver? Enter manually",
"manualButtonDesc": "Even businesses not listed on Naver Maps/Place can get\na full brand analysis, lyrics, music, and video\njust by entering the name, region, and address.",
"manualModalTitle": "Enter Business Info Directly",
"manualModalSubtitle": "Even businesses not on Naver can start brand analysis by filling in the details below.",
"manualAnalyzeButton": "Get Started",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualLabelRegion": "Region",
@ -210,8 +236,6 @@
"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.",
@ -268,8 +292,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...\n(3060 sec)",
"generatingSong": "Generating music...\n(12 min)",
"generatingLyrics": "Generating lyrics... (3060 sec)",
"generatingSong": "Generating music... (12 min)",
"songQueued": "Song generation queued...",
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
"lyricGenerationFailed": "Lyrics generation request failed.",

View File

@ -1,4 +1,10 @@
{
"wizardSteps": {
"brandAnalysis": "분석",
"asset": "이미지",
"sound": "사운드",
"video": "영상"
},
"header": {
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
"start": "시작하기",
@ -28,8 +34,7 @@
"finish": "완료",
"landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
},
@ -137,7 +142,29 @@
"uploadFailed": "업로드에 실패했습니다.",
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)",
"youtubeCallback": {
"connecting": "YouTube 연결 중...",
"pleaseWait": "잠시만 기다려주세요.",
"failed": "연결 실패",
"errorAuthCancelled": "Google 인증이 취소되었거나 실패했습니다.",
"errorNoCode": "인증 코드를 받지 못했습니다.",
"errorProcessing": "YouTube 연결 처리 중 오류가 발생했습니다."
},
"connectError": {
"title": "{{platform}} 연결 실패",
"defaultError": "알 수 없는 오류가 발생했습니다.",
"detail": "다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.",
"retry": "다시 시도"
},
"connectSuccess": {
"title": "{{name}} 연결 완료",
"messageWithChannel": "{{platform}} 계정이 성공적으로 연결되었습니다.",
"messageWithoutChannel": "계정이 성공적으로 연결되었습니다.",
"verified": "인증됨",
"countdown": "{{count}}초 후 자동으로 이동합니다...",
"goNow": "지금 이동"
}
},
"upload": {
"title": "YouTube 업로드",
@ -158,21 +185,19 @@
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
"landing": {
"hero": {
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"searchTypeLabel": "업체명 | URL",
"placeholderCombined": "업체명 또는 URL을 입력하세요.",
"guideCombined": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 정보를 가져옵니다.",
"errorInputRequired": "업체명 또는 URL을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://naver.me/abcdef)",
"analyzeButton": "브랜드 분석",
"scrollMore": "스크롤하여 더 보기",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"searching": "검색 중...",
"searchTypeManual": "직접 입력",
"manualModalTitle": "업체 정보 입력",
"searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기",
"manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.",
"manualModalTitle": "업체 정보 직접 입력",
"manualModalSubtitle": "네이버에 등록되지 않은 업체도 아래 정보만 입력하면 가사·음악·영상 제작까지 할 수 있어요.",
"manualAnalyzeButton": "시작하기",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
@ -210,8 +235,6 @@
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기",
"searching": "검색 중...",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"searchTypeManual": "직접 입력",
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
"errorUrlRequired": "URL을 입력해주세요.",

View File

@ -1,21 +0,0 @@
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>

View File

@ -19,6 +19,7 @@ import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUser
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';
@ -288,19 +289,6 @@ 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);
@ -439,7 +427,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
error={analysisError}
/>
);
@ -496,6 +483,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
businessInfo={currentBusinessInfo}
imageTaskId={imageTaskId}
mId={analysisData?.m_id ?? 0}
bizType={analysisData?.biz_type ?? 'place'}
videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress}
onGoToPayment={handleGoToPayment}
@ -551,12 +539,22 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case '내 정보':
return <MyInfoContent initialTab={myInfoInitialTab} />;
case '새 프로젝트 만들기':
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
if (wizardStep === 0 || wizardStep === -1) {
// 로딩(-1), URL 입력(-2) 스텝퍼 없이 전체 화면으로 표시
if (wizardStep === -1 || wizardStep === -2) {
return renderWizardContent();
}
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
if (wizardStep === 0) {
return (
<>
<WizardStepper currentStep={wizardStep} />
{renderWizardContent()}
</>
);
}
return (
<div className="wizard-page-container">
<WizardStepper currentStep={wizardStep} />
{renderWizardContent()}
</div>
);

View File

@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete, downloadSong } from '../../utils/api';
import { LANGUAGE_MAP } from '../../types/api';
interface BusinessInfo {
@ -16,6 +16,7 @@ interface SoundStudioContentProps {
businessInfo?: BusinessInfo;
imageTaskId: string | null;
mId: number;
bizType?: string;
onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number;
@ -26,6 +27,10 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
const MAX_RETRY_COUNT = 3;
// 생성 완료된 곡 상태를 새로고침 후에도 복원하기 위한 키
// GenerationFlow의 clearAllProjectStorage가 이 키를 포함해 정리함
const SONG_GENERATION_KEY = 'castad_song_generation';
const sanitizeError = (message: string, fallback: string): string => {
if (
message.includes('Traceback') ||
@ -54,6 +59,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo,
imageTaskId,
mId,
bizType = 'place',
onStatusChange,
videoGenerationStatus = 'idle',
videoGenerationProgress = 0,
@ -89,6 +95,41 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
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로 일단 복원 후, 서버에서 최신 URL 재조회 시도
setSongTaskId(parsed.songTaskId);
setAudioUrl(parsed.audioUrl);
setLyrics(parsed.lyrics ?? '');
setStatus('complete');
// downloadSong으로 최신 URL을 재조회해 갱신 (만료 방지)
downloadSong(parsed.songTaskId)
.then((res) => {
if (res.success && res.song_result_url) {
setAudioUrl(res.song_result_url);
// 저장값도 갱신
localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({
...parsed,
audioUrl: res.song_result_url,
}));
}
})
.catch(() => {
// 재조회 실패 시 저장된 URL 그대로 사용
});
} catch {
// 파싱 실패 시 무시
}
}, []); // 마운트 1회만 실행
// Close language dropdown when clicking outside
// Auto-navigate to next page when video generation is complete
useEffect(() => {
@ -128,6 +169,18 @@ 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);
@ -286,6 +339,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
return;
}
// 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거
localStorage.removeItem(SONG_GENERATION_KEY);
setStatus('generating_lyric');
setErrorMessage(null);
setStatusMessage(t('soundStudio.generatingLyrics'));
@ -306,6 +362,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
m_id: mId,
region: businessInfo.region,
task_id: imageTaskId,
biz_type: bizType,
orientation,
...(isInstrumental && { instrumental: true }),
});

View File

@ -1,43 +1,18 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import React from 'react';
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) => void;
onTestData?: (data: CrawlingResponse) => void;
error: string | null;
}
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);
}
};
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => {
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>
@ -49,17 +24,6 @@ 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

@ -2,7 +2,6 @@
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';
@ -11,9 +10,6 @@ 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;
@ -40,18 +36,15 @@ interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => 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, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation();
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
const { t } = useTranslation();
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
const [testError, setTestError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]);
const tutorial = useTutorial();
@ -66,22 +59,6 @@ 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) => {
@ -179,7 +156,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setIsLoginPromptOpen(true);
}
}}
error={externalError || testError || null}
error={externalError || null}
/>
</div>
@ -193,13 +170,6 @@ 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}

View File

@ -1,11 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
const SocialConnectError: React.FC = () => {
// URL 파라미터 파싱
const { t } = useTranslation();
const searchParams = new URLSearchParams(window.location.search);
const platform = searchParams.get('platform') || 'unknown';
const errorMessage = searchParams.get('error') || '알 수 없는 오류가 발생했습니다.';
const errorMessage = searchParams.get('error') || t('social.connectError.defaultError');
const platformName = platform === 'youtube' ? 'YouTube' :
platform === 'instagram' ? 'Instagram' :
@ -25,19 +27,19 @@ const SocialConnectError: React.FC = () => {
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</div>
<h1 className="social-connect-title">{platformName} </h1>
<h1 className="social-connect-title">{t('social.connectError.title', { platform: 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,7 +26,6 @@ const SocialConnectSuccess: React.FC = () => {
platform === 'instagram' ? 'Instagram' :
platform === 'facebook' ? 'Facebook' : platform;
// 연결된 계정 정보를 localStorage에 저장
useEffect(() => {
if (platform && accountId) {
const connectedAccount: ConnectedSocialAccount = {
@ -42,7 +41,6 @@ const SocialConnectSuccess: React.FC = () => {
}, [platform, accountId, channelName, profileImage]);
const navigateToHome = () => {
// URL을 루트로 변경하고 페이지 새로고침하여 generation_flow로 이동
window.location.href = '/';
};
@ -64,7 +62,6 @@ const SocialConnectSuccess: React.FC = () => {
return (
<div className="social-connect-page">
<div className="social-connect-card success">
{/* 프로필 이미지가 있으면 표시 */}
{profileImage ? (
<img
src={profileImage}
@ -81,15 +78,13 @@ const SocialConnectSuccess: React.FC = () => {
)}
<h1 className="social-connect-title">
{channelName || platformName}
{t('social.connectSuccess.title', { name: channelName || platformName })}
</h1>
<p className="social-connect-message">
{channelName ? (
<>{platformName} .</>
) : (
<> .</>
)}
{channelName
? t('social.connectSuccess.messageWithChannel', { platform: platformName })
: t('social.connectSuccess.messageWithoutChannel')}
</p>
{channelName && (
@ -98,20 +93,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">
{countdown} ...
{t('social.connectSuccess.countdown', { count: countdown })}
</p>
<button
onClick={navigateToHome}
className="social-connect-button"
>
{t('social.connectSuccess.goNow')}
</button>
</div>
</div>

View File

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

View File

@ -0,0 +1,947 @@
/* =====================================================
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: #002224;
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

@ -0,0 +1,502 @@
/* =====================================================
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;
}
.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

1187
src/styles/dashboard.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

12
src/styles/index.css Normal file
View File

@ -0,0 +1,12 @@
/* 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';

1642
src/styles/landing.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,593 @@
/* =====================================================
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

1354
src/styles/studio-assets.css Normal file

File diff suppressed because it is too large Load Diff

74
src/styles/tokens.css Normal file
View File

@ -0,0 +1,74 @@
/* =====================================================
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

@ -36,10 +36,12 @@ export interface CrawlingResponse {
image_list: string[];
image_count: number;
m_id: number;
biz_type: string;
processed_info: {
customer_name: string;
region: string;
detail_region_info: string;
biz_type: string;
};
marketing_analysis: MarketingAnalysis;
}
@ -67,6 +69,7 @@ export interface LyricGenerateRequest {
m_id: number;
region: string;
task_id: string;
biz_type?: string;
orientation?: 'vertical' | 'horizontal';
instrumental?: boolean;
}