Compare commits
12 Commits
feature-sh
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
a853c714fd | |
|
|
5512d90a9e | |
|
|
e2b22bd8e9 | |
|
|
5b7eba9540 | |
|
|
e7d6d89fe5 | |
|
|
bd92eb2226 | |
|
|
2f720dfa92 | |
|
|
8a39a34630 | |
|
|
26ae588285 | |
|
|
6c499ae061 | |
|
|
888ac4fa5e | |
|
|
429c8d4708 |
|
|
@ -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?
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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)' 중심의 마케팅 전개"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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, '.'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" />
|
<meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" />
|
||||||
<!-- ③ Open Graph 설명 -->
|
<!-- ③ Open Graph 설명 -->
|
||||||
<meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
|
<meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
|
||||||
<meta property="og:url" content="https://demo.castad.net/" />
|
<meta property="og:url" content="https://ado2.o2osolution.ai" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
|
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
|
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
|
||||||
|
|
@ -162,7 +162,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/index.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#121a1d]">
|
<body class="bg-[#121a1d]">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
|
||||||
"홍천애견동반숙소",
|
|
||||||
"강원도애견동반펜션",
|
|
||||||
"스테이펫",
|
|
||||||
"펫캉스",
|
|
||||||
"반려견동반여행",
|
|
||||||
"홍천감성숙소",
|
|
||||||
"강원도힐링스테이",
|
|
||||||
"숲뷰숙소",
|
|
||||||
"주말여행추천",
|
|
||||||
"프라이빗스테이"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
||||||
<li><strong>서비스명:</strong> ADO2</li>
|
<li><strong>서비스명:</strong> ADO2</li>
|
||||||
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
|
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
|
||||||
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
|
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
|
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
||||||
<li><strong>서비스명:</strong> ADO2</li>
|
<li><strong>서비스명:</strong> ADO2</li>
|
||||||
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
|
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
|
||||||
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
|
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
|
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
|
||||||
|
|
|
||||||
|
|
@ -334,14 +334,14 @@ const App: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||||
const handleManualInput = async (businessName: string, address: string) => {
|
const handleManualInput = async (businessName: string, address: string, category: string) => {
|
||||||
setAfterLoadTarget('generation_flow');
|
setAfterLoadTarget('generation_flow');
|
||||||
setViewMode('loading');
|
setViewMode('loading');
|
||||||
setIsAnalysisComplete(false);
|
setIsAnalysisComplete(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await marketingAnalysis(businessName, address);
|
const data = await marketingAnalysis(businessName, address, category);
|
||||||
|
|
||||||
if (!validateCrawlingResponse(data)) {
|
if (!validateCrawlingResponse(data)) {
|
||||||
throw new Error(t('app.autocompleteError'));
|
throw new Error(t('app.autocompleteError'));
|
||||||
|
|
@ -507,7 +507,6 @@ const App: React.FC = () => {
|
||||||
onAnalyze={handleStartAnalysis}
|
onAnalyze={handleStartAnalysis}
|
||||||
onAutocomplete={handleAutocomplete}
|
onAutocomplete={handleAutocomplete}
|
||||||
onManualInput={handleManualInput}
|
onManualInput={handleManualInput}
|
||||||
onTestData={handleTestData}
|
|
||||||
onNext={() => scrollToSection(1)}
|
onNext={() => scrollToSection(1)}
|
||||||
error={error}
|
error={error}
|
||||||
scrollProgress={scrollProgress}
|
scrollProgress={scrollProgress}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import CitySelectModal, { REGIONS } from './CitySelectModal';
|
||||||
|
|
||||||
interface BusinessNameInputModalProps {
|
interface BusinessNameInputModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (businessName: string, address: string) => void;
|
onSubmit: (businessName: string, address: string, category: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
|
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
|
||||||
|
|
@ -13,6 +13,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
const [businessName, setBusinessName] = useState('');
|
const [businessName, setBusinessName] = useState('');
|
||||||
const [selectedCity, setSelectedCity] = useState('');
|
const [selectedCity, setSelectedCity] = useState('');
|
||||||
const [detailAddress, setDetailAddress] = useState('');
|
const [detailAddress, setDetailAddress] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -41,7 +42,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
|
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
|
||||||
onSubmit(businessName.trim(), fullAddress);
|
onSubmit(businessName.trim(), fullAddress, category.trim());
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -60,6 +61,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
|
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
|
||||||
<button className="manual-modal-close" onClick={onClose}>✕</button>
|
<button className="manual-modal-close" onClick={onClose}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="manual-modal-subtitle">{t('landing.hero.manualModalSubtitle')}</p>
|
||||||
|
|
||||||
<div className="manual-modal-body">
|
<div className="manual-modal-body">
|
||||||
<div className="manual-modal-field">
|
<div className="manual-modal-field">
|
||||||
|
|
@ -103,6 +105,19 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="manual-modal-field">
|
||||||
|
<label className="manual-modal-label">{t('landing.hero.manualLabelCategory')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="manual-modal-input"
|
||||||
|
placeholder={t('landing.hero.manualPlaceholderCategory')}
|
||||||
|
value={category}
|
||||||
|
onChange={e => setCategory(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="manual-modal-actions">
|
<div className="manual-modal-actions">
|
||||||
<button type="button" className="manual-modal-cancel" onClick={onClose}>
|
<button type="button" className="manual-modal-cancel" onClick={onClose}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
|
|
@ -113,7 +128,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
>
|
>
|
||||||
{t('landing.hero.analyzeButton')}
|
{t('landing.hero.manualAnalyzeButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toggleVideoLike } from '../utils/api';
|
||||||
|
|
||||||
|
interface ContentCardSocialActionsProps {
|
||||||
|
videoId: number;
|
||||||
|
storeName: string;
|
||||||
|
region?: string;
|
||||||
|
commentCount: number;
|
||||||
|
initialLikeCount: number;
|
||||||
|
initialIsLiked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||||
|
videoId,
|
||||||
|
storeName,
|
||||||
|
region,
|
||||||
|
commentCount,
|
||||||
|
initialLikeCount,
|
||||||
|
initialIsLiked = false,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
||||||
|
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||||
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
const shareBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const shareMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const shareUrl = `${window.location.origin}/video/${videoId}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shareMenuOpen) return;
|
||||||
|
|
||||||
|
const closeMenu = () => setShareMenuOpen(false);
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) &&
|
||||||
|
shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
window.addEventListener('scroll', closeMenu, true);
|
||||||
|
window.addEventListener('resize', closeMenu);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
window.removeEventListener('scroll', closeMenu, true);
|
||||||
|
window.removeEventListener('resize', closeMenu);
|
||||||
|
};
|
||||||
|
}, [shareMenuOpen]);
|
||||||
|
|
||||||
|
// 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return;
|
||||||
|
const margin = 8;
|
||||||
|
const menuRect = shareMenuRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
let left = anchorRect.left;
|
||||||
|
if (left + menuRect.width > window.innerWidth - margin) {
|
||||||
|
left = window.innerWidth - menuRect.width - margin;
|
||||||
|
}
|
||||||
|
left = Math.max(margin, left);
|
||||||
|
|
||||||
|
let top = anchorRect.bottom + 6;
|
||||||
|
if (top + menuRect.height > window.innerHeight - margin) {
|
||||||
|
top = anchorRect.top - menuRect.height - 6;
|
||||||
|
}
|
||||||
|
top = Math.max(margin, top);
|
||||||
|
|
||||||
|
setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
|
||||||
|
}, [shareMenuOpen, anchorRect]);
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
} catch {
|
||||||
|
// clipboard API 미지원 환경에서는 무시
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKakaoShare = () => {
|
||||||
|
const kakao = window.Kakao;
|
||||||
|
if (kakao?.Share) {
|
||||||
|
kakao.Share.sendDefault({
|
||||||
|
objectType: 'feed',
|
||||||
|
content: {
|
||||||
|
title: storeName || t('videoDetail.kakaoDefaultTitle'),
|
||||||
|
description: t('videoDetail.kakaoDescription', { region: region ?? '' }),
|
||||||
|
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
|
||||||
|
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
||||||
|
},
|
||||||
|
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
||||||
|
});
|
||||||
|
} else if (navigator.share) {
|
||||||
|
navigator.share({ url: shareUrl }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
handleCopyLink();
|
||||||
|
}
|
||||||
|
setShareMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFacebookShare = () => {
|
||||||
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
||||||
|
setShareMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTwitterShare = () => {
|
||||||
|
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
||||||
|
setShareMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLikeClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const prevLiked = isLiked;
|
||||||
|
const prevCount = likeCount;
|
||||||
|
|
||||||
|
setIsLiked(!prevLiked);
|
||||||
|
setLikeCount(prevLiked ? prevCount - 1 : prevCount + 1);
|
||||||
|
|
||||||
|
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
||||||
|
likeDebounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await toggleVideoLike(String(videoId));
|
||||||
|
setIsLiked(res.is_liked);
|
||||||
|
setLikeCount(res.like_count);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle like:', err);
|
||||||
|
setIsLiked(prevLiked);
|
||||||
|
setLikeCount(prevCount);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareBtnClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const rect = shareBtnRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
// 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다
|
||||||
|
setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left });
|
||||||
|
setMenuPos({ top: rect.bottom + 6, left: rect.left });
|
||||||
|
}
|
||||||
|
setShareMenuOpen((v) => !v);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className={`content-card-like-btn ${isLiked ? 'liked' : ''}`}
|
||||||
|
onClick={handleLikeClick}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
{likeCount}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="content-card-comment">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
{commentCount}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={shareBtnRef}
|
||||||
|
className="content-card-share-btn"
|
||||||
|
onClick={handleShareBtnClick}
|
||||||
|
title={t('videoDetail.share')}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{shareMenuOpen && menuPos && createPortal(
|
||||||
|
<div
|
||||||
|
ref={shareMenuRef}
|
||||||
|
className="video-detail-share-menu"
|
||||||
|
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left }}
|
||||||
|
>
|
||||||
|
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="20" height="20" rx="4" fill="#FEE500" />
|
||||||
|
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E" />
|
||||||
|
</svg>
|
||||||
|
{t('videoDetail.shareKakao')}
|
||||||
|
</button>
|
||||||
|
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
||||||
|
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
|
||||||
|
</svg>
|
||||||
|
{t('videoDetail.shareFacebook')}
|
||||||
|
</button>
|
||||||
|
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
{t('videoDetail.shareTwitter')}
|
||||||
|
</button>
|
||||||
|
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentCardSocialActions;
|
||||||
|
|
@ -11,9 +11,9 @@ const Footer: React.FC = () => {
|
||||||
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
|
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
|
||||||
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<a href="https://demo.castad.net/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a>
|
<a href="https://ado2.o2osolution.ai/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a>
|
||||||
<span className="footer-link-divider">|</span>
|
<span className="footer-link-divider">|</span>
|
||||||
<a href="https://demo.castad.net/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
|
<a href="https://ado2.o2osolution.ai/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-right">
|
<div className="footer-right">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
|
import { searchAccommodation, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
|
||||||
import { useSearchHistory } from './SearchHistory/useSearchHistory';
|
import { useSearchHistory } from './SearchHistory/useSearchHistory';
|
||||||
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
|
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
|
||||||
import BusinessNameInputModal from './BusinessNameInputModal';
|
import BusinessNameInputModal from './BusinessNameInputModal';
|
||||||
|
|
@ -18,10 +18,27 @@ const isValidUrl = (string: string): boolean => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// URL 형태로 판단되는 입력: 프로토콜/www. 시작 또는 도메인 형태(naver.me/xxx 등)
|
||||||
|
const isUrlLike = (value: string): boolean => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return /^(https?:\/\/|www\.)/i.test(trimmed)
|
||||||
|
|| /^[a-z0-9-]+(\.[a-z0-9-]+)+([/?#]|$)/i.test(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeUrlInput = (value: string): string => {
|
||||||
|
return value.replace(/[^\x00-\x7F]/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 텍스트 안에 포함된 첫 번째 URL을 추출 (없으면 null)
|
||||||
|
const extractUrl = (text: string): string | null => {
|
||||||
|
const match = text.match(/(https?:\/\/|www\.)[^\s]+/i);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
};
|
||||||
|
|
||||||
interface SearchInputFormProps {
|
interface SearchInputFormProps {
|
||||||
onAnalyze?: (value: string, type: SearchType) => void;
|
onAnalyze?: (value: string, type: SearchType) => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onManualInput?: (businessName: string, address: string) => void;
|
onManualInput?: (businessName: string, address: string, category: string) => void;
|
||||||
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
||||||
onManualButtonClick?: () => void;
|
onManualButtonClick?: () => void;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
|
@ -36,8 +53,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [searchType, setSearchType] = useState<SearchType>('name');
|
const [searchType] = useState<SearchType>('name');
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
|
|
@ -47,16 +63,12 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
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 debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
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)) {
|
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
}
|
}
|
||||||
|
|
@ -65,19 +77,8 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
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) => {
|
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
||||||
if (!query.trim() || searchType !== 'name') {
|
if (!query.trim()) {
|
||||||
setAutocompleteResults([]);
|
setAutocompleteResults([]);
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -93,11 +94,12 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutocompleteLoading(false);
|
setIsAutocompleteLoading(false);
|
||||||
}
|
}
|
||||||
}, [searchType]);
|
}, []);
|
||||||
|
|
||||||
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
||||||
setInputValue(item.title.replace(/<[^>]*>/g, ''));
|
const cleanTitle = cleanSearchTitle(item.title);
|
||||||
setSelectedItem(item);
|
setInputValue(cleanTitle);
|
||||||
|
setSelectedItem({ ...item, title: cleanTitle });
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setAutocompleteResults([]);
|
setAutocompleteResults([]);
|
||||||
setHighlightedIndex(-1);
|
setHighlightedIndex(-1);
|
||||||
|
|
@ -133,18 +135,20 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
if (!inputValue.trim()) {
|
const trimmed = inputValue.trim();
|
||||||
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
|
if (!trimmed) {
|
||||||
|
setLocalError(t('landing.hero.errorInputRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
|
if (isValidUrl(trimmed)) {
|
||||||
setLocalError(t('landing.hero.errorInvalidUrl'));
|
setLocalError('');
|
||||||
|
onAnalyze?.(trimmed, 'url');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
|
||||||
if (searchType === 'name' && selectedItem && onAutocomplete) {
|
if (selectedItem && onAutocomplete) {
|
||||||
onAutocomplete({
|
onAutocomplete({
|
||||||
address: selectedItem.address,
|
address: selectedItem.address,
|
||||||
roadAddress: selectedItem.roadAddress,
|
roadAddress: selectedItem.roadAddress,
|
||||||
|
|
@ -155,6 +159,67 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyInputChange = (value: string) => {
|
||||||
|
setInputValue(value);
|
||||||
|
setSelectedItem(null);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
hideOnInput(value);
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
if (isValidUrl(value)) {
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
} else {
|
||||||
|
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모바일 키보드 클립보드 추천 등 paste 이벤트 없이 여러 글자가 한 번에 들어오는 경우 처리
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
const isBulkInsert = value.length - inputValue.length > 1;
|
||||||
|
if (isBulkInsert) {
|
||||||
|
const extracted = extractUrl(value);
|
||||||
|
if (extracted && extracted !== value.trim()) {
|
||||||
|
applyInputChange(sanitizeUrlInput(extracted));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isUrlLike(value)) {
|
||||||
|
const sanitized = sanitizeUrlInput(value);
|
||||||
|
if (sanitized !== value) {
|
||||||
|
applyInputChange(sanitized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyInputChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
const pastedText = e.clipboardData.getData('text');
|
||||||
|
|
||||||
|
// 붙여넣은 텍스트에 URL이 포함되어 있고 URL 외 다른 텍스트도 있으면 URL만 추출해서 입력창에 넣기
|
||||||
|
const extracted = extractUrl(pastedText);
|
||||||
|
if (extracted && extracted !== pastedText.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
applyInputChange(sanitizeUrlInput(extracted));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const start = input.selectionStart ?? inputValue.length;
|
||||||
|
const end = input.selectionEnd ?? inputValue.length;
|
||||||
|
const merged = inputValue.slice(0, start) + pastedText + inputValue.slice(end);
|
||||||
|
|
||||||
|
// 붙여넣은 후 완성될 전체 값이 URL 형태일 때만 비영문 문자 제거
|
||||||
|
if (!isUrlLike(merged)) return;
|
||||||
|
|
||||||
|
const sanitized = sanitizeUrlInput(merged);
|
||||||
|
if (sanitized === merged) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
applyInputChange(sanitized);
|
||||||
|
};
|
||||||
|
|
||||||
const handleManualButtonClick = () => {
|
const handleManualButtonClick = () => {
|
||||||
if (onManualButtonClick) {
|
if (onManualButtonClick) {
|
||||||
onManualButtonClick();
|
onManualButtonClick();
|
||||||
|
|
@ -168,84 +233,30 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="hero-form">
|
<div className="hero-form">
|
||||||
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
||||||
{/* 드롭다운 */}
|
{/* 유형 라벨 */}
|
||||||
<div className="hero-dropdown-container" ref={dropdownRef}>
|
{/* <div className="hero-dropdown-container">
|
||||||
<button
|
<div className="hero-dropdown-trigger">
|
||||||
type="button"
|
<span className="hero-dropdown-main">
|
||||||
className="hero-dropdown-trigger"
|
{t('landing.hero.searchTypeLabel')}
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
</span>
|
||||||
>
|
</div>
|
||||||
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
</div> */}
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{isDropdownOpen && (
|
|
||||||
<div className="hero-dropdown-menu">
|
|
||||||
{searchTypeOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setSearchType(option.value);
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입력 필드 */}
|
{/* 입력 필드 */}
|
||||||
<div className="hero-input-container" ref={autocompleteRef}>
|
<div className="hero-input-container" ref={autocompleteRef}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
let value = e.target.value;
|
onPaste={handlePaste}
|
||||||
if (searchType === 'url') {
|
|
||||||
const urlMatch = value.match(/https?:\/\/\S+/);
|
|
||||||
if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
|
|
||||||
}
|
|
||||||
setInputValue(value);
|
|
||||||
setSelectedItem(null);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
hideOnInput(value);
|
|
||||||
if (localError) setLocalError('');
|
|
||||||
if (searchType === 'name') {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
||||||
if (searchType !== 'url') return;
|
|
||||||
const pasted = e.clipboardData.getData('text');
|
|
||||||
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
|
|
||||||
if (urlMatch) {
|
|
||||||
e.preventDefault();
|
|
||||||
setInputValue(urlMatch[0]);
|
|
||||||
if (localError) setLocalError('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true);
|
if (autocompleteResults.length > 0) setShowAutocomplete(true);
|
||||||
openHistory(inputValue);
|
openHistory(inputValue);
|
||||||
}}
|
}}
|
||||||
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
|
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={getPlaceholder()}
|
placeholder={t('landing.hero.placeholderCombined')}
|
||||||
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -255,12 +266,8 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
items={filteredHistory}
|
items={filteredHistory}
|
||||||
onSelect={(item) => {
|
onSelect={(item) => {
|
||||||
closeHistory();
|
closeHistory();
|
||||||
if (item.type === 'url') {
|
setInputValue(item.value);
|
||||||
setSearchType('url');
|
if (item.type === 'name') {
|
||||||
setInputValue(item.value);
|
|
||||||
} else {
|
|
||||||
setSearchType('name');
|
|
||||||
setInputValue(item.value);
|
|
||||||
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
|
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -273,7 +280,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 자동완성 결과 */}
|
{/* 자동완성 결과 */}
|
||||||
{showAutocomplete && searchType === 'name' && (
|
{showAutocomplete && (
|
||||||
<div className="hero-autocomplete-dropdown">
|
<div className="hero-autocomplete-dropdown">
|
||||||
{isAutocompleteLoading ? (
|
{isAutocompleteLoading ? (
|
||||||
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
||||||
|
|
@ -313,7 +320,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>}
|
{error && <p className="hero-error">{error}</p>}
|
||||||
|
|
||||||
|
|
@ -321,17 +328,21 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
{t('landing.hero.analyzeButton')}
|
{t('landing.hero.analyzeButton')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="hero-manual-button" onClick={handleManualButtonClick}>
|
<div className="hero-divider"><span>또는</span></div>
|
||||||
{t('landing.hero.searchTypeManual')}
|
|
||||||
</button>
|
<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 미제공 시 모달을 내부에서 관리 */}
|
||||||
{!onManualButtonClick && isManualModalOpen && (
|
{!onManualButtonClick && isManualModalOpen && (
|
||||||
<BusinessNameInputModal
|
<BusinessNameInputModal
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
onClose={() => setIsManualModalOpen(false)}
|
||||||
onSubmit={(businessName, address) => {
|
onSubmit={(businessName, address, category) => {
|
||||||
setIsManualModalOpen(false);
|
setIsManualModalOpen(false);
|
||||||
onManualInput?.(businessName, address);
|
onManualInput?.(businessName, address, category);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
|
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
|
||||||
const [uploadChannelName, setUploadChannelName] = useState<string>('');
|
const [uploadChannelName, setUploadChannelName] = useState<string>('');
|
||||||
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
|
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
|
||||||
|
const [uploadScheduledAt, setUploadScheduledAt] = useState<string | null>(null);
|
||||||
|
|
||||||
// 드롭다운 외부 클릭 시 닫기
|
// 드롭다운 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -373,12 +374,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
// 예약 업로드: 폴링 없이 바로 완료 처리
|
// 예약 업로드: 폴링 없이 바로 완료 처리
|
||||||
setUploadStatus('completed');
|
setUploadStatus('completed');
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
|
setUploadScheduledAt(uploadResponse.scheduled_at ?? null);
|
||||||
onClose();
|
onClose();
|
||||||
resetForm();
|
resetForm();
|
||||||
} else {
|
} else {
|
||||||
// 즉시 업로드: 완료될 때까지 폴링
|
// 즉시 업로드: 완료될 때까지 폴링
|
||||||
const result = await waitForUploadComplete(
|
const result = await waitForUploadComplete(
|
||||||
uploadResponse.upload_id,
|
uploadResponse.upload_id.toString(),
|
||||||
(status, progress) => {
|
(status, progress) => {
|
||||||
setUploadStatus(status as UploadStatus);
|
setUploadStatus(status as UploadStatus);
|
||||||
setUploadProgress(progress || 0);
|
setUploadProgress(progress || 0);
|
||||||
|
|
@ -432,6 +434,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
setUploadVideoTitle('');
|
setUploadVideoTitle('');
|
||||||
setUploadChannelName('');
|
setUploadChannelName('');
|
||||||
setUploadIsScheduled(false);
|
setUploadIsScheduled(false);
|
||||||
|
setUploadScheduledAt(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|
@ -461,6 +464,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
youtubeUrl={uploadYoutubeUrl}
|
youtubeUrl={uploadYoutubeUrl}
|
||||||
errorMessage={uploadErrorMessage}
|
errorMessage={uploadErrorMessage}
|
||||||
isScheduled={uploadIsScheduled}
|
isScheduled={uploadIsScheduled}
|
||||||
|
scheduledAt={uploadScheduledAt}
|
||||||
onGoToCalendar={onGoToCalendar}
|
onGoToCalendar={onGoToCalendar}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,6 @@ export const tutorialSteps: TutorialStepDef[] = [
|
||||||
{
|
{
|
||||||
key: TUTORIAL_KEYS.LANDING,
|
key: TUTORIAL_KEYS.LANDING,
|
||||||
hints: [
|
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',
|
targetSelector: '.hero-input-wrapper',
|
||||||
titleKey: 'tutorial.landing.field.title',
|
titleKey: 'tutorial.landing.field.title',
|
||||||
|
|
@ -65,10 +56,10 @@ export const tutorialSteps: TutorialStepDef[] = [
|
||||||
variant: 'bubble',
|
variant: 'bubble',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
targetSelector: '.hero-manual-button',
|
targetSelector: '.hero-manual-card-title',
|
||||||
titleKey: 'tutorial.landing.manual.title',
|
titleKey: 'tutorial.landing.manual.title',
|
||||||
descriptionKey: 'tutorial.landing.manual.desc',
|
descriptionKey: 'tutorial.landing.manual.desc',
|
||||||
position: 'bottom',
|
position: 'top',
|
||||||
clickToAdvance: false,
|
clickToAdvance: false,
|
||||||
noSpotlight: true,
|
noSpotlight: true,
|
||||||
variant: 'bubble',
|
variant: 'bubble',
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface UploadProgressModalProps {
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
isScheduled?: boolean;
|
isScheduled?: boolean;
|
||||||
|
scheduledAt?: string | null;
|
||||||
onGoToCalendar?: () => void;
|
onGoToCalendar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
youtubeUrl,
|
youtubeUrl,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isScheduled = false,
|
isScheduled = false,
|
||||||
|
scheduledAt,
|
||||||
onGoToCalendar,
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -148,6 +150,28 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
{t('upload.viewOnYoutube')}
|
{t('upload.viewOnYoutube')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scheduled conflict notice */}
|
||||||
|
{status === 'completed' && isScheduled && scheduledAt && (
|
||||||
|
<div className="upload-progress-schedule-notice">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{t('upload.scheduleConflict', {
|
||||||
|
time: new Date(scheduledAt).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`;
|
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCommentDate = (dateString: string) => {
|
const formatCommentDate = (dateString: string) => {
|
||||||
|
|
@ -120,12 +120,12 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
kakao.Share.sendDefault({
|
kakao.Share.sendDefault({
|
||||||
objectType: 'feed',
|
objectType: 'feed',
|
||||||
content: {
|
content: {
|
||||||
title: video?.store_name ?? 'ADO2 영상',
|
title: video?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
|
||||||
description: `${video?.region ?? ''} · ADO2 AI 마케팅 영상`,
|
description: t('videoDetail.kakaoDescription', { region: video?.region ?? '' }),
|
||||||
imageUrl: 'https://demo.castad.net/favicon_48.svg',
|
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
|
||||||
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
||||||
},
|
},
|
||||||
buttons: [{ title: '영상 보기', link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
||||||
});
|
});
|
||||||
} else if (navigator.share) {
|
} else if (navigator.share) {
|
||||||
navigator.share({ url: shareUrl }).catch(() => {});
|
navigator.share({ url: shareUrl }).catch(() => {});
|
||||||
|
|
@ -222,7 +222,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
|
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
|
||||||
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>(삭제된 댓글입니다.)</span>;
|
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>{t('videoDetail.deletedComment')}</span>;
|
||||||
return content;
|
return content;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -231,7 +231,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="video-detail-header">
|
<div className="video-detail-header">
|
||||||
{isModal ? (
|
{isModal ? (
|
||||||
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label="닫기">
|
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -293,7 +293,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||||
</svg>
|
</svg>
|
||||||
{copied ? '복사됨!' : '공유하기'}
|
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
||||||
</button>
|
</button>
|
||||||
{shareMenuOpen && (
|
{shareMenuOpen && (
|
||||||
<div className="video-detail-share-menu">
|
<div className="video-detail-share-menu">
|
||||||
|
|
@ -303,21 +303,21 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
<rect width="20" height="20" rx="4" fill="#FEE500"/>
|
<rect width="20" height="20" rx="4" fill="#FEE500"/>
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
|
||||||
</svg>
|
</svg>
|
||||||
카카오톡
|
{t('videoDetail.shareKakao')}
|
||||||
</button>
|
</button>
|
||||||
{/* 페이스북 */}
|
{/* 페이스북 */}
|
||||||
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
||||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
|
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
|
||||||
</svg>
|
</svg>
|
||||||
페이스북
|
{t('videoDetail.shareFacebook')}
|
||||||
</button>
|
</button>
|
||||||
{/* X (트위터) */}
|
{/* X (트위터) */}
|
||||||
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||||
</svg>
|
</svg>
|
||||||
X (트위터)
|
{t('videoDetail.shareTwitter')}
|
||||||
</button>
|
</button>
|
||||||
{/* URL 복사 */}
|
{/* URL 복사 */}
|
||||||
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
||||||
|
|
@ -325,7 +325,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
</svg>
|
</svg>
|
||||||
{copied ? '복사됨!' : 'URL 복사'}
|
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -335,7 +335,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
{/* 댓글 섹션 */}
|
{/* 댓글 섹션 */}
|
||||||
<div className="video-detail-comments">
|
<div className="video-detail-comments">
|
||||||
<div className="video-detail-comments-header">
|
<div className="video-detail-comments-header">
|
||||||
<h3 className="video-detail-comments-title">댓글</h3>
|
<h3 className="video-detail-comments-title">{t('videoDetail.commentsTitle')}</h3>
|
||||||
<span className="video-detail-comments-count">{commentsTotal}</span>
|
<span className="video-detail-comments-count">{commentsTotal}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -344,16 +344,16 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
<div className="video-detail-comment-profile">
|
<div className="video-detail-comment-profile">
|
||||||
<img
|
<img
|
||||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
|
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
|
||||||
alt="아바타 변경"
|
alt={t('videoDetail.changeAvatarTitle')}
|
||||||
className="video-detail-comment-avatar"
|
className="video-detail-comment-avatar"
|
||||||
onClick={handleChangeAvatar}
|
onClick={handleChangeAvatar}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
title="클릭하여 아바타 변경"
|
title={t('videoDetail.changeAvatarTitle')}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="video-detail-nickname-input"
|
className="video-detail-nickname-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="작성자 이름"
|
placeholder={t('videoDetail.nicknamePlaceholder')}
|
||||||
value={commentNickname}
|
value={commentNickname}
|
||||||
onChange={(e) => setCommentNickname(e.target.value)}
|
onChange={(e) => setCommentNickname(e.target.value)}
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
|
|
@ -365,7 +365,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
<textarea
|
<textarea
|
||||||
ref={commentTextareaRef}
|
ref={commentTextareaRef}
|
||||||
className="video-detail-comment-input"
|
className="video-detail-comment-input"
|
||||||
placeholder={authed ? '댓글을 입력하세요...' : '로그인 후 댓글을 작성할 수 있습니다'}
|
placeholder={authed ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={commentInput}
|
value={commentInput}
|
||||||
|
|
@ -388,12 +388,12 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
onClick={handleCommentSubmit}
|
onClick={handleCommentSubmit}
|
||||||
disabled={!authed || !commentInput.trim() || commentSubmitting}
|
disabled={!authed || !commentInput.trim() || commentSubmitting}
|
||||||
>
|
>
|
||||||
{commentSubmitting ? '작성 중' : '작성'}
|
{commentSubmitting ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{comments.length === 0 && !commentsLoading ? (
|
{comments.length === 0 && !commentsLoading ? (
|
||||||
<p className="video-detail-comments-empty">아직 댓글이 없습니다.</p>
|
<p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="video-detail-comment-list">
|
<ul className="video-detail-comment-list">
|
||||||
{comments.map((c) => (
|
{comments.map((c) => (
|
||||||
|
|
@ -405,7 +405,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
/>
|
/>
|
||||||
<div className="video-detail-comment-body">
|
<div className="video-detail-comment-body">
|
||||||
<span className="video-detail-comment-nickname">
|
<span className="video-detail-comment-nickname">
|
||||||
{c.nickname || '익명'}
|
{c.nickname || t('videoDetail.anonymous')}
|
||||||
</span>
|
</span>
|
||||||
<p className="video-detail-comment-text">
|
<p className="video-detail-comment-text">
|
||||||
{renderCommentContent(c.content, c.is_deleted)}
|
{renderCommentContent(c.content, c.is_deleted)}
|
||||||
|
|
@ -417,7 +417,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
className="video-detail-comment-delete"
|
className="video-detail-comment-delete"
|
||||||
onClick={() => handleDeleteComment(c.id)}
|
onClick={() => handleDeleteComment(c.id)}
|
||||||
>
|
>
|
||||||
삭제
|
{t('videoDetail.deleteComment')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -434,7 +434,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
/>
|
/>
|
||||||
<div className="video-detail-comment-body">
|
<div className="video-detail-comment-body">
|
||||||
<span className="video-detail-comment-nickname">
|
<span className="video-detail-comment-nickname">
|
||||||
{r.nickname || '익명'}
|
{r.nickname || t('videoDetail.anonymous')}
|
||||||
</span>
|
</span>
|
||||||
<p className="video-detail-comment-text">
|
<p className="video-detail-comment-text">
|
||||||
{renderCommentContent(r.content, r.is_deleted)}
|
{renderCommentContent(r.content, r.is_deleted)}
|
||||||
|
|
@ -446,7 +446,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
className="video-detail-comment-delete"
|
className="video-detail-comment-delete"
|
||||||
onClick={() => handleDeleteComment(r.id)}
|
onClick={() => handleDeleteComment(r.id)}
|
||||||
>
|
>
|
||||||
삭제
|
{t('videoDetail.deleteComment')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -467,7 +467,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||||
onClick={() => fetchComments(commentsPage + 1, true)}
|
onClick={() => fetchComments(commentsPage + 1, true)}
|
||||||
disabled={commentsLoading}
|
disabled={commentsLoading}
|
||||||
>
|
>
|
||||||
{commentsLoading ? '불러오는 중...' : '댓글 더 보기'}
|
{commentsLoading ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
|
||||||
background: '#01282A',
|
background: '#01282A',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '900px',
|
maxWidth: '800px',
|
||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
|
import './styles/index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"wizardSteps": {
|
||||||
|
"brandAnalysis": "Analysis",
|
||||||
|
"asset": "Image",
|
||||||
|
"sound": "Sound",
|
||||||
|
"video": "Video"
|
||||||
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"loginFailedAlert": "Failed to get login URL. Please try again.",
|
"loginFailedAlert": "Failed to get login URL. Please try again.",
|
||||||
"start": "Get Started",
|
"start": "Get Started",
|
||||||
|
|
@ -28,8 +34,7 @@
|
||||||
"finish": "Done",
|
"finish": "Done",
|
||||||
"landing": {
|
"landing": {
|
||||||
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
||||||
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
|
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." },
|
||||||
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
|
|
||||||
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
|
"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." }
|
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
|
||||||
},
|
},
|
||||||
|
|
@ -42,12 +47,18 @@
|
||||||
"sound": {
|
"sound": {
|
||||||
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
|
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
|
||||||
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
||||||
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music 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." },
|
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
|
||||||
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
||||||
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
||||||
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
||||||
},
|
},
|
||||||
|
"completion": {
|
||||||
|
"contentInfo": { "title": "Content Info", "desc": "Check the file name, genre, resolution, and lyrics of the generated content." },
|
||||||
|
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
|
||||||
|
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
|
||||||
|
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
|
||||||
|
},
|
||||||
"myInfo": {
|
"myInfo": {
|
||||||
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
|
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
|
||||||
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
|
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
|
||||||
|
|
@ -60,12 +71,6 @@
|
||||||
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
|
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
|
||||||
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
|
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
|
||||||
},
|
},
|
||||||
"completion": {
|
|
||||||
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." },
|
|
||||||
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
|
|
||||||
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
|
|
||||||
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
|
|
||||||
},
|
|
||||||
"upload": {
|
"upload": {
|
||||||
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
|
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
|
||||||
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
|
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
|
||||||
|
|
@ -94,7 +99,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company":"O2O Inc.",
|
"company": "O2O Inc.",
|
||||||
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
|
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
|
||||||
"headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
|
"headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
|
||||||
"researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
|
"researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
|
||||||
|
|
@ -123,7 +128,7 @@
|
||||||
"privacyPrivate": "Private",
|
"privacyPrivate": "Private",
|
||||||
"publishTimeLabel": "Publish Time",
|
"publishTimeLabel": "Publish Time",
|
||||||
"publishNow": "Publish Now",
|
"publishNow": "Publish Now",
|
||||||
"publishSchedule": "Schedule (Coming Soon)",
|
"publishSchedule": "Schedule",
|
||||||
"footerNote": "",
|
"footerNote": "",
|
||||||
"footerNoteLink": "",
|
"footerNoteLink": "",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
@ -137,7 +142,29 @@
|
||||||
"uploadFailed": "Upload failed.",
|
"uploadFailed": "Upload failed.",
|
||||||
"autoSeoTitle": "Auto-generating... (30–60 sec)",
|
"autoSeoTitle": "Auto-generating... (30–60 sec)",
|
||||||
"autoSeoDescription": "Auto-generating... (30–60 sec)",
|
"autoSeoDescription": "Auto-generating... (30–60 sec)",
|
||||||
"autoSeoTags": "Auto-generating... (30–60 sec)"
|
"autoSeoTags": "Auto-generating... (30–60 sec)",
|
||||||
|
"youtubeCallback": {
|
||||||
|
"connecting": "Connecting YouTube...",
|
||||||
|
"pleaseWait": "Please wait a moment.",
|
||||||
|
"failed": "Connection Failed",
|
||||||
|
"errorAuthCancelled": "Google authentication was cancelled or failed.",
|
||||||
|
"errorNoCode": "Authorization code was not received.",
|
||||||
|
"errorProcessing": "An error occurred while processing the YouTube connection."
|
||||||
|
},
|
||||||
|
"connectError": {
|
||||||
|
"title": "{{platform}} Connection Failed",
|
||||||
|
"defaultError": "An unknown error occurred.",
|
||||||
|
"detail": "Please try again. If the problem persists, contact customer support.",
|
||||||
|
"retry": "Try Again"
|
||||||
|
},
|
||||||
|
"connectSuccess": {
|
||||||
|
"title": "{{name}} Connected",
|
||||||
|
"messageWithChannel": "{{platform}} account has been successfully connected.",
|
||||||
|
"messageWithoutChannel": "Account has been successfully connected.",
|
||||||
|
"verified": "Verified",
|
||||||
|
"countdown": "Redirecting in {{count}} seconds...",
|
||||||
|
"goNow": "Go Now"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube Upload",
|
"title": "YouTube Upload",
|
||||||
|
|
@ -154,33 +181,34 @@
|
||||||
"confirm": "OK",
|
"confirm": "OK",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"doNotClose": "Upload is in progress. Do not close this window.",
|
"doNotClose": "Upload is in progress. Do not close this window.",
|
||||||
"goToCalendar": "View in Calendar"
|
"goToCalendar": "View in Calendar",
|
||||||
|
"scheduleConflict": "Schedule adjusted to {{time}} due to a conflict."
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeBusinessName": "Business Name",
|
"searchTypeLabel": "Business Name | URL",
|
||||||
"placeholderBusinessName": "Enter a business name",
|
"placeholderCombined": "Enter a business name or Naver Maps/Place URL",
|
||||||
"guideUrl": "Search for a place on Naver Maps, tap Share,\nthen paste the URL here.",
|
"guideCombined": "Paste a Naver Maps share URL, or enter a business name to automatically retrieve information.",
|
||||||
"guideBusinessName": "Search by business name to retrieve information.",
|
"errorInputRequired": "Please enter a business name or URL.",
|
||||||
"errorUrlRequired": "Please enter a URL.",
|
"errorInvalidUrl": "Invalid URL format. (e.g., https://naver.me/abcdef)",
|
||||||
"errorNameRequired": "Please enter a business name.",
|
|
||||||
"errorInvalidUrl": "Invalid URL format. (e.g., https://example.com)",
|
|
||||||
"analyzeButton": "Brand Analysis",
|
"analyzeButton": "Brand Analysis",
|
||||||
"scrollMore": "Scroll to see more",
|
"scrollMore": "Scroll to see more",
|
||||||
"testDataLoading": "Loading...",
|
|
||||||
"testData": "Test Data",
|
|
||||||
"testDataLoadFailed": "Failed to load test data.",
|
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"searchTypeManual": "Manual Input",
|
"searchTypeManual": "Not on Naver? Enter manually",
|
||||||
"manualModalTitle": "Enter Business Info",
|
"manualButtonDesc": "Even businesses not listed on Naver Maps/Place\ncan get lyrics, music, and video production\njust by entering the name, region, and address.",
|
||||||
|
"manualModalTitle": "Enter Business Info Directly",
|
||||||
|
"manualModalSubtitle": "Even businesses not on Naver can get lyrics, music, and video production by filling in the details below.",
|
||||||
|
"manualAnalyzeButton": "Get Started",
|
||||||
"manualLabelName": "Business Name",
|
"manualLabelName": "Business Name",
|
||||||
"manualLabelAddress": "Address",
|
"manualLabelAddress": "Address",
|
||||||
"manualLabelRegion": "Region",
|
"manualLabelRegion": "Region",
|
||||||
"manualLabelDetail": "Detail Address",
|
"manualLabelDetail": "Detail Address",
|
||||||
|
"manualLabelCategory": "Category",
|
||||||
"manualPlaceholderName": "Enter the business name",
|
"manualPlaceholderName": "Enter the business name",
|
||||||
"manualPlaceholderAddress": "Enter the address",
|
"manualPlaceholderAddress": "Enter the address",
|
||||||
"manualPlaceholderRegion": "Select a region",
|
"manualPlaceholderRegion": "Select a region",
|
||||||
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
|
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
|
||||||
|
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "Welcome to ADO2.AI",
|
"title": "Welcome to ADO2.AI",
|
||||||
|
|
@ -210,8 +238,6 @@
|
||||||
"guideBusinessName": "Search by business name to retrieve information.",
|
"guideBusinessName": "Search by business name to retrieve information.",
|
||||||
"searchButton": "Search",
|
"searchButton": "Search",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"testDataLoading": "Loading...",
|
|
||||||
"testData": "Test Data",
|
|
||||||
"searchTypeManual": "Manual Input",
|
"searchTypeManual": "Manual Input",
|
||||||
"errorSelectFromList": "Please select a business from the list.",
|
"errorSelectFromList": "Please select a business from the list.",
|
||||||
"errorUrlRequired": "Please enter a URL.",
|
"errorUrlRequired": "Please enter a URL.",
|
||||||
|
|
@ -222,10 +248,12 @@
|
||||||
"manualLabelAddress": "Address",
|
"manualLabelAddress": "Address",
|
||||||
"manualLabelRegion": "Region",
|
"manualLabelRegion": "Region",
|
||||||
"manualLabelDetail": "Detail Address",
|
"manualLabelDetail": "Detail Address",
|
||||||
|
"manualLabelCategory": "Category",
|
||||||
"manualPlaceholderName": "Enter the business name",
|
"manualPlaceholderName": "Enter the business name",
|
||||||
"manualPlaceholderAddress": "Enter the address",
|
"manualPlaceholderAddress": "Enter the address",
|
||||||
"manualPlaceholderRegion": "Select a region",
|
"manualPlaceholderRegion": "Select a region",
|
||||||
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
|
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
|
||||||
|
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
|
||||||
},
|
},
|
||||||
"assetManagement": {
|
"assetManagement": {
|
||||||
"title": "Brand Assets",
|
"title": "Brand Assets",
|
||||||
|
|
@ -241,7 +269,7 @@
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"uploadFailed": "Image upload failed.",
|
"uploadFailed": "Image upload failed.",
|
||||||
"uploading": "Uploading... (30 sec – 2 min)",
|
"uploading": "Uploading... (30 sec – 1 min)",
|
||||||
"nextStep": "Next Step"
|
"nextStep": "Next Step"
|
||||||
},
|
},
|
||||||
"soundStudio": {
|
"soundStudio": {
|
||||||
|
|
@ -254,7 +282,7 @@
|
||||||
"genreLabel": "Select Genre",
|
"genreLabel": "Select Genre",
|
||||||
"genreAuto": "Auto Select",
|
"genreAuto": "Auto Select",
|
||||||
"genreBallad": "Ballad",
|
"genreBallad": "Ballad",
|
||||||
"languageLabel": "Language",
|
"languageLabel": "Select Language",
|
||||||
"languageKorean": "Korean",
|
"languageKorean": "Korean",
|
||||||
"lyricsColumn": "Lyrics",
|
"lyricsColumn": "Lyrics",
|
||||||
"lyricsHint": "Select the lyrics area to edit",
|
"lyricsHint": "Select the lyrics area to edit",
|
||||||
|
|
@ -268,8 +296,8 @@
|
||||||
"videoGenerating": "Generating Video",
|
"videoGenerating": "Generating Video",
|
||||||
"noBusinessInfo": "No business information. Please try again.",
|
"noBusinessInfo": "No business information. Please try again.",
|
||||||
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
|
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
|
||||||
"generatingLyrics": "Generating lyrics...\n(30–60 sec)",
|
"generatingLyrics": "Generating lyrics... (30–60 sec)",
|
||||||
"generatingSong": "Generating music...\n(1–2 min)",
|
"generatingSong": "Generating music... (1–2 min)",
|
||||||
"songQueued": "Song generation queued...",
|
"songQueued": "Song generation queued...",
|
||||||
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
|
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
|
||||||
"lyricGenerationFailed": "Lyrics generation request failed.",
|
"lyricGenerationFailed": "Lyrics generation request failed.",
|
||||||
|
|
@ -283,7 +311,7 @@
|
||||||
"songRegenerationError": "An error occurred during music regeneration.",
|
"songRegenerationError": "An error occurred during music regeneration.",
|
||||||
"creditsRemaining": "{{count}} left",
|
"creditsRemaining": "{{count}} left",
|
||||||
"creditsExhausted": "Not enough credits.",
|
"creditsExhausted": "Not enough credits.",
|
||||||
"chargeCredits": "Purchase credits"
|
"chargeCredits": "Top Up Credits"
|
||||||
},
|
},
|
||||||
"completion": {
|
"completion": {
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
|
|
@ -337,7 +365,7 @@
|
||||||
"noLyricsBGM": "Background music is generated without lyrics.",
|
"noLyricsBGM": "Background music is generated without lyrics.",
|
||||||
"downloading": "Downloading...",
|
"downloading": "Downloading...",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"uploadToSocial": "Upload to Social"
|
"uploadToSocial": "Upload"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|
@ -496,7 +524,7 @@
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"uploadToSocial": "Upload to social media",
|
"uploadToSocial": "Upload",
|
||||||
"sortLatest": "Latest",
|
"sortLatest": "Latest",
|
||||||
"sortOldest": "Oldest",
|
"sortOldest": "Oldest",
|
||||||
"sortLikes": "Most Liked",
|
"sortLikes": "Most Liked",
|
||||||
|
|
@ -525,9 +553,9 @@
|
||||||
"marketPositioning": "Market Positioning",
|
"marketPositioning": "Market Positioning",
|
||||||
"coreValue": "Core Value",
|
"coreValue": "Core Value",
|
||||||
"categoryDefinition": "Category Definition",
|
"categoryDefinition": "Category Definition",
|
||||||
"targetPersona": "Target Persona",
|
"targetPersona": "Key Customer Type",
|
||||||
"ageSuffix": "years old",
|
"ageSuffix": "years old",
|
||||||
"sellingPoints": "Unique Selling Points (USP)",
|
"sellingPoints": "Key Selling Points",
|
||||||
"recommendedKeywords": "Recommended Target Keywords",
|
"recommendedKeywords": "Recommended Target Keywords",
|
||||||
"generateContent": "Generate Content",
|
"generateContent": "Generate Content",
|
||||||
"pageDescBefore": " reveals ",
|
"pageDescBefore": " reveals ",
|
||||||
|
|
@ -568,7 +596,7 @@
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noResults": "No recent results",
|
"noResults": "No recent results",
|
||||||
"noResultsDesc": "Upload your content to social channels",
|
"noResultsDesc": "Upload your content to social channels",
|
||||||
"ado2Contents": "ADO2 Contents",
|
"myContents": "My Contents",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"cancelConfirm": "Are you sure you want to cancel this reservation?",
|
"cancelConfirm": "Are you sure you want to cancel this reservation?",
|
||||||
|
|
@ -580,7 +608,33 @@
|
||||||
},
|
},
|
||||||
"loginPrompt": {
|
"loginPrompt": {
|
||||||
"title": "Login Required",
|
"title": "Login Required",
|
||||||
"loginBtn": "Login with Kakao"
|
"loginBtn": "Login"
|
||||||
|
},
|
||||||
|
"videoDetail": {
|
||||||
|
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
||||||
|
"kakaoDefaultTitle": "ADO2 Video",
|
||||||
|
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
|
||||||
|
"kakaoButtonTitle": "Watch Video",
|
||||||
|
"deletedComment": "(This comment has been deleted.)",
|
||||||
|
"closeAriaLabel": "Close",
|
||||||
|
"share": "Share",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"shareKakao": "KakaoTalk",
|
||||||
|
"shareFacebook": "Facebook",
|
||||||
|
"shareTwitter": "X (Twitter)",
|
||||||
|
"copyUrl": "Copy URL",
|
||||||
|
"commentsTitle": "Comments",
|
||||||
|
"changeAvatarTitle": "Click to change avatar",
|
||||||
|
"nicknamePlaceholder": "Author name",
|
||||||
|
"commentPlaceholder": "Write a comment...",
|
||||||
|
"commentLoginRequired": "Please log in to write a comment",
|
||||||
|
"commentSubmitting": "Submitting",
|
||||||
|
"commentSubmit": "Submit",
|
||||||
|
"noComments": "No comments yet.",
|
||||||
|
"anonymous": "Anonymous",
|
||||||
|
"deleteComment": "Delete",
|
||||||
|
"loadMoreComments": "Load more comments",
|
||||||
|
"loadingComments": "Loading..."
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"loginProcessing": "Processing login...",
|
"loginProcessing": "Processing login...",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"wizardSteps": {
|
||||||
|
"brandAnalysis": "분석",
|
||||||
|
"asset": "이미지",
|
||||||
|
"sound": "사운드",
|
||||||
|
"video": "영상"
|
||||||
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
||||||
"start": "시작하기",
|
"start": "시작하기",
|
||||||
|
|
@ -28,8 +34,7 @@
|
||||||
"finish": "완료",
|
"finish": "완료",
|
||||||
"landing": {
|
"landing": {
|
||||||
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
||||||
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
|
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
|
||||||
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
|
|
||||||
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
|
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
|
||||||
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
||||||
},
|
},
|
||||||
|
|
@ -137,7 +142,29 @@
|
||||||
"uploadFailed": "업로드에 실패했습니다.",
|
"uploadFailed": "업로드에 실패했습니다.",
|
||||||
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
|
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
|
||||||
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
|
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
|
||||||
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
|
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)",
|
||||||
|
"youtubeCallback": {
|
||||||
|
"connecting": "YouTube 연결 중...",
|
||||||
|
"pleaseWait": "잠시만 기다려주세요.",
|
||||||
|
"failed": "연결 실패",
|
||||||
|
"errorAuthCancelled": "Google 인증이 취소되었거나 실패했습니다.",
|
||||||
|
"errorNoCode": "인증 코드를 받지 못했습니다.",
|
||||||
|
"errorProcessing": "YouTube 연결 처리 중 오류가 발생했습니다."
|
||||||
|
},
|
||||||
|
"connectError": {
|
||||||
|
"title": "{{platform}} 연결 실패",
|
||||||
|
"defaultError": "알 수 없는 오류가 발생했습니다.",
|
||||||
|
"detail": "다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.",
|
||||||
|
"retry": "다시 시도"
|
||||||
|
},
|
||||||
|
"connectSuccess": {
|
||||||
|
"title": "{{name}} 연결 완료",
|
||||||
|
"messageWithChannel": "{{platform}} 계정이 성공적으로 연결되었습니다.",
|
||||||
|
"messageWithoutChannel": "계정이 성공적으로 연결되었습니다.",
|
||||||
|
"verified": "인증됨",
|
||||||
|
"countdown": "{{count}}초 후 자동으로 이동합니다...",
|
||||||
|
"goNow": "지금 이동"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube 업로드",
|
"title": "YouTube 업로드",
|
||||||
|
|
@ -154,33 +181,33 @@
|
||||||
"confirm": "확인",
|
"confirm": "확인",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||||
"goToCalendar": "캘린더에서 확인"
|
"goToCalendar": "캘린더에서 확인",
|
||||||
},
|
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeBusinessName": "업체명",
|
"searchTypeLabel": "업체명 | URL",
|
||||||
"placeholderBusinessName": "업체명을 입력하세요.",
|
"placeholderCombined": "업체명 또는 URL을 입력하세요.",
|
||||||
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
|
"guideCombined": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 정보를 가져옵니다.",
|
||||||
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
"errorInputRequired": "업체명 또는 URL을 입력해주세요.",
|
||||||
"errorUrlRequired": "URL을 입력해주세요.",
|
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://naver.me/abcdef)",
|
||||||
"errorNameRequired": "업체명을 입력해주세요.",
|
|
||||||
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
|
|
||||||
"analyzeButton": "브랜드 분석",
|
"analyzeButton": "브랜드 분석",
|
||||||
"scrollMore": "스크롤하여 더 보기",
|
"scrollMore": "스크롤하여 더 보기",
|
||||||
"testDataLoading": "로딩 중...",
|
|
||||||
"testData": "테스트 데이터",
|
|
||||||
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
|
|
||||||
"searching": "검색 중...",
|
"searching": "검색 중...",
|
||||||
"searchTypeManual": "직접 입력",
|
"searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기",
|
||||||
"manualModalTitle": "업체 정보 입력",
|
"manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.",
|
||||||
|
"manualModalTitle": "업체 정보 직접 입력",
|
||||||
|
"manualModalSubtitle": "네이버에 등록되지 않은 업체도 아래 정보만 입력하면 가사·음악·영상 제작까지 할 수 있어요.",
|
||||||
|
"manualAnalyzeButton": "시작하기",
|
||||||
"manualLabelName": "업체명",
|
"manualLabelName": "업체명",
|
||||||
"manualLabelAddress": "주소",
|
"manualLabelAddress": "주소",
|
||||||
"manualLabelRegion": "지역",
|
"manualLabelRegion": "지역",
|
||||||
"manualLabelDetail": "상세 주소",
|
"manualLabelDetail": "상세 주소",
|
||||||
|
"manualLabelCategory": "업종",
|
||||||
"manualPlaceholderName": "업체명을 입력하세요.",
|
"manualPlaceholderName": "업체명을 입력하세요.",
|
||||||
"manualPlaceholderAddress": "주소를 입력하세요.",
|
"manualPlaceholderAddress": "주소를 입력하세요.",
|
||||||
"manualPlaceholderRegion": "지역을 선택하세요.",
|
"manualPlaceholderRegion": "지역을 선택하세요.",
|
||||||
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
|
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
|
||||||
|
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
||||||
|
|
@ -210,8 +237,6 @@
|
||||||
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||||
"searchButton": "검색하기",
|
"searchButton": "검색하기",
|
||||||
"searching": "검색 중...",
|
"searching": "검색 중...",
|
||||||
"testDataLoading": "로딩 중...",
|
|
||||||
"testData": "테스트 데이터",
|
|
||||||
"searchTypeManual": "직접 입력",
|
"searchTypeManual": "직접 입력",
|
||||||
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
|
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
|
||||||
"errorUrlRequired": "URL을 입력해주세요.",
|
"errorUrlRequired": "URL을 입력해주세요.",
|
||||||
|
|
@ -222,10 +247,12 @@
|
||||||
"manualLabelAddress": "주소",
|
"manualLabelAddress": "주소",
|
||||||
"manualLabelRegion": "지역",
|
"manualLabelRegion": "지역",
|
||||||
"manualLabelDetail": "상세 주소",
|
"manualLabelDetail": "상세 주소",
|
||||||
|
"manualLabelCategory": "업종",
|
||||||
"manualPlaceholderName": "업체명을 입력하세요.",
|
"manualPlaceholderName": "업체명을 입력하세요.",
|
||||||
"manualPlaceholderAddress": "주소를 입력하세요.",
|
"manualPlaceholderAddress": "주소를 입력하세요.",
|
||||||
"manualPlaceholderRegion": "지역을 선택하세요.",
|
"manualPlaceholderRegion": "지역을 선택하세요.",
|
||||||
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
|
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
|
||||||
|
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
|
||||||
},
|
},
|
||||||
"assetManagement": {
|
"assetManagement": {
|
||||||
"title": "브랜드 에셋",
|
"title": "브랜드 에셋",
|
||||||
|
|
@ -241,7 +268,7 @@
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
"loadMore": "더보기",
|
"loadMore": "더보기",
|
||||||
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
||||||
"uploading": "업로드 중 (30초~2분 소요)",
|
"uploading": "업로드 중 (30~60초 소요)",
|
||||||
"nextStep": "다음 단계"
|
"nextStep": "다음 단계"
|
||||||
},
|
},
|
||||||
"soundStudio": {
|
"soundStudio": {
|
||||||
|
|
@ -337,7 +364,7 @@
|
||||||
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
|
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
|
||||||
"downloading": "다운로드 중...",
|
"downloading": "다운로드 중...",
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"uploadToSocial": "소셜 채널 업로드"
|
"uploadToSocial": "업로드"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "대시보드",
|
"title": "대시보드",
|
||||||
|
|
@ -496,7 +523,7 @@
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"previous": "이전",
|
"previous": "이전",
|
||||||
"next": "다음",
|
"next": "다음",
|
||||||
"uploadToSocial": "소셜 미디어에 업로드",
|
"uploadToSocial": "업로드",
|
||||||
"sortLatest": "최신순",
|
"sortLatest": "최신순",
|
||||||
"sortOldest": "오래된순",
|
"sortOldest": "오래된순",
|
||||||
"sortLikes": "좋아요 많은순",
|
"sortLikes": "좋아요 많은순",
|
||||||
|
|
@ -568,7 +595,7 @@
|
||||||
"loading": "불러오는 중...",
|
"loading": "불러오는 중...",
|
||||||
"noResults": "최근 결과 없음",
|
"noResults": "최근 결과 없음",
|
||||||
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
||||||
"ado2Contents": "ADO2 콘텐츠",
|
"myContents": "내 콘텐츠",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"retry": "재시도",
|
"retry": "재시도",
|
||||||
"cancelConfirm": "예약을 취소하시겠습니까?",
|
"cancelConfirm": "예약을 취소하시겠습니까?",
|
||||||
|
|
@ -582,6 +609,32 @@
|
||||||
"title": "로그인이 필요합니다.",
|
"title": "로그인이 필요합니다.",
|
||||||
"loginBtn": "로그인"
|
"loginBtn": "로그인"
|
||||||
},
|
},
|
||||||
|
"videoDetail": {
|
||||||
|
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
||||||
|
"kakaoDefaultTitle": "ADO2 영상",
|
||||||
|
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
|
||||||
|
"kakaoButtonTitle": "영상 보기",
|
||||||
|
"deletedComment": "(삭제된 댓글입니다.)",
|
||||||
|
"closeAriaLabel": "닫기",
|
||||||
|
"share": "공유하기",
|
||||||
|
"copied": "복사됨!",
|
||||||
|
"shareKakao": "카카오톡",
|
||||||
|
"shareFacebook": "페이스북",
|
||||||
|
"shareTwitter": "X (트위터)",
|
||||||
|
"copyUrl": "URL 복사",
|
||||||
|
"commentsTitle": "댓글",
|
||||||
|
"changeAvatarTitle": "클릭하여 아바타 변경",
|
||||||
|
"nicknamePlaceholder": "작성자 이름",
|
||||||
|
"commentPlaceholder": "댓글을 입력하세요...",
|
||||||
|
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
||||||
|
"commentSubmitting": "작성 중",
|
||||||
|
"commentSubmit": "작성",
|
||||||
|
"noComments": "아직 댓글이 없습니다.",
|
||||||
|
"anonymous": "익명",
|
||||||
|
"deleteComment": "삭제",
|
||||||
|
"loadMoreComments": "댓글 더 보기",
|
||||||
|
"loadingComments": "불러오는 중..."
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"loginProcessing": "로그인 처리 중...",
|
"loginProcessing": "로그인 처리 중...",
|
||||||
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
||||||
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
|
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
|
||||||
return Math.min(prev + increment, 100);
|
return Math.min(prev + increment, 100);
|
||||||
});
|
});
|
||||||
}, 100);
|
}, 200);
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { VideoListItem } from '../../types/api';
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||||
import CitySelectModal from '../../components/CitySelectModal';
|
import CitySelectModal from '../../components/CitySelectModal';
|
||||||
|
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||||
|
|
||||||
interface ADO2ContentsPageProps {
|
interface ADO2ContentsPageProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
|
@ -181,16 +182,14 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||||
<h3 className="content-card-title">{video.store_name}</h3>
|
<h3 className="content-card-title">{video.store_name}</h3>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
||||||
<span className="content-card-like">
|
<ContentCardSocialActions
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
videoId={video.video_id}
|
||||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
storeName={video.store_name}
|
||||||
</svg>
|
region={video.region}
|
||||||
{video.like_count ?? 0}
|
commentCount={video.comment_count ?? 0}
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
|
initialLikeCount={video.like_count ?? 0}
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
initialIsLiked={video.is_liked_by_me}
|
||||||
</svg>
|
/>
|
||||||
{video.comment_count ?? 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
|
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
|
||||||
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
|
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
|
||||||
|
|
@ -43,15 +44,23 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getImageSrc = (item: ImageItem): string => {
|
const getImageSrc = (item: ImageItem): string => {
|
||||||
return item.type === 'url' ? item.url : item.preview;
|
return item.type === 'url' ? item.preview_url : item.preview;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextWithUpload = async () => {
|
const handleNextWithUpload = async () => {
|
||||||
if (imageList.length === 0) return;
|
if (imageList.length === 0) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setUploadProgress(prev => {
|
||||||
|
if (prev >= 99) return prev;
|
||||||
|
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlImages: ImageUrlItem[] = imageList
|
const urlImages: ImageUrlItem[] = imageList
|
||||||
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
|
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
|
||||||
|
|
@ -63,13 +72,23 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
|
|
||||||
if (urlImages.length > 0 || fileImages.length > 0) {
|
if (urlImages.length > 0 || fileImages.length > 0) {
|
||||||
const response = await uploadImages(urlImages, fileImages);
|
const response = await uploadImages(urlImages, fileImages);
|
||||||
|
clearInterval(interval);
|
||||||
|
setUploadProgress(100);
|
||||||
|
// 이미지 변경 시 이전 음악/영상 생성 결과 초기화 — 반드시 재생성하도록 강제
|
||||||
|
localStorage.removeItem('castad_song_generation');
|
||||||
|
localStorage.removeItem('castad_song_completion');
|
||||||
|
localStorage.removeItem('castad_song_task_id');
|
||||||
|
localStorage.removeItem('castad_video_generation');
|
||||||
|
localStorage.removeItem('castad_video_complete');
|
||||||
onNext(response.task_id);
|
onNext(response.task_id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
clearInterval(interval);
|
||||||
console.error('Image upload failed:', error);
|
console.error('Image upload failed:', error);
|
||||||
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
|
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -104,6 +123,29 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="asset-page">
|
<main className="asset-page">
|
||||||
|
{isUploading && (
|
||||||
|
<div className="asset-upload-overlay">
|
||||||
|
<div className="asset-upload-overlay-content">
|
||||||
|
<div className="loading-spinner">
|
||||||
|
<div className="loading-ring"></div>
|
||||||
|
<div className="loading-dot">
|
||||||
|
<div className="loading-dot-inner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="comp2-loading-text">{t('assetManagement.uploading')}</p>
|
||||||
|
<div className="loading-progress-wrapper">
|
||||||
|
<div className="loading-progress-bar">
|
||||||
|
<div
|
||||||
|
className="loading-progress-fill"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="loading-progress-text">{Math.floor(uploadProgress)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fixed Header - 뒤로가기 버튼 */}
|
{/* Fixed Header - 뒤로가기 버튼 */}
|
||||||
<div className="asset-sticky-header">
|
<div className="asset-sticky-header">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
|
|
@ -145,6 +187,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
<img
|
<img
|
||||||
src={getImageSrc(item)}
|
src={getImageSrc(item)}
|
||||||
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
{item.type === 'file' && (
|
{item.type === 'file' && (
|
||||||
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -409,14 +409,14 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="comp2-page">
|
<main className="comp2-page">
|
||||||
<div className="comp2-header">
|
{/* <div className="comp2-header">
|
||||||
<button onClick={onBack} className="comp2-back-btn">
|
<button onClick={onBack} className="comp2-back-btn">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M15 18l-6-6 6-6" />
|
<path d="M15 18l-6-6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{t('completion.back')}</span>
|
<span>{t('completion.back')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<div className="comp2-title-row">
|
<div className="comp2-title-row">
|
||||||
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
|
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
|
||||||
|
|
@ -476,11 +476,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
<div className="comp2-info-header">
|
<div className="comp2-info-header">
|
||||||
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
|
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="comp2-file-info">
|
||||||
|
<h3 className="comp2-filename">{getFileName()}</h3>
|
||||||
|
{/* <p className="comp2-filesize">19.6MB</p> */}
|
||||||
|
</div>
|
||||||
<div className="comp2-info-content">
|
<div className="comp2-info-content">
|
||||||
<div className="comp2-file-info">
|
|
||||||
<h3 className="comp2-filename">{getFileName()}</h3>
|
|
||||||
{/* <p className="comp2-filesize">19.6MB</p> */}
|
|
||||||
</div>
|
|
||||||
<div className="comp2-meta-grid">
|
<div className="comp2-meta-grid">
|
||||||
<div className="comp2-meta-item">
|
<div className="comp2-meta-item">
|
||||||
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
|
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
|
||||||
|
|
@ -500,19 +500,22 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
||||||
}
|
}
|
||||||
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
||||||
const outro = lines.slice(-1);
|
const sections: { tag: string | null; lines: string[] }[] = [];
|
||||||
const body = lines.slice(0, -1);
|
lines.forEach((line: string) => {
|
||||||
const half = Math.ceil(body.length / 2);
|
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
||||||
const sections = [
|
if (tagMatch) {
|
||||||
{ tag: '[Verse]', lines: body.slice(0, half) },
|
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
||||||
{ tag: '[Chorus]', lines: body.slice(half) },
|
} else if (sections.length === 0) {
|
||||||
{ tag: '[Outro]', lines: outro },
|
sections.push({ tag: null, lines: [line] });
|
||||||
].filter(s => s.lines.length > 0);
|
} else {
|
||||||
|
sections[sections.length - 1].lines.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="comp2-lyrics-paragraphs">
|
<div className="comp2-lyrics-paragraphs">
|
||||||
{sections.map((section, i) => (
|
{sections.filter(s => s.lines.length > 0).map((section, i) => (
|
||||||
<div key={i} className="comp2-lyrics-para-section">
|
<div key={i} className="comp2-lyrics-para-section">
|
||||||
<span className="comp2-lyrics-tag">{section.tag}</span>
|
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
||||||
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -645,7 +645,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate?.('ADO2 콘텐츠')}
|
onClick={() => onNavigate?.('내 콘텐츠')}
|
||||||
style={{
|
style={{
|
||||||
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
|
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
|
||||||
backgroundColor: '#a65eff', cursor: 'pointer',
|
backgroundColor: '#a65eff', cursor: 'pointer',
|
||||||
|
|
@ -653,7 +653,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('contentCalendar.ado2Contents')}
|
{t('contentCalendar.myContents')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -717,7 +717,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 바텀시트 */}
|
{/* 바텀시트 */}
|
||||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 10, display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#01393b',
|
backgroundColor: '#01393b',
|
||||||
borderTop: '1px solid #046266',
|
borderTop: '1px solid #046266',
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@ import MyInfoContent from './MyInfoContent';
|
||||||
import ContentCalendarContent from './ContentCalendarContent';
|
import ContentCalendarContent from './ContentCalendarContent';
|
||||||
import LoadingSection from '../Analysis/LoadingSection';
|
import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
import WizardStepper from '../../components/WizardStepper';
|
||||||
|
|
||||||
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
||||||
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
||||||
|
|
@ -51,7 +52,7 @@ interface BusinessInfo {
|
||||||
interface GenerationFlowProps {
|
interface GenerationFlowProps {
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
initialActiveItem?: string;
|
initialActiveItem?: string;
|
||||||
initialImageList?: string[];
|
initialImageList?: ImageListItem[];
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
initialAnalysisData?: CrawlingResponse | null;
|
initialAnalysisData?: CrawlingResponse | null;
|
||||||
}
|
}
|
||||||
|
|
@ -168,9 +169,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
||||||
const getInitialImageList = (): ImageItem[] => {
|
const getInitialImageList = (): ImageItem[] => {
|
||||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||||
return analysisData.image_list.map(url => ({ type: 'url', url }));
|
return analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
|
||||||
}
|
}
|
||||||
return initialImageList.map(url => ({ type: 'url', url }));
|
return initialImageList.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
||||||
|
|
@ -182,7 +183,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
||||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||||
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
|
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
|
||||||
setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
|
setImageList(analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevAnalysisMIdRef.current = analysisData?.m_id;
|
prevAnalysisMIdRef.current = analysisData?.m_id;
|
||||||
|
|
@ -262,13 +263,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||||
const handleManualInput = async (businessName: string, address: string) => {
|
const handleManualInput = async (businessName: string, address: string, category: string) => {
|
||||||
goToWizardStep(-1);
|
goToWizardStep(-1);
|
||||||
setIsAnalysisComplete(false);
|
setIsAnalysisComplete(false);
|
||||||
setAnalysisError(null);
|
setAnalysisError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await marketingAnalysis(businessName, address);
|
const data = await marketingAnalysis(businessName, address, category);
|
||||||
|
|
||||||
if (data.processed_info) {
|
if (data.processed_info) {
|
||||||
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
|
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
|
||||||
|
|
@ -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(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||||
|
|
@ -313,7 +301,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then((data: CrawlingResponse) => {
|
.then((data: CrawlingResponse) => {
|
||||||
// 언어 변경 시에는 기존 m_id 유지
|
// 언어 변경 시에는 기존 m_id 유지
|
||||||
const tagged = { ...data, m_id: parsed.m_id || generateRandomMId(), _isTestData: true };
|
const tagged = { ...data, m_id: parsed.m_id, _isTestData: true };
|
||||||
setAnalysisData(tagged);
|
setAnalysisData(tagged);
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||||
})
|
})
|
||||||
|
|
@ -439,7 +427,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
onAnalyze={handleStartAnalysis}
|
onAnalyze={handleStartAnalysis}
|
||||||
onAutocomplete={handleAutocomplete}
|
onAutocomplete={handleAutocomplete}
|
||||||
onManualInput={handleManualInput}
|
onManualInput={handleManualInput}
|
||||||
onTestData={handleTestData}
|
|
||||||
error={analysisError}
|
error={analysisError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -496,6 +483,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
businessInfo={currentBusinessInfo}
|
businessInfo={currentBusinessInfo}
|
||||||
imageTaskId={imageTaskId}
|
imageTaskId={imageTaskId}
|
||||||
mId={analysisData?.m_id ?? 0}
|
mId={analysisData?.m_id ?? 0}
|
||||||
|
industry={analysisData?.industry ?? ''}
|
||||||
videoGenerationStatus={videoGenerationStatus}
|
videoGenerationStatus={videoGenerationStatus}
|
||||||
videoGenerationProgress={videoGenerationProgress}
|
videoGenerationProgress={videoGenerationProgress}
|
||||||
onGoToPayment={handleGoToPayment}
|
onGoToPayment={handleGoToPayment}
|
||||||
|
|
@ -551,12 +539,22 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
case '내 정보':
|
case '내 정보':
|
||||||
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
||||||
case '새 프로젝트 만들기':
|
case '새 프로젝트 만들기':
|
||||||
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
|
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
|
||||||
if (wizardStep === 0 || wizardStep === -1) {
|
if (wizardStep === -1 || wizardStep === -2) {
|
||||||
return renderWizardContent();
|
return renderWizardContent();
|
||||||
}
|
}
|
||||||
|
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
|
||||||
|
if (wizardStep === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WizardStepper currentStep={wizardStep} />
|
||||||
|
{renderWizardContent()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="wizard-page-container">
|
<div className="wizard-page-container">
|
||||||
|
<WizardStepper currentStep={wizardStep} />
|
||||||
{renderWizardContent()}
|
{renderWizardContent()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -648,7 +646,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
||||||
)}
|
)}
|
||||||
{tutorialUI}
|
{tutorialUI}
|
||||||
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
<div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getVideosList, deleteVideo } from '../../utils/api';
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
import VideoDetailModal from '../../components/VideoDetailModal';
|
||||||
|
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
|
|
||||||
|
|
@ -278,16 +279,14 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||||
<p className="content-card-date">
|
<p className="content-card-date">
|
||||||
{formatDate(video.created_at)}
|
{formatDate(video.created_at)}
|
||||||
</p>
|
</p>
|
||||||
<span className="content-card-like">
|
<ContentCardSocialActions
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
videoId={video.video_id}
|
||||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
storeName={video.store_name}
|
||||||
</svg>
|
region={video.region}
|
||||||
{video.like_count ?? 0}
|
commentCount={video.comment_count ?? 0}
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
|
initialLikeCount={video.like_count ?? 0}
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
initialIsLiked={video.is_liked_by_me}
|
||||||
</svg>
|
/>
|
||||||
{video.comment_count ?? 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
|
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
|
||||||
import { LANGUAGE_MAP } from '../../types/api';
|
import { LANGUAGE_MAP } from '../../types/api';
|
||||||
|
|
@ -16,6 +16,7 @@ interface SoundStudioContentProps {
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
imageTaskId: string | null;
|
imageTaskId: string | null;
|
||||||
mId: number;
|
mId: number;
|
||||||
|
industry?: string;
|
||||||
onStatusChange?: (status: string) => void;
|
onStatusChange?: (status: string) => void;
|
||||||
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
||||||
videoGenerationProgress?: number;
|
videoGenerationProgress?: number;
|
||||||
|
|
@ -26,6 +27,46 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
|
||||||
|
|
||||||
const MAX_RETRY_COUNT = 3;
|
const MAX_RETRY_COUNT = 3;
|
||||||
|
|
||||||
|
// 생성 완료된 곡 상태를 새로고침 후에도 복원하기 위한 키
|
||||||
|
// GenerationFlow의 clearAllProjectStorage가 이 키를 포함해 정리함
|
||||||
|
const SONG_GENERATION_KEY = 'castad_song_generation';
|
||||||
|
|
||||||
|
const GENRE_VALUES = ['kpop', 'pop', 'ballad', 'hip-hop', 'rnb', 'edm', 'jazz', 'rock'];
|
||||||
|
|
||||||
|
const genreMap: Record<string, string> = {
|
||||||
|
'자동 선택': '', // 런타임에 랜덤 결정
|
||||||
|
'K-POP': 'kpop',
|
||||||
|
'POP': 'pop',
|
||||||
|
'발라드': 'ballad',
|
||||||
|
'Hip-Hop': 'hip-hop',
|
||||||
|
'R&B': 'rnb',
|
||||||
|
'EDM': 'edm',
|
||||||
|
'JAZZ': 'jazz',
|
||||||
|
'ROCK': 'rock',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEffectiveGenre = (selectedGenre: string): string => {
|
||||||
|
if (selectedGenre === '자동 선택') {
|
||||||
|
return GENRE_VALUES[Math.floor(Math.random() * GENRE_VALUES.length)];
|
||||||
|
}
|
||||||
|
return genreMap[selectedGenre] || 'pop';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환
|
||||||
|
const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => {
|
||||||
|
const displayMap: Record<string, string> = {
|
||||||
|
kpop: 'K-POP',
|
||||||
|
pop: 'POP',
|
||||||
|
ballad: t('soundStudio.genreBallad'),
|
||||||
|
'hip-hop': 'Hip-Hop',
|
||||||
|
rnb: 'R&B',
|
||||||
|
edm: 'EDM',
|
||||||
|
jazz: 'JAZZ',
|
||||||
|
rock: 'ROCK',
|
||||||
|
};
|
||||||
|
return displayMap[genreValue] || genreValue;
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeError = (message: string, fallback: string): string => {
|
const sanitizeError = (message: string, fallback: string): string => {
|
||||||
if (
|
if (
|
||||||
message.includes('Traceback') ||
|
message.includes('Traceback') ||
|
||||||
|
|
@ -54,6 +95,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
businessInfo,
|
businessInfo,
|
||||||
imageTaskId,
|
imageTaskId,
|
||||||
mId,
|
mId,
|
||||||
|
industry = '',
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
videoGenerationStatus = 'idle',
|
videoGenerationStatus = 'idle',
|
||||||
videoGenerationProgress = 0,
|
videoGenerationProgress = 0,
|
||||||
|
|
@ -63,8 +105,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
const [selectedType, setSelectedType] = useState('보컬');
|
const [selectedType, setSelectedType] = useState('보컬');
|
||||||
const [selectedLang, setSelectedLang] = useState('한국어');
|
const [selectedLang, setSelectedLang] = useState('한국어');
|
||||||
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
|
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
|
||||||
|
const [usedGenre, setUsedGenre] = useState('');
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [status, setStatus] = useState<GenerationStatus>('idle');
|
const [status, setStatus] = useState<GenerationStatus>('idle');
|
||||||
const [lyrics, setLyrics] = useState('');
|
const [lyrics, setLyrics] = useState('');
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
|
@ -75,20 +117,40 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const [songTaskId, setSongTaskId] = useState<string | null>(null);
|
const [songTaskId, setSongTaskId] = useState<string | null>(null);
|
||||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
// 완료 데이터를 localStorage에 저장
|
// 완료 데이터를 localStorage에 저장
|
||||||
const saveSongCompletionData = () => {
|
const saveSongCompletionData = () => {
|
||||||
const completionData = {
|
const completionData = {
|
||||||
businessName: businessInfo?.customer_name || '알 수 없음',
|
businessName: businessInfo?.customer_name || '알 수 없음',
|
||||||
genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre,
|
// '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장
|
||||||
|
genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre,
|
||||||
lyrics: lyrics,
|
lyrics: lyrics,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
|
localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 새로고침 후 생성 완료된 곡 상태 복원
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem(SONG_GENERATION_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return;
|
||||||
|
|
||||||
|
// 저장된 URL로 복원 (public Blob URL이라 만료되지 않음)
|
||||||
|
// 백엔드에 /song/download 엔드포인트가 없으므로 재조회하지 않는다.
|
||||||
|
setSongTaskId(parsed.songTaskId);
|
||||||
|
setAudioUrl(parsed.audioUrl);
|
||||||
|
setLyrics(parsed.lyrics ?? '');
|
||||||
|
setStatus('complete');
|
||||||
|
} catch {
|
||||||
|
// 파싱 실패 시 무시
|
||||||
|
}
|
||||||
|
}, []); // 마운트 1회만 실행
|
||||||
|
|
||||||
// Close language dropdown when clicking outside
|
// Close language dropdown when clicking outside
|
||||||
// Auto-navigate to next page when video generation is complete
|
// Auto-navigate to next page when video generation is complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -128,6 +190,18 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
|
|
||||||
|
// 새로고침 후에도 복원할 수 있도록 완료된 곡 상태를 저장
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({
|
||||||
|
audioUrl: statusResponse.song_result_url,
|
||||||
|
songTaskId: taskId,
|
||||||
|
lyrics: currentLyrics,
|
||||||
|
status: 'complete',
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// localStorage 쓰기 실패는 무시
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Polling failed:', error);
|
console.error('Polling failed:', error);
|
||||||
|
|
||||||
|
|
@ -158,20 +232,13 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
const genreMap: Record<string, string> = {
|
|
||||||
'자동 선택': 'pop',
|
|
||||||
'K-POP': 'kpop',
|
|
||||||
'발라드': 'ballad',
|
|
||||||
'Hip-Hop': 'hip-hop',
|
|
||||||
'R&B': 'rnb',
|
|
||||||
'EDM': 'edm',
|
|
||||||
'JAZZ': 'jazz',
|
|
||||||
'ROCK': 'rock',
|
|
||||||
};
|
|
||||||
|
|
||||||
const isInstrumental = selectedType === '배경음악';
|
const isInstrumental = selectedType === '배경음악';
|
||||||
|
const effectiveGenre = getEffectiveGenre(selectedGenre);
|
||||||
|
setUsedGenre(effectiveGenre);
|
||||||
|
|
||||||
const songResponse = await generateSong(imageTaskId, {
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: genreMap[selectedGenre] || 'pop',
|
genre: effectiveGenre,
|
||||||
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
|
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -192,41 +259,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMove = (clientX: number) => {
|
|
||||||
if (!progressBarRef.current || !audioRef.current) return;
|
|
||||||
const rect = progressBarRef.current.getBoundingClientRect();
|
|
||||||
const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
||||||
const newTime = (newProgress / 100) * duration;
|
|
||||||
audioRef.current.currentTime = newTime;
|
|
||||||
setProgress(newProgress);
|
|
||||||
setCurrentTime(newTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseDown = (e: React.MouseEvent) => {
|
|
||||||
if (!audioUrl) return;
|
|
||||||
setIsDragging(true);
|
|
||||||
handleMove(e.clientX);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
|
||||||
if (isDragging) handleMove(e.clientX);
|
|
||||||
};
|
|
||||||
const onMouseUp = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isDragging) {
|
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
|
||||||
window.addEventListener('mouseup', onMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
|
||||||
window.removeEventListener('mouseup', onMouseUp);
|
|
||||||
};
|
|
||||||
}, [isDragging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
|
@ -286,6 +318,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거
|
||||||
|
localStorage.removeItem(SONG_GENERATION_KEY);
|
||||||
|
|
||||||
setStatus('generating_lyric');
|
setStatus('generating_lyric');
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||||
|
|
@ -296,6 +331,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
const isInstrumental = selectedType === '배경음악';
|
const isInstrumental = selectedType === '배경음악';
|
||||||
|
// 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다.
|
||||||
|
// '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다.
|
||||||
|
const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre];
|
||||||
|
|
||||||
// 1. 가사 생성 요청
|
// 1. 가사 생성 요청
|
||||||
console.log('[SoundStudio] Sending m_id:', mId);
|
console.log('[SoundStudio] Sending m_id:', mId);
|
||||||
|
|
@ -306,26 +344,20 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
m_id: mId,
|
m_id: mId,
|
||||||
region: businessInfo.region,
|
region: businessInfo.region,
|
||||||
task_id: imageTaskId,
|
task_id: imageTaskId,
|
||||||
|
industry,
|
||||||
orientation,
|
orientation,
|
||||||
...(isInstrumental && { instrumental: true }),
|
...(isInstrumental && { instrumental: true }),
|
||||||
|
...(manualGenre && { genre: manualGenre }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!lyricResponse.success || !lyricResponse.task_id) {
|
if (!lyricResponse.success || !lyricResponse.task_id) {
|
||||||
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const genreMap: Record<string, string> = {
|
|
||||||
'자동 선택': 'pop',
|
|
||||||
'K-POP': 'kpop',
|
|
||||||
'발라드': 'ballad',
|
|
||||||
'Hip-Hop': 'hip-hop',
|
|
||||||
'R&B': 'rnb',
|
|
||||||
'EDM': 'edm',
|
|
||||||
'JAZZ': 'jazz',
|
|
||||||
'ROCK': 'rock',
|
|
||||||
};
|
|
||||||
|
|
||||||
let songLyrics: string | undefined;
|
let songLyrics: string | undefined;
|
||||||
|
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다.
|
||||||
|
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다.
|
||||||
|
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre);
|
||||||
|
|
||||||
if (!isInstrumental) {
|
if (!isInstrumental) {
|
||||||
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
||||||
|
|
@ -350,13 +382,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
setLyrics(lyricDetailResponse.lyric_result);
|
setLyrics(lyricDetailResponse.lyric_result);
|
||||||
songLyrics = lyricDetailResponse.lyric_result;
|
songLyrics = lyricDetailResponse.lyric_result;
|
||||||
|
// 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용
|
||||||
|
effectiveGenre = lyricDetailResponse.genre || effectiveGenre;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('generating_song');
|
setStatus('generating_song');
|
||||||
setStatusMessage(t('soundStudio.generatingSong'));
|
setStatusMessage(t('soundStudio.generatingSong'));
|
||||||
|
setUsedGenre(effectiveGenre);
|
||||||
|
|
||||||
const songResponse = await generateSong(imageTaskId, {
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: genreMap[selectedGenre] || 'pop',
|
genre: effectiveGenre,
|
||||||
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
|
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -402,6 +437,24 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
|
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
|
||||||
|
|
||||||
|
// 드래그로 인한 잦은 리렌더에서도 가사 파싱(정규식/배열 처리)이 매번 재실행되지 않도록 메모이제이션
|
||||||
|
const lyricsSections = useMemo(() => {
|
||||||
|
if (!lyrics) return [];
|
||||||
|
const lines = lyrics.split('\n').filter(l => l.trim());
|
||||||
|
const sections: { tag: string | null; lines: string[] }[] = [];
|
||||||
|
lines.forEach(line => {
|
||||||
|
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
||||||
|
if (tagMatch) {
|
||||||
|
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
||||||
|
} else if (sections.length === 0) {
|
||||||
|
sections.push({ tag: null, lines: [line] });
|
||||||
|
} else {
|
||||||
|
sections[sections.length - 1].lines.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sections.filter(s => s.lines.length > 0);
|
||||||
|
}, [lyrics]);
|
||||||
|
|
||||||
// status 변경 시 부모에 알림
|
// status 변경 시 부모에 알림
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStatusChange?.(status);
|
onStatusChange?.(status);
|
||||||
|
|
@ -495,7 +548,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="genre-row">
|
<div className="genre-row">
|
||||||
{['JAZZ', 'ROCK'].map(genre => (
|
{['JAZZ', 'ROCK', 'POP'].map(genre => (
|
||||||
<button
|
<button
|
||||||
key={genre}
|
key={genre}
|
||||||
onClick={() => setSelectedGenre(genre)}
|
onClick={() => setSelectedGenre(genre)}
|
||||||
|
|
@ -505,7 +558,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
{genre}
|
{genre}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="genre-btn-placeholder"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -615,9 +667,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 진행 표시 전용 — Azure Blob 익명 응답에 Accept-Ranges가 없어 seek가 불안정하므로 드래그 미지원 */}
|
||||||
<div
|
<div
|
||||||
ref={progressBarRef}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
|
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -633,27 +684,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
{/* Lyrics Display */}
|
{/* Lyrics Display */}
|
||||||
<div className="lyrics-display">
|
<div className="lyrics-display">
|
||||||
{lyrics ? (() => {
|
{lyrics ? (
|
||||||
const lines = lyrics.split('\n').filter(l => l.trim());
|
<div className="lyrics-paragraphs">
|
||||||
const outro = lines.slice(-1);
|
{lyricsSections.map((section, i) => (
|
||||||
const body = lines.slice(0, -1);
|
<div key={i} className="lyrics-section">
|
||||||
const half = Math.ceil(body.length / 2);
|
{section.tag && <span className="lyrics-tag">{section.tag}</span>}
|
||||||
const sections = [
|
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
||||||
{ tag: '[Verse]', lines: body.slice(0, half) },
|
</div>
|
||||||
{ tag: '[Chorus]', lines: body.slice(half) },
|
))}
|
||||||
{ tag: '[Outro]', lines: outro },
|
</div>
|
||||||
].filter(s => s.lines.length > 0);
|
) : (
|
||||||
return (
|
|
||||||
<div className="lyrics-paragraphs">
|
|
||||||
{sections.map((section, i) => (
|
|
||||||
<div key={i} className="lyrics-section">
|
|
||||||
<span className="lyrics-tag">{section.tag}</span>
|
|
||||||
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})() : (
|
|
||||||
<div className="lyrics-placeholder">
|
<div className="lyrics-placeholder">
|
||||||
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
|
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,18 @@
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AutocompleteRequest } from '../../utils/api';
|
import { AutocompleteRequest } from '../../utils/api';
|
||||||
import { CrawlingResponse } from '../../types/api';
|
|
||||||
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
||||||
|
|
||||||
// 환경변수에서 테스트 모드 확인
|
|
||||||
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
|
||||||
|
|
||||||
interface UrlInputContentProps {
|
interface UrlInputContentProps {
|
||||||
onAnalyze: (value: string, type?: SearchType) => void;
|
onAnalyze: (value: string, type?: SearchType) => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onManualInput?: (businessName: string, address: string) => void;
|
onManualInput?: (businessName: string, address: string, category: string) => void;
|
||||||
onTestData?: (data: CrawlingResponse) => void;
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => {
|
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => {
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
|
||||||
|
|
||||||
const handleTestData = async () => {
|
|
||||||
if (!onTestData) return;
|
|
||||||
setIsLoadingTest(true);
|
|
||||||
try {
|
|
||||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
||||||
const response = await fetch(jsonFile);
|
|
||||||
const data: CrawlingResponse = await response.json();
|
|
||||||
onTestData(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('테스트 데이터 로드 실패:', err);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingTest(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="url-input-container">
|
<div className="url-input-container">
|
||||||
<div className="url-input-content">
|
<div className="url-input-content">
|
||||||
{/* 로고 */}
|
|
||||||
<div className="url-input-logo">
|
<div className="url-input-logo">
|
||||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,17 +24,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
|
||||||
{isTestPage && onTestData && (
|
|
||||||
<button
|
|
||||||
onClick={handleTestData}
|
|
||||||
disabled={isLoadingTest}
|
|
||||||
className="test-data-button"
|
|
||||||
>
|
|
||||||
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
||||||
import { CrawlingResponse } from '../../types/api';
|
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
|
@ -11,9 +10,6 @@ import LoginPromptModal from '../../components/LoginPromptModal';
|
||||||
import SearchInputForm from '../../components/SearchInputForm';
|
import SearchInputForm from '../../components/SearchInputForm';
|
||||||
import { SearchType } 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
|
// Orb configuration with movement zones to prevent overlap
|
||||||
interface OrbConfig {
|
interface OrbConfig {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,19 +35,16 @@ const orbConfigs: OrbConfig[] = [
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
onAnalyze?: (value: string, type?: SearchType) => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onManualInput?: (businessName: string, address: string) => void;
|
onManualInput?: (businessName: string, address: string, category: string) => void;
|
||||||
onTestData?: (data: CrawlingResponse) => void;
|
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
scrollProgress?: number;
|
scrollProgress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
|
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
||||||
const [testError, setTestError] = useState('');
|
|
||||||
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
|
||||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const animationRefs = useRef<number[]>([]);
|
const animationRefs = useRef<number[]>([]);
|
||||||
const tutorial = useTutorial();
|
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 랜덤 이동 애니메이션
|
// Orb 랜덤 이동 애니메이션
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
||||||
|
|
@ -179,7 +156,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
setIsLoginPromptOpen(true);
|
setIsLoginPromptOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
error={externalError || testError || null}
|
error={externalError || null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -193,13 +170,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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 && (
|
{tutorial.isActive && !isManualModalOpen && (
|
||||||
<TutorialOverlay
|
<TutorialOverlay
|
||||||
hints={tutorial.hints}
|
hints={tutorial.hints}
|
||||||
|
|
@ -214,10 +184,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
{isManualModalOpen && (
|
{isManualModalOpen && (
|
||||||
<BusinessNameInputModal
|
<BusinessNameInputModal
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
onClose={() => setIsManualModalOpen(false)}
|
||||||
onSubmit={(businessName, address) => {
|
onSubmit={(businessName, address, category) => {
|
||||||
if (tutorial.isActive) tutorial.nextHint();
|
if (tutorial.isActive) tutorial.nextHint();
|
||||||
setIsManualModalOpen(false);
|
setIsManualModalOpen(false);
|
||||||
onManualInput?.(businessName, address);
|
onManualInput?.(businessName, address, category);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const SocialConnectError: React.FC = () => {
|
const SocialConnectError: React.FC = () => {
|
||||||
// URL 파라미터 파싱
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const platform = searchParams.get('platform') || 'unknown';
|
const platform = searchParams.get('platform') || 'unknown';
|
||||||
const errorMessage = searchParams.get('error') || '알 수 없는 오류가 발생했습니다.';
|
const errorMessage = searchParams.get('error') || t('social.connectError.defaultError');
|
||||||
|
|
||||||
const platformName = platform === 'youtube' ? 'YouTube' :
|
const platformName = platform === 'youtube' ? 'YouTube' :
|
||||||
platform === 'instagram' ? 'Instagram' :
|
platform === 'instagram' ? 'Instagram' :
|
||||||
|
|
@ -25,19 +27,19 @@ const SocialConnectError: React.FC = () => {
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">{platformName} 연결 실패</h1>
|
<h1 className="social-connect-title">{t('social.connectError.title', { platform: platformName })}</h1>
|
||||||
<p className="social-connect-message">
|
<p className="social-connect-message">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
<p className="social-connect-detail">
|
<p className="social-connect-detail">
|
||||||
다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.
|
{t('social.connectError.detail')}
|
||||||
</p>
|
</p>
|
||||||
<div className="social-connect-actions">
|
<div className="social-connect-actions">
|
||||||
<button
|
<button
|
||||||
onClick={navigateToHome}
|
onClick={navigateToHome}
|
||||||
className="social-connect-button"
|
className="social-connect-button"
|
||||||
>
|
>
|
||||||
다시 시도
|
{t('social.connectError.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
// 연결된 소셜 계정 정보를 저장하는 localStorage 키
|
|
||||||
const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
|
const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
|
||||||
|
|
||||||
interface ConnectedSocialAccount {
|
interface ConnectedSocialAccount {
|
||||||
|
|
@ -13,9 +13,9 @@ interface ConnectedSocialAccount {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SocialConnectSuccess: React.FC = () => {
|
const SocialConnectSuccess: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [countdown, setCountdown] = useState(3);
|
const [countdown, setCountdown] = useState(3);
|
||||||
|
|
||||||
// URL 파라미터 파싱
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const platform = searchParams.get('platform') || 'unknown';
|
const platform = searchParams.get('platform') || 'unknown';
|
||||||
const accountId = searchParams.get('account_id') || '';
|
const accountId = searchParams.get('account_id') || '';
|
||||||
|
|
@ -26,7 +26,6 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
platform === 'instagram' ? 'Instagram' :
|
platform === 'instagram' ? 'Instagram' :
|
||||||
platform === 'facebook' ? 'Facebook' : platform;
|
platform === 'facebook' ? 'Facebook' : platform;
|
||||||
|
|
||||||
// 연결된 계정 정보를 localStorage에 저장
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platform && accountId) {
|
if (platform && accountId) {
|
||||||
const connectedAccount: ConnectedSocialAccount = {
|
const connectedAccount: ConnectedSocialAccount = {
|
||||||
|
|
@ -42,7 +41,6 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
}, [platform, accountId, channelName, profileImage]);
|
}, [platform, accountId, channelName, profileImage]);
|
||||||
|
|
||||||
const navigateToHome = () => {
|
const navigateToHome = () => {
|
||||||
// URL을 루트로 변경하고 페이지 새로고침하여 generation_flow로 이동
|
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -64,7 +62,6 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="social-connect-page">
|
<div className="social-connect-page">
|
||||||
<div className="social-connect-card success">
|
<div className="social-connect-card success">
|
||||||
{/* 프로필 이미지가 있으면 표시 */}
|
|
||||||
{profileImage ? (
|
{profileImage ? (
|
||||||
<img
|
<img
|
||||||
src={profileImage}
|
src={profileImage}
|
||||||
|
|
@ -81,15 +78,13 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="social-connect-title">
|
<h1 className="social-connect-title">
|
||||||
{channelName || platformName} 연결 완료
|
{t('social.connectSuccess.title', { name: channelName || platformName })}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="social-connect-message">
|
<p className="social-connect-message">
|
||||||
{channelName ? (
|
{channelName
|
||||||
<>{platformName} 계정이 성공적으로 연결되었습니다.</>
|
? t('social.connectSuccess.messageWithChannel', { platform: platformName })
|
||||||
) : (
|
: t('social.connectSuccess.messageWithoutChannel')}
|
||||||
<>계정이 성공적으로 연결되었습니다.</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{channelName && (
|
{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">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
인증됨
|
{t('social.connectSuccess.verified')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="social-connect-countdown">
|
<p className="social-connect-countdown">
|
||||||
{countdown}초 후 자동으로 이동합니다...
|
{t('social.connectSuccess.countdown', { count: countdown })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={navigateToHome}
|
onClick={navigateToHome}
|
||||||
className="social-connect-button"
|
className="social-connect-button"
|
||||||
>
|
>
|
||||||
지금 이동
|
{t('social.connectSuccess.goNow')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
|
|
||||||
const YouTubeOAuthCallback: React.FC = () => {
|
const YouTubeOAuthCallback: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<'processing' | 'error'>('processing');
|
const [status, setStatus] = useState<'processing' | 'error'>('processing');
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
|
||||||
|
|
@ -18,7 +20,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('[YouTube OAuth] Error from Google:', error);
|
console.error('[YouTube OAuth] Error from Google:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage('Google 인증이 취소되었거나 실패했습니다.');
|
setErrorMessage(t('social.youtubeCallback.errorAuthCancelled'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
|
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
@ -28,7 +30,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
console.error('[YouTube OAuth] No code received');
|
console.error('[YouTube OAuth] No code received');
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage('인증 코드를 받지 못했습니다.');
|
setErrorMessage(t('social.youtubeCallback.errorNoCode'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=no_code';
|
window.location.href = '/social/connect/error?platform=youtube&error=no_code';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
@ -46,7 +48,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[YouTube OAuth] Failed to process callback:', err);
|
console.error('[YouTube OAuth] Failed to process callback:', err);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage('YouTube 연결 처리 중 오류가 발생했습니다.');
|
setErrorMessage(t('social.youtubeCallback.errorProcessing'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
|
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
@ -66,8 +68,8 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
|
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">YouTube 연결 중...</h1>
|
<h1 className="social-connect-title">{t('social.youtubeCallback.connecting')}</h1>
|
||||||
<p className="social-connect-message">잠시만 기다려주세요.</p>
|
<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" />
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">연결 실패</h1>
|
<h1 className="social-connect-title">{t('social.youtubeCallback.failed')}</h1>
|
||||||
<p className="social-connect-message">{errorMessage}</p>
|
<p className="social-connect-message">{errorMessage}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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: var(--color-bg-darker);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Area */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 72rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.main-content {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Layout */
|
||||||
|
.dashboard-layout {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background-color: var(--color-bg-darker);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Card Components
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Base Card */
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-3xl);
|
||||||
|
padding: var(--spacing-page);
|
||||||
|
border: 1px solid var(--color-border-white-5);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.card {
|
||||||
|
padding: var(--spacing-page-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Variants */
|
||||||
|
.card-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-flex-2 {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Inner (Darker Background) */
|
||||||
|
.card-inner {
|
||||||
|
background-color: rgba(18, 26, 29, 0.5);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
border: 1px solid var(--color-border-white-5);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Header Components
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Page Header */
|
||||||
|
.page-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Title */
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Subtitle */
|
||||||
|
.page-subtitle {
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Title (Mint color uppercase) */
|
||||||
|
.section-title {
|
||||||
|
color: #94FBE0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Title Purple */
|
||||||
|
.section-title-purple {
|
||||||
|
color: #AE72F9;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Button Components
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Primary Button (Purple) */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-purple);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1rem 4rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: var(--shadow-purple);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--color-purple-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background-color: #4b5563;
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Button (Mint) */
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-mint);
|
||||||
|
color: var(--color-bg-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
transform: scale(1);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--color-mint-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
background-color: rgba(166, 255, 234, 0.5);
|
||||||
|
color: rgba(18, 26, 29, 0.5);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outline Button */
|
||||||
|
.btn-outline {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--color-border-gray-600);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1rem 4rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
border-color: var(--color-text-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back Button */
|
||||||
|
.btn-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--color-border-gray-600);
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-gray-300);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
transition: background-color var(--transition-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
background-color: rgba(31, 41, 55, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Regenerate Button (Mint Outline) */
|
||||||
|
.btn-regenerate {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--color-mint-20);
|
||||||
|
color: var(--color-mint);
|
||||||
|
border: 1px solid var(--color-mint-30);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
transition: background-color var(--transition-normal);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regenerate:hover {
|
||||||
|
background-color: var(--color-mint-30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regenerate:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection Button */
|
||||||
|
.btn-select {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--color-border-gray-700);
|
||||||
|
background-color: rgba(18, 26, 29, 0.4);
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-select:hover {
|
||||||
|
border-color: var(--color-border-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-select.active {
|
||||||
|
border-color: var(--color-mint);
|
||||||
|
background-color: var(--color-bg-dark);
|
||||||
|
color: var(--color-mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Form Components
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Select Input */
|
||||||
|
.select-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background-color: var(--color-bg-dark);
|
||||||
|
border: 1px solid var(--color-border-gray-700);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Input */
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background-color: var(--color-bg-dark);
|
||||||
|
border: 1px solid var(--color-border-gray-700);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea - Lyrics */
|
||||||
|
.textarea-lyrics {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
background-color: transparent;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
line-height: 1.625;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-lyrics:focus {
|
||||||
|
outline: none;
|
||||||
|
color: var(--color-text-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Navigation Components
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Back Button Container */
|
||||||
|
.back-button-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1040px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom Button Container */
|
||||||
|
.bottom-button-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 30;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-button-container > * {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bottom-button-container {
|
||||||
|
left: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Sidebar Components
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Sidebar Container */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-bg-dark);
|
||||||
|
border-right: 1px solid var(--color-border-white-5);
|
||||||
|
transition: all var(--transition-slow);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.expanded {
|
||||||
|
width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.mobile-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.mobile-closed {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sidebar.mobile-closed {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Header */
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header.collapsed {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Logo */
|
||||||
|
.sidebar-logo {
|
||||||
|
font-family: 'Playfair Display', serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--color-text-white);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo:hover {
|
||||||
|
color: var(--color-mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Menu */
|
||||||
|
.sidebar-menu {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Menu Item */
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.active {
|
||||||
|
background-color: var(--color-mint);
|
||||||
|
color: var(--color-bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:not(.active) {
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:not(.active):not(.disabled):hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.active .sidebar-item-icon {
|
||||||
|
color: var(--color-bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Footer */
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Credit Card */
|
||||||
|
.credit-card {
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--color-border-white-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-label {
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-value {
|
||||||
|
color: var(--color-text-white);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.375rem;
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-upgrade-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem;
|
||||||
|
background-color: var(--color-bg-dark);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-gray-700);
|
||||||
|
transition: background-color var(--transition-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-upgrade-btn:hover {
|
||||||
|
background-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Section */
|
||||||
|
.profile-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section.collapsed {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--color-border-gray-700);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-default {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--color-bg-gray-700);
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
color: var(--color-text-white);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-credits {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Language Switch */
|
||||||
|
.sidebar-language-switch {
|
||||||
|
padding: 0 1rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle container */
|
||||||
|
.lang-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-option {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-option.active {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-option:hover:not(.active) {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sliding indicator */
|
||||||
|
.lang-toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
width: calc(50% - 2px);
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-slider.left {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-slider.right {
|
||||||
|
transform: translateX(calc(100% + 2px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed state - simple button */
|
||||||
|
.lang-toggle-collapsed {
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-collapsed:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Footer Actions */
|
||||||
|
.sidebar-footer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer-actions .logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-inquiry-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-normal);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-inquiry-btn:hover {
|
||||||
|
color: var(--color-text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-inquiry-btn.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logout Button */
|
||||||
|
.logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
transition: color var(--transition-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
color: var(--color-text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 전용 사이드바 튜토리얼 토글 */
|
||||||
|
.sidebar-tutorial-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sidebar-tutorial-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color var(--transition-normal), background-color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tutorial-btn:hover {
|
||||||
|
color: var(--color-text-white);
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tutorial-btn.active {
|
||||||
|
color: var(--color-mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tutorial-label {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tutorial-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tutorial-badge.on {
|
||||||
|
color: var(--color-mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tutorial-badge.off {
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Menu Button */
|
||||||
|
.mobile-menu-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 40;
|
||||||
|
padding: 0.625rem;
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-white-10);
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn:hover {
|
||||||
|
color: var(--color-text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Overlay */
|
||||||
|
.mobile-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mobile-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
/* =====================================================
|
||||||
|
Business Settings Components
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Settings Container */
|
||||||
|
.settings-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--color-bg-dark);
|
||||||
|
color: var(--color-text-white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.settings-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.settings-container {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Header */
|
||||||
|
.settings-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
max-width: 42rem;
|
||||||
|
margin-left: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.settings-title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.settings-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.settings-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-description {
|
||||||
|
color: var(--color-text-gray-400);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.625;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.settings-description {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.settings-description {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Card */
|
||||||
|
.settings-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-inner {
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border-white-5);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.settings-card-inner {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.settings-card-inner {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--radius-3xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-title {
|
||||||
|
color: var(--color-mint);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.settings-card-title {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.settings-card-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Social Items */
|
||||||
|
.social-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.social-items {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-item {
|
||||||
|
background-color: var(--color-bg-dark);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-white-5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: border-color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.social-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-item:hover {
|
||||||
|
border-color: var(--color-border-white-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-item-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.social-item-left {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-item-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.social-item-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-item-icon-inner {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.social-item-icon-inner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-item-name {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.social-item-name {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-item-connect {
|
||||||
|
color: var(--color-mint);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.social-item-connect {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-item-connect:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Footer Component
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Landing Footer */
|
||||||
|
.landing-footer {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--Color-teal-800, #002224);
|
||||||
|
padding: 40px 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.landing-footer {
|
||||||
|
padding: 48px 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.landing-footer {
|
||||||
|
padding: 60px 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
max-width: 1144px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.footer-content {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
min-width: 262px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-logo {
|
||||||
|
height: 24px;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copyright {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #379599;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
line-height: 1.19;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #379599;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-divider {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #379599;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
max-width: 600px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #CEE5E6;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
line-height: 1.19;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.footer-info {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Header Component
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
/* Landing Header */
|
||||||
|
.landing-header {
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
max-width: 1280px;
|
||||||
|
padding: 12px 12px 12px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10100;
|
||||||
|
background: rgba(0, 34, 36, 0.3);
|
||||||
|
border: 1px solid rgba(229, 241, 242, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
backdrop-filter: blur(40px);
|
||||||
|
-webkit-backdrop-filter: blur(40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.landing-header {
|
||||||
|
width: calc(100% - 10rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo img {
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .lang-toggle {
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
padding: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .lang-toggle-option {
|
||||||
|
padding: 4px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-login-btn {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: #94FBE0;
|
||||||
|
color: #000000;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
line-height: 1.19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-login-btn:hover {
|
||||||
|
background-color: #7fe8cc;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(148, 251, 224, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-login-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-start-btn {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: #AE72F9;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
line-height: 1.19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-start-btn:hover {
|
||||||
|
background-color: #9B5DE5;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(174, 114, 249, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-start-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-avatar {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border-gray-700);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.header-avatar {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================
|
||||||
|
Content Safe Area (for Landing Pages)
|
||||||
|
===================================================== */
|
||||||
|
.content-safe-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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';
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -32,14 +32,21 @@ export interface MarketingAnalysis {
|
||||||
target_keywords: string[];
|
target_keywords: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageListItem {
|
||||||
|
preview: string; // 미리보기용 CDN 리사이즈 URL
|
||||||
|
original: string; // 업로드용 원본 URL
|
||||||
|
}
|
||||||
|
|
||||||
export interface CrawlingResponse {
|
export interface CrawlingResponse {
|
||||||
image_list: string[];
|
image_list: ImageListItem[] | null;
|
||||||
image_count: number;
|
image_count: number;
|
||||||
m_id: number;
|
m_id: number;
|
||||||
|
industry: string;
|
||||||
processed_info: {
|
processed_info: {
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
region: string;
|
region: string;
|
||||||
detail_region_info: string;
|
detail_region_info: string;
|
||||||
|
industry: string;
|
||||||
};
|
};
|
||||||
marketing_analysis: MarketingAnalysis;
|
marketing_analysis: MarketingAnalysis;
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +54,8 @@ export interface CrawlingResponse {
|
||||||
// URL 이미지 (서버에서 가져온 이미지)
|
// URL 이미지 (서버에서 가져온 이미지)
|
||||||
export interface UrlImage {
|
export interface UrlImage {
|
||||||
type: 'url';
|
type: 'url';
|
||||||
url: string;
|
url: string; // 업로드용 원본 URL
|
||||||
|
preview_url: string; // 미리보기용 CDN 리사이즈 URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업로드된 파일 이미지
|
// 업로드된 파일 이미지
|
||||||
|
|
@ -67,8 +75,10 @@ export interface LyricGenerateRequest {
|
||||||
m_id: number;
|
m_id: number;
|
||||||
region: string;
|
region: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
|
industry?: string;
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
instrumental?: boolean;
|
instrumental?: boolean;
|
||||||
|
genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가사 생성 응답
|
// 가사 생성 응답
|
||||||
|
|
@ -98,6 +108,7 @@ export interface LyricDetailResponse {
|
||||||
status: string;
|
status: string;
|
||||||
lyric_prompt: string;
|
lyric_prompt: string;
|
||||||
lyric_result: string;
|
lyric_result: string;
|
||||||
|
genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값)
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -412,9 +423,11 @@ export interface SocialUploadRequest {
|
||||||
// 소셜 업로드 응답
|
// 소셜 업로드 응답
|
||||||
export interface SocialUploadResponse {
|
export interface SocialUploadResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
upload_id: string;
|
upload_id: number;
|
||||||
status: 'pending' | 'uploading' | 'completed' | 'failed';
|
platform: string;
|
||||||
|
status: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
scheduled_at?: string | null; // 예약 업로드 충돌 시 실제 예약 시간 반환
|
||||||
}
|
}
|
||||||
|
|
||||||
// 소셜 업로드 상태 조회 응답
|
// 소셜 업로드 상태 조회 응답
|
||||||
|
|
|
||||||
|
|
@ -844,6 +844,16 @@ export interface AccommodationSearchItem {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 네이버 검색 API는 title에 <b> 태그와 & 같은 HTML 엔티티를 함께 내려주므로
|
||||||
|
// 태그 제거 후 엔티티까지 디코딩해야 "&" 같은 원문이 그대로 노출되지 않는다.
|
||||||
|
// textarea는 DOM에 붙이지 않고 content model상 스크립트를 실행하지 않으므로 엔티티 디코딩 용도로 안전하다.
|
||||||
|
export const cleanSearchTitle = (title: string): string => {
|
||||||
|
const withoutTags = title.replace(/<[^>]*>/g, '');
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.innerHTML = withoutTags;
|
||||||
|
return textarea.value;
|
||||||
|
};
|
||||||
|
|
||||||
export interface AccommodationSearchResponse {
|
export interface AccommodationSearchResponse {
|
||||||
count: number;
|
count: number;
|
||||||
items: AccommodationSearchItem[];
|
items: AccommodationSearchItem[];
|
||||||
|
|
@ -901,7 +911,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업체명·주소 직접 입력으로 마케팅 분석
|
// 업체명·주소 직접 입력으로 마케팅 분석
|
||||||
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
|
export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise<CrawlingResponse> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
||||||
|
|
||||||
|
|
@ -911,7 +921,7 @@ export async function marketingAnalysis(storeName: string, address: string): Pro
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ store_name: storeName, address }),
|
body: JSON.stringify({ store_name: storeName, address, category }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue