Compare commits
No commits in common. "main" and "feature-dashboard" have entirely different histories.
main
...
feature-da
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ArrowLeft, Sparkles, MapPin, Target, Zap, LayoutGrid, Users, Crown, TrendingUp } from 'lucide-react';
|
||||||
|
import { LALA_CABIN_DATA } from './constants';
|
||||||
|
import { GeometricChart } from './components/GeometricChart';
|
||||||
|
import { KeywordBubble } from './components/KeywordBubble';
|
||||||
|
import { BrandData, USP } from './types';
|
||||||
|
|
||||||
|
// Logic to calculate scores based on ICP (Ideal Customer Profile) signals
|
||||||
|
const calculateDynamicScores = (data: BrandData): USP[] => {
|
||||||
|
// 1. Extract Demand Signals from Targets (Needs + Triggers)
|
||||||
|
const marketSignals = data.targets.flatMap(t => [...t.needs, ...t.triggers]).map(s => s.replace(/\s+/g, ''));
|
||||||
|
|
||||||
|
// High value keywords that represent the "Core Value" of this specific property type
|
||||||
|
const coreKeywords = ['프라이빗', '감성', '독채', '캐빈', '조명', '불멍', 'Private', 'Mood'];
|
||||||
|
|
||||||
|
return data.usps.map(usp => {
|
||||||
|
let calculatedScore = 65; // Base score
|
||||||
|
const contentStr = (usp.label + usp.subLabel + usp.description).replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// 2. Cross-reference USP with Market Signals
|
||||||
|
marketSignals.forEach(signal => {
|
||||||
|
if (contentStr.includes(signal)) calculatedScore += 4;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Boost based on Core Keywords (Weighted Importance)
|
||||||
|
coreKeywords.forEach(keyword => {
|
||||||
|
if (contentStr.includes(keyword)) calculatedScore += 6;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Special Boost based on specific Marketing Pillars (checking English SubLabels)
|
||||||
|
if (usp.subLabel === 'CONCEPT') calculatedScore += 10;
|
||||||
|
if (usp.subLabel === 'PRIVACY') calculatedScore += 8;
|
||||||
|
if (usp.subLabel === 'NIGHT MOOD') calculatedScore += 8;
|
||||||
|
if (usp.subLabel === 'PHOTO SPOT') calculatedScore += 5;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...usp,
|
||||||
|
score: Math.min(Math.round(calculatedScore), 99) // Cap at 99
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const rawData = LALA_CABIN_DATA;
|
||||||
|
|
||||||
|
// Calculate scores on mount (or when data changes)
|
||||||
|
const scoredUSPs = useMemo(() => calculateDynamicScores(rawData), [rawData]);
|
||||||
|
|
||||||
|
// Find the top selling point
|
||||||
|
const topUSP = useMemo(() => [...scoredUSPs].sort((a, b) => b.score - a.score)[0], [scoredUSPs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
|
||||||
|
{/* Top Navigation */}
|
||||||
|
<div className="p-6">
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 rounded-full border border-brand-muted/30 text-brand-muted hover:text-brand-accent hover:border-brand-accent transition-all text-sm group">
|
||||||
|
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
|
<span>뒤로가기</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Header */}
|
||||||
|
<div className="text-center mb-10 px-4">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Sparkles className="text-brand-accent w-8 h-8 animate-pulse relative z-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold mb-3 tracking-tight text-white">브랜드 인텔리전스</h1>
|
||||||
|
<p className="text-brand-muted text-lg max-w-xl mx-auto">
|
||||||
|
<span className="text-brand-accent font-semibold">AI 데이터 분석</span>을 통해 도출된 라라캐빈의 핵심 전략입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid Container */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 md:px-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
|
||||||
|
{/* LEFT COLUMN: Identity & Text Analysis */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Main Identity Card */}
|
||||||
|
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 shadow-lg relative overflow-hidden group hover:border-brand-accent/20 transition-all duration-500">
|
||||||
|
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-brand-accent to-brand-purple"></div>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<span className="text-brand-accent font-bold text-sm uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<LayoutGrid size={14} /> 브랜드 정체성
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl font-bold mb-2 text-white tracking-tight">{rawData.name}</h2>
|
||||||
|
<div className="flex items-start gap-2 text-brand-muted text-sm mb-6">
|
||||||
|
<MapPin size={14} className="mt-0.5 shrink-0 text-brand-accent" />
|
||||||
|
<div>
|
||||||
|
<p>{rawData.address}</p>
|
||||||
|
<p className="opacity-70">{rawData.subAddress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 text-gray-300 leading-relaxed border-t border-white/5 pt-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted">입지 특성 분석</h3>
|
||||||
|
<p className="text-sm opacity-90 leading-6">{rawData.locationAnalysis}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted">컨셉 확장성</h3>
|
||||||
|
<p className="text-sm opacity-90 leading-6">{rawData.conceptAnalysis}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Positioning & Strategy Card */}
|
||||||
|
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
|
||||||
|
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
|
||||||
|
<Target size={20} className="text-brand-purple" /> 시장 포지셔닝
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="bg-brand-bg/50 p-5 rounded-xl border border-brand-muted/20 hover:border-brand-accent/30 transition-colors group">
|
||||||
|
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">카테고리 정의</span>
|
||||||
|
<span className="font-bold text-lg text-white">{rawData.positioning.category}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
|
||||||
|
<span className="block text-xs text-brand-accent mb-1 font-semibold">핵심 가치 (Core Value)</span>
|
||||||
|
<span className="font-semibold text-white">{rawData.positioning.coreValue}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Audience Card */}
|
||||||
|
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
|
||||||
|
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
|
||||||
|
<Users size={20} className="text-brand-purple" /> 타겟 페르소나
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{rawData.targets.map((target, idx) => (
|
||||||
|
<div key={idx} className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 bg-brand-bg/30 rounded-xl border border-white/5 hover:border-brand-accent/20 transition-all group">
|
||||||
|
<div className="min-w-[120px]">
|
||||||
|
<div className="font-bold text-white group-hover:text-brand-accent transition-colors">{target.segment}</div>
|
||||||
|
<div className="text-xs text-brand-muted">{target.age}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{target.needs.map((need, i) => (
|
||||||
|
<span key={i} className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium">{need}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2">
|
||||||
|
<span className="text-brand-muted">Trigger:</span> {target.triggers.join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN: Visuals & Keywords */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Chart Card */}
|
||||||
|
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden">
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-2 z-10">
|
||||||
|
<h3 className="text-xl font-bold text-white">주요 셀링 포인트 (USP)</h3>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded">
|
||||||
|
<TrendingUp size={12} />
|
||||||
|
<span>AI Data Analysis</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center justify-center relative z-10 -my-4">
|
||||||
|
<GeometricChart data={scoredUSPs} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core Competitiveness Highlight */}
|
||||||
|
{topUSP && (
|
||||||
|
<div className="mt-2 mb-6 p-4 rounded-xl bg-gradient-to-r from-brand-accent/10 to-transparent border border-brand-accent/20 relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-2 opacity-10">
|
||||||
|
<Crown size={64} className="text-brand-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="text-xs text-brand-accent font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||||
|
<Crown size={12} /> Core Competitiveness
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-white">{topUSP.label}</div>
|
||||||
|
<div className="text-xs text-brand-accent/80 font-mono tracking-wider">{topUSP.subLabel}</div>
|
||||||
|
</div>
|
||||||
|
{/* Score removed */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 z-10">
|
||||||
|
{scoredUSPs.filter(u => u.label !== topUSP.label).slice(0, 4).map((usp, idx) => (
|
||||||
|
<div key={idx} className="p-3 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-colors">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight">{usp.subLabel}</div>
|
||||||
|
{/* Score removed */}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-white mb-1">{usp.label}</div>
|
||||||
|
<div className="text-xs text-gray-400 leading-tight line-clamp-1">{usp.description}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keywords Card */}
|
||||||
|
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden">
|
||||||
|
<h3 className="text-xl font-bold mb-6 text-center text-white">추천 타겟 키워드</h3>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 relative z-10">
|
||||||
|
{rawData.keywords.map((keyword, idx) => (
|
||||||
|
<KeywordBubble key={idx} text={keyword} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Action Button (Sticky Bottom) */}
|
||||||
|
<div className="fixed bottom-8 left-0 right-0 flex justify-center z-50 pointer-events-none">
|
||||||
|
<button className="pointer-events-auto bg-brand-purple hover:bg-brand-purpleHover text-white font-bold py-3 px-12 rounded-full shadow-2xl shadow-brand-purple/40 transform hover:scale-105 transition-all duration-300 flex items-center gap-2">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
콘텐츠 생성
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/drive/198bB4uG9EOOi0btQWINncwYJz7xEjWS3
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { USP } from '../types';
|
||||||
|
|
||||||
|
interface GeometricChartProps {
|
||||||
|
data: USP[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GeometricChart: React.FC<GeometricChartProps> = ({ data }) => {
|
||||||
|
// Increased canvas size to prevent labels (especially on the right side like 'Privacy') from being cut off
|
||||||
|
const size = 500;
|
||||||
|
const center = size / 2;
|
||||||
|
const radius = 110; // Slightly increased radius for better visibility
|
||||||
|
const sides = data.length;
|
||||||
|
|
||||||
|
// Mint Color #94FBE0
|
||||||
|
const accentColor = "#94FBE0";
|
||||||
|
|
||||||
|
// Calculate polygon points
|
||||||
|
const getPoints = (r: number) => {
|
||||||
|
return data.map((_, i) => {
|
||||||
|
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
||||||
|
const x = center + r * Math.cos(angle);
|
||||||
|
const y = center + r * Math.sin(angle);
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate data points based on score
|
||||||
|
const getDataPoints = () => {
|
||||||
|
return data.map((item, i) => {
|
||||||
|
const normalizedScore = item.score / 100;
|
||||||
|
const r = radius * normalizedScore;
|
||||||
|
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
||||||
|
const x = center + r * Math.cos(angle);
|
||||||
|
const y = center + r * Math.sin(angle);
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate label positions (pushed out slightly further)
|
||||||
|
const labelRadius = radius + 55; // Increased padding for labels
|
||||||
|
const labels = data.map((item, i) => {
|
||||||
|
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
||||||
|
const x = center + labelRadius * Math.cos(angle);
|
||||||
|
const y = center + labelRadius * Math.sin(angle);
|
||||||
|
|
||||||
|
// Adjust text anchor based on position
|
||||||
|
let anchor: 'start' | 'middle' | 'end' = 'middle';
|
||||||
|
if (x < center - 20) anchor = 'end';
|
||||||
|
if (x > center + 20) anchor = 'start';
|
||||||
|
|
||||||
|
return { x, y, text: item.label, sub: item.subLabel, anchor, score: item.score };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-2 relative w-full h-full">
|
||||||
|
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto max-w-[500px]" style={{ overflow: 'visible' }}>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="polyGradient" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
|
||||||
|
<stop offset="0%" stopColor={accentColor} stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor={accentColor} stopOpacity="0.05" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Background Grids (Concentric) - Increased Opacity for Visibility */}
|
||||||
|
{[1, 0.75, 0.5, 0.25].map((scale, i) => (
|
||||||
|
<polygon
|
||||||
|
key={i}
|
||||||
|
points={getPoints(radius * scale)}
|
||||||
|
fill="none"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeOpacity={0.25 - (0.02 * i)}
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray={i === 0 ? "0" : "4 2"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Axes Lines - Increased Opacity */}
|
||||||
|
{data.map((_, i) => {
|
||||||
|
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
||||||
|
const x = center + radius * Math.cos(angle);
|
||||||
|
const y = center + radius * Math.sin(angle);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={center}
|
||||||
|
y1={center}
|
||||||
|
x2={x}
|
||||||
|
y2={y}
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeOpacity="0.25"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Data Shape */}
|
||||||
|
<polygon
|
||||||
|
points={getDataPoints()}
|
||||||
|
fill="url(#polyGradient)"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Data Points (Dots) - Increased base size and highlight size */}
|
||||||
|
{data.map((item, i) => {
|
||||||
|
const normalizedScore = item.score / 100;
|
||||||
|
const r = radius * normalizedScore;
|
||||||
|
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
||||||
|
const x = center + r * Math.cos(angle);
|
||||||
|
const y = center + r * Math.sin(angle);
|
||||||
|
const isHigh = item.score >= 90;
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
{isHigh && (
|
||||||
|
<circle cx={x} cy={y} r="10" fill={accentColor} fillOpacity="0.4" />
|
||||||
|
)}
|
||||||
|
{/* Base dot is brighter (white) for all, slightly smaller for non-high */}
|
||||||
|
<circle cx={x} cy={y} r={isHigh ? 5 : 4} fill="#fff" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{labels.map((l, i) => {
|
||||||
|
const isHigh = l.score >= 90;
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<text
|
||||||
|
x={l.x}
|
||||||
|
y={l.y - 7}
|
||||||
|
textAnchor={l.anchor}
|
||||||
|
fill={isHigh ? "#fff" : "#e2e8f0"}
|
||||||
|
fontSize={isHigh ? "14" : "12"}
|
||||||
|
fontWeight={isHigh ? "700" : "600"}
|
||||||
|
className="tracking-tight"
|
||||||
|
style={{ fontFamily: "sans-serif" }}
|
||||||
|
>
|
||||||
|
{l.text}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={l.x}
|
||||||
|
y={l.y + 9}
|
||||||
|
textAnchor={l.anchor}
|
||||||
|
fill={isHigh ? accentColor : "#94a3b8"}
|
||||||
|
fontSize={isHigh ? "11" : "10"}
|
||||||
|
fontWeight={isHigh ? "600" : "400"}
|
||||||
|
className="uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{l.sub}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface KeywordBubbleProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeywordBubble: React.FC<KeywordBubbleProps> = ({ text }) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-brand-cardHover/50 border border-brand-accent/20 rounded-full px-4 py-2 text-sm text-brand-text shadow-sm hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all duration-300 cursor-default whitespace-nowrap">
|
||||||
|
# {text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { BrandData } from './types';
|
||||||
|
|
||||||
|
export const LALA_CABIN_DATA: BrandData = {
|
||||||
|
name: "라라캐빈 펜션",
|
||||||
|
address: "경기 가평군 조종면 와곡길 176",
|
||||||
|
subAddress: "[별관] 경기 가평군 조종면 운악리 175-6",
|
||||||
|
summary: "서울·경기 북부 근교 1~2시간대 숏브레이크 권역에 위치한 캐빈 감성 스테이. 조종면의 한적함과 프라이빗한 독채 구조를 강점으로 내세우며, '숲속 캐빈 무드'와 '야간 조명 감성'이 핵심입니다.",
|
||||||
|
locationAnalysis: "조종면은 청평/가평읍 대비 '한적함·프라이빗' 이미지가 강해 커플 및 소규모 힐링 고객에게 유리하며, 운악산 권역의 자연형 스테이 포지셔닝에 적합합니다.",
|
||||||
|
conceptAnalysis: "'라라캐빈'은 오두막(Cabin) 연상을 강하게 주며, 감성 목조/숲속/프라이빗 스테이로 스토리텔링 확장성이 큽니다. 별관의 동 분리 구조는 '세컨드 하우스' 느낌을 강화합니다.",
|
||||||
|
usps: [
|
||||||
|
{ label: "입지 환경", subLabel: "LOCATION", score: 85, description: "서울근교 한적한 조종면 자연권 숲속 드라이브" },
|
||||||
|
{ label: "브랜드 컨셉", subLabel: "CONCEPT", score: 95, description: "숙소 자체가 여행 목적이 되는 캐빈 감성" },
|
||||||
|
{ label: "프라이버시", subLabel: "PRIVACY", score: 90, description: "우리만 쓰는 느낌의 별관 분리동 독립 동선" },
|
||||||
|
{ label: "야간 무드", subLabel: "NIGHT MOOD", score: 92, description: "어두울수록 예쁜 스테이 장면과 불멍" },
|
||||||
|
{ label: "힐링 요소", subLabel: "HEALING", score: 88, description: "창밖 자연로딩, 숲뷰 산책 리셋" },
|
||||||
|
{ label: "포토 스팟", subLabel: "PHOTO SPOT", score: 94, description: "찍는 곳마다 커버샷 무드, 인스타 각" },
|
||||||
|
{ label: "숏브레이크", subLabel: "SHORT GETAWAY", score: 80, description: "1박2일 가볍게 떠나는 주말 리셋 여행" }
|
||||||
|
],
|
||||||
|
keywords: [
|
||||||
|
"가평 독채 펜션", "숲속 오두막", "감성 숙소", "불멍 스테이",
|
||||||
|
"서울근교 드라이브", "프라이빗 힐링", "커플 여행", "운악산 펜션",
|
||||||
|
"반려견 동반", "비오는날 감성"
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
segment: "서울·경기 커플",
|
||||||
|
age: "25~39세",
|
||||||
|
needs: ["사진", "분위기", "프라이빗"],
|
||||||
|
triggers: ["숲속 캐빈 무드", "비 오는 날 감성", "밤 조명"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
segment: "소규모 우정여행",
|
||||||
|
age: "20~34세",
|
||||||
|
needs: ["예쁜 공간", "수다", "바베큐"],
|
||||||
|
triggers: ["인스타 각", "감성 포토존", "테이블 셋업"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
segment: "힐링 부부",
|
||||||
|
age: "30~50세",
|
||||||
|
needs: ["조용한 휴식", "숙면", "따뜻함"],
|
||||||
|
triggers: ["조용한 숲뷰", "따뜻한 캐빈", "기념일"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
positioning: {
|
||||||
|
category: "서울근교 숲속 캐빈 감성 스테이",
|
||||||
|
coreValue: "프라이빗한 휴식 + 사진이 되는 공간 + 밤 무드",
|
||||||
|
strategy: "시설 나열보다 '장면(Scenes)' 중심의 마케팅 전개"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ADO2 Brand Analysis</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
bg: '#011c1e',
|
||||||
|
card: '#083336',
|
||||||
|
cardHover: '#0b3f42',
|
||||||
|
accent: '#94FBE0',
|
||||||
|
purple: '#a855f7',
|
||||||
|
purpleHover: '#9333ea',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
muted: '#94a3b8'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Pretendard', 'Apple SD Gothic Neo', 'Malgun Gothic', 'sans-serif'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; background-color: #011c1e; color: white; }
|
||||||
|
/* Custom scrollbar for refined look */
|
||||||
|
::-webkit-scrollbar { width: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: #011c1e; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #083336; border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #94FBE0; }
|
||||||
|
</style>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
||||||
|
"react/": "https://esm.sh/react@^19.2.3/",
|
||||||
|
"react": "https://esm.sh/react@^19.2.3",
|
||||||
|
"lucide-react": "https://esm.sh/lucide-react@^0.563.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Could not find root element to mount to");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "ADO2 Marketing Intelligence - Lala Cabin",
|
||||||
|
"description": "AI-powered marketing analysis dashboard for Lala Cabin Pension visualizing brand identity, target demographics, and key selling points.",
|
||||||
|
"requestFramePermissions": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "ado2-marketing-intelligence---lala-cabin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"lucide-react": "^0.563.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
export interface USP {
|
||||||
|
label: string;
|
||||||
|
subLabel: string;
|
||||||
|
score: number; // 0-100
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetPersona {
|
||||||
|
segment: string;
|
||||||
|
age: string;
|
||||||
|
needs: string[];
|
||||||
|
triggers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandData {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
subAddress: string;
|
||||||
|
summary: string;
|
||||||
|
locationAnalysis: string;
|
||||||
|
conceptAnalysis: string;
|
||||||
|
usps: USP[];
|
||||||
|
keywords: string[];
|
||||||
|
targets: TargetPersona[];
|
||||||
|
positioning: {
|
||||||
|
category: string;
|
||||||
|
coreValue: string;
|
||||||
|
strategy: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import path from 'path';
|
||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
12
index.html
12
index.html
|
|
@ -4,15 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>ADO2</title>
|
<title>CASTAD</title>
|
||||||
<!-- ① 페이지 설명 -->
|
|
||||||
<meta name="description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
|
|
||||||
<!-- ② Open Graph 제목 -->
|
|
||||||
<meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" />
|
|
||||||
<!-- ③ Open Graph 설명 -->
|
|
||||||
<meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
|
|
||||||
<meta property="og:url" content="https://ado2.o2osolution.ai" />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
|
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
|
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
@ -36,7 +28,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
|
||||||
|
|
@ -162,6 +153,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<link rel="stylesheet" href="/index.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#121a1d]">
|
<body class="bg-[#121a1d]">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 660.69 145.69">
|
|
||||||
<!-- Generator: Adobe Illustrator 30.5.1, SVG Export Plug-In . SVG Version: 2.1.4 Build 3) -->
|
|
||||||
<defs>
|
|
||||||
<style>
|
|
||||||
.st0 {
|
|
||||||
fill: #fff;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<g>
|
|
||||||
<g>
|
|
||||||
<path class="st0" d="M53.46,93.51l11.28-12.51h64.08l-31.22-63.81L32.69,93.51H15.09L90.17,5.91c2.48-2.89,6.19-5.64,10.73-5.64s7.29,2.48,8.8,5.64l43.45,87.6H53.46Z"/>
|
|
||||||
<path class="st0" d="M162.65,93.51l10.04-57.34h14.99l-7.84,44.83h42.22c23.38,0,43.18-16.78,43.18-38.5,0-16.64-12.51-28.33-31.35-28.33h-57.21l11.83-12.65h47.58c26.68,0,44.14,15.68,44.14,38.5,0,29.84-27.5,53.49-60.37,53.49h-57.21Z"/>
|
|
||||||
<path class="st0" d="M343.75,95.02c-36.17,0-54.45-12.1-54.45-38.37,0-38.09,23.24-56.65,71.23-56.65,36.17,0,54.46,11.96,54.46,38.23,0,38.09-23.1,56.79-71.23,56.79ZM358.33,12.51c-39.6,0-53.9,12.24-53.9,42.63,0,19.94,13.2,27.36,41.53,27.36,39.47,0,53.91-12.38,53.91-42.77,0-19.94-13.2-27.23-41.53-27.23Z"/>
|
|
||||||
<path class="st0" d="M417.47,93.51l4.81-27.23c2.89-16.5,15.68-24.48,36.58-24.48h40.15c16.91,0,23.79-5.78,23.79-15.68,0-9.08-9.08-11.96-26.4-11.96h-59.54l11.96-12.65h55.97c23.51,0,32.73,8.66,32.73,21.59,0,16.91-11,29.7-39.05,29.7h-40.15c-12.65,0-19.8,3.99-21.59,13.34l-2.47,14.85h94.75l-11.83,12.51h-99.7Z"/>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<path class="st0" d="M542.35,92.89c-2.38,0-4.15-1.38-4.15-3.61,0-3.07,2.69-4.84,5.68-4.84,2.46,0,4.3,1.31,4.3,3.53,0,3.07-2.69,4.92-5.84,4.92Z"/>
|
|
||||||
<path class="st0" d="M577.37,92.2l6.3-6.99h35.79l-17.43-35.63-36.25,42.62h-9.83l41.93-48.92c1.38-1.61,3.46-3.15,5.99-3.15s4.07,1.38,4.92,3.15l24.27,48.92h-55.68Z"/>
|
|
||||||
<path class="st0" d="M643.26,92.2l9.14-51.38h8.29l-9.14,51.38h-8.29Z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<path class="st0" d="M.43,125.73c1.34-5.83,5.61-10.49,11.96-10.49,5.4,0,8.98,3.3,9.1,8.33,0,.17-.08.25-.23.25h-2.56c-.15,0-.23-.08-.23-.23-.17-3.74-2.48-5.9-6.18-5.9-4.69,0-7.56,3.67-8.65,8.31-1.31,5.71.33,11.09,6.32,11.09,3.43,0,6.17-1.81,7.52-6.23.03-.13.13-.2.27-.2h2.61c.17,0,.25.12.2.3-1.66,5.75-5.58,8.58-10.76,8.58-8.23,0-10.96-6.85-9.37-13.81Z"/>
|
|
||||||
<path class="st0" d="M25.26,129.28c1.02-4.34,4.26-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM38.31,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
|
|
||||||
<path class="st0" d="M44.55,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
|
||||||
<path class="st0" d="M66.61,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
|
|
||||||
<path class="st0" d="M78.34,129.22c.92-4.04,3.95-7.71,8.98-7.71s7.96,3.69,6.8,9.15c-.02.13-.12.2-.25.2h-12.78c-.37,3.18.57,6.5,4.51,6.5,2.35,0,4.21-1.12,5.09-3.94.05-.13.15-.2.28-.2h2.41c.17,0,.25.12.2.29-1.17,3.99-4.07,5.96-8.01,5.96-6.68,0-8.38-5.39-7.24-10.25ZM91.3,128.82c.47-3-1.14-5.21-4.15-5.21-3.17,0-4.98,2.43-5.71,5.21h9.87Z"/>
|
|
||||||
<path class="st0" d="M97.15,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
|
||||||
<path class="st0" d="M119.21,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
|
|
||||||
<path class="st0" d="M138.69,138.85l2.7-11.23,2.63-11.75c.03-.15.13-.22.27-.22h3.48c.15,0,.23.08.25.22l2.25,14.38.77,4.94,3.05-5.01,8.73-14.36c.07-.12.17-.17.3-.17h3.84c.15,0,.25.1.2.27l-2.76,11.69-2.53,11.28c-.03.15-.13.22-.27.22h-2.66c-.17,0-.25-.1-.22-.27l2.13-9.05,2.4-9.94-11.64,19.08c-.07.12-.17.17-.29.17h-2.11c-.15,0-.23-.08-.27-.22l-3.03-19.33-2.23,10.24-2.08,9.1c-.03.15-.13.22-.27.22h-2.41c-.17,0-.25-.1-.22-.27Z"/>
|
|
||||||
<path class="st0" d="M168.95,129.55c1.01-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM181.38,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
|
|
||||||
<path class="st0" d="M187.85,138.85l2.04-8.54,1.83-8.23c.03-.15.13-.22.27-.22h2.56c.15,0,.25.1.2.27l-.79,3.07c1.74-2.29,4.07-3.5,6.42-3.5.37,0,.72.03,1.02.12.15.03.2.13.17.28l-.44,1.88c-.03.15-.13.2-.28.18-.27-.07-.59-.08-.9-.08-2.76,0-5.55,1.93-6.89,5.38l-2.14,9.45c-.03.15-.13.22-.27.22h-2.58c-.17,0-.27-.1-.22-.27Z"/>
|
|
||||||
<path class="st0" d="M202.32,138.87l2.71-11.38,2.58-11.61c.03-.15.13-.22.27-.22h2.58c.17,0,.27.1.22.27l-3.08,12.9,7.81-6.85c.1-.07.18-.12.3-.12h3.32c.22,0,.28.15.12.3l-7.04,5.93,3.94,10.74c.05.18-.02.29-.2.29h-2.68c-.13,0-.22-.05-.27-.2l-.8-2.51-2.33-6.4-2.98,2.61-1.39,6.3c-.03.15-.13.22-.27.22h-2.58c-.17,0-.25-.1-.22-.27Z"/>
|
|
||||||
<path class="st0" d="M219.82,129.22c.92-4.04,3.95-7.71,8.98-7.71s7.96,3.69,6.8,9.15c-.02.13-.12.2-.25.2h-12.78c-.37,3.18.57,6.5,4.51,6.5,2.35,0,4.21-1.12,5.09-3.94.05-.13.15-.2.28-.2h2.41c.17,0,.25.12.2.29-1.17,3.99-4.07,5.96-8.01,5.96-6.68,0-8.38-5.39-7.24-10.25ZM232.79,128.82c.47-3-1.14-5.21-4.15-5.21-3.17,0-4.98,2.43-5.71,5.21h9.87Z"/>
|
|
||||||
<path class="st0" d="M240.64,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
|
|
||||||
<path class="st0" d="M251.75,138.85l2.04-8.65,1.83-8.13c.03-.15.13-.22.27-.22h2.65c.17,0,.25.1.22.27l-1.94,8.06-1.94,8.71c-.02.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.22-.27ZM256.2,119.55l.65-2.73c.03-.15.13-.22.27-.22h2.61c.17,0,.25.1.22.27l-.64,2.75c-.03.13-.13.22-.27.22h-2.63c-.17,0-.25-.12-.22-.28Z"/>
|
|
||||||
<path class="st0" d="M260.47,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
|
||||||
<path class="st0" d="M279.79,140.28c0-.15.1-.25.25-.25h2.58c.15,0,.23.08.25.25.03,2.09,1.71,3.28,4.04,3.28,3.1,0,4.71-1.78,5.63-5.71l.28-1.26c-1.34,1.56-3.12,2.58-5.31,2.58-5.06,0-6.85-4.72-5.7-9.68.99-4.31,3.89-7.89,8.34-7.89,2.88,0,4.54,1.44,5.26,3.54l.65-3.05c.03-.15.13-.22.27-.22h2.58c.17,0,.25.1.22.27l-2.04,8.63-1.61,7.12c-1.21,5.26-4,7.81-8.73,7.81-4.04,0-6.97-2.14-6.97-5.41ZM294.28,130.83c.82-3.6.13-7.12-3.59-7.12-3.23,0-5.08,2.7-5.83,5.93-.87,3.77-.02,7.42,3.54,7.42,3.02,0,5.09-2.78,5.88-6.23Z"/>
|
|
||||||
<path class="st0" d="M307.42,138.82l13.57-22.99c.07-.12.15-.17.28-.17h3.69c.15,0,.23.07.25.23l2.85,22.95c.02.17-.08.27-.23.27h-2.56c-.15,0-.23-.08-.25-.23l-.79-6.85h-9.53l-3.95,6.92c-.07.12-.15.17-.29.17h-2.88c-.18,0-.25-.12-.15-.3ZM323.99,129.52l-1.31-11.61-6.62,11.61h7.92Z"/>
|
|
||||||
<path class="st0" d="M332.8,132.49l.74-3.25,1.61-7.15c.03-.15.13-.22.27-.22h2.63c.17,0,.25.1.2.27l-1.64,6.82-.72,3.3c-.75,3.3.49,4.94,2.78,4.94,2.63,0,4.49-2.43,5.63-5.19l.72-3.08,1.51-6.84c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-1.84,7.57-2.08,9.2c-.02.15-.12.22-.25.22h-2.51c-.15,0-.25-.1-.2-.27l.69-2.76c-1.24,1.86-3.43,3.43-6.01,3.43-3.38,0-5.71-2.48-4.66-7.04Z"/>
|
|
||||||
<path class="st0" d="M353.75,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
|
|
||||||
<path class="st0" d="M365.46,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM378.51,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
|
|
||||||
<path class="st0" d="M384.76,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.19-1.84,3.22-3.43,5.71-3.43,2.18,0,3.85,1.17,4.31,3.45,1.27-1.84,3.42-3.45,5.93-3.45,3.25,0,5.55,2.45,4.51,7.04l-.75,3.25-1.61,7.15c-.02.15-.12.22-.25.22h-2.63c-.17,0-.25-.1-.22-.27l1.64-6.82.74-3.3c.79-3.47-.49-4.94-2.46-4.94-2.24,0-3.84,2.04-4.98,4.52-.02.25-.05.55-.12.84l-.6,2.6-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.15,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.77-3.47-.49-4.94-2.48-4.94-2.41,0-4.09,2.41-5.23,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
|
||||||
<path class="st0" d="M415.88,129.55c1-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM428.31,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
|
|
||||||
<path class="st0" d="M437.29,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
|
|
||||||
<path class="st0" d="M448.39,138.85l2.04-8.65,1.83-8.13c.03-.15.13-.22.27-.22h2.65c.17,0,.25.1.22.27l-1.94,8.06-1.94,8.71c-.02.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.22-.27ZM452.85,119.55l.65-2.73c.03-.15.13-.22.27-.22h2.61c.17,0,.25.1.22.27l-.64,2.75c-.03.13-.13.22-.27.22h-2.63c-.17,0-.25-.12-.22-.28Z"/>
|
|
||||||
<path class="st0" d="M458.71,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM471.76,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
|
|
||||||
<path class="st0" d="M478,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
|
||||||
<path class="st0" d="M506.33,138.85l2.81-11.76,2.5-11.21c.03-.15.13-.22.27-.22h7.04c5.68,0,8.44,3.43,7.37,8.13-.9,3.89-4.31,6.28-9.6,6.28h-5.23l-1.98,8.83c-.03.15-.13.22-.27.22h-2.7c-.17,0-.25-.1-.22-.27ZM516.95,127.74c3.6,0,5.7-1.62,6.3-4.22.7-3.08-.79-5.51-4.52-5.51h-4.41l-2.33,9.73h4.96Z"/>
|
|
||||||
<path class="st0" d="M527.7,138.85l2.78-11.61,2.53-11.36c.03-.15.13-.22.27-.22h2.56c.17,0,.25.1.22.27l-2.7,11.28-2.61,11.69c-.03.15-.13.22-.27.22h-2.56c-.17,0-.27-.1-.22-.27Z"/>
|
|
||||||
<path class="st0" d="M537.92,129.55c1-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM550.35,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
|
|
||||||
<path class="st0" d="M559.32,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
|
|
||||||
<path class="st0" d="M572.12,138.85l1.83-7.52,1.71-7.22h-2.45c-.17,0-.25-.1-.22-.29l.42-1.74c.03-.15.13-.22.27-.22h2.5l.37-1.56c.85-3.77,3.32-5.09,5.71-5.09.8,0,1.64.12,2.23.25.15.05.22.15.18.3l-.4,1.69c-.03.17-.13.22-.3.17-.45-.12-.97-.17-1.57-.17-1.36,0-2.4.85-2.9,3.08l-.32,1.32h3.79c.17,0,.25.1.22.27l-.4,1.76c-.03.13-.13.22-.27.22h-3.85l-1.66,7.22-1.64,7.57c-.03.15-.13.22-.27.22h-2.75c-.17,0-.25-.1-.22-.27Z"/>
|
|
||||||
<path class="st0" d="M584.65,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM597.7,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
|
|
||||||
<path class="st0" d="M603.95,138.85l2.04-8.54,1.83-8.23c.03-.15.13-.22.27-.22h2.56c.15,0,.25.1.2.27l-.79,3.07c1.74-2.29,4.07-3.5,6.42-3.5.37,0,.72.03,1.02.12.15.03.2.13.17.28l-.44,1.88c-.03.15-.13.2-.29.18-.27-.07-.59-.08-.9-.08-2.76,0-5.55,1.93-6.89,5.38l-2.14,9.45c-.03.15-.13.22-.27.22h-2.58c-.17,0-.27-.1-.22-.27Z"/>
|
|
||||||
<path class="st0" d="M618.42,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.19-1.84,3.22-3.43,5.71-3.43,2.18,0,3.85,1.17,4.31,3.45,1.27-1.84,3.42-3.45,5.93-3.45,3.25,0,5.55,2.45,4.51,7.04l-.75,3.25-1.61,7.15c-.02.15-.12.22-.25.22h-2.63c-.17,0-.25-.1-.22-.27l1.64-6.82.74-3.3c.79-3.47-.49-4.94-2.46-4.94-2.25,0-3.84,2.04-4.98,4.52-.02.25-.05.55-.12.84l-.6,2.6-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.15,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.77-3.47-.49-4.94-2.48-4.94-2.41,0-4.09,2.41-5.23,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,244 @@
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"image_list": [
|
||||||
|
"https://ldb-phinf.pstatic.net/20260112_189/1768176025117RmItD_PNG/ChatGPT_Image_2026%B3%E2_1%BF%F9_9%C0%CF_%BF%C0%C8%C4_03_21_56.png",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_290/1765776039787CALYj_JPEG/IMG_0060.JPG",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_70/1765776016674Nbo0Q_JPEG/IMG_0067_%BA%B9%BB%E7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_136/17657760168457kDf6_JPEG/KakaoTalk_20251214_181208552_03_%BA%B9%BB%E7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_38/1765776039760cYgOj_JPEG/IMG_0044.JPG",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_248/1764320049551B1gHl_JPEG/KakaoTalk_20251128_174635084_27.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_209/1764320105053L8V2f_JPEG/KakaoTalk_20251128_174635084_17.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241205_181/1733360083850LsEVv_JPEG/KakaoTalk_20241203_155353799_09.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_209/1764320017959Rh3vs_JPEG/KakaoTalk_20251128_174635084_13.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_102/1764320169035vluJN_JPEG/KakaoTalk_20251128_174635084_12.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_249/17328584834569zWJT_JPEG/6S5A0642-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_196/1732858501391FjLpd_JPEG/6S5A0657.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_230/1733105198846QGbcW_JPEG/8.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_94/1733105214373Q8ncR_JPEG/%B5%BF%B1%D7%B6%F3%B9%CC_%C3%A2%B9%AE_%B9%E6%B0%A1%C0%BB_2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_110/1701910802632rDM5r_JPEG/a_%28301%29-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_7/1733106037765MNR8e_JPEG/9-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_42/17055625561156LIx7_JPEG/6S5A0809-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_246/1705562556109bihHB_JPEG/6S5A1820-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_259/1733104881690lDgnH_JPEG/3-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_76/1701910651888PpVap_JPEG/%C6%EA%B5%E5%B6%F3%C0%CC%B7%EB.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_242/1701910651863IAAlE_JPEG/%B0%AD%BE%C6%C1%F6_%BA%F1%C7%B0.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_120/17331038087267aQ0S_JPEG/KakaoTalk_20241015_154536888_04.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_164/17521338301365LRwl_JPEG/6S5A6748-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_169/1752133830016fqFlL_JPEG/6S5A6760-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_80/1701920469022ef7TW_JPEG/6S5A0614-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_140/17173835278369nntD_JPEG/6S5A6707.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_173/1752133829861tPALd_JPEG/6S5A7136.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_248/1733106037776BGjJx_JPEG/1-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_275/1717383527874oJOpz_JPEG/DJI_0040-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_153/1752133811173ba7oE_JPEG/6S5A6458.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_185/1752133829868FyD76_JPEG/6S5A6815.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_220/1752133829963w1aee_JPEG/6S5A6856.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_228/1752133830002U65jg_JPEG/6S5A6864.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_178/1752133829793fQQ2E_JPEG/6S5A7016-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_279/1752133829838lTSC9_JPEG/6S5A7034-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_179/1752133811452tOl7F_JPEG/6S5A6363.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_75/1752133811152vd5ts_JPEG/6S5A6513.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_147/1752133810693TfR5M_JPEG/6S5A6556.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_202/1752133811177NRBDn_JPEG/6S5A6619-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_196/1752133811066ufvpA_JPEG/6S5A6657.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_280/1752133811307QCs3n_JPEG/6S5A6709.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_85/1717383527884ST2DF_JPEG/6S5A6540.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_249/1717383528285Om6dI_JPEG/6S5A6648.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_288/1717383528022TxA4c_JPEG/6S5A6785.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_284/1725865224424ux2W4_JPEG/6S5A9299-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_87/1732858513628bWPt7_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_50/1733105457578cGfYD_JPEG/6S5A9807-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_100/1717383527950q4yNf_JPEG/6S5A7056.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_221/1717383527791s4ziP_JPEG/6S5A6673.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_134/17331166295182o4vs_JPEG/47%C6%F2_%2829%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_249/17331060371685uFQi_JPEG/3-1_%281%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_238/1725865224397wnWE5_JPEG/DJI_0157.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_34/1701833355386Nsg5j_JPEG/6S5A9995.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_293/1733105206236d8l2f_JPEG/8-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_224/1733105239528RLN59_JPEG/3-1-min.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_31/1733116629874BYgpO_JPEG/47%C6%F2_%2823%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241205_106/1733360083918ER1RI_JPEG/KakaoTalk_20241203_155353799_27.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_126/17328584834200qVDw_JPEG/6S5A0645.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_35/1725865128231ar2eM_PNG/KakaoTalk_20240710_163045662.png",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_153/1733105180908PyVIC_JPEG/KakaoTalk_20231027_143438563_25-min.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_128/1732858517984PFmrJ_JPEG/KakaoTalk_20241119_105023750.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_124/1701910720268qeACJ_JPEG/%B9%D9%BA%A3%C5%A5.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_55/173310545746333mf8_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_11/1733106430749ovoeY_JPEG/37%C6%F2_%2820%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_185/1701910772479mexsh_JPEG/a_%28282%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_211/17055625561294XsN2_JPEG/6S5A1803-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_156/17055625560847R7SL_JPEG/6S5A0934-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_179/17019107724184CCEU_JPEG/6S5A9906.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_287/1701910772339cG7K0_JPEG/a_%28119%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_40/1701910675360dvC1i_JPEG/%C8%AD%C0%E5%BD%C7_%281%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_263/1701910675262pevKk_JPEG/%C8%AD%C0%E5%BD%C7_%284%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_73/1701910651864xPI48_JPEG/%BB%E7%B6%F7_%BC%A4%C7%AA.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_265/17019204486629hzae_JPEG/6S5A0645.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_147/1733103809000xsMrG_JPEG/KakaoTalk_20241015_154532027_01.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_77/1733103809037HSO4S_JPEG/KakaoTalk_20241015_154532027_02.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_233/1733103809020IMEdL_JPEG/KakaoTalk_20241015_154532027_06.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_53/1733103808802Wui9x_JPEG/KakaoTalk_20241015_154532027_04.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_230/1733103808998dOq0b_JPEG/KakaoTalk_20241015_154532027_15.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_122/1733103808509lOt9x_JPEG/KakaoTalk_20241015_154536888_13.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_246/1733103809045RdnEP_JPEG/KakaoTalk_20241015_154536888_11.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_287/1733103808656oB1xz_JPEG/KakaoTalk_20241015_154536888_06.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_222/1733103808508hkf5K_JPEG/KakaoTalk_20241015_154536888_08.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_229/1733103808818KGAps_JPEG/KakaoTalk_20241015_154536888_02.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_12/1733103808751cVAsT_JPEG/KakaoTalk_20241015_154536888_03.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_261/1725865224473Uo7V9_JPEG/6S5A9371-%C6%ED%C1%FD-%C6%ED%C1%FD-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_22/1725865224495AL798_JPEG/6S5A9557-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_177/1725865224462eX1Pi_JPEG/DJI_0149-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_165/1725865224480S7c1j_JPEG/6S5A9468-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_85/1725865224421CNRrk_JPEG/DJI_0155.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_62/1725865224361fP2d2_JPEG/6S5A9434.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_155/1725865224363Xxt8y_JPEG/6S5A9431-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_13/1725865224457BGLGs_JPEG/6S5A9456.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_162/1717383527882Vnrsk_JPEG/6S5A6786.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_256/1717383527892WXOKA_JPEG/6S5A6896.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_130/1717383527800oHSKA_JPEG/6S5A6904.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_118/17173835277794IXlt_JPEG/6S5A6946.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_257/1717383527822ITvHM_JPEG/6S5A6973.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_170/1717383527880sTdFE_JPEG/DJI_0039-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_10/1717383527863CVLJ2_JPEG/DJI_0087-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_94/1717383527921sDeDB_JPEG/DJI_0091-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_81/1701833426718D5Qwk_JPEG/b_%28119%29-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_153/17055625561274LAs5_JPEG/6S5A1460-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_8/1705562556139zGkjb_JPEG/6S5A1780.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_57/1705562556129IR40O_JPEG/6S5A1199.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_260/1705562556111cA7PG_JPEG/6S5A1927-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_183/1701910720221oI66j_JPEG/%B9%D9%BA%A3%C5%A52.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_247/1701910720113MBgKD_JPEG/%B9%D9%BA%A3%C5%A5_%282%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_227/1701910720098FE7Si_JPEG/6S5A0001.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_24/1701833408064fgoVj_JPEG/b_%2874%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_100/1717383527883LWVq4_JPEG/6S5A6022-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_169/17173835278856AEUV_JPEG/6S5A5974-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_155/1717383527937gxiyc_JPEG/6S5A5988.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_160/170183333003287LrV_JPEG/6S5A0243-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_217/1717383528190t41X5_JPEG/6S5A6027.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_142/1717383527892jHtgI_JPEG/6S5A6643.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_251/1717383527948jTN2a_JPEG/6S5A6125.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_189/1717383527880wu4Bv_JPEG/6S5A6326.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_14/1717383527794qCuuu_JPEG/6S5A6337.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
|
||||||
|
],
|
||||||
|
"image_count": 119,
|
||||||
|
"m_id": 1,
|
||||||
|
"processed_info": {
|
||||||
|
"customer_name": "풀스테이 스테이펫 홍천",
|
||||||
|
"region": "",
|
||||||
|
"detail_region_info": "강원 홍천군 화촌면 답연밭길 5-2 스테이펫 홍천"
|
||||||
|
},
|
||||||
|
"marketing_analysis": {
|
||||||
|
"brand_identity": {
|
||||||
|
"location_feature_analysis": "강원 홍천 화촌면의 자연 친화적 입지는 도심 소음에서 벗어난 ‘조용한 숲세권’ 체류 니즈에 최적이며, 수도권에서 주말 이동이 가능한 거리감으로 숏브레이크 수요를 흡수하기 유리합니다.",
|
||||||
|
"concept_scalability": "‘스테이펫’ 콘셉트는 반려동물 동반 숙박을 넘어 펫 어메니티·펫 포토·펫 웰니스(산책 코스/케어 서비스)까지 확장 가능해, 체류 전 과정의 경험 가치를 패키지화하기 좋습니다."
|
||||||
|
},
|
||||||
|
"market_positioning": {
|
||||||
|
"category_definition": "자연 속 프라이빗 애견 동반 힐링 스테이",
|
||||||
|
"core_value": "반려동물과 ‘눈치 없이’ 온전히 쉬는 프라이빗한 쉼"
|
||||||
|
},
|
||||||
|
"target_persona": [
|
||||||
|
{
|
||||||
|
"persona": "주말 펫캉스 커플(반려견 1마리) – 조용한 자연에서 둘+한 마리의 리셋 여행을 찾는 2030",
|
||||||
|
"age": {
|
||||||
|
"min_age": 27,
|
||||||
|
"max_age": 39
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"반려견 동반 가능 숙소",
|
||||||
|
"자연 뷰/산책하기 좋은 동선",
|
||||||
|
"청결한 침구와 탈취 관리",
|
||||||
|
"프라이빗한 공간 구성",
|
||||||
|
"감성 사진이 잘 나오는 무드"
|
||||||
|
],
|
||||||
|
"decision_trigger": "반려견 동반 + 프라이빗하게 쉴 수 있다는 확신(후기/시설 안내)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"persona": "도심 탈출 1인/2인 힐링러 – 조용한 곳에서 휴식·독서를 즐기며 재충전하는 직장인",
|
||||||
|
"age": {
|
||||||
|
"min_age": 29,
|
||||||
|
"max_age": 45
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"소음 적은 입지",
|
||||||
|
"자연 속 뷰",
|
||||||
|
"편안한 침대/휴식형 라운지",
|
||||||
|
"야간 감성 조명",
|
||||||
|
"체크인/이용 동선의 간편함"
|
||||||
|
],
|
||||||
|
"decision_trigger": "‘조용함’과 ‘자연 몰입’이 보장되는 입지/분위기"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"persona": "가족형 펫오너 – 반려견과 함께 안전하고 편하게 머물며 추억을 만드는 3040",
|
||||||
|
"age": {
|
||||||
|
"min_age": 33,
|
||||||
|
"max_age": 49
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"반려견 안전 동선(미끄럼/울타리 등)",
|
||||||
|
"넓은 휴식 공간",
|
||||||
|
"바베큐 등 가족형 액티비티",
|
||||||
|
"주차/이동 편의",
|
||||||
|
"위생/청결 신뢰"
|
||||||
|
],
|
||||||
|
"decision_trigger": "가족 단위도 불편 없는 공간감 + 펫 프렌들리 정책"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selling_points": [
|
||||||
|
{
|
||||||
|
"english_category": "PET FRIENDLY",
|
||||||
|
"korean_category": "애견 동반",
|
||||||
|
"description": "반려견과 함께하는 완전한 휴식",
|
||||||
|
"score": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "HEALING",
|
||||||
|
"korean_category": "힐링 요소",
|
||||||
|
"description": "홍천 자연 속 리셋 스테이",
|
||||||
|
"score": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "PRIVACY",
|
||||||
|
"korean_category": "프라이버시",
|
||||||
|
"description": "눈치 없이 즐기는 프라이빗 쉼",
|
||||||
|
"score": 88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "SHORT GETAWAY",
|
||||||
|
"korean_category": "숏브레이크",
|
||||||
|
"description": "주말에 딱, 가볍게 떠나는 홍천",
|
||||||
|
"score": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "NIGHT MOOD",
|
||||||
|
"korean_category": "야간 감성",
|
||||||
|
"description": "밤이 더 예쁜 감성 무드",
|
||||||
|
"score": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "BBQ PARTY",
|
||||||
|
"korean_category": "바베큐",
|
||||||
|
"description": "자연을 곁들인 바베큐 한 판",
|
||||||
|
"score": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "PHOTO SPOT",
|
||||||
|
"korean_category": "포토 스팟",
|
||||||
|
"description": "반려견 인생샷 남기는 공간",
|
||||||
|
"score": 73
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_keywords": [
|
||||||
|
"홍천애견동반숙소",
|
||||||
|
"강원도애견동반펜션",
|
||||||
|
"스테이펫",
|
||||||
|
"펫캉스",
|
||||||
|
"반려견동반여행",
|
||||||
|
"홍천감성숙소",
|
||||||
|
"강원도힐링스테이",
|
||||||
|
"숲뷰숙소",
|
||||||
|
"주말여행추천",
|
||||||
|
"프라이빗스테이"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"image_list": [
|
||||||
|
"https://ldb-phinf.pstatic.net/20260112_189/1768176025117RmItD_PNG/ChatGPT_Image_2026%B3%E2_1%BF%F9_9%C0%CF_%BF%C0%C8%C4_03_21_56.png",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_290/1765776039787CALYj_JPEG/IMG_0060.JPG",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_70/1765776016674Nbo0Q_JPEG/IMG_0067_%BA%B9%BB%E7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_136/17657760168457kDf6_JPEG/KakaoTalk_20251214_181208552_03_%BA%B9%BB%E7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251215_38/1765776039760cYgOj_JPEG/IMG_0044.JPG",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_248/1764320049551B1gHl_JPEG/KakaoTalk_20251128_174635084_27.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_209/1764320105053L8V2f_JPEG/KakaoTalk_20251128_174635084_17.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241205_181/1733360083850LsEVv_JPEG/KakaoTalk_20241203_155353799_09.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_209/1764320017959Rh3vs_JPEG/KakaoTalk_20251128_174635084_13.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20251128_102/1764320169035vluJN_JPEG/KakaoTalk_20251128_174635084_12.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_249/17328584834569zWJT_JPEG/6S5A0642-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_196/1732858501391FjLpd_JPEG/6S5A0657.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_230/1733105198846QGbcW_JPEG/8.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_94/1733105214373Q8ncR_JPEG/%B5%BF%B1%D7%B6%F3%B9%CC_%C3%A2%B9%AE_%B9%E6%B0%A1%C0%BB_2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_110/1701910802632rDM5r_JPEG/a_%28301%29-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_7/1733106037765MNR8e_JPEG/9-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_42/17055625561156LIx7_JPEG/6S5A0809-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_246/1705562556109bihHB_JPEG/6S5A1820-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_259/1733104881690lDgnH_JPEG/3-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_76/1701910651888PpVap_JPEG/%C6%EA%B5%E5%B6%F3%C0%CC%B7%EB.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_242/1701910651863IAAlE_JPEG/%B0%AD%BE%C6%C1%F6_%BA%F1%C7%B0.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_120/17331038087267aQ0S_JPEG/KakaoTalk_20241015_154536888_04.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_164/17521338301365LRwl_JPEG/6S5A6748-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_169/1752133830016fqFlL_JPEG/6S5A6760-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_80/1701920469022ef7TW_JPEG/6S5A0614-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_140/17173835278369nntD_JPEG/6S5A6707.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_173/1752133829861tPALd_JPEG/6S5A7136.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_248/1733106037776BGjJx_JPEG/1-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_275/1717383527874oJOpz_JPEG/DJI_0040-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_153/1752133811173ba7oE_JPEG/6S5A6458.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_185/1752133829868FyD76_JPEG/6S5A6815.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_220/1752133829963w1aee_JPEG/6S5A6856.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_228/1752133830002U65jg_JPEG/6S5A6864.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_178/1752133829793fQQ2E_JPEG/6S5A7016-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_279/1752133829838lTSC9_JPEG/6S5A7034-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_179/1752133811452tOl7F_JPEG/6S5A6363.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_75/1752133811152vd5ts_JPEG/6S5A6513.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_147/1752133810693TfR5M_JPEG/6S5A6556.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_202/1752133811177NRBDn_JPEG/6S5A6619-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_196/1752133811066ufvpA_JPEG/6S5A6657.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20250710_280/1752133811307QCs3n_JPEG/6S5A6709.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_85/1717383527884ST2DF_JPEG/6S5A6540.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_249/1717383528285Om6dI_JPEG/6S5A6648.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_288/1717383528022TxA4c_JPEG/6S5A6785.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_284/1725865224424ux2W4_JPEG/6S5A9299-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_87/1732858513628bWPt7_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_50/1733105457578cGfYD_JPEG/6S5A9807-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_100/1717383527950q4yNf_JPEG/6S5A7056.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_221/1717383527791s4ziP_JPEG/6S5A6673.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_134/17331166295182o4vs_JPEG/47%C6%F2_%2829%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_249/17331060371685uFQi_JPEG/3-1_%281%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_238/1725865224397wnWE5_JPEG/DJI_0157.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_34/1701833355386Nsg5j_JPEG/6S5A9995.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_293/1733105206236d8l2f_JPEG/8-1.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_224/1733105239528RLN59_JPEG/3-1-min.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_31/1733116629874BYgpO_JPEG/47%C6%F2_%2823%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241205_106/1733360083918ER1RI_JPEG/KakaoTalk_20241203_155353799_27.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_126/17328584834200qVDw_JPEG/6S5A0645.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_35/1725865128231ar2eM_PNG/KakaoTalk_20240710_163045662.png",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_153/1733105180908PyVIC_JPEG/KakaoTalk_20231027_143438563_25-min.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241129_128/1732858517984PFmrJ_JPEG/KakaoTalk_20241119_105023750.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_124/1701910720268qeACJ_JPEG/%B9%D9%BA%A3%C5%A7.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_55/173310545746333mf8_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_11/1733106430749ovoeY_JPEG/37%C6%F2_%2820%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_185/1701910772479mexsh_JPEG/a_%28282%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_211/17055625561294XsN2_JPEG/6S5A1803-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_156/17055625560847R7SL_JPEG/6S5A0934-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_179/17019107724184CCEU_JPEG/6S5A9906.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_287/1701910772339cG7K0_JPEG/a_%28119%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_40/1701910675360dvC1i_JPEG/%C8%AD%C0%E5%BD%C7_%281%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_263/1701910675262pevKk_JPEG/%C8%AD%C0%E5%BD%C7_%284%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_73/1701910651864xPI48_JPEG/%BB%E7%B6%F7_%BC%A4%C7%AA.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_265/17019204486629hzae_JPEG/6S5A0645.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_147/1733103809000xsMrG_JPEG/KakaoTalk_20241015_154532027_01.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_77/1733103809037HSO4S_JPEG/KakaoTalk_20241015_154532027_02.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_233/1733103809020IMEdL_JPEG/KakaoTalk_20241015_154532027_06.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_53/1733103808802Wui9x_JPEG/KakaoTalk_20241015_154532027_04.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_230/1733103808998dOq0b_JPEG/KakaoTalk_20241015_154532027_15.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_122/1733103808509lOt9x_JPEG/KakaoTalk_20241015_154536888_13.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_246/1733103809045RdnEP_JPEG/KakaoTalk_20241015_154536888_11.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_287/1733103808656oB1xz_JPEG/KakaoTalk_20241015_154536888_06.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_222/1733103808508hkf5K_JPEG/KakaoTalk_20241015_154536888_08.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_229/1733103808818KGAps_JPEG/KakaoTalk_20241015_154536888_02.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20241202_12/1733103808751cVAsT_JPEG/KakaoTalk_20241015_154536888_03.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_261/1725865224473Uo7V9_JPEG/6S5A9371-%C6%ED%C1%FD-%C6%ED%C1%FD-2.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_22/1725865224495AL798_JPEG/6S5A9557-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_177/1725865224462eX1Pi_JPEG/DJI_0149-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_165/1725865224480S7c1j_JPEG/6S5A9468-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_85/1725865224421CNRrk_JPEG/DJI_0155.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_62/1725865224361fP2d2_JPEG/6S5A9434.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_155/1725865224363Xxt8y_JPEG/6S5A9431-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240909_13/1725865224457BGLGs_JPEG/6S5A9456.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_162/1717383527882Vnrsk_JPEG/6S5A6786.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_256/1717383527892WXOKA_JPEG/6S5A6896.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_130/1717383527800oHSKA_JPEG/6S5A6904.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_118/17173835277794IXlt_JPEG/6S5A6946.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_257/1717383527822ITvHM_JPEG/6S5A6973.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_170/1717383527880sTdFE_JPEG/DJI_0039-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_10/1717383527863CVLJ2_JPEG/DJI_0087-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_94/1717383527921sDeDB_JPEG/DJI_0091-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_81/1701833426718D5Qwk_JPEG/b_%28119%29-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_153/17055625561274LAs5_JPEG/6S5A1460-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_8/1705562556139zGkjb_JPEG/6S5A1780.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_57/1705562556129IR40O_JPEG/6S5A1199.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240118_260/1705562556111cA7PG_JPEG/6S5A1927-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_183/1701910720221oI66j_JPEG/%B9%D9%BA%A3%C5%A52.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_247/1701910720113MBgKD_JPEG/%B9%D9%BA%A3%C5%A5_%282%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231207_227/1701910720098FE7Si_JPEG/6S5A0001.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_24/1701833408064fgoVj_JPEG/b_%2874%29.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_100/1717383527883LWVq4_JPEG/6S5A6022-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_169/17173835278856AEUV_JPEG/6S5A5974-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_155/1717383527937gxiyc_JPEG/6S5A5988.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20231206_160/170183333003287LrV_JPEG/6S5A0243-%C6%ED%C1%FD.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_217/1717383528190t41X5_JPEG/6S5A6027.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_142/1717383527892jHtgI_JPEG/6S5A6643.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_251/1717383527948jTN2a_JPEG/6S5A6125.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_189/1717383527880wu4Bv_JPEG/6S5A6326.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_14/1717383527794qCuuu_JPEG/6S5A6337.jpg",
|
||||||
|
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
|
||||||
|
],
|
||||||
|
"image_count": 119,
|
||||||
|
"m_id": 1,
|
||||||
|
"processed_info": {
|
||||||
|
"customer_name": "Fullstay StayPet Hongcheon",
|
||||||
|
"region": "",
|
||||||
|
"detail_region_info": "5-2 Dapyeonbat-gil, Hwachon-myeon, Hongcheon-gun, Gangwon-do, Korea"
|
||||||
|
},
|
||||||
|
"marketing_analysis": {
|
||||||
|
"brand_identity": {
|
||||||
|
"location_feature_analysis": "Located in the nature-rich Hwachon-myeon area of Hongcheon, Gangwon-do, this property offers the ideal setting for a 'quiet forest retreat' away from urban noise. Its proximity to the Seoul metropolitan area makes it perfect for weekend short-break getaways.",
|
||||||
|
"concept_scalability": "The 'StayPet' concept extends beyond simple pet-friendly accommodation to include pet amenities, pet photo zones, and pet wellness experiences (walking trails/care services), enabling a comprehensive packaged experience throughout the entire stay."
|
||||||
|
},
|
||||||
|
"market_positioning": {
|
||||||
|
"category_definition": "Private Pet-Friendly Healing Stay in Nature",
|
||||||
|
"core_value": "A private retreat where you and your pet can relax without worry"
|
||||||
|
},
|
||||||
|
"target_persona": [
|
||||||
|
{
|
||||||
|
"persona": "Weekend Pet-cation Couples (with 1 dog) – Millennials seeking a reset trip for two + one furry friend in peaceful nature",
|
||||||
|
"age": {
|
||||||
|
"min_age": 27,
|
||||||
|
"max_age": 39
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"Pet-friendly accommodation",
|
||||||
|
"Nature views / great walking trails",
|
||||||
|
"Clean bedding and odor management",
|
||||||
|
"Private space configuration",
|
||||||
|
"Aesthetic mood for great photos"
|
||||||
|
],
|
||||||
|
"decision_trigger": "Confidence in pet-friendly + private relaxation (verified through reviews and facility info)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"persona": "Urban Escape Solo/Duo Healers – Working professionals recharging with quiet rest and reading in nature",
|
||||||
|
"age": {
|
||||||
|
"min_age": 29,
|
||||||
|
"max_age": 45
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"Low-noise location",
|
||||||
|
"Nature views",
|
||||||
|
"Comfortable beds / relaxation lounge",
|
||||||
|
"Ambient night lighting",
|
||||||
|
"Simple check-in / easy access"
|
||||||
|
],
|
||||||
|
"decision_trigger": "Guaranteed 'quietness' and 'nature immersion' through location and atmosphere"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"persona": "Family Pet Owners – Families in their 30s-40s creating memories with their pets in a safe, comfortable stay",
|
||||||
|
"age": {
|
||||||
|
"min_age": 33,
|
||||||
|
"max_age": 49
|
||||||
|
},
|
||||||
|
"favor_target": [
|
||||||
|
"Pet safety features (non-slip floors / fencing)",
|
||||||
|
"Spacious relaxation areas",
|
||||||
|
"Family activities like BBQ",
|
||||||
|
"Parking / travel convenience",
|
||||||
|
"Hygiene / cleanliness assurance"
|
||||||
|
],
|
||||||
|
"decision_trigger": "Spacious enough for families + reliable pet-friendly policies"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selling_points": [
|
||||||
|
{
|
||||||
|
"english_category": "PET FRIENDLY",
|
||||||
|
"korean_category": "Pet-Friendly",
|
||||||
|
"description": "A complete retreat with your beloved pet",
|
||||||
|
"score": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "HEALING",
|
||||||
|
"korean_category": "Healing",
|
||||||
|
"description": "A reset stay in Hongcheon's natural surroundings",
|
||||||
|
"score": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "PRIVACY",
|
||||||
|
"korean_category": "Privacy",
|
||||||
|
"description": "A private retreat — relax without a care",
|
||||||
|
"score": 88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "SHORT GETAWAY",
|
||||||
|
"korean_category": "Short Getaway",
|
||||||
|
"description": "A quick weekend escape to Hongcheon",
|
||||||
|
"score": 82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "NIGHT MOOD",
|
||||||
|
"korean_category": "Night Mood",
|
||||||
|
"description": "An aesthetic ambiance that shines at night",
|
||||||
|
"score": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "BBQ PARTY",
|
||||||
|
"korean_category": "BBQ Party",
|
||||||
|
"description": "A BBQ feast surrounded by nature",
|
||||||
|
"score": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"english_category": "PHOTO SPOT",
|
||||||
|
"korean_category": "Photo Spot",
|
||||||
|
"description": "The perfect spot for unforgettable pet photos",
|
||||||
|
"score": 73
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"target_keywords": [
|
||||||
|
"Hongcheon pet-friendly stay",
|
||||||
|
"Gangwon-do pet pension",
|
||||||
|
"StayPet",
|
||||||
|
"Pet-cation",
|
||||||
|
"Travel with pets",
|
||||||
|
"Hongcheon aesthetic stay",
|
||||||
|
"Gangwon healing retreat",
|
||||||
|
"Forest view accommodation",
|
||||||
|
"Weekend trip recommendation",
|
||||||
|
"Private stay"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>개인정보처리방침 - ADO2</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
|
|
||||||
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
|
|
||||||
h2 { font-size: 19px; margin-top: 40px; color: #111; }
|
|
||||||
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
|
|
||||||
ul { padding-left: 20px; }
|
|
||||||
.google-policy { background: #f0f7ff; border-left: 4px solid #4285f4; padding: 16px 20px; margin: 24px 0; border-radius: 4px; }
|
|
||||||
.google-policy p { color: #1a1a2e; margin: 0; }
|
|
||||||
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>개인정보처리방침 (Privacy Policy)</h1>
|
|
||||||
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 | <strong>최종 수정일:</strong> 2026년 5월 27일</p>
|
|
||||||
|
|
||||||
<p>㈜에이아이오투오(이하 "회사")는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")를 제공함에 있어 사용자의 개인정보를 중요시하며, 「개인정보 보호법」 등 관련 법령을 성실히 준수합니다.</p>
|
|
||||||
|
|
||||||
<h2>1. 수집하는 개인정보 항목 및 수집 방법</h2>
|
|
||||||
<p>회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집합니다.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>카카오 로그인을 통한 수집:</strong> 이름, 이메일 주소, 프로필 사진 — 서비스 회원 가입 및 로그인에 사용</li>
|
|
||||||
<li><strong>Google 사용자 프로필 :</strong> YouTube 채널 연동 시 연결된 구글 계정의 이름 및 프로필 사진 확인 — 채널 소유자 식별 목적으로만 사용</li>
|
|
||||||
<li><strong>YouTube Data API :</strong> 채널 정보, 동영상 목록, 재생목록 등 YouTube 계정 데이터 (읽기 전용)</li>
|
|
||||||
<li><strong>YouTube Data API :</strong> AI가 생성한 콘텐츠를 YouTube에 업로드하기 위한 동영상 관리 권한</li>
|
|
||||||
<li><strong>YouTube Analytics API :</strong> 채널 및 동영상의 조회수, 시청 시간 등 성과 지표 데이터 (읽기 전용)</li>
|
|
||||||
<li><strong>서비스 이용 과정에서 자동 수집:</strong> 접속 IP, 쿠키, 서비스 이용 기록</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>2. Google 사용자 데이터의 수집 및 이용 목적</h2>
|
|
||||||
<p>회사가 접근하는 Google 사용자 데이터는 아래 목적으로만 사용되며, 명시된 목적 외에는 사용하지 않습니다.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Google 사용자 프로필:</strong> YouTube 채널 연동 과정에서 연결 대상 구글 계정을 식별하는 용도로만 사용하며, 서비스 로그인에는 사용되지 않습니다.</li>
|
|
||||||
<li><strong>YouTube 계정 데이터 (읽기):</strong> 기존 채널 정보·동영상 현황을 분석하여 AI 콘텐츠 전략 수립에 활용합니다.</li>
|
|
||||||
<li><strong>YouTube 동영상 업로드:</strong> AI가 생성한 영상을 사용자의 YouTube 채널에 업로드합니다. 업로드는 반드시 사용자의 명시적 요청에 의해서만 실행됩니다.</li>
|
|
||||||
<li><strong>YouTube 분석 데이터:</strong> 채널 성과 지표를 분석하여 AI 마케팅 전략 수립 및 콘텐츠 최적화에 활용합니다.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="google-policy">
|
|
||||||
<p><strong>[Google API 서비스 사용자 데이터 정책 준수]</strong><br><br>
|
|
||||||
㈜에이아이오투오가 운영하는 ADO2 서비스가 Google API로부터 수신한 정보의 사용 및 타 앱으로의 전송은,
|
|
||||||
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank">Google API 서비스 사용자 데이터 정책</a>의
|
|
||||||
제한적 사용(Limited Use) 요건을 포함한 모든 정책을 엄격히 준수합니다.<br><br>
|
|
||||||
특히, Google API로부터 수신한 데이터는 <strong>AI·ML 모델 학습에 사용되지 않으며</strong>, 사용자가 명시적으로 요청한 서비스 기능 제공 목적 외에는 사용·전송·공유되지 않습니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>3. Google 사용자 데이터의 제3자 공유</h2>
|
|
||||||
<p>회사는 Google API로부터 수신한 사용자 데이터를 아래 경우를 제외하고 어떠한 제3자에게도 판매하거나 공유하지 않습니다.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>AI 콘텐츠 생성 처리:</strong> 서비스 제공에 필수적인 AI 생성 기능 수행을 위해 처리 서버로 전달될 수 있으며, 해당 처리는 서비스 제공 목적으로만 사용됩니다.</li>
|
|
||||||
<li><strong>법적 요구:</strong> 관련 법령에 의거한 수사기관 등의 적법한 요청이 있는 경우</li>
|
|
||||||
</ul>
|
|
||||||
<p>Google 사용자 데이터는 광고, 마케팅, 프로파일링 목적으로 사용되지 않습니다.</p>
|
|
||||||
|
|
||||||
<h2>4. Google 사용자 데이터의 저장 및 보안</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Google OAuth 액세스 토큰 및 리프레시 토큰은 암호화된 상태로 보관됩니다.</li>
|
|
||||||
<li>Google API를 통해 읽어온 데이터는 서비스 기능 처리에 필요한 최소한의 시간 동안만 임시 보유하며, 처리 완료 후 삭제됩니다.</li>
|
|
||||||
<li>서버와의 모든 통신은 TLS(HTTPS)를 통해 암호화됩니다.</li>
|
|
||||||
<li>데이터 접근 권한은 서비스 운영에 필요한 최소 인원에게만 부여됩니다.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>5. 개인정보의 보유 및 이용 기간 / 데이터 삭제</h2>
|
|
||||||
<p>원칙적으로 회원 탈퇴 또는 개인정보 수집·이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.</p>
|
|
||||||
<ul>
|
|
||||||
<li>전자상거래 관련 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)</li>
|
|
||||||
<li>접속 로그 기록: 3개월 (통신비밀보호법)</li>
|
|
||||||
<li>Google 연동 토큰: 연동 해제 또는 회원 탈퇴 즉시 삭제</li>
|
|
||||||
</ul>
|
|
||||||
<p><strong>데이터 삭제 요청 방법:</strong> 아래 이메일(<a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a>)로 요청하시면 <strong>영업일 기준 7일 이내</strong>에 처리 결과를 안내해 드립니다. Google 계정 연동 해제는 서비스 내 계정 설정에서 직접 처리하실 수 있으며, 해제 즉시 관련 토큰이 삭제됩니다.</p>
|
|
||||||
|
|
||||||
<h2>6. 정보주체의 권리 및 행사 방법</h2>
|
|
||||||
<p>사용자는 언제든지 자신의 개인정보에 대한 열람, 수정, 삭제, 처리 정지를 요청할 수 있습니다. 서비스 내 계정 설정에서 직접 처리하거나 아래 문의처로 연락해 주시기 바랍니다.</p>
|
|
||||||
<p>또한, <a href="https://myaccount.google.com/permissions" target="_blank">Google 계정 권한 관리 페이지</a>에서 ADO2 앱의 Google 데이터 접근 권한을 언제든지 직접 취소하실 수 있습니다.</p>
|
|
||||||
|
|
||||||
<h2>7. 개인정보 보호책임자 및 문의처</h2>
|
|
||||||
<p>개인정보 보호와 관련된 불만 처리 및 피해 구제에 관한 사항은 아래로 문의해 주시기 바랍니다.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
|
||||||
<li><strong>서비스명:</strong> ADO2</li>
|
|
||||||
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
|
|
||||||
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>서비스 약관 - ADO2</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
|
|
||||||
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
|
|
||||||
h2 { font-size: 19px; margin-top: 40px; color: #111; }
|
|
||||||
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
|
|
||||||
ul { padding-left: 20px; }
|
|
||||||
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>서비스 이용약관 (Terms of Service)</h1>
|
|
||||||
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 | <strong>최종 수정일:</strong> 2026년 5월 21일</p>
|
|
||||||
|
|
||||||
<h2>제 1 조 (목적)</h2>
|
|
||||||
<p>본 약관은 ㈜에이아이오투오(이하 "회사")가 제공하는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")의 이용과 관련하여, 회사와 이용자(이하 "회원") 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.</p>
|
|
||||||
|
|
||||||
<h2>제 2 조 (용어의 정의)</h2>
|
|
||||||
<ul>
|
|
||||||
<li>"서비스"라 함은 회사가 제공하는 ADO2 AI 마케팅 자동화 플랫폼 및 관련 제반 기능을 의미합니다.</li>
|
|
||||||
<li>"회원"이라 함은 본 약관에 동의하고 회사와 이용계약을 체결하여 서비스를 이용하는 자를 말합니다.</li>
|
|
||||||
<li>"콘텐츠"라 함은 서비스 내에서 AI가 생성하거나 회원이 등록하는 텍스트, 이미지, 음악, 영상 등 일체의 자료를 말합니다.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>제 3 조 (약관의 효력 및 변경)</h2>
|
|
||||||
<p>회사는 본 약관의 내용을 서비스 화면에 게시하며, 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있습니다. 약관이 변경되는 경우 시행일 7일 전부터 공지합니다.</p>
|
|
||||||
|
|
||||||
<h2>제 4 조 (서비스의 제공 및 변경)</h2>
|
|
||||||
<p>회사는 AI 기반 마케팅 콘텐츠(가사, 이미지, 영상 등) 자동 생성, Google·YouTube 등 외부 플랫폼 연동, SNS 자동 배포 등의 서비스를 제공합니다. 운영상·기술상의 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.</p>
|
|
||||||
|
|
||||||
<h2>제 5 조 (회원의 의무)</h2>
|
|
||||||
<ul>
|
|
||||||
<li>타인의 Google 계정 등 외부 서비스 계정을 무단으로 도용하여 서비스를 이용해서는 안 됩니다.</li>
|
|
||||||
<li>스팸 발송, API 한도 고의 초과, 허위 정보 등록 등 비정상적인 방법으로 서비스를 이용해서는 안 됩니다.</li>
|
|
||||||
<li>회사 또는 제3자의 지식재산권을 침해해서는 안 됩니다.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>제 6 조 (외부 API 연동 및 데이터 활용)</h2>
|
|
||||||
<p>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API 및 YouTube Analytics API를 사용하며, 이를 통해 수집·처리되는 데이터는 <a href="/privacy.html">개인정보처리방침</a>에 따라 관리됩니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.</p>
|
|
||||||
<p>YouTube API 서비스 이용과 관련하여 <a href="https://www.youtube.com/t/terms" target="_blank">YouTube 이용약관</a> 및 <a href="https://policies.google.com/privacy" target="_blank">Google 개인정보처리방침</a>이 함께 적용됩니다.</p>
|
|
||||||
|
|
||||||
<h2>제 7 조 (AI 생성 콘텐츠의 권리)</h2>
|
|
||||||
<p>서비스 내에서 AI가 생성한 콘텐츠에 대한 권리 관계는 관련 법령 및 회사의 별도 정책에 따릅니다. 회원이 직접 입력한 정보(매장 URL, 상호명 등)를 기반으로 생성된 콘텐츠에 대한 책임은 회원에게 있습니다.</p>
|
|
||||||
|
|
||||||
<h2>제 8 조 (책임 제한)</h2>
|
|
||||||
<p>회사는 천재지변, 외부 플랫폼(Google, YouTube, Naver 등)의 장애, 통신 장애 등 불가항력으로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</p>
|
|
||||||
|
|
||||||
<h2>제 9 조 (준거법 및 재판관할)</h2>
|
|
||||||
<p>본 약관과 관련된 분쟁은 대한민국 법을 준거법으로 하며, 소송은 회사의 소재지를 관할하는 법원에 제소합니다.</p>
|
|
||||||
|
|
||||||
<h2>문의처</h2>
|
|
||||||
<ul>
|
|
||||||
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
|
||||||
<li><strong>서비스명:</strong> ADO2</li>
|
|
||||||
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
|
|
||||||
<li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
87
src/App.tsx
87
src/App.tsx
|
|
@ -12,10 +12,7 @@ import GenerationFlow from './pages/Dashboard/GenerationFlow';
|
||||||
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
||||||
import SocialConnectError from './pages/Social/SocialConnectError';
|
import SocialConnectError from './pages/Social/SocialConnectError';
|
||||||
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||||
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
|
||||||
import VideoDetailPage from './components/VideoDetailPage';
|
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest } from './utils/api';
|
|
||||||
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
|
||||||
import { CrawlingResponse } from './types/api';
|
import { CrawlingResponse } from './types/api';
|
||||||
|
|
||||||
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
||||||
|
|
@ -99,27 +96,8 @@ const App: React.FC = () => {
|
||||||
parseSavedAnalysisData()
|
parseSavedAnalysisData()
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
|
||||||
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
|
|
||||||
const [scrollProgress, setScrollProgress] = useState(0);
|
const [scrollProgress, setScrollProgress] = useState(0);
|
||||||
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
||||||
const tutorialVideoCheckedRef = useRef(false);
|
|
||||||
|
|
||||||
// generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewMode !== 'generation_flow') return;
|
|
||||||
if (tutorialVideoCheckedRef.current) return;
|
|
||||||
tutorialVideoCheckedRef.current = true;
|
|
||||||
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
|
||||||
if (localStorage.getItem(ENABLED_KEY) !== null) return;
|
|
||||||
getVideosList(1, 1).then(response => {
|
|
||||||
const hasVideos = response.items.some(v => v.result_movie_url?.trim());
|
|
||||||
if (hasVideos) {
|
|
||||||
localStorage.setItem(ENABLED_KEY, 'false');
|
|
||||||
window.dispatchEvent(new Event('ado2-tutorial-auto-disable'));
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}, [viewMode]);
|
|
||||||
|
|
||||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -164,12 +142,6 @@ const App: React.FC = () => {
|
||||||
localStorage.removeItem('castad_wizard_step');
|
localStorage.removeItem('castad_wizard_step');
|
||||||
localStorage.removeItem('castad_active_item');
|
localStorage.removeItem('castad_active_item');
|
||||||
}
|
}
|
||||||
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
|
||||||
sessionStorage.removeItem('castad_login_redirect');
|
|
||||||
if (redirectPath && redirectPath !== '/') {
|
|
||||||
window.location.href = redirectPath;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInitialTab('새 프로젝트 만들기');
|
setInitialTab('새 프로젝트 만들기');
|
||||||
setViewMode('generation_flow');
|
setViewMode('generation_flow');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -283,9 +255,7 @@ const App: React.FC = () => {
|
||||||
const handleStartAnalysis = async (url: string) => {
|
const handleStartAnalysis = async (url: string) => {
|
||||||
if (!url.trim()) return;
|
if (!url.trim()) return;
|
||||||
|
|
||||||
setAfterLoadTarget('analysis');
|
|
||||||
setViewMode('loading');
|
setViewMode('loading');
|
||||||
setIsAnalysisComplete(false);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -298,20 +268,18 @@ const App: React.FC = () => {
|
||||||
|
|
||||||
setAnalysisData(data);
|
setAnalysisData(data);
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||||
saveSearchHistory({ type: 'url', value: url });
|
setViewMode('analysis');
|
||||||
setIsAnalysisComplete(true);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Crawling failed:', err);
|
console.error('Crawling failed:', err);
|
||||||
setError(t('app.analysisError'));
|
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
|
||||||
|
setError(errorMessage);
|
||||||
setViewMode('landing');
|
setViewMode('landing');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 업체명 자동완성으로 분석 시작
|
// 업체명 자동완성으로 분석 시작
|
||||||
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
||||||
setAfterLoadTarget('analysis');
|
|
||||||
setViewMode('loading');
|
setViewMode('loading');
|
||||||
setIsAnalysisComplete(false);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -324,36 +292,11 @@ const App: React.FC = () => {
|
||||||
|
|
||||||
setAnalysisData(data);
|
setAnalysisData(data);
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||||
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
|
setViewMode('analysis');
|
||||||
setIsAnalysisComplete(true);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Autocomplete failed:', err);
|
console.error('Autocomplete failed:', err);
|
||||||
setError(t('app.autocompleteError'));
|
const errorMessage = err instanceof Error ? err.message : t('app.autocompleteGeneralError');
|
||||||
setViewMode('landing');
|
setError(errorMessage);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
|
||||||
const handleManualInput = async (businessName: string, address: string, category: string) => {
|
|
||||||
setAfterLoadTarget('generation_flow');
|
|
||||||
setViewMode('loading');
|
|
||||||
setIsAnalysisComplete(false);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await marketingAnalysis(businessName, address, category);
|
|
||||||
|
|
||||||
if (!validateCrawlingResponse(data)) {
|
|
||||||
throw new Error(t('app.autocompleteError'));
|
|
||||||
}
|
|
||||||
|
|
||||||
setAnalysisData(data);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
|
||||||
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
|
|
||||||
setIsAnalysisComplete(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Marketing analysis failed:', err);
|
|
||||||
setError(t('app.autocompleteError'));
|
|
||||||
setViewMode('landing');
|
setViewMode('landing');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -440,13 +383,6 @@ const App: React.FC = () => {
|
||||||
return <SocialConnectError />;
|
return <SocialConnectError />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영상 상세 페이지 (/video/{id}) — 인증 게이트는 VideoDetailPage 내부에서 처리
|
|
||||||
const videoDetailMatch = pathname.match(/^\/video\/([^/]+)$/);
|
|
||||||
if (videoDetailMatch) {
|
|
||||||
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||||
if (isProcessingCallback) {
|
if (isProcessingCallback) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -459,12 +395,7 @@ const App: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'loading') {
|
if (viewMode === 'loading') {
|
||||||
return (
|
return <LoadingSection />;
|
||||||
<LoadingSection
|
|
||||||
isComplete={isAnalysisComplete}
|
|
||||||
onComplete={() => setViewMode(afterLoadTarget)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'analysis' && analysisData) {
|
if (viewMode === 'analysis' && analysisData) {
|
||||||
|
|
@ -506,7 +437,7 @@ const App: React.FC = () => {
|
||||||
<HeroSection
|
<HeroSection
|
||||||
onAnalyze={handleStartAnalysis}
|
onAnalyze={handleStartAnalysis}
|
||||||
onAutocomplete={handleAutocomplete}
|
onAutocomplete={handleAutocomplete}
|
||||||
onManualInput={handleManualInput}
|
onTestData={handleTestData}
|
||||||
onNext={() => scrollToSection(1)}
|
onNext={() => scrollToSection(1)}
|
||||||
error={error}
|
error={error}
|
||||||
scrollProgress={scrollProgress}
|
scrollProgress={scrollProgress}
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import CitySelectModal, { REGIONS } from './CitySelectModal';
|
|
||||||
|
|
||||||
interface BusinessNameInputModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (businessName: string, address: string, category: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [businessName, setBusinessName] = useState('');
|
|
||||||
const [selectedCity, setSelectedCity] = useState('');
|
|
||||||
const [detailAddress, setDetailAddress] = useState('');
|
|
||||||
const [category, setCategory] = useState('');
|
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleCitySelect = (city: string) => {
|
|
||||||
const region = REGIONS.find(r => r.cities.includes(city));
|
|
||||||
// 특별시/광역시는 도 이름 없이 도시명만 사용
|
|
||||||
const prefix = region && region.label !== '특별시 / 광역시' ? `${region.label} ` : '';
|
|
||||||
setSelectedCity(`${prefix}${city}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValid = businessName.trim().length > 0 && selectedCity.length > 0 && detailAddress.trim().length > 0;
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!isValid) return;
|
|
||||||
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
|
|
||||||
onSubmit(businessName.trim(), fullAddress, category.trim());
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && isValid) handleSubmit();
|
|
||||||
};
|
|
||||||
|
|
||||||
// CitySelectModal의 selected prop용 — 도시명만 추출
|
|
||||||
const cityOnly = selectedCity.split(' ').pop() ?? '';
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<>
|
|
||||||
<div className="manual-modal-backdrop" onClick={onClose}>
|
|
||||||
<div className="manual-modal" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="manual-modal-header">
|
|
||||||
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
|
|
||||||
<button className="manual-modal-close" onClick={onClose}>✕</button>
|
|
||||||
</div>
|
|
||||||
<p className="manual-modal-subtitle">{t('landing.hero.manualModalSubtitle')}</p>
|
|
||||||
|
|
||||||
<div className="manual-modal-body">
|
|
||||||
<div className="manual-modal-field">
|
|
||||||
<label className="manual-modal-label">{t('landing.hero.manualLabelName')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="manual-modal-input"
|
|
||||||
placeholder={t('landing.hero.manualPlaceholderName')}
|
|
||||||
value={businessName}
|
|
||||||
onChange={e => setBusinessName(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
maxLength={50}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="manual-modal-field">
|
|
||||||
<label className="manual-modal-label">{t('landing.hero.manualLabelRegion')}</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`manual-modal-city-btn ${selectedCity ? 'selected' : ''}`}
|
|
||||||
onClick={() => setIsCityModalOpen(true)}
|
|
||||||
>
|
|
||||||
<span>{selectedCity || t('landing.hero.manualPlaceholderRegion')}</span>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="manual-modal-field">
|
|
||||||
<label className="manual-modal-label">{t('landing.hero.manualLabelDetail')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="manual-modal-input"
|
|
||||||
placeholder={t('landing.hero.manualPlaceholderDetail')}
|
|
||||||
value={detailAddress}
|
|
||||||
onChange={e => setDetailAddress(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
maxLength={100}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="manual-modal-field">
|
|
||||||
<label className="manual-modal-label">{t('landing.hero.manualLabelCategory')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="manual-modal-input"
|
|
||||||
placeholder={t('landing.hero.manualPlaceholderCategory')}
|
|
||||||
value={category}
|
|
||||||
onChange={e => setCategory(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
maxLength={30}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="manual-modal-actions">
|
|
||||||
<button type="button" className="manual-modal-cancel" onClick={onClose}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="manual-modal-submit"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!isValid}
|
|
||||||
>
|
|
||||||
{t('landing.hero.manualAnalyzeButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isCityModalOpen && (
|
|
||||||
<CitySelectModal
|
|
||||||
selected={cityOnly}
|
|
||||||
onSelect={(city) => { handleCitySelect(city); setIsCityModalOpen(false); }}
|
|
||||||
onClose={() => setIsCityModalOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BusinessNameInputModal;
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export const REGIONS: { label: string; cities: string[] }[] = [
|
|
||||||
{
|
|
||||||
label: '특별시 / 광역시',
|
|
||||||
cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '경기도',
|
|
||||||
cities: [
|
|
||||||
'수원시', '성남시', '고양시', '용인시', '부천시', '안산시', '안양시', '남양주시',
|
|
||||||
'화성시', '평택시', '의정부시', '시흥시', '파주시', '김포시', '광주시', '광명시',
|
|
||||||
'군포시', '하남시', '오산시', '이천시', '안성시', '구리시', '양주시', '포천시',
|
|
||||||
'여주시', '동두천시', '과천시', '가평군', '양평군', '연천군',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '강원도',
|
|
||||||
cities: [
|
|
||||||
'춘천시', '원주시', '강릉시', '동해시', '태백시', '속초시', '삼척시',
|
|
||||||
'홍천군', '횡성군', '영월군', '평창군', '정선군', '철원군', '화천군',
|
|
||||||
'양구군', '인제군', '고성군', '양양군',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '충청북도',
|
|
||||||
cities: [
|
|
||||||
'청주시', '충주시', '제천시',
|
|
||||||
'보은군', '옥천군', '영동군', '증평군', '진천군', '괴산군', '음성군', '단양군',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '충청남도',
|
|
||||||
cities: [
|
|
||||||
'천안시', '공주시', '보령시', '아산시', '서산시', '논산시', '계룡시', '당진시',
|
|
||||||
'금산군', '부여군', '서천군', '청양군', '홍성군', '예산군', '태안군',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '전라북도',
|
|
||||||
cities: [
|
|
||||||
'전주시', '군산시', '익산시', '정읍시', '남원시', '김제시',
|
|
||||||
'완주군', '진안군', '무주군', '장수군', '임실군', '순창군', '고창군', '부안군',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '전라남도',
|
|
||||||
cities: [
|
|
||||||
'목포시', '여수시', '순천시', '나주시', '광양시',
|
|
||||||
'담양군', '곡성군', '구례군', '고흥군', '보성군', '화순군', '장흥군', '강진군',
|
|
||||||
'해남군', '영암군', '무안군', '함평군', '영광군', '장성군', '완도군', '진도군', '신안군',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '경상북도',
|
|
||||||
cities: [
|
|
||||||
'포항시', '경주시', '김천시', '안동시', '구미시', '영주시', '영천시', '상주시', '문경시', '경산시',
|
|
||||||
'의성군', '청송군', '영양군', '영덕군', '청도군', '고령군', '성주군', '칠곡군',
|
|
||||||
'예천군', '봉화군', '울진군', '울릉군',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '경상남도',
|
|
||||||
cities: [
|
|
||||||
'창원시', '진주시', '통영시', '사천시', '김해시', '밀양시', '거제시', '양산시',
|
|
||||||
'의령군', '함안군', '창녕군', '고성군', '남해군', '하동군', '산청군', '함양군', '거창군', '합천군',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '제주도',
|
|
||||||
cities: ['제주시', '서귀포시'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface CitySelectModalProps {
|
|
||||||
selected: string;
|
|
||||||
onSelect: (city: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, onClose }) => {
|
|
||||||
const [activeRegion, setActiveRegion] = useState<string | null>(
|
|
||||||
() => REGIONS.find(r => r.cities.includes(selected))?.label ?? null
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => { document.body.style.overflow = ''; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const activeEntry = REGIONS.find(r => r.label === activeRegion);
|
|
||||||
const cities = activeEntry?.cities ?? [];
|
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
|
||||||
onSelect(value === selected ? '' : value);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="city-modal-backdrop" onClick={onClose}>
|
|
||||||
<div className="city-modal" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="city-modal-header">
|
|
||||||
<span className="city-modal-title">
|
|
||||||
{activeRegion ? (
|
|
||||||
<>
|
|
||||||
<button className="city-modal-back" onClick={() => setActiveRegion(null)}>←</button>
|
|
||||||
{activeRegion}
|
|
||||||
</>
|
|
||||||
) : '지역 선택'}
|
|
||||||
</span>
|
|
||||||
<button className="city-modal-close" onClick={onClose}>✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeRegion === null ? (
|
|
||||||
<div className="city-modal-grid">
|
|
||||||
{REGIONS.map(r => {
|
|
||||||
const isRegionSelected = selected === r.label;
|
|
||||||
const hasCitySelected = r.cities.includes(selected);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={r.label}
|
|
||||||
className={`city-modal-region-item ${isRegionSelected || hasCitySelected ? 'has-selected' : ''}`}
|
|
||||||
onClick={() => setActiveRegion(r.label)}
|
|
||||||
>
|
|
||||||
<span>{r.label}</span>
|
|
||||||
{isRegionSelected && <span className="city-modal-region-badge">전체</span>}
|
|
||||||
{hasCitySelected && <span className="city-modal-region-badge">{selected}</span>}
|
|
||||||
<span className="city-modal-arrow">›</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="city-modal-item-wrap">
|
|
||||||
{activeRegion !== '특별시 / 광역시' && (
|
|
||||||
<button
|
|
||||||
className={`city-modal-item city-modal-item-all ${selected === activeRegion ? 'active' : ''}`}
|
|
||||||
onClick={() => handleSelect(activeRegion!)}
|
|
||||||
>
|
|
||||||
전체
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{cities.map(city => (
|
|
||||||
<button
|
|
||||||
key={city}
|
|
||||||
className={`city-modal-item ${selected === city ? 'active' : ''}`}
|
|
||||||
onClick={() => handleSelect(city)}
|
|
||||||
>
|
|
||||||
{city}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CitySelectModal;
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { toggleVideoLike } from '../utils/api';
|
|
||||||
|
|
||||||
interface ContentCardSocialActionsProps {
|
|
||||||
videoId: number;
|
|
||||||
storeName: string;
|
|
||||||
region?: string;
|
|
||||||
commentCount: number;
|
|
||||||
initialLikeCount: number;
|
|
||||||
initialIsLiked?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
|
||||||
videoId,
|
|
||||||
storeName,
|
|
||||||
region,
|
|
||||||
commentCount,
|
|
||||||
initialLikeCount,
|
|
||||||
initialIsLiked = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
|
||||||
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
|
||||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
|
||||||
const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null);
|
|
||||||
|
|
||||||
const shareBtnRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const shareMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const shareUrl = `${window.location.origin}/video/${videoId}`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shareMenuOpen) return;
|
|
||||||
|
|
||||||
const closeMenu = () => setShareMenuOpen(false);
|
|
||||||
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) &&
|
|
||||||
shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
closeMenu();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
window.addEventListener('scroll', closeMenu, true);
|
|
||||||
window.addEventListener('resize', closeMenu);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
window.removeEventListener('scroll', closeMenu, true);
|
|
||||||
window.removeEventListener('resize', closeMenu);
|
|
||||||
};
|
|
||||||
}, [shareMenuOpen]);
|
|
||||||
|
|
||||||
// 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return;
|
|
||||||
const margin = 8;
|
|
||||||
const menuRect = shareMenuRef.current.getBoundingClientRect();
|
|
||||||
|
|
||||||
let left = anchorRect.left;
|
|
||||||
if (left + menuRect.width > window.innerWidth - margin) {
|
|
||||||
left = window.innerWidth - menuRect.width - margin;
|
|
||||||
}
|
|
||||||
left = Math.max(margin, left);
|
|
||||||
|
|
||||||
let top = anchorRect.bottom + 6;
|
|
||||||
if (top + menuRect.height > window.innerHeight - margin) {
|
|
||||||
top = anchorRect.top - menuRect.height - 6;
|
|
||||||
}
|
|
||||||
top = Math.max(margin, top);
|
|
||||||
|
|
||||||
setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
|
|
||||||
}, [shareMenuOpen, anchorRect]);
|
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
|
||||||
} catch {
|
|
||||||
// clipboard API 미지원 환경에서는 무시
|
|
||||||
}
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKakaoShare = () => {
|
|
||||||
const kakao = window.Kakao;
|
|
||||||
if (kakao?.Share) {
|
|
||||||
kakao.Share.sendDefault({
|
|
||||||
objectType: 'feed',
|
|
||||||
content: {
|
|
||||||
title: storeName || t('videoDetail.kakaoDefaultTitle'),
|
|
||||||
description: t('videoDetail.kakaoDescription', { region: region ?? '' }),
|
|
||||||
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
|
|
||||||
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
|
||||||
},
|
|
||||||
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
|
||||||
});
|
|
||||||
} else if (navigator.share) {
|
|
||||||
navigator.share({ url: shareUrl }).catch(() => {});
|
|
||||||
} else {
|
|
||||||
handleCopyLink();
|
|
||||||
}
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFacebookShare = () => {
|
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTwitterShare = () => {
|
|
||||||
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLikeClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const prevLiked = isLiked;
|
|
||||||
const prevCount = likeCount;
|
|
||||||
|
|
||||||
setIsLiked(!prevLiked);
|
|
||||||
setLikeCount(prevLiked ? prevCount - 1 : prevCount + 1);
|
|
||||||
|
|
||||||
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
|
||||||
likeDebounceRef.current = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const res = await toggleVideoLike(String(videoId));
|
|
||||||
setIsLiked(res.is_liked);
|
|
||||||
setLikeCount(res.like_count);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle like:', err);
|
|
||||||
setIsLiked(prevLiked);
|
|
||||||
setLikeCount(prevCount);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShareBtnClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const rect = shareBtnRef.current?.getBoundingClientRect();
|
|
||||||
if (rect) {
|
|
||||||
// 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다
|
|
||||||
setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left });
|
|
||||||
setMenuPos({ top: rect.bottom + 6, left: rect.left });
|
|
||||||
}
|
|
||||||
setShareMenuOpen((v) => !v);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
className={`content-card-like-btn ${isLiked ? 'liked' : ''}`}
|
|
||||||
onClick={handleLikeClick}
|
|
||||||
>
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
|
||||||
</svg>
|
|
||||||
{likeCount}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="content-card-comment">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
||||||
</svg>
|
|
||||||
{commentCount}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
ref={shareBtnRef}
|
|
||||||
className="content-card-share-btn"
|
|
||||||
onClick={handleShareBtnClick}
|
|
||||||
title={t('videoDetail.share')}
|
|
||||||
>
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
|
|
||||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{shareMenuOpen && menuPos && createPortal(
|
|
||||||
<div
|
|
||||||
ref={shareMenuRef}
|
|
||||||
className="video-detail-share-menu"
|
|
||||||
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left }}
|
|
||||||
>
|
|
||||||
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="20" height="20" rx="4" fill="#FEE500" />
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E" />
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareKakao')}
|
|
||||||
</button>
|
|
||||||
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
|
||||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareFacebook')}
|
|
||||||
</button>
|
|
||||||
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareTwitter')}
|
|
||||||
</button>
|
|
||||||
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
|
|
||||||
</button>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContentCardSocialActions;
|
|
||||||
|
|
@ -10,11 +10,6 @@ const Footer: React.FC = () => {
|
||||||
<div className="footer-left">
|
<div className="footer-left">
|
||||||
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
|
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
|
||||||
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
||||||
<div className="footer-links">
|
|
||||||
<a href="https://ado2.o2osolution.ai/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a>
|
|
||||||
<span className="footer-link-divider">|</span>
|
|
||||||
<a href="https://ado2.o2osolution.ai/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-right">
|
<div className="footer-right">
|
||||||
<p className="footer-info">{t('footer.company')}</p>
|
<p className="footer-info">{t('footer.company')}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { getKakaoLoginUrl } from '../utils/api';
|
|
||||||
|
|
||||||
interface LoginPromptModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem('castad_login_redirect', window.location.pathname);
|
|
||||||
const response = await getKakaoLoginUrl();
|
|
||||||
window.location.href = response.auth_url;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to get Kakao login URL:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(255, 255, 255, 0.25)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 9999,
|
|
||||||
}}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#01282A',
|
|
||||||
border: '1px solid rgba(155, 202, 204, 0.2)',
|
|
||||||
borderRadius: '16px',
|
|
||||||
padding: '40px 32px',
|
|
||||||
width: '360px',
|
|
||||||
maxWidth: '90vw',
|
|
||||||
textAlign: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="닫기"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '16px',
|
|
||||||
right: '16px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#9BCACC',
|
|
||||||
padding: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<line x1="4" y1="4" x2="16" y2="16"/>
|
|
||||||
<line x1="16" y1="4" x2="4" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<h2 style={{ color: '#FFFFFF', fontSize: '20px', fontWeight: 600, marginBottom: '12px' }}>
|
|
||||||
{t('loginPrompt.title')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleLogin}
|
|
||||||
style={{
|
|
||||||
width: '50%',
|
|
||||||
margin: '0 auto',
|
|
||||||
padding: '14px',
|
|
||||||
background: '#FEE500',
|
|
||||||
color: '#3C1E1E',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '15px',
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M10 2C5.582 2 2 4.91 2 8.5c0 2.26 1.37 4.25 3.44 5.44L4.6 17.1a.3.3 0 0 0 .44.33l4.03-2.67c.3.03.62.04.93.04 4.418 0 8-2.91 8-6.5S14.418 2 10 2z" fill="#3C1E1E"/>
|
|
||||||
</svg>
|
|
||||||
{t('loginPrompt.loginBtn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginPromptModal;
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { SearchHistoryItem } from './useSearchHistory';
|
|
||||||
|
|
||||||
interface SearchHistoryDropdownProps {
|
|
||||||
items: SearchHistoryItem[];
|
|
||||||
onSelect: (item: SearchHistoryItem) => void;
|
|
||||||
onDelete: (e: React.MouseEvent, value: string) => void;
|
|
||||||
className?: string;
|
|
||||||
itemClassName?: string;
|
|
||||||
titleClassName?: string;
|
|
||||||
addressClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchHistoryDropdown: React.FC<SearchHistoryDropdownProps> = ({
|
|
||||||
items,
|
|
||||||
onSelect,
|
|
||||||
onDelete,
|
|
||||||
className = 'url-input-autocomplete-dropdown',
|
|
||||||
itemClassName = 'url-input-autocomplete-item',
|
|
||||||
titleClassName = 'url-input-autocomplete-title',
|
|
||||||
addressClassName = 'url-input-autocomplete-address',
|
|
||||||
}) => {
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={itemClassName}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
|
||||||
onMouseDown={(e) => { e.preventDefault(); onSelect(item); }}
|
|
||||||
>
|
|
||||||
<div className={titleClassName}>{item.value}</div>
|
|
||||||
{item.roadAddress && (
|
|
||||||
<div className={addressClassName}>{item.roadAddress}</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => onDelete(e, item.value)}
|
|
||||||
style={{ color:'#737983', background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', opacity: 0.8, flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchHistoryDropdown;
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export const SEARCH_HISTORY_KEY = 'castad_search_history';
|
|
||||||
export const SEARCH_HISTORY_MAX = 5;
|
|
||||||
|
|
||||||
export interface SearchHistoryItem {
|
|
||||||
type: 'url' | 'name';
|
|
||||||
value: string;
|
|
||||||
address?: string;
|
|
||||||
roadAddress?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const saveSearchHistory = (item: SearchHistoryItem) => {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
|
|
||||||
const history: SearchHistoryItem[] = saved ? JSON.parse(saved) : [];
|
|
||||||
const filtered = history.filter(h => h.value !== item.value);
|
|
||||||
const updated = [item, ...filtered].slice(0, SEARCH_HISTORY_MAX);
|
|
||||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSearchHistory = (searchType: 'url' | 'name') => {
|
|
||||||
const [history, setHistory] = useState<SearchHistoryItem[]>([]);
|
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
|
|
||||||
if (saved) setHistory(JSON.parse(saved));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredHistory = history.filter(h => h.type === searchType);
|
|
||||||
|
|
||||||
const deleteItem = (value: string) => {
|
|
||||||
const updated = history.filter(h => h.value !== value);
|
|
||||||
setHistory(updated);
|
|
||||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
|
|
||||||
if (updated.filter(h => h.type === searchType).length === 0) {
|
|
||||||
setShowHistory(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openHistory = (inputValue: string) => {
|
|
||||||
if (!inputValue && filteredHistory.length > 0) setShowHistory(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeHistory = () => setShowHistory(false);
|
|
||||||
|
|
||||||
const hideOnInput = (value: string) => {
|
|
||||||
if (value) setShowHistory(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem };
|
|
||||||
};
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { searchAccommodation, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
|
|
||||||
import { useSearchHistory } from './SearchHistory/useSearchHistory';
|
|
||||||
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
|
|
||||||
import BusinessNameInputModal from './BusinessNameInputModal';
|
|
||||||
|
|
||||||
type SearchType = 'url' | 'name' | 'manual';
|
|
||||||
|
|
||||||
export type { SearchType };
|
|
||||||
|
|
||||||
const isValidUrl = (string: string): boolean => {
|
|
||||||
try {
|
|
||||||
const url = new URL(string);
|
|
||||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// URL 형태로 판단되는 입력: 프로토콜/www. 시작 또는 도메인 형태(naver.me/xxx 등)
|
|
||||||
const isUrlLike = (value: string): boolean => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return /^(https?:\/\/|www\.)/i.test(trimmed)
|
|
||||||
|| /^[a-z0-9-]+(\.[a-z0-9-]+)+([/?#]|$)/i.test(trimmed);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeUrlInput = (value: string): string => {
|
|
||||||
return value.replace(/[^\x00-\x7F]/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트 안에 포함된 첫 번째 URL을 추출 (없으면 null)
|
|
||||||
const extractUrl = (text: string): string | null => {
|
|
||||||
const match = text.match(/(https?:\/\/|www\.)[^\s]+/i);
|
|
||||||
return match ? match[0] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SearchInputFormProps {
|
|
||||||
onAnalyze?: (value: string, type: SearchType) => void;
|
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
|
||||||
onManualInput?: (businessName: string, address: string, category: string) => void;
|
|
||||||
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
|
||||||
onManualButtonClick?: () => void;
|
|
||||||
error?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
|
||||||
onAnalyze,
|
|
||||||
onAutocomplete,
|
|
||||||
onManualInput,
|
|
||||||
onManualButtonClick,
|
|
||||||
error: externalError,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [searchType] = useState<SearchType>('name');
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
|
||||||
const [localError, setLocalError] = useState('');
|
|
||||||
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
|
|
||||||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
||||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
|
||||||
|
|
||||||
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory('name');
|
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
|
||||||
if (!query.trim()) {
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsAutocompleteLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await searchAccommodation(query);
|
|
||||||
setAutocompleteResults(response.items || []);
|
|
||||||
setShowAutocomplete(response.items && response.items.length > 0);
|
|
||||||
} catch {
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
} finally {
|
|
||||||
setIsAutocompleteLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
|
||||||
const cleanTitle = cleanSearchTitle(item.title);
|
|
||||||
setInputValue(cleanTitle);
|
|
||||||
setSelectedItem({ ...item, title: cleanTitle });
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (showAutocomplete && autocompleteResults.length > 0) {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex(prev => prev < autocompleteResults.length - 1 ? prev + 1 : 0);
|
|
||||||
return;
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex(prev => prev > 0 ? prev - 1 : autocompleteResults.length - 1);
|
|
||||||
return;
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault();
|
|
||||||
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
|
|
||||||
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault();
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStart = () => {
|
|
||||||
const trimmed = inputValue.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
setLocalError(t('landing.hero.errorInputRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isValidUrl(trimmed)) {
|
|
||||||
setLocalError('');
|
|
||||||
onAnalyze?.(trimmed, 'url');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalError('');
|
|
||||||
|
|
||||||
if (selectedItem && onAutocomplete) {
|
|
||||||
onAutocomplete({
|
|
||||||
address: selectedItem.address,
|
|
||||||
roadAddress: selectedItem.roadAddress,
|
|
||||||
title: selectedItem.title,
|
|
||||||
});
|
|
||||||
} else if (onAnalyze) {
|
|
||||||
onAnalyze(inputValue, searchType);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyInputChange = (value: string) => {
|
|
||||||
setInputValue(value);
|
|
||||||
setSelectedItem(null);
|
|
||||||
setHighlightedIndex(-1);
|
|
||||||
hideOnInput(value);
|
|
||||||
if (localError) setLocalError('');
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
if (isValidUrl(value)) {
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
} else {
|
|
||||||
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 모바일 키보드 클립보드 추천 등 paste 이벤트 없이 여러 글자가 한 번에 들어오는 경우 처리
|
|
||||||
const handleChange = (value: string) => {
|
|
||||||
const isBulkInsert = value.length - inputValue.length > 1;
|
|
||||||
if (isBulkInsert) {
|
|
||||||
const extracted = extractUrl(value);
|
|
||||||
if (extracted && extracted !== value.trim()) {
|
|
||||||
applyInputChange(sanitizeUrlInput(extracted));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isUrlLike(value)) {
|
|
||||||
const sanitized = sanitizeUrlInput(value);
|
|
||||||
if (sanitized !== value) {
|
|
||||||
applyInputChange(sanitized);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyInputChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
||||||
const pastedText = e.clipboardData.getData('text');
|
|
||||||
|
|
||||||
// 붙여넣은 텍스트에 URL이 포함되어 있고 URL 외 다른 텍스트도 있으면 URL만 추출해서 입력창에 넣기
|
|
||||||
const extracted = extractUrl(pastedText);
|
|
||||||
if (extracted && extracted !== pastedText.trim()) {
|
|
||||||
e.preventDefault();
|
|
||||||
applyInputChange(sanitizeUrlInput(extracted));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const start = input.selectionStart ?? inputValue.length;
|
|
||||||
const end = input.selectionEnd ?? inputValue.length;
|
|
||||||
const merged = inputValue.slice(0, start) + pastedText + inputValue.slice(end);
|
|
||||||
|
|
||||||
// 붙여넣은 후 완성될 전체 값이 URL 형태일 때만 비영문 문자 제거
|
|
||||||
if (!isUrlLike(merged)) return;
|
|
||||||
|
|
||||||
const sanitized = sanitizeUrlInput(merged);
|
|
||||||
if (sanitized === merged) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
applyInputChange(sanitized);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManualButtonClick = () => {
|
|
||||||
if (onManualButtonClick) {
|
|
||||||
onManualButtonClick();
|
|
||||||
} else {
|
|
||||||
setIsManualModalOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error = externalError || localError;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="hero-form">
|
|
||||||
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
|
||||||
{/* 유형 라벨 */}
|
|
||||||
{/* <div className="hero-dropdown-container">
|
|
||||||
<div className="hero-dropdown-trigger">
|
|
||||||
<span className="hero-dropdown-main">
|
|
||||||
{t('landing.hero.searchTypeLabel')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* 입력 필드 */}
|
|
||||||
<div className="hero-input-container" ref={autocompleteRef}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
onFocus={() => {
|
|
||||||
setIsFocused(true);
|
|
||||||
if (autocompleteResults.length > 0) setShowAutocomplete(true);
|
|
||||||
openHistory(inputValue);
|
|
||||||
}}
|
|
||||||
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={t('landing.hero.placeholderCombined')}
|
|
||||||
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 최근 검색 히스토리 */}
|
|
||||||
{showHistory && !showAutocomplete && (
|
|
||||||
<SearchHistoryDropdown
|
|
||||||
items={filteredHistory}
|
|
||||||
onSelect={(item) => {
|
|
||||||
closeHistory();
|
|
||||||
setInputValue(item.value);
|
|
||||||
if (item.type === 'name') {
|
|
||||||
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
|
|
||||||
className="hero-autocomplete-dropdown"
|
|
||||||
itemClassName="hero-autocomplete-item"
|
|
||||||
titleClassName="hero-autocomplete-title"
|
|
||||||
addressClassName="hero-autocomplete-address"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 자동완성 결과 */}
|
|
||||||
{showAutocomplete && (
|
|
||||||
<div className="hero-autocomplete-dropdown">
|
|
||||||
{isAutocompleteLoading ? (
|
|
||||||
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
|
||||||
) : (
|
|
||||||
autocompleteResults.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
|
|
||||||
onMouseDown={(e) => { e.preventDefault(); handleSelectAutocomplete(item); }}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
|
||||||
>
|
|
||||||
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
|
||||||
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입력 clear 버튼 */}
|
|
||||||
{inputValue && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hero-input-clear"
|
|
||||||
onClick={() => {
|
|
||||||
setInputValue('');
|
|
||||||
setSelectedItem(null);
|
|
||||||
setLocalError('');
|
|
||||||
setAutocompleteResults([]);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="hero-input-hint">{t('landing.hero.guideCombined')}</span>
|
|
||||||
|
|
||||||
{error && <p className="hero-error">{error}</p>}
|
|
||||||
|
|
||||||
<button onClick={handleStart} className="hero-button">
|
|
||||||
{t('landing.hero.analyzeButton')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="hero-divider"><span>또는</span></div>
|
|
||||||
|
|
||||||
<div className="hero-manual-card" onClick={handleManualButtonClick} role="button" tabIndex={0}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleManualButtonClick()}>
|
|
||||||
<span className="hero-manual-card-title">{t('landing.hero.searchTypeManual')}</span>
|
|
||||||
<p className="hero-manual-button-desc">{t('landing.hero.manualButtonDesc')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
|
|
||||||
{!onManualButtonClick && isManualModalOpen && (
|
|
||||||
<BusinessNameInputModal
|
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
|
||||||
onSubmit={(businessName, address, category) => {
|
|
||||||
setIsManualModalOpen(false);
|
|
||||||
onManualInput?.(businessName, address, category);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchInputForm;
|
|
||||||
|
|
@ -12,13 +12,11 @@ interface SidebarItemProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
id?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => {
|
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={id}
|
|
||||||
onClick={isDisabled ? undefined : onClick}
|
onClick={isDisabled ? undefined : onClick}
|
||||||
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
|
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||||
title={isCollapsed ? label : ""}
|
title={isCollapsed ? label : ""}
|
||||||
|
|
@ -37,13 +35,9 @@ interface SidebarProps {
|
||||||
onHome?: () => void;
|
onHome?: () => void;
|
||||||
userInfo?: UserMeResponse | null;
|
userInfo?: UserMeResponse | null;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
credits?: number | null;
|
|
||||||
tutorialAvailable?: boolean;
|
|
||||||
tutorialEnabled?: boolean;
|
|
||||||
onToggleTutorial?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||||
|
|
@ -88,8 +82,8 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||||
{ id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
{ id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
||||||
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
||||||
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
||||||
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
// { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
||||||
{ id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> },
|
{ id: '콘텐츠 캘린더', label: '콘텐츠 캘린더', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> },
|
||||||
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
|
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -144,13 +138,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||||
{menuItems.map(item => (
|
{menuItems.map(item => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
id={
|
|
||||||
item.id === '내 정보'
|
|
||||||
? 'sidebar-my-info'
|
|
||||||
: item.id === 'ADO2 콘텐츠'
|
|
||||||
? 'sidebar-ado2-contents'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|
@ -162,24 +149,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
{/* 모바일 전용 튜토리얼 토글 */}
|
|
||||||
{tutorialAvailable && (
|
|
||||||
<button
|
|
||||||
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`}
|
|
||||||
onClick={() => onToggleTutorial?.()}
|
|
||||||
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<path d="M12 8v4l3 3"/>
|
|
||||||
</svg>
|
|
||||||
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span>
|
|
||||||
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}>
|
|
||||||
{tutorialEnabled ? 'ON' : 'OFF'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="sidebar-language-switch">
|
<div className="sidebar-language-switch">
|
||||||
<LanguageSwitch isCollapsed={isCollapsed} />
|
<LanguageSwitch isCollapsed={isCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,9 +171,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
||||||
{credits !== null && credits !== undefined && (
|
|
||||||
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,12 +191,12 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
|
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
|
||||||
title={t('sidebar.inquiry')}
|
title="고객의견"
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
{!isCollapsed && <span>{t('sidebar.inquiry')}</span>}
|
{!isCollapsed && <span>고객의견</span>}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,11 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
|
||||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||||
import { useTutorial } from './Tutorial/useTutorial';
|
|
||||||
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
|
|
||||||
import TutorialOverlay from './Tutorial/TutorialOverlay';
|
|
||||||
|
|
||||||
interface SocialPostingModalProps {
|
interface SocialPostingModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
video: VideoListItem | null;
|
video: VideoListItem | null;
|
||||||
onGoToCalendar?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrivacyType = 'public' | 'unlisted' | 'private';
|
type PrivacyType = 'public' | 'unlisted' | 'private';
|
||||||
|
|
@ -117,11 +113,9 @@ const MiniCalendar: React.FC<{
|
||||||
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
video,
|
video
|
||||||
onGoToCalendar,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tutorial = useTutorial();
|
|
||||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
|
@ -141,11 +135,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
|
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
|
||||||
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
const channelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
const privacyDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const hasBeenOpenedRef = useRef(false);
|
|
||||||
const loadedForTaskIdRef = useRef<string | null>(null);
|
|
||||||
const loadedAtRef = useRef<number>(0);
|
|
||||||
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
|
|
||||||
const SEO_CACHE_TTL = 50 * 60 * 1000;
|
|
||||||
|
|
||||||
// Upload progress modal state
|
// Upload progress modal state
|
||||||
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
const [showUploadProgress, setShowUploadProgress] = useState(false);
|
||||||
|
|
@ -156,8 +145,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
// 업로드 정보 (모달이 닫힌 후에도 유지)
|
// 업로드 정보 (모달이 닫힌 후에도 유지)
|
||||||
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
|
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
|
||||||
const [uploadChannelName, setUploadChannelName] = useState<string>('');
|
const [uploadChannelName, setUploadChannelName] = useState<string>('');
|
||||||
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
|
|
||||||
const [uploadScheduledAt, setUploadScheduledAt] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 드롭다운 외부 클릭 시 닫기
|
// 드롭다운 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -184,56 +171,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// 모달 오픈/닫힘 시 튜토리얼 트리거
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
hasBeenOpenedRef.current = true;
|
|
||||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL);
|
|
||||||
}, 400);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
} else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) {
|
|
||||||
hasBeenOpenedRef.current = false;
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM);
|
|
||||||
}, 400);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [isLoadingAutoDescription]);
|
|
||||||
|
|
||||||
// 소셜 계정 로드
|
// 소셜 계정 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (isOpen) {
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
loadSocialAccounts();
|
loadSocialAccounts();
|
||||||
|
|
||||||
const taskId = video?.task_id ?? null;
|
|
||||||
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
|
|
||||||
|
|
||||||
if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) {
|
|
||||||
loadedForTaskIdRef.current = taskId;
|
|
||||||
loadedAtRef.current = now;
|
|
||||||
loadAutocomplete();
|
loadAutocomplete();
|
||||||
} else if (taskId) {
|
|
||||||
const cached = seoCache.current.get(taskId);
|
|
||||||
if (cached) {
|
|
||||||
setTitle(cached.title);
|
|
||||||
setDescription(cached.description);
|
|
||||||
setTags(cached.tags);
|
|
||||||
}
|
}
|
||||||
}
|
}, [isOpen, video]);
|
||||||
}, [isOpen, video?.task_id]);
|
|
||||||
|
|
||||||
const loadSocialAccounts = async () => {
|
const loadSocialAccounts = async () => {
|
||||||
setIsLoadingAccounts(true);
|
setIsLoadingAccounts(true);
|
||||||
|
|
@ -242,9 +186,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
|
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
|
||||||
setSocialAccounts(activeAccounts);
|
setSocialAccounts(activeAccounts);
|
||||||
if (activeAccounts.length > 0) {
|
if (activeAccounts.length > 0) {
|
||||||
const lastUsed = localStorage.getItem('lastUsedSocialChannel');
|
setSelectedChannel(activeAccounts[0].platform_user_id);
|
||||||
const defaultAccount = activeAccounts.find(acc => acc.platform_user_id === lastUsed) || activeAccounts[0];
|
|
||||||
setSelectedChannel(defaultAccount.platform_user_id);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TokenExpiredError) {
|
if (error instanceof TokenExpiredError) {
|
||||||
|
|
@ -274,11 +216,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||||
seoCache.current.set(video.task_id, {
|
|
||||||
title: autoSeoResponse.title || '',
|
|
||||||
description: autoSeoResponse.description || '',
|
|
||||||
tags: autoSeoResponse.keywords?.join(',') || '',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load autocomplete:', error);
|
console.error('Failed to load autocomplete:', error);
|
||||||
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
||||||
|
|
@ -330,7 +267,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
// 업로드 정보 저장 (모달이 닫힌 후에도 유지)
|
// 업로드 정보 저장 (모달이 닫힌 후에도 유지)
|
||||||
setUploadVideoTitle(title.trim());
|
setUploadVideoTitle(title.trim());
|
||||||
setUploadChannelName(selectedAcc.display_name);
|
setUploadChannelName(selectedAcc.display_name);
|
||||||
setUploadIsScheduled(publishTime === 'schedule');
|
|
||||||
setShowUploadProgress(true);
|
setShowUploadProgress(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -368,19 +304,16 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('lastUsedSocialChannel', selectedAcc.platform_user_id);
|
|
||||||
|
|
||||||
if (publishTime === 'schedule') {
|
if (publishTime === 'schedule') {
|
||||||
// 예약 업로드: 폴링 없이 바로 완료 처리
|
// 예약 업로드: 폴링 없이 바로 완료 처리
|
||||||
setUploadStatus('completed');
|
setUploadStatus('completed');
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
setUploadScheduledAt(uploadResponse.scheduled_at ?? null);
|
|
||||||
onClose();
|
onClose();
|
||||||
resetForm();
|
resetForm();
|
||||||
} else {
|
} else {
|
||||||
// 즉시 업로드: 완료될 때까지 폴링
|
// 즉시 업로드: 완료될 때까지 폴링
|
||||||
const result = await waitForUploadComplete(
|
const result = await waitForUploadComplete(
|
||||||
uploadResponse.upload_id.toString(),
|
uploadResponse.upload_id,
|
||||||
(status, progress) => {
|
(status, progress) => {
|
||||||
setUploadStatus(status as UploadStatus);
|
setUploadStatus(status as UploadStatus);
|
||||||
setUploadProgress(progress || 0);
|
setUploadProgress(progress || 0);
|
||||||
|
|
@ -433,8 +366,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
setUploadErrorMessage(undefined);
|
setUploadErrorMessage(undefined);
|
||||||
setUploadVideoTitle('');
|
setUploadVideoTitle('');
|
||||||
setUploadChannelName('');
|
setUploadChannelName('');
|
||||||
setUploadIsScheduled(false);
|
|
||||||
setUploadScheduledAt(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|
@ -463,28 +394,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
channelName={uploadChannelName || selectedAccount?.display_name || ''}
|
channelName={uploadChannelName || selectedAccount?.display_name || ''}
|
||||||
youtubeUrl={uploadYoutubeUrl}
|
youtubeUrl={uploadYoutubeUrl}
|
||||||
errorMessage={uploadErrorMessage}
|
errorMessage={uploadErrorMessage}
|
||||||
isScheduled={uploadIsScheduled}
|
isScheduled={publishTime === 'schedule'}
|
||||||
scheduledAt={uploadScheduledAt}
|
|
||||||
onGoToCalendar={onGoToCalendar}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isOpen || !video) {
|
if (!isOpen || !video) {
|
||||||
return (
|
// Still render upload progress modal even when main modal is closed
|
||||||
<>
|
return showUploadProgress ? uploadProgressModalElement : null;
|
||||||
{showUploadProgress && uploadProgressModalElement}
|
|
||||||
{tutorial.isActive && (
|
|
||||||
<TutorialOverlay
|
|
||||||
hints={tutorial.hints}
|
|
||||||
currentIndex={tutorial.currentHintIndex}
|
|
||||||
onNext={tutorial.nextHint}
|
|
||||||
onPrev={tutorial.prevHint}
|
|
||||||
onSkip={tutorial.skipTutorial}
|
|
||||||
groupProgress={tutorial.groupProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -560,7 +476,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
|
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
|
||||||
>
|
>
|
||||||
<div className="social-posting-channel-selected">
|
<div className="social-posting-channel-selected">
|
||||||
{selectedAccount ? (
|
{selectedAccount && (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={getPlatformIcon(selectedAccount.platform)}
|
src={getPlatformIcon(selectedAccount.platform)}
|
||||||
|
|
@ -569,8 +485,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
/>
|
/>
|
||||||
<span>{selectedAccount.display_name}</span>
|
<span>{selectedAccount.display_name}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<span className="social-posting-channel-placeholder">{t('social.selectChannel')}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
|
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
|
||||||
|
|
@ -608,60 +522,48 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
<label className="social-posting-label">
|
<label className="social-posting-label">
|
||||||
{t('social.postTitleLabel')} <span className="required">*</span>
|
{t('social.postTitleLabel')} <span className="required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="seo-input-wrapper">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder={isLoadingAutoDescription ? '' : t('social.postTitlePlaceholder')}
|
placeholder={isLoadingAutoDescription ? t('social.autoSeoTitle') : t('social.postTitlePlaceholder')}
|
||||||
|
// placeholder={t('social.postTitlePlaceholder')}
|
||||||
className="social-posting-input"
|
className="social-posting-input"
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
disabled={isLoadingAutoDescription}
|
disabled={isLoadingAutoDescription}
|
||||||
/>
|
/>
|
||||||
{isLoadingAutoDescription && (
|
|
||||||
<span className="seo-shimmer-text">{t('social.autoSeoTitle')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="social-posting-char-count">{title.length}/100</span>
|
<span className="social-posting-char-count">{title.length}/100</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="social-posting-field">
|
<div className="social-posting-field">
|
||||||
<label className="social-posting-label">{t('social.postContentLabel')}</label>
|
<label className="social-posting-label">{t('social.postContentLabel')}</label>
|
||||||
<div className="seo-input-wrapper">
|
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder={isLoadingAutoDescription ? '' : t('social.postContentPlaceholder')}
|
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoDescription') : t('social.postContentPlaceholder'))}
|
||||||
|
// placeholder={t('social.postContentPlaceholder')}
|
||||||
className="social-posting-textarea"
|
className="social-posting-textarea"
|
||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
rows={4}
|
rows={4}
|
||||||
disabled={isLoadingAutoDescription}
|
disabled={isLoadingAutoDescription}
|
||||||
/>
|
/>
|
||||||
{isLoadingAutoDescription && (
|
|
||||||
<span className="seo-shimmer-text">{t('social.autoSeoDescription')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="social-posting-char-count">{description.length}/5,000</span>
|
<span className="social-posting-char-count">{description.length}/5,000</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="social-posting-field">
|
<div className="social-posting-field">
|
||||||
<label className="social-posting-label">{t('social.tagsLabel')}</label>
|
<label className="social-posting-label">{t('social.tagsLabel')}</label>
|
||||||
<div className="seo-input-wrapper">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => setTags(e.target.value)}
|
onChange={(e) => setTags(e.target.value)}
|
||||||
placeholder={isLoadingAutoDescription ? '' : t('social.tagsPlaceholder')}
|
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoTags') : t('social.tagsPlaceholder'))}
|
||||||
|
// placeholder={t('social.tagsPlaceholder')}
|
||||||
className="social-posting-input"
|
className="social-posting-input"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
disabled={isLoadingAutoDescription}
|
disabled={isLoadingAutoDescription}
|
||||||
/>
|
/>
|
||||||
{isLoadingAutoDescription && (
|
|
||||||
<span className="seo-shimmer-text">{t('social.autoSeoTags')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="social-posting-char-count">{tags.length}/500</span>
|
<span className="social-posting-char-count">{tags.length}/500</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -859,16 +761,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{uploadProgressModalElement}
|
{uploadProgressModalElement}
|
||||||
{tutorial.isActive && (
|
|
||||||
<TutorialOverlay
|
|
||||||
hints={tutorial.hints}
|
|
||||||
currentIndex={tutorial.currentHintIndex}
|
|
||||||
onNext={tutorial.nextHint}
|
|
||||||
onPrev={tutorial.prevHint}
|
|
||||||
onSkip={tutorial.skipTutorial}
|
|
||||||
groupProgress={tutorial.groupProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { TutorialHint } from './tutorialSteps';
|
|
||||||
|
|
||||||
interface Rect {
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
bottom: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipPos {
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TutorialOverlayProps {
|
|
||||||
hints: TutorialHint[];
|
|
||||||
currentIndex: number;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrev: () => void;
|
|
||||||
onSkip: () => void;
|
|
||||||
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PADDING = 8;
|
|
||||||
|
|
||||||
function getTargetRect(selector: string): Rect | null {
|
|
||||||
const els = Array.from(document.querySelectorAll(selector));
|
|
||||||
const el = els.find(e => {
|
|
||||||
const r = (e as HTMLElement).getBoundingClientRect();
|
|
||||||
return r.width > 0 && r.height > 0;
|
|
||||||
}) ?? els[0];
|
|
||||||
if (!el) return null;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSpotlightRect(
|
|
||||||
rect: Rect,
|
|
||||||
padding: number,
|
|
||||||
override?: { top?: number; right?: number; bottom?: number; left?: number }
|
|
||||||
): Rect {
|
|
||||||
const pTop = override?.top ?? padding;
|
|
||||||
const pRight = override?.right ?? padding;
|
|
||||||
const pBottom = override?.bottom ?? padding;
|
|
||||||
const pLeft = override?.left ?? padding;
|
|
||||||
const left = Math.max(0, Math.floor(rect.left - pLeft));
|
|
||||||
const top = Math.max(0, Math.floor(rect.top - pTop));
|
|
||||||
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
|
|
||||||
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
|
|
||||||
|
|
||||||
return {
|
|
||||||
top,
|
|
||||||
bottom,
|
|
||||||
left,
|
|
||||||
width: Math.max(0, right - left),
|
|
||||||
height: Math.max(0, bottom - top),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
|
|
||||||
|
|
||||||
switch (position) {
|
|
||||||
case 'bottom':
|
|
||||||
return {
|
|
||||||
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
|
|
||||||
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
|
||||||
};
|
|
||||||
case 'top':
|
|
||||||
return {
|
|
||||||
top: Math.max(rect.top - tooltipH - PADDING, 8),
|
|
||||||
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
|
|
||||||
};
|
|
||||||
case 'right':
|
|
||||||
return {
|
|
||||||
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
|
||||||
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
|
|
||||||
};
|
|
||||||
case 'left':
|
|
||||||
return {
|
|
||||||
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
|
|
||||||
left: Math.max(rect.left - tooltipW - PADDING, 8),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
|
|
||||||
hints,
|
|
||||||
currentIndex,
|
|
||||||
onNext,
|
|
||||||
onPrev,
|
|
||||||
onSkip,
|
|
||||||
groupProgress,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
|
||||||
const tooltipRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const [tooltipSize, setTooltipSize] = useState({ w: 300, h: 160 });
|
|
||||||
|
|
||||||
const hint = hints[currentIndex];
|
|
||||||
const isLast = currentIndex === hints.length - 1;
|
|
||||||
// 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로
|
|
||||||
const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true);
|
|
||||||
|
|
||||||
const updateRect = useCallback(() => {
|
|
||||||
if (!hint) return;
|
|
||||||
setTargetRect(getTargetRect(hint.targetSelector));
|
|
||||||
}, [hint]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateRect();
|
|
||||||
window.addEventListener('resize', updateRect);
|
|
||||||
window.addEventListener('scroll', updateRect, true);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', updateRect);
|
|
||||||
window.removeEventListener('scroll', updateRect, true);
|
|
||||||
};
|
|
||||||
}, [updateRect]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hint) return;
|
|
||||||
let retryTimer: number | undefined;
|
|
||||||
let rectTimer: number | undefined;
|
|
||||||
let cleanupTarget: (() => void) | undefined;
|
|
||||||
|
|
||||||
const bindToTarget = (el: HTMLElement) => {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
rectTimer = window.setTimeout(updateRect, 200);
|
|
||||||
|
|
||||||
const shouldClickAdvance = hint.clickToAdvance !== false;
|
|
||||||
if (shouldClickAdvance) {
|
|
||||||
el.style.cursor = 'pointer';
|
|
||||||
el.addEventListener('click', onNext);
|
|
||||||
cleanupTarget = () => {
|
|
||||||
el.style.cursor = '';
|
|
||||||
el.removeEventListener('click', onNext);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
cleanupTarget = () => {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryBind = (): boolean => {
|
|
||||||
const els = Array.from(document.querySelectorAll(hint.targetSelector));
|
|
||||||
const el = (els.find(e => {
|
|
||||||
const r = (e as HTMLElement).getBoundingClientRect();
|
|
||||||
return r.width > 0 && r.height > 0;
|
|
||||||
}) ?? els[0]) as HTMLElement | null;
|
|
||||||
if (!el) return false;
|
|
||||||
bindToTarget(el);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!tryBind()) {
|
|
||||||
retryTimer = window.setInterval(() => {
|
|
||||||
if (tryBind() && retryTimer) {
|
|
||||||
window.clearInterval(retryTimer);
|
|
||||||
retryTimer = undefined;
|
|
||||||
}
|
|
||||||
}, 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (retryTimer) window.clearInterval(retryTimer);
|
|
||||||
if (rectTimer) window.clearTimeout(rectTimer);
|
|
||||||
cleanupTarget?.();
|
|
||||||
};
|
|
||||||
}, [hint, updateRect, onNext]);
|
|
||||||
|
|
||||||
// 툴팁 DOM 크기 측정 — 힌트가 바뀔 때만 재측정
|
|
||||||
useEffect(() => {
|
|
||||||
const el = tooltipRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const { offsetWidth, offsetHeight } = el;
|
|
||||||
if (offsetWidth && offsetHeight) {
|
|
||||||
setTooltipSize(prev =>
|
|
||||||
prev.w === offsetWidth && prev.h === offsetHeight
|
|
||||||
? prev
|
|
||||||
: { w: offsetWidth, h: offsetHeight }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [currentIndex, hints]);
|
|
||||||
|
|
||||||
if (!hint) return null;
|
|
||||||
|
|
||||||
const isTargetVisible = targetRect
|
|
||||||
? targetRect.top + 60 < window.innerHeight && targetRect.bottom > 60
|
|
||||||
: true;
|
|
||||||
|
|
||||||
if (!isTargetVisible) return null;
|
|
||||||
|
|
||||||
const spotlightPadding = hint.spotlightPadding ?? PADDING;
|
|
||||||
const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
|
|
||||||
|
|
||||||
// 툴팁은 spotlightRect 기준으로 배치해야 스포트라이트와 겹치지 않음
|
|
||||||
const tooltipPos: TooltipPos = (spotlightRect ?? targetRect)
|
|
||||||
? calcTooltipPos((spotlightRect ?? targetRect)!, hint.position, tooltipSize.w, tooltipSize.h)
|
|
||||||
: {
|
|
||||||
top: window.innerHeight / 2 - tooltipSize.h / 2,
|
|
||||||
left: window.innerWidth / 2 - tooltipSize.w / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tutorial-overlay-root">
|
|
||||||
{spotlightRect ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="tutorial-overlay-blocker"
|
|
||||||
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="tutorial-overlay-blocker"
|
|
||||||
style={{
|
|
||||||
top: spotlightRect.top,
|
|
||||||
left: 0,
|
|
||||||
width: spotlightRect.left,
|
|
||||||
height: spotlightRect.height,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="tutorial-overlay-blocker"
|
|
||||||
style={{
|
|
||||||
top: spotlightRect.top,
|
|
||||||
left: spotlightRect.left + spotlightRect.width,
|
|
||||||
right: 0,
|
|
||||||
height: spotlightRect.height,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="tutorial-overlay-blocker"
|
|
||||||
style={{
|
|
||||||
top: spotlightRect.top + spotlightRect.height,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="tutorial-spotlight-ring"
|
|
||||||
style={{
|
|
||||||
top: spotlightRect.top,
|
|
||||||
left: spotlightRect.left,
|
|
||||||
width: spotlightRect.width,
|
|
||||||
height: spotlightRect.height,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={tooltipRef}
|
|
||||||
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
|
|
||||||
style={{ top: tooltipPos.top, left: tooltipPos.left }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
|
|
||||||
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
|
|
||||||
{hint.noteKey && (
|
|
||||||
<p className="tutorial-tooltip-note">{t(hint.noteKey)}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="tutorial-tooltip-footer">
|
|
||||||
<span className="tutorial-tooltip-counter">
|
|
||||||
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
|
|
||||||
</span>
|
|
||||||
<div className="tutorial-tooltip-actions">
|
|
||||||
<button className="tutorial-btn-skip" onClick={onSkip}>
|
|
||||||
{t('tutorial.skip')}
|
|
||||||
</button>
|
|
||||||
{currentIndex > 0 && (
|
|
||||||
<button className="tutorial-btn-prev" onClick={onPrev}>
|
|
||||||
{t('tutorial.prev')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(hint.clickToAdvance !== true || isFinish) && (
|
|
||||||
<button className="tutorial-btn-next" onClick={onNext}>
|
|
||||||
{isFinish ? t('tutorial.finish') : t('tutorial.next')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TutorialOverlay;
|
|
||||||
|
|
||||||
interface TutorialRestartPopupProps {
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className="tutorial-restart-backdrop">
|
|
||||||
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
|
|
||||||
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
|
|
||||||
<div className="tutorial-restart-actions">
|
|
||||||
<button className="tutorial-restart-cancel" onClick={onCancel}>
|
|
||||||
{t('tutorial.restart.cancel')}
|
|
||||||
</button>
|
|
||||||
<button className="tutorial-restart-confirm" onClick={onConfirm}>
|
|
||||||
{t('tutorial.restart.confirm')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,377 +0,0 @@
|
||||||
export interface TutorialHint {
|
|
||||||
targetSelector: string;
|
|
||||||
titleKey: string;
|
|
||||||
descriptionKey: string;
|
|
||||||
position: 'top' | 'bottom' | 'left' | 'right';
|
|
||||||
noteKey?: string;
|
|
||||||
variant?: 'bubble';
|
|
||||||
clickToAdvance?: boolean;
|
|
||||||
advanceSelector?: string;
|
|
||||||
noSpotlight?: boolean;
|
|
||||||
spotlightPadding?: number;
|
|
||||||
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TutorialStepDef {
|
|
||||||
key: string;
|
|
||||||
hints: TutorialHint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TUTORIAL_KEYS = {
|
|
||||||
LANDING: 'landing',
|
|
||||||
ANALYSIS: 'analysis',
|
|
||||||
ASSET: 'asset',
|
|
||||||
SOUND: 'sound',
|
|
||||||
SOUND_LYRICS: 'soundLyrics',
|
|
||||||
SOUND_AUDIO: 'soundAudio',
|
|
||||||
GENERATING:'generating',
|
|
||||||
COMPLETION: 'completion',
|
|
||||||
MY_INFO: 'myInfo',
|
|
||||||
ADO2_CONTENTS: 'ado2Contents',
|
|
||||||
UPLOAD_MODAL: 'uploadModal',
|
|
||||||
UPLOAD_FORM: 'uploadForm',
|
|
||||||
DASHBOARD: 'dashboard',
|
|
||||||
CONTENT_CALENDAR: 'contentCalendar',
|
|
||||||
FEEDBACK: 'feedback',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
|
|
||||||
export const TUTORIAL_PAGE_GROUPS: string[][] = [
|
|
||||||
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
|
|
||||||
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
|
|
||||||
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const tutorialSteps: TutorialStepDef[] = [
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.LANDING,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.hero-input-wrapper',
|
|
||||||
titleKey: 'tutorial.landing.field.title',
|
|
||||||
descriptionKey: 'tutorial.landing.field.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.hero-manual-card-title',
|
|
||||||
titleKey: 'tutorial.landing.manual.title',
|
|
||||||
descriptionKey: 'tutorial.landing.manual.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.hero-button',
|
|
||||||
titleKey: 'tutorial.landing.button.title',
|
|
||||||
descriptionKey: 'tutorial.landing.button.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
clickToAdvance: true,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.ASSET,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.asset-column.asset-column-left',
|
|
||||||
titleKey: 'tutorial.asset.image.title',
|
|
||||||
descriptionKey: 'tutorial.asset.image.desc',
|
|
||||||
position: 'left',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn',
|
|
||||||
titleKey: 'tutorial.asset.upload.title',
|
|
||||||
descriptionKey: 'tutorial.asset.upload.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.asset-ratio-section',
|
|
||||||
titleKey: 'tutorial.asset.ratio.title',
|
|
||||||
descriptionKey: 'tutorial.asset.ratio.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.asset-next-button',
|
|
||||||
titleKey: 'tutorial.asset.next.title',
|
|
||||||
descriptionKey: 'tutorial.asset.next.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.SOUND,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.genre-grid',
|
|
||||||
titleKey: 'tutorial.sound.genre.title',
|
|
||||||
descriptionKey: 'tutorial.sound.genre.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.language-grid',
|
|
||||||
titleKey: 'tutorial.sound.language.title',
|
|
||||||
descriptionKey: 'tutorial.sound.language.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.btn-generate-sound',
|
|
||||||
titleKey: 'tutorial.sound.generate.title',
|
|
||||||
descriptionKey: 'tutorial.sound.generate.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.SOUND_LYRICS,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.lyrics-display',
|
|
||||||
titleKey: 'tutorial.sound.lyrics.title',
|
|
||||||
descriptionKey: 'tutorial.sound.lyrics.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.status-message-new',
|
|
||||||
titleKey: 'tutorial.sound.lyricsWait.title',
|
|
||||||
descriptionKey: 'tutorial.sound.lyricsWait.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.SOUND_AUDIO,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.audio-player',
|
|
||||||
titleKey: 'tutorial.sound.audioPlayer.title',
|
|
||||||
descriptionKey: 'tutorial.sound.audioPlayer.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.btn-video-generate',
|
|
||||||
titleKey: 'tutorial.sound.video.title',
|
|
||||||
descriptionKey: 'tutorial.sound.video.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.MY_INFO,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.youtube-connect-section',
|
|
||||||
titleKey: 'tutorial.myInfo.myInfo.title',
|
|
||||||
descriptionKey: 'tutorial.myInfo.myInfo.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.myinfo-social-btn',
|
|
||||||
titleKey: 'tutorial.myInfo.connect.title',
|
|
||||||
descriptionKey: 'tutorial.myInfo.connect.desc',
|
|
||||||
noteKey: 'tutorial.myInfo.connect.note',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.myinfo-connected-accounts',
|
|
||||||
titleKey: 'tutorial.myInfo.connected.title',
|
|
||||||
descriptionKey: 'tutorial.myInfo.connected.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '#sidebar-ado2-contents',
|
|
||||||
titleKey: 'tutorial.myInfo.ado2.title',
|
|
||||||
descriptionKey: 'tutorial.myInfo.ado2.desc',
|
|
||||||
position: 'right',
|
|
||||||
clickToAdvance: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.ADO2_CONTENTS,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.ado2-content-card',
|
|
||||||
titleKey: 'tutorial.ado2.list.title',
|
|
||||||
descriptionKey: 'tutorial.ado2.list.desc',
|
|
||||||
position: 'right',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.content-upload-btn',
|
|
||||||
titleKey: 'tutorial.ado2.download.title',
|
|
||||||
descriptionKey: 'tutorial.ado2.download.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.content-delete-btn',
|
|
||||||
titleKey: 'tutorial.ado2.delete.title',
|
|
||||||
descriptionKey: 'tutorial.ado2.delete.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.content-download-btn',
|
|
||||||
titleKey: 'tutorial.ado2.upload.title',
|
|
||||||
descriptionKey: 'tutorial.ado2.upload.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.GENERATING,
|
|
||||||
hints:[
|
|
||||||
{
|
|
||||||
targetSelector: '.comp2-info-section',
|
|
||||||
titleKey: 'tutorial.completion.contentInfo.title',
|
|
||||||
descriptionKey: 'tutorial.completion.contentInfo.desc',
|
|
||||||
position: 'left',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.comp2-video-section',
|
|
||||||
titleKey: 'tutorial.completion.generating.title',
|
|
||||||
descriptionKey: 'tutorial.completion.generating.desc',
|
|
||||||
position: 'top',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.COMPLETION,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.comp2-video-section',
|
|
||||||
titleKey: 'tutorial.completion.completion.title',
|
|
||||||
descriptionKey: 'tutorial.completion.completion.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '#sidebar-my-info',
|
|
||||||
titleKey: 'tutorial.completion.myInfo.title',
|
|
||||||
descriptionKey: 'tutorial.completion.myInfo.desc',
|
|
||||||
position: 'right',
|
|
||||||
clickToAdvance: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.UPLOAD_MODAL,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.social-posting-content',
|
|
||||||
titleKey: 'tutorial.upload.seo.title',
|
|
||||||
descriptionKey: 'tutorial.upload.seo.desc',
|
|
||||||
position: 'right',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.UPLOAD_FORM,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.social-posting-form',
|
|
||||||
titleKey: 'tutorial.upload.required.title',
|
|
||||||
descriptionKey: 'tutorial.upload.required.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.social-posting-radio-group',
|
|
||||||
titleKey: 'tutorial.upload.schedule.title',
|
|
||||||
descriptionKey: 'tutorial.upload.schedule.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.social-posting-btn:not(.cancel)',
|
|
||||||
titleKey: 'tutorial.upload.submit.title',
|
|
||||||
descriptionKey: 'tutorial.upload.submit.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.DASHBOARD,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.stats-grid-8',
|
|
||||||
titleKey: 'tutorial.dashboard.metrics.title',
|
|
||||||
descriptionKey: 'tutorial.dashboard.metrics.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.yoy-chart-card',
|
|
||||||
titleKey: 'tutorial.dashboard.chart.title',
|
|
||||||
descriptionKey: 'tutorial.dashboard.chart.desc',
|
|
||||||
position: 'top',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.tutorial-center-anchor',
|
|
||||||
titleKey: 'tutorial.dashboard.more.title',
|
|
||||||
descriptionKey: 'tutorial.dashboard.more.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.CONTENT_CALENDAR,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.calendar-grid-area',
|
|
||||||
titleKey: 'tutorial.contentCalendar.grid.title',
|
|
||||||
descriptionKey: 'tutorial.contentCalendar.grid.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.calendar-side-panel',
|
|
||||||
titleKey: 'tutorial.contentCalendar.panel.title',
|
|
||||||
descriptionKey: 'tutorial.contentCalendar.panel.desc',
|
|
||||||
position: 'left',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: TUTORIAL_KEYS.FEEDBACK,
|
|
||||||
hints: [
|
|
||||||
{
|
|
||||||
targetSelector: '.tutorial-center-anchor',
|
|
||||||
titleKey: 'tutorial.feedback.complete.title',
|
|
||||||
descriptionKey: 'tutorial.feedback.complete.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
clickToAdvance: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.sidebar-inquiry-btn',
|
|
||||||
titleKey: 'tutorial.feedback.title',
|
|
||||||
descriptionKey: 'tutorial.feedback.desc',
|
|
||||||
position: 'right',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
|
|
||||||
|
|
||||||
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
|
|
||||||
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
|
|
||||||
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
|
|
||||||
if (!group) return null;
|
|
||||||
let offset = 0;
|
|
||||||
let total = 0;
|
|
||||||
for (const k of group) {
|
|
||||||
const step = tutorialSteps.find(s => s.key === k);
|
|
||||||
const count = step?.hints.length ?? 0;
|
|
||||||
if (k === key) offset = total;
|
|
||||||
total += count;
|
|
||||||
}
|
|
||||||
const isLastKeyInGroup = group[group.length - 1] === key;
|
|
||||||
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEEN_KEY = 'ado2_tutorial_seen';
|
|
||||||
const PROGRESS_KEY = 'ado2_tutorial_progress';
|
|
||||||
const ENABLED_KEY = 'ado2_tutorial_enabled';
|
|
||||||
|
|
||||||
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
|
|
||||||
let globalSkip: (() => void) | null = null;
|
|
||||||
|
|
||||||
function getSeenKeys(): string[] {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markSeen(key: string) {
|
|
||||||
const seen = getSeenKeys();
|
|
||||||
if (!seen.includes(key)) {
|
|
||||||
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
|
|
||||||
}
|
|
||||||
clearProgress(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveProgress(key: string, index: number) {
|
|
||||||
try {
|
|
||||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
|
||||||
progress[key] = index;
|
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadProgress(key: string): number {
|
|
||||||
try {
|
|
||||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
|
||||||
return progress[key] ?? 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearProgress(key: string) {
|
|
||||||
try {
|
|
||||||
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
|
|
||||||
delete progress[key];
|
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseTutorialReturn {
|
|
||||||
isActive: boolean;
|
|
||||||
isEnabled: boolean;
|
|
||||||
isRestartPopupVisible: boolean;
|
|
||||||
currentHintIndex: number;
|
|
||||||
hints: TutorialHint[];
|
|
||||||
tutorialKey: string | null;
|
|
||||||
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
|
|
||||||
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
|
|
||||||
nextHint: () => void;
|
|
||||||
prevHint: () => void;
|
|
||||||
skipTutorial: () => void;
|
|
||||||
toggleTutorial: (currentKey: string | null) => void;
|
|
||||||
showRestartPopup: (key: string) => void;
|
|
||||||
confirmRestart: () => void;
|
|
||||||
cancelRestart: () => void;
|
|
||||||
hasSeen: (key: string) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTutorial(): UseTutorialReturn {
|
|
||||||
const [isActive, setIsActive] = useState(false);
|
|
||||||
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
|
|
||||||
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
|
|
||||||
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
|
|
||||||
const [currentHintIndex, setCurrentHintIndex] = useState(0);
|
|
||||||
const [hints, setHints] = useState<TutorialHint[]>([]);
|
|
||||||
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
|
|
||||||
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
|
|
||||||
|
|
||||||
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
|
|
||||||
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
|
|
||||||
const step = tutorialSteps.find(s => s.key === key);
|
|
||||||
if (!step || step.hints.length === 0) return;
|
|
||||||
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
|
|
||||||
globalSkip?.();
|
|
||||||
const savedIndex = forceFromStart ? 0 : loadProgress(key);
|
|
||||||
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
|
|
||||||
onCompleteRef.current = onComplete;
|
|
||||||
setHints(step.hints);
|
|
||||||
setTutorialKey(key);
|
|
||||||
setCurrentHintIndex(resumeIndex);
|
|
||||||
setIsActive(true);
|
|
||||||
// 이 인스턴스의 skip을 전역에 등록
|
|
||||||
globalSkip = () => {
|
|
||||||
if (key) saveProgress(key, resumeIndex);
|
|
||||||
setIsActive(false);
|
|
||||||
setCurrentHintIndex(0);
|
|
||||||
globalSkip = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const nextHint = useCallback(() => {
|
|
||||||
setCurrentHintIndex(prev => {
|
|
||||||
if (prev < hints.length - 1) {
|
|
||||||
const next = prev + 1;
|
|
||||||
if (tutorialKey) saveProgress(tutorialKey, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
|
|
||||||
setIsActive(false);
|
|
||||||
if (tutorialKey) markSeen(tutorialKey);
|
|
||||||
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
|
|
||||||
onCompleteRef.current?.();
|
|
||||||
onCompleteRef.current = undefined;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}, [hints.length, tutorialKey]);
|
|
||||||
|
|
||||||
const prevHint = useCallback(() => {
|
|
||||||
setCurrentHintIndex(prev => Math.max(0, prev - 1));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시
|
|
||||||
const skipTutorial = useCallback(() => {
|
|
||||||
if (tutorialKey) saveProgress(tutorialKey, currentHintIndex);
|
|
||||||
setIsActive(false);
|
|
||||||
setCurrentHintIndex(0);
|
|
||||||
}, [tutorialKey, currentHintIndex]);
|
|
||||||
|
|
||||||
const toggleTutorial = useCallback((currentKey: string | null) => {
|
|
||||||
if (isEnabled) {
|
|
||||||
// off: 튜토리얼 중단 + 비활성화
|
|
||||||
setIsActive(false);
|
|
||||||
setIsEnabled(false);
|
|
||||||
localStorage.setItem(ENABLED_KEY, 'false');
|
|
||||||
} else {
|
|
||||||
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
|
|
||||||
localStorage.removeItem(SEEN_KEY);
|
|
||||||
localStorage.removeItem(PROGRESS_KEY);
|
|
||||||
localStorage.setItem(ENABLED_KEY, 'true');
|
|
||||||
setIsEnabled(true);
|
|
||||||
if (currentKey) {
|
|
||||||
startTutorial(currentKey, undefined, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isEnabled, startTutorial]);
|
|
||||||
|
|
||||||
// 튜토리얼 다시 보기: 팝업 표시만
|
|
||||||
const showRestartPopup = useCallback((key: string) => {
|
|
||||||
setPendingRestartKey(key);
|
|
||||||
setIsRestartPopupVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
|
|
||||||
const confirmRestart = useCallback(() => {
|
|
||||||
setIsRestartPopupVisible(false);
|
|
||||||
if (pendingRestartKey) {
|
|
||||||
localStorage.removeItem(SEEN_KEY);
|
|
||||||
localStorage.removeItem(PROGRESS_KEY);
|
|
||||||
startTutorial(pendingRestartKey, undefined, true);
|
|
||||||
}
|
|
||||||
setPendingRestartKey(null);
|
|
||||||
}, [pendingRestartKey, startTutorial]);
|
|
||||||
|
|
||||||
// 팝업에서 취소
|
|
||||||
const cancelRestart = useCallback(() => {
|
|
||||||
setIsRestartPopupVisible(false);
|
|
||||||
setPendingRestartKey(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const hasSeen = useCallback((key: string) => {
|
|
||||||
return getSeenKeys().includes(key);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 영상 보유 사용자 자동 off 이벤트 수신
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => {
|
|
||||||
setIsActive(false);
|
|
||||||
setIsEnabled(false);
|
|
||||||
};
|
|
||||||
window.addEventListener('ado2-tutorial-auto-disable', handler);
|
|
||||||
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isActive,
|
|
||||||
isEnabled,
|
|
||||||
isRestartPopupVisible,
|
|
||||||
currentHintIndex,
|
|
||||||
hints,
|
|
||||||
tutorialKey,
|
|
||||||
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
|
|
||||||
startTutorial,
|
|
||||||
nextHint,
|
|
||||||
prevHint,
|
|
||||||
skipTutorial,
|
|
||||||
toggleTutorial,
|
|
||||||
showRestartPopup,
|
|
||||||
confirmRestart,
|
|
||||||
cancelRestart,
|
|
||||||
hasSeen,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -14,8 +14,6 @@ interface UploadProgressModalProps {
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
isScheduled?: boolean;
|
isScheduled?: boolean;
|
||||||
scheduledAt?: string | null;
|
|
||||||
onGoToCalendar?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
|
|
@ -28,8 +26,6 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
youtubeUrl,
|
youtubeUrl,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isScheduled = false,
|
isScheduled = false,
|
||||||
scheduledAt,
|
|
||||||
onGoToCalendar,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
@ -150,43 +146,14 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
{t('upload.viewOnYoutube')}
|
{t('upload.viewOnYoutube')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scheduled conflict notice */}
|
|
||||||
{status === 'completed' && isScheduled && scheduledAt && (
|
|
||||||
<div className="upload-progress-schedule-notice">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
||||||
</svg>
|
|
||||||
<span>
|
|
||||||
{t('upload.scheduleConflict', {
|
|
||||||
time: new Date(scheduledAt).toLocaleString('ko-KR', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="upload-progress-footer">
|
<div className="upload-progress-footer">
|
||||||
{canClose ? (
|
{canClose ? (
|
||||||
<>
|
|
||||||
<button className="upload-progress-btn primary" onClick={onClose}>
|
<button className="upload-progress-btn primary" onClick={onClose}>
|
||||||
{status === 'completed' ? t('upload.confirm') : t('upload.close')}
|
{status === 'completed' ? t('upload.confirm') : t('upload.close')}
|
||||||
</button>
|
</button>
|
||||||
{status === 'completed' && onGoToCalendar && (
|
|
||||||
<span className="upload-progress-calendar-link" onClick={() => { onClose(); onGoToCalendar(); }}>
|
|
||||||
{t('upload.goToCalendar')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="upload-progress-note">{t('upload.doNotClose')}</p>
|
<p className="upload-progress-note">{t('upload.doNotClose')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,485 +0,0 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
getVideoById,
|
|
||||||
getVideoComments,
|
|
||||||
postVideoComment,
|
|
||||||
deleteComment,
|
|
||||||
toggleVideoLike,
|
|
||||||
isLoggedIn,
|
|
||||||
} from '../utils/api';
|
|
||||||
import { VideoDetailItem, CommentItem } from '../types/api';
|
|
||||||
import LoginPromptModal from './LoginPromptModal';
|
|
||||||
|
|
||||||
interface VideoDetailContentProps {
|
|
||||||
videoId: string;
|
|
||||||
isModal?: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModal = false, onClose }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const authed = isLoggedIn();
|
|
||||||
|
|
||||||
const [video, setVideo] = useState<VideoDetailItem | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [likeCount, setLikeCount] = useState(0);
|
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
|
||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
|
||||||
const [isLandscape, setIsLandscape] = useState(false);
|
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
||||||
|
|
||||||
const [comments, setComments] = useState<CommentItem[]>([]);
|
|
||||||
const [commentsTotal, setCommentsTotal] = useState(0);
|
|
||||||
const [commentsPage, setCommentsPage] = useState(1);
|
|
||||||
const [commentsHasNext, setCommentsHasNext] = useState(false);
|
|
||||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
|
||||||
const [commentInput, setCommentInput] = useState('');
|
|
||||||
const [commentSubmitting, setCommentSubmitting] = useState(false);
|
|
||||||
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
const [commentNickname, setCommentNickname] = useState('');
|
|
||||||
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
|
|
||||||
|
|
||||||
// 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음
|
|
||||||
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
|
|
||||||
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
|
|
||||||
|
|
||||||
const handleChangeAvatar = useCallback(() => {
|
|
||||||
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchComments = useCallback(async (page: number, append = false) => {
|
|
||||||
setCommentsLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await getVideoComments(videoId, page, 20);
|
|
||||||
const sorted = [...res.items].sort(
|
|
||||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
||||||
);
|
|
||||||
setComments(prev => append ? [...prev, ...sorted] : sorted);
|
|
||||||
setCommentsTotal(res.total);
|
|
||||||
setCommentsHasNext(res.has_next);
|
|
||||||
setCommentsPage(page);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch comments:', err);
|
|
||||||
} finally {
|
|
||||||
setCommentsLoading(false);
|
|
||||||
}
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchVideo = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await getVideoById(videoId);
|
|
||||||
setVideo(data);
|
|
||||||
setLikeCount(data.like_count);
|
|
||||||
setIsLiked(data.is_liked_by_me);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch video:', err);
|
|
||||||
setError(t('ado2Contents.loadFailed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchVideo();
|
|
||||||
fetchComments(1);
|
|
||||||
}, [videoId, fetchComments]);
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCommentDate = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareUrl = `${window.location.origin}/video/${videoId}`;
|
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
|
||||||
} catch {
|
|
||||||
// clipboard API 미지원 환경에서는 무시
|
|
||||||
}
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKakaoShare = () => {
|
|
||||||
const kakao = window.Kakao;
|
|
||||||
if (kakao?.Share) {
|
|
||||||
kakao.Share.sendDefault({
|
|
||||||
objectType: 'feed',
|
|
||||||
content: {
|
|
||||||
title: video?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
|
|
||||||
description: t('videoDetail.kakaoDescription', { region: video?.region ?? '' }),
|
|
||||||
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
|
|
||||||
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
|
||||||
},
|
|
||||||
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
|
|
||||||
});
|
|
||||||
} else if (navigator.share) {
|
|
||||||
navigator.share({ url: shareUrl }).catch(() => {});
|
|
||||||
} else {
|
|
||||||
handleCopyLink();
|
|
||||||
}
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFacebookShare = () => {
|
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTwitterShare = () => {
|
|
||||||
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shareMenuRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shareMenuOpen) return;
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
|
|
||||||
setShareMenuOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, [shareMenuOpen]);
|
|
||||||
|
|
||||||
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const handleLike = () => {
|
|
||||||
if (!authed) { setShowLoginModal(true); return; }
|
|
||||||
|
|
||||||
// 1. UI 즉시 업데이트 (Optimistic)
|
|
||||||
setIsLiked(prev => !prev);
|
|
||||||
setLikeCount(prev => isLiked ? prev - 1 : prev + 1);
|
|
||||||
|
|
||||||
// 2. 기존 debounce 타이머 취소 후 재설정
|
|
||||||
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
|
|
||||||
likeDebounceRef.current = setTimeout(async () => {
|
|
||||||
const prevLiked = isLiked;
|
|
||||||
const prevCount = likeCount;
|
|
||||||
try {
|
|
||||||
await toggleVideoLike(videoId);
|
|
||||||
} catch (err) {
|
|
||||||
// 3. 실패 시 롤백
|
|
||||||
console.error('Failed to toggle like:', err);
|
|
||||||
setIsLiked(prevLiked);
|
|
||||||
setLikeCount(prevCount);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHeaderAction = () => {
|
|
||||||
if (!isModal && !authed) { setShowLoginModal(true); return; }
|
|
||||||
onClose?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommentFocus = () => {
|
|
||||||
if (!authed) setShowLoginModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommentSubmit = async () => {
|
|
||||||
if (!authed) { setShowLoginModal(true); return; }
|
|
||||||
if (!commentInput.trim() || commentSubmitting) return;
|
|
||||||
setCommentSubmitting(true);
|
|
||||||
try {
|
|
||||||
await postVideoComment(videoId, commentInput.trim(), commentNickname);
|
|
||||||
setCommentInput('');
|
|
||||||
if (commentTextareaRef.current) {
|
|
||||||
commentTextareaRef.current.style.height = 'auto';
|
|
||||||
}
|
|
||||||
setCommentNickname('');
|
|
||||||
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
|
|
||||||
await fetchComments(1);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to post comment:', err);
|
|
||||||
} finally {
|
|
||||||
setCommentSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteComment = async (commentId: number) => {
|
|
||||||
try {
|
|
||||||
await deleteComment(commentId);
|
|
||||||
await fetchComments(commentsPage);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete comment:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
|
|
||||||
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>{t('videoDetail.deletedComment')}</span>;
|
|
||||||
return content;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={isModal ? 'video-detail-modal-content' : 'video-detail-page-content'}>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="video-detail-header">
|
|
||||||
{isModal ? (
|
|
||||||
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M13 4l-6 6 6 6"/>
|
|
||||||
</svg>
|
|
||||||
{t('sidebar.ado2Contents')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="ado2-contents-loading">
|
|
||||||
<div className="loading-spinner"></div>
|
|
||||||
<p>{t('ado2Contents.loading')}</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="ado2-contents-error"><p>{error}</p></div>
|
|
||||||
) : video ? (
|
|
||||||
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
|
||||||
<video
|
|
||||||
src={video.result_movie_url}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
controlsList="nodownload"
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
className="video-detail-player"
|
|
||||||
onLoadedMetadata={(e) => {
|
|
||||||
const v = e.currentTarget;
|
|
||||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="video-detail-info">
|
|
||||||
<h2 className="video-detail-store">{video.store_name}</h2>
|
|
||||||
<p className="video-detail-date">{formatDate(video.created_at)}</p>
|
|
||||||
|
|
||||||
{/* 좋아요 + 링크 복사 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
className={`video-detail-like-btn ${isLiked ? 'liked' : ''}`}
|
|
||||||
onClick={handleLike}
|
|
||||||
disabled={false}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
|
||||||
</svg>
|
|
||||||
{likeCount}
|
|
||||||
</button>
|
|
||||||
<div style={{ position: 'relative' }} ref={shareMenuRef}>
|
|
||||||
<button
|
|
||||||
className="video-detail-copy-btn"
|
|
||||||
onClick={() => setShareMenuOpen(v => !v)}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
|
||||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
|
||||||
</svg>
|
|
||||||
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
|
||||||
</button>
|
|
||||||
{shareMenuOpen && (
|
|
||||||
<div className="video-detail-share-menu">
|
|
||||||
{/* 카카오톡 */}
|
|
||||||
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="20" height="20" rx="4" fill="#FEE500"/>
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareKakao')}
|
|
||||||
</button>
|
|
||||||
{/* 페이스북 */}
|
|
||||||
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
|
||||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareFacebook')}
|
|
||||||
</button>
|
|
||||||
{/* X (트위터) */}
|
|
||||||
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
|
||||||
</svg>
|
|
||||||
{t('videoDetail.shareTwitter')}
|
|
||||||
</button>
|
|
||||||
{/* URL 복사 */}
|
|
||||||
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
||||||
</svg>
|
|
||||||
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 댓글 섹션 */}
|
|
||||||
<div className="video-detail-comments">
|
|
||||||
<div className="video-detail-comments-header">
|
|
||||||
<h3 className="video-detail-comments-title">{t('videoDetail.commentsTitle')}</h3>
|
|
||||||
<span className="video-detail-comments-count">{commentsTotal}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 댓글 작성자 프로필 선택 */}
|
|
||||||
{authed && (
|
|
||||||
<div className="video-detail-comment-profile">
|
|
||||||
<img
|
|
||||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
|
|
||||||
alt={t('videoDetail.changeAvatarTitle')}
|
|
||||||
className="video-detail-comment-avatar"
|
|
||||||
onClick={handleChangeAvatar}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
title={t('videoDetail.changeAvatarTitle')}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="video-detail-nickname-input"
|
|
||||||
type="text"
|
|
||||||
placeholder={t('videoDetail.nicknamePlaceholder')}
|
|
||||||
value={commentNickname}
|
|
||||||
onChange={(e) => setCommentNickname(e.target.value)}
|
|
||||||
maxLength={20}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="video-detail-comment-input-wrap">
|
|
||||||
<textarea
|
|
||||||
ref={commentTextareaRef}
|
|
||||||
className="video-detail-comment-input"
|
|
||||||
placeholder={authed ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')}
|
|
||||||
maxLength={500}
|
|
||||||
rows={1}
|
|
||||||
value={commentInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCommentInput(e.target.value);
|
|
||||||
e.target.style.height = 'auto';
|
|
||||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
||||||
}}
|
|
||||||
onFocus={handleCommentFocus}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleCommentSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!authed}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="video-detail-comment-submit"
|
|
||||||
onClick={handleCommentSubmit}
|
|
||||||
disabled={!authed || !commentInput.trim() || commentSubmitting}
|
|
||||||
>
|
|
||||||
{commentSubmitting ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{comments.length === 0 && !commentsLoading ? (
|
|
||||||
<p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="video-detail-comment-list">
|
|
||||||
{comments.map((c) => (
|
|
||||||
<li key={c.id} className="video-detail-comment-item">
|
|
||||||
<img
|
|
||||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
|
|
||||||
alt="avatar"
|
|
||||||
className="video-detail-comment-avatar"
|
|
||||||
/>
|
|
||||||
<div className="video-detail-comment-body">
|
|
||||||
<span className="video-detail-comment-nickname">
|
|
||||||
{c.nickname || t('videoDetail.anonymous')}
|
|
||||||
</span>
|
|
||||||
<p className="video-detail-comment-text">
|
|
||||||
{renderCommentContent(c.content, c.is_deleted)}
|
|
||||||
</p>
|
|
||||||
<div className="video-detail-comment-bottom">
|
|
||||||
<span className="video-detail-comment-date">{formatCommentDate(c.created_at)}</span>
|
|
||||||
{c.is_mine && !c.is_deleted && (
|
|
||||||
<button
|
|
||||||
className="video-detail-comment-delete"
|
|
||||||
onClick={() => handleDeleteComment(c.id)}
|
|
||||||
>
|
|
||||||
{t('videoDetail.deleteComment')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대댓글 (읽기 전용) */}
|
|
||||||
{c.replies && c.replies.length > 0 && (
|
|
||||||
<ul className="video-detail-reply-list">
|
|
||||||
{c.replies.map((r) => (
|
|
||||||
<li key={r.id} className="video-detail-reply-item">
|
|
||||||
<img
|
|
||||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
|
|
||||||
alt="avatar"
|
|
||||||
className="video-detail-comment-avatar small"
|
|
||||||
/>
|
|
||||||
<div className="video-detail-comment-body">
|
|
||||||
<span className="video-detail-comment-nickname">
|
|
||||||
{r.nickname || t('videoDetail.anonymous')}
|
|
||||||
</span>
|
|
||||||
<p className="video-detail-comment-text">
|
|
||||||
{renderCommentContent(r.content, r.is_deleted)}
|
|
||||||
</p>
|
|
||||||
<div className="video-detail-comment-bottom">
|
|
||||||
<span className="video-detail-comment-date">{formatCommentDate(r.created_at)}</span>
|
|
||||||
{r.is_mine && !r.is_deleted && (
|
|
||||||
<button
|
|
||||||
className="video-detail-comment-delete"
|
|
||||||
onClick={() => handleDeleteComment(r.id)}
|
|
||||||
>
|
|
||||||
{t('videoDetail.deleteComment')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{commentsHasNext && (
|
|
||||||
<button
|
|
||||||
className="video-detail-comments-more"
|
|
||||||
onClick={() => fetchComments(commentsPage + 1, true)}
|
|
||||||
disabled={commentsLoading}
|
|
||||||
>
|
|
||||||
{commentsLoading ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showLoginModal && (
|
|
||||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VideoDetailContent;
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import VideoDetailContent from './VideoDetailContent';
|
|
||||||
|
|
||||||
interface VideoDetailModalProps {
|
|
||||||
videoId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose }) => {
|
|
||||||
useEffect(() => {
|
|
||||||
// 모달 열릴 때 배경 스크롤 잠금
|
|
||||||
const prev = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = prev;
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0, 0, 0, 0.85)',
|
|
||||||
zIndex: 2000,
|
|
||||||
display: 'flex',
|
|
||||||
overflowY: 'auto',
|
|
||||||
padding: '24px 16px',
|
|
||||||
}}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#01282A',
|
|
||||||
borderRadius: '16px',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '800px',
|
|
||||||
margin: 'auto',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VideoDetailModal;
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import VideoDetailContent from './VideoDetailContent';
|
|
||||||
|
|
||||||
interface VideoDetailPageProps {
|
|
||||||
videoId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VideoDetailPage: React.FC<VideoDetailPageProps> = ({ videoId }) => {
|
|
||||||
const handleBack = () => {
|
|
||||||
localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
|
|
||||||
window.location.href = '/';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="video-detail-page">
|
|
||||||
<VideoDetailContent videoId={videoId} isModal={false} onClose={handleBack} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VideoDetailPage;
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface WizardStepperProps {
|
|
||||||
currentStep: number; // 0~3, matching wizardStep in GenerationFlow
|
|
||||||
}
|
|
||||||
|
|
||||||
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
t('wizardSteps.brandAnalysis'),
|
|
||||||
t('wizardSteps.asset'),
|
|
||||||
t('wizardSteps.sound'),
|
|
||||||
t('wizardSteps.video'),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="wizard-stepper">
|
|
||||||
{steps.map((label, index) => {
|
|
||||||
const isDone = index < currentStep;
|
|
||||||
const isCurrent = index === currentStep;
|
|
||||||
const stateClass = isDone ? ' done' : isCurrent ? ' current' : ' pending';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
{/* 노드: 2rem 고정 너비. 라벨은 absolute로 노드 중심 아래에 붙음 */}
|
|
||||||
<div className={`wizard-step${stateClass}`}>
|
|
||||||
<div className="wizard-stepper-node">
|
|
||||||
{isDone ? (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<span>{index + 1}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="wizard-stepper-label">{label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 연결선: flex:1로 남은 공간을 균등하게 채움 */}
|
|
||||||
{index < steps.length - 1 && (
|
|
||||||
<div className={`wizard-step-line${isDone ? ' done' : ''}`} />
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WizardStepper;
|
|
||||||
|
|
@ -2,20 +2,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
import './styles/index.css';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
Kakao: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const kakaoKey = import.meta.env.VITE_KAKAO_JS_KEY as string | undefined;
|
|
||||||
if (kakaoKey && window.Kakao && !window.Kakao.isInitialized()) {
|
|
||||||
window.Kakao.init(kakaoKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Could not find root element to mount to");
|
throw new Error("Could not find root element to mount to");
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
{
|
{
|
||||||
"wizardSteps": {
|
|
||||||
"brandAnalysis": "Analysis",
|
|
||||||
"asset": "Image",
|
|
||||||
"sound": "Sound",
|
|
||||||
"video": "Video"
|
|
||||||
},
|
|
||||||
"header": {
|
"header": {
|
||||||
"loginFailedAlert": "Failed to get login URL. Please try again.",
|
"loginFailedAlert": "Failed to get login URL. Please try again.",
|
||||||
"start": "Get Started",
|
"start": "Get Started",
|
||||||
|
|
@ -18,95 +12,16 @@
|
||||||
"myContents": "My Contents",
|
"myContents": "My Contents",
|
||||||
"myInfo": "My Info",
|
"myInfo": "My Info",
|
||||||
"defaultUser": "User",
|
"defaultUser": "User",
|
||||||
"credits": "Credits left: {{count}}",
|
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
"logout": "Log Out",
|
"logout": "Log Out"
|
||||||
"tutorialRestart": "Restart Tutorial",
|
|
||||||
"tutorial": "Tutorial",
|
|
||||||
"tutorialOn": "Enable Tutorial",
|
|
||||||
"tutorialOff": "Disable Tutorial",
|
|
||||||
"inquiry": "Feedback"
|
|
||||||
},
|
|
||||||
"tutorial": {
|
|
||||||
"skip": "Skip",
|
|
||||||
"next": "Next",
|
|
||||||
"prev": "Back",
|
|
||||||
"finish": "Done",
|
|
||||||
"landing": {
|
|
||||||
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
|
|
||||||
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." },
|
|
||||||
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
|
|
||||||
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
|
|
||||||
},
|
|
||||||
"asset": {
|
|
||||||
"image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." },
|
|
||||||
"upload": { "title": "Add Images", "desc": "You can freely add more images." },
|
|
||||||
"ratio": { "title": "Select Video Ratio", "desc": "Choose the ratio for the video to be generated." },
|
|
||||||
"next": { "title": "Next Step", "desc": "Proceed to the next step when ready." }
|
|
||||||
},
|
|
||||||
"sound": {
|
|
||||||
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
|
|
||||||
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
|
|
||||||
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music." },
|
|
||||||
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
|
|
||||||
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
|
|
||||||
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
|
|
||||||
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
|
|
||||||
},
|
|
||||||
"completion": {
|
|
||||||
"contentInfo": { "title": "Content Info", "desc": "Check the file name, genre, resolution, and lyrics of the generated content." },
|
|
||||||
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
|
|
||||||
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
|
|
||||||
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
|
|
||||||
},
|
|
||||||
"myInfo": {
|
|
||||||
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
|
|
||||||
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
|
|
||||||
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
|
|
||||||
"ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
|
|
||||||
},
|
|
||||||
"ado2": {
|
|
||||||
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." },
|
|
||||||
"download": { "title": "Download", "desc": "Download the video to your device." },
|
|
||||||
"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": {
|
|
||||||
"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." },
|
|
||||||
"schedule": { "title": "Schedule Upload", "desc": "Post now or schedule for a specific time." },
|
|
||||||
"submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." }
|
|
||||||
},
|
|
||||||
"dashboard": {
|
|
||||||
"metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats for content uploaded via ADO2." },
|
|
||||||
"chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." },
|
|
||||||
"more": { "title": "More Analytics", "desc": "Even more statistics are available at a glance on the dashboard." }
|
|
||||||
},
|
|
||||||
"contentCalendar": {
|
|
||||||
"grid": { "title": "Content Calendar", "desc": "View your content schedule by date.\nWhy not select today?" },
|
|
||||||
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
|
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right." },
|
|
||||||
"title": "Customer Feedback",
|
|
||||||
"desc": "Share any issues or suggestions to help us improve."
|
|
||||||
},
|
|
||||||
"restart": {
|
|
||||||
"title": "Restart Tutorial?",
|
|
||||||
"desc": "The tutorial will restart from the current screen.",
|
|
||||||
"confirm": "Start",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company": "O2O Inc.",
|
"company":"O2O Inc.",
|
||||||
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
|
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
|
||||||
"headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
|
"headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
|
||||||
"researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
|
"researchCenter": "R&D : 13453 Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
|
||||||
"phone": "Tel : 070-4260-8310 | 010-2755-6463",
|
"phone": "Tel : 070-4260-8310 | 010-2755-6463",
|
||||||
"email": "Email : o2oteam@o2o.kr",
|
"email": "Email : o2oteam@o2o.kr"
|
||||||
"privacyPolicy": "Privacy Policy",
|
|
||||||
"termsOfService": "Terms of Service"
|
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
"title": "Social Media Posting",
|
"title": "Social Media Posting",
|
||||||
|
|
@ -128,7 +43,7 @@
|
||||||
"privacyPrivate": "Private",
|
"privacyPrivate": "Private",
|
||||||
"publishTimeLabel": "Publish Time",
|
"publishTimeLabel": "Publish Time",
|
||||||
"publishNow": "Publish Now",
|
"publishNow": "Publish Now",
|
||||||
"publishSchedule": "Schedule",
|
"publishSchedule": "Schedule (Coming Soon)",
|
||||||
"footerNote": "",
|
"footerNote": "",
|
||||||
"footerNoteLink": "",
|
"footerNoteLink": "",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
@ -140,31 +55,9 @@
|
||||||
"invalidVideoInfo": "Video information is invalid. (missing video_id)",
|
"invalidVideoInfo": "Video information is invalid. (missing video_id)",
|
||||||
"uploadStartFailed": "Failed to start upload.",
|
"uploadStartFailed": "Failed to start upload.",
|
||||||
"uploadFailed": "Upload failed.",
|
"uploadFailed": "Upload failed.",
|
||||||
"autoSeoTitle": "Auto-generating... (30–60 sec)",
|
"autoSeoTitle": "This will be automatically generated. please wait.",
|
||||||
"autoSeoDescription": "Auto-generating... (30–60 sec)",
|
"autoSeoDescription": "This will be automatically generated. please wait.",
|
||||||
"autoSeoTags": "Auto-generating... (30–60 sec)",
|
"autoSeoTags": "This will be automatically generated. please wait."
|
||||||
"youtubeCallback": {
|
|
||||||
"connecting": "Connecting YouTube...",
|
|
||||||
"pleaseWait": "Please wait a moment.",
|
|
||||||
"failed": "Connection Failed",
|
|
||||||
"errorAuthCancelled": "Google authentication was cancelled or failed.",
|
|
||||||
"errorNoCode": "Authorization code was not received.",
|
|
||||||
"errorProcessing": "An error occurred while processing the YouTube connection."
|
|
||||||
},
|
|
||||||
"connectError": {
|
|
||||||
"title": "{{platform}} Connection Failed",
|
|
||||||
"defaultError": "An unknown error occurred.",
|
|
||||||
"detail": "Please try again. If the problem persists, contact customer support.",
|
|
||||||
"retry": "Try Again"
|
|
||||||
},
|
|
||||||
"connectSuccess": {
|
|
||||||
"title": "{{name}} Connected",
|
|
||||||
"messageWithChannel": "{{platform}} account has been successfully connected.",
|
|
||||||
"messageWithoutChannel": "Account has been successfully connected.",
|
|
||||||
"verified": "Verified",
|
|
||||||
"countdown": "Redirecting in {{count}} seconds...",
|
|
||||||
"goNow": "Go Now"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube Upload",
|
"title": "YouTube Upload",
|
||||||
|
|
@ -180,35 +73,23 @@
|
||||||
"viewOnYoutube": "View on YouTube",
|
"viewOnYoutube": "View on YouTube",
|
||||||
"confirm": "OK",
|
"confirm": "OK",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"doNotClose": "Upload is in progress. Do not close this window.",
|
"doNotClose": "Upload is in progress. Do not close this window."
|
||||||
"goToCalendar": "View in Calendar",
|
|
||||||
"scheduleConflict": "Schedule adjusted to {{time}} due to a conflict."
|
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeLabel": "Business Name | URL",
|
"searchTypeBusinessName": "Business Name",
|
||||||
"placeholderCombined": "Enter a business name or Naver Maps/Place URL",
|
"placeholderBusinessName": "Enter a business name",
|
||||||
"guideCombined": "Paste a Naver Maps share URL, or enter a business name to automatically retrieve information.",
|
"guideUrl": "A video will be automatically generated from the information gathered from the URL.",
|
||||||
"errorInputRequired": "Please enter a business name or URL.",
|
"guideBusinessName": "Search by business name to retrieve information.",
|
||||||
"errorInvalidUrl": "Invalid URL format. (e.g., https://naver.me/abcdef)",
|
"errorUrlRequired": "Please enter a URL.",
|
||||||
|
"errorNameRequired": "Please enter a business name.",
|
||||||
|
"errorInvalidUrl": "Invalid URL format. (e.g., https://example.com)",
|
||||||
"analyzeButton": "Brand Analysis",
|
"analyzeButton": "Brand Analysis",
|
||||||
"scrollMore": "Scroll to see more",
|
"scrollMore": "Scroll to see more",
|
||||||
"searching": "Searching...",
|
"testDataLoading": "Loading...",
|
||||||
"searchTypeManual": "Not on Naver? Enter manually",
|
"testData": "Test Data",
|
||||||
"manualButtonDesc": "Even businesses not listed on Naver Maps/Place\ncan get lyrics, music, and video production\njust by entering the name, region, and address.",
|
"testDataLoadFailed": "Failed to load test data.",
|
||||||
"manualModalTitle": "Enter Business Info Directly",
|
"searching": "Searching..."
|
||||||
"manualModalSubtitle": "Even businesses not on Naver can get lyrics, music, and video production by filling in the details below.",
|
|
||||||
"manualAnalyzeButton": "Get Started",
|
|
||||||
"manualLabelName": "Business Name",
|
|
||||||
"manualLabelAddress": "Address",
|
|
||||||
"manualLabelRegion": "Region",
|
|
||||||
"manualLabelDetail": "Detail Address",
|
|
||||||
"manualLabelCategory": "Category",
|
|
||||||
"manualPlaceholderName": "Enter the business name",
|
|
||||||
"manualPlaceholderAddress": "Enter the address",
|
|
||||||
"manualPlaceholderRegion": "Select a region",
|
|
||||||
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
|
|
||||||
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
|
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "Welcome to ADO2.AI",
|
"title": "Welcome to ADO2.AI",
|
||||||
|
|
@ -216,7 +97,7 @@
|
||||||
"feature1Title": "Business Core Information Analysis",
|
"feature1Title": "Business Core Information Analysis",
|
||||||
"feature1Desc": "Enter the URL of your homepage,\nNaver Map, blog, etc.",
|
"feature1Desc": "Enter the URL of your homepage,\nNaver Map, blog, etc.",
|
||||||
"feature2Title": "Automated Promotional Content Creation",
|
"feature2Title": "Automated Promotional Content Creation",
|
||||||
"feature2Desc": "Based on analyzed information,\nautomatically create music, subtitles,\nand videos tailored to your business",
|
"feature2Desc": "Based on analyzed information,\nautomatically create music, subtitles, songs,\nand videos tailored to your business",
|
||||||
"feature3Title": "Multi-Channel Auto Distribution",
|
"feature3Title": "Multi-Channel Auto Distribution",
|
||||||
"feature3Desc": "Completed videos can be downloaded\nor uploaded directly to social media"
|
"feature3Desc": "Completed videos can be downloaded\nor uploaded directly to social media"
|
||||||
},
|
},
|
||||||
|
|
@ -234,26 +115,12 @@
|
||||||
"urlInput": {
|
"urlInput": {
|
||||||
"searchTypeBusinessName": "Business Name",
|
"searchTypeBusinessName": "Business Name",
|
||||||
"placeholderBusinessName": "Enter a business name",
|
"placeholderBusinessName": "Enter a business name",
|
||||||
"guideUrl": "Search for a place on Naver Maps, tap Share, then paste the URL here.",
|
"guideUrl": "A video will be automatically generated from the information gathered from the URL.",
|
||||||
"guideBusinessName": "Search by business name to retrieve information.",
|
"guideBusinessName": "Search by business name to retrieve information.",
|
||||||
"searchButton": "Search",
|
"searchButton": "Search",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"searchTypeManual": "Manual Input",
|
"testDataLoading": "Loading...",
|
||||||
"errorSelectFromList": "Please select a business from the list.",
|
"testData": "Test Data"
|
||||||
"errorUrlRequired": "Please enter a URL.",
|
|
||||||
"errorNameRequired": "Please enter a business name.",
|
|
||||||
"errorInvalidUrl": "Invalid URL format. (e.g. https://example.com)",
|
|
||||||
"manualModalTitle": "Enter Business Info",
|
|
||||||
"manualLabelName": "Business Name",
|
|
||||||
"manualLabelAddress": "Address",
|
|
||||||
"manualLabelRegion": "Region",
|
|
||||||
"manualLabelDetail": "Detail Address",
|
|
||||||
"manualLabelCategory": "Category",
|
|
||||||
"manualPlaceholderName": "Enter the business name",
|
|
||||||
"manualPlaceholderAddress": "Enter the address",
|
|
||||||
"manualPlaceholderRegion": "Select a region",
|
|
||||||
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
|
|
||||||
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
|
|
||||||
},
|
},
|
||||||
"assetManagement": {
|
"assetManagement": {
|
||||||
"title": "Brand Assets",
|
"title": "Brand Assets",
|
||||||
|
|
@ -261,7 +128,7 @@
|
||||||
"imageAlt": "Image",
|
"imageAlt": "Image",
|
||||||
"uploadBadge": "Uploaded",
|
"uploadBadge": "Uploaded",
|
||||||
"imageUpload": "Image Upload",
|
"imageUpload": "Image Upload",
|
||||||
"dragAndDrop": "Drag & drop or\nclick to upload",
|
"dragAndDrop": "Drag and drop\nimages to upload",
|
||||||
"videoRatio": "Video Ratio",
|
"videoRatio": "Video Ratio",
|
||||||
"minImages": "Min. 5 images",
|
"minImages": "Min. 5 images",
|
||||||
"youtubeShorts": "YouTube Shorts",
|
"youtubeShorts": "YouTube Shorts",
|
||||||
|
|
@ -269,7 +136,7 @@
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"uploadFailed": "Image upload failed.",
|
"uploadFailed": "Image upload failed.",
|
||||||
"uploading": "Uploading... (30 sec – 1 min)",
|
"uploading": "Uploading...",
|
||||||
"nextStep": "Next Step"
|
"nextStep": "Next Step"
|
||||||
},
|
},
|
||||||
"soundStudio": {
|
"soundStudio": {
|
||||||
|
|
@ -282,22 +149,19 @@
|
||||||
"genreLabel": "Select Genre",
|
"genreLabel": "Select Genre",
|
||||||
"genreAuto": "Auto Select",
|
"genreAuto": "Auto Select",
|
||||||
"genreBallad": "Ballad",
|
"genreBallad": "Ballad",
|
||||||
"languageLabel": "Select Language",
|
"languageLabel": "Language",
|
||||||
"languageKorean": "Korean",
|
"languageKorean": "Korean",
|
||||||
"lyricsColumn": "Lyrics",
|
"lyricsColumn": "Lyrics",
|
||||||
"lyricsHint": "Select the lyrics area to edit",
|
"lyricsHint": "Select the lyrics area to edit",
|
||||||
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
|
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
|
||||||
"lyricsPlaceholderBGM": "Background music is generated without lyrics.",
|
|
||||||
"generateButton": "Generate Sound",
|
"generateButton": "Generate Sound",
|
||||||
"regenerateButton": "Regenerate",
|
|
||||||
"regenerateHint": "Press the regenerate button to create new lyrics and music.",
|
|
||||||
"generating": "Generating...",
|
"generating": "Generating...",
|
||||||
"generateVideo": "Generate Video",
|
"generateVideo": "Generate Video",
|
||||||
"videoGenerating": "Generating Video",
|
"videoGenerating": "Generating Video",
|
||||||
"noBusinessInfo": "No business information. Please try again.",
|
"noBusinessInfo": "No business information. Please try again.",
|
||||||
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
|
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
|
||||||
"generatingLyrics": "Generating lyrics... (30–60 sec)",
|
"generatingLyrics": "Generating lyrics...",
|
||||||
"generatingSong": "Generating music... (1–2 min)",
|
"generatingSong": "Generating song...",
|
||||||
"songQueued": "Song generation queued...",
|
"songQueued": "Song generation queued...",
|
||||||
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
|
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
|
||||||
"lyricGenerationFailed": "Lyrics generation request failed.",
|
"lyricGenerationFailed": "Lyrics generation request failed.",
|
||||||
|
|
@ -308,10 +172,7 @@
|
||||||
"musicGenerationFailed": "Music generation failed.",
|
"musicGenerationFailed": "Music generation failed.",
|
||||||
"multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.",
|
"multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.",
|
||||||
"musicGenerationError": "An error occurred during music generation.",
|
"musicGenerationError": "An error occurred during music generation.",
|
||||||
"songRegenerationError": "An error occurred during music regeneration.",
|
"songRegenerationError": "An error occurred during music regeneration."
|
||||||
"creditsRemaining": "{{count}} left",
|
|
||||||
"creditsExhausted": "Not enough credits.",
|
|
||||||
"chargeCredits": "Top Up Credits"
|
|
||||||
},
|
},
|
||||||
"completion": {
|
"completion": {
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
|
|
@ -319,8 +180,6 @@
|
||||||
"titleError": "Video Generation Failed",
|
"titleError": "Video Generation Failed",
|
||||||
"titleComplete": "Content Creation Complete",
|
"titleComplete": "Content Creation Complete",
|
||||||
"imageAndVideo": "Images & Video",
|
"imageAndVideo": "Images & Video",
|
||||||
"checkingSubtitle": "Checking subtitle generation status...",
|
|
||||||
"waitingSubtitle": "Generating subtitles...",
|
|
||||||
"requestingGeneration": "Requesting video generation...",
|
"requestingGeneration": "Requesting video generation...",
|
||||||
"generatingVideo": "Generating video...",
|
"generatingVideo": "Generating video...",
|
||||||
"processingAfterRefresh": "Processing video... (recovered after refresh)",
|
"processingAfterRefresh": "Processing video... (recovered after refresh)",
|
||||||
|
|
@ -332,7 +191,7 @@
|
||||||
"statusPlanned": "Scheduled",
|
"statusPlanned": "Scheduled",
|
||||||
"statusWaiting": "Waiting",
|
"statusWaiting": "Waiting",
|
||||||
"statusTranscribing": "Transcribing",
|
"statusTranscribing": "Transcribing",
|
||||||
"statusRendering": "Rendering (2–3 min)",
|
"statusRendering": "Rendering",
|
||||||
"statusSucceeded": "Completed",
|
"statusSucceeded": "Completed",
|
||||||
"statusDefault": "Processing...",
|
"statusDefault": "Processing...",
|
||||||
"aiOptimization": "AI Optimization",
|
"aiOptimization": "AI Optimization",
|
||||||
|
|
@ -354,18 +213,7 @@
|
||||||
"videoNotReady": "The video is not ready yet.",
|
"videoNotReady": "The video is not ready yet.",
|
||||||
"youtubeUploadMessage": "Uploading video to YouTube channel \"{{channelName}}\".\n\n(Upload feature coming soon)",
|
"youtubeUploadMessage": "Uploading video to YouTube channel \"{{channelName}}\".\n\n(Upload feature coming soon)",
|
||||||
"deployComingSoon": "The deployment feature for the selected social channel is coming soon.",
|
"deployComingSoon": "The deployment feature for the selected social channel is coming soon.",
|
||||||
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page.",
|
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
|
||||||
"contentComplete": "Content Creation Complete",
|
|
||||||
"contentCompleteDesc": "Your optimized content has been created through AI analysis and editing",
|
|
||||||
"contentInfo": "File Name",
|
|
||||||
"genre": "Genre",
|
|
||||||
"resolution": "Resolution",
|
|
||||||
"lyrics": "Lyrics",
|
|
||||||
"sampleLyrics": "Loading lyrics...",
|
|
||||||
"noLyricsBGM": "Background music is generated without lyrics.",
|
|
||||||
"downloading": "Downloading...",
|
|
||||||
"download": "Download",
|
|
||||||
"uploadToSocial": "Upload"
|
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|
@ -438,44 +286,13 @@
|
||||||
"stayIntroReel": "Stay Meomum Introduction Reel",
|
"stayIntroReel": "Stay Meomum Introduction Reel",
|
||||||
"newYearEvent": "New Year Special Event",
|
"newYearEvent": "New Year Special Event",
|
||||||
"nightTimelapse": "Pension Night View Timelapse"
|
"nightTimelapse": "Pension Night View Timelapse"
|
||||||
},
|
}
|
||||||
"metricTotalViews": "Views",
|
|
||||||
"metricWatchTime": "Watch Time",
|
|
||||||
"metricAvgViewDuration": "Avg. Watch Duration",
|
|
||||||
"metricNewSubscribers": "New Subscribers",
|
|
||||||
"metricUploadedVideos": "Uploaded Videos",
|
|
||||||
"unitHours": "h",
|
|
||||||
"unitMinutes": "m",
|
|
||||||
"noDataInPeriod": "No data available for this period.",
|
|
||||||
"dataLoading": "Loading data...",
|
|
||||||
"noUploadsTitle": "No uploaded videos yet.",
|
|
||||||
"noUploadsDesc": "Upload videos via ADO2 to see real stats.",
|
|
||||||
"reconnectButton": "Reconnect →",
|
|
||||||
"connectButton": "Connect →",
|
|
||||||
"retryButton": "Retry →",
|
|
||||||
"selectAccount": "Select an account",
|
|
||||||
"modeAnnual": "Annual",
|
|
||||||
"modeMonthly": "Monthly",
|
|
||||||
"chartThisYear": "This Year",
|
|
||||||
"chartLastYear": "Last Year",
|
|
||||||
"chartThisMonth": "This Month",
|
|
||||||
"chartLastMonth": "Last Month",
|
|
||||||
"monthOverMonth": "Month-over-Month Growth",
|
|
||||||
"noAudienceData": "Not enough data to show real audience insights.",
|
|
||||||
"mockContentNotice": "The content shown below is sample data, not your actual content.",
|
|
||||||
"dataDelayTitle": "Data is being prepared",
|
|
||||||
"dataDelayDesc": "According to YouTube's policy, analytics data may take up to 48 hours to be reflected after a video is uploaded. Please check back later.",
|
|
||||||
"sampleHide": "Hide Sample",
|
|
||||||
"sampleShow": "Show Sample",
|
|
||||||
"recentVideosDesc": "Showing stats for the latest 30 videos uploaded via ADO2.",
|
|
||||||
"channelStatsDesc": "Showing stats for the selected channel.",
|
|
||||||
"youtubeApiFailed": "Failed to call YouTube Analytics API."
|
|
||||||
},
|
},
|
||||||
"myInfo": {
|
"myInfo": {
|
||||||
"title": "My Info",
|
"title": "My Info",
|
||||||
"tabBasic": "Basic Info",
|
"tabBasic": "Basic Info",
|
||||||
"tabPayment": "Payment Info",
|
"tabPayment": "Payment Info",
|
||||||
"tabBusiness": "Social Channel Management",
|
"tabBusiness": "My Business & Social Channel Management",
|
||||||
"basicPlaceholder": "Basic information settings are coming soon.",
|
"basicPlaceholder": "Basic information settings are coming soon.",
|
||||||
"paymentPlaceholder": "Payment information settings are coming soon.",
|
"paymentPlaceholder": "Payment information settings are coming soon.",
|
||||||
"myBusiness": "My Business",
|
"myBusiness": "My Business",
|
||||||
|
|
@ -490,22 +307,7 @@
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"disconnectAccount": "Disconnect",
|
"disconnectAccount": "Disconnect",
|
||||||
"loadingAccounts": "Loading account information...",
|
"loadingAccounts": "Loading account information...",
|
||||||
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page.",
|
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
|
||||||
"creditsTitle": "Credit Balance",
|
|
||||||
"creditsLoading": "Loading...",
|
|
||||||
"creditsLabel": "Available Credits",
|
|
||||||
"creditsUnit": "credits",
|
|
||||||
"creditsDesc": "You can request a top-up from the admin if you run low on credits.",
|
|
||||||
"creditsChargeBtn": "Top Up",
|
|
||||||
"chargePopupTitle": "Credit Top-up Request",
|
|
||||||
"chargeAmountLabel": "Credits to Request",
|
|
||||||
"chargeNoteLabel": "Additional Notes",
|
|
||||||
"chargeNotePlaceholder": "Enter any message for the admin",
|
|
||||||
"chargeCancel": "Cancel",
|
|
||||||
"chargeSubmit": "Submit",
|
|
||||||
"chargeSubmitting": "Submitting...",
|
|
||||||
"chargeSuccess": "Your top-up request has been submitted!",
|
|
||||||
"chargeConfirm": "OK"
|
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 Contents",
|
"title": "ADO2 Contents",
|
||||||
|
|
@ -524,14 +326,7 @@
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"uploadToSocial": "Upload",
|
"uploadToSocial": "Upload to social media"
|
||||||
"sortLatest": "Latest",
|
|
||||||
"sortOldest": "Oldest",
|
|
||||||
"sortLikes": "Most Liked",
|
|
||||||
"sortComments": "Most Commented",
|
|
||||||
"regionPlaceholder": "Select Region",
|
|
||||||
"searchPlaceholder": "Search by name",
|
|
||||||
"searchBtn": "Search"
|
|
||||||
},
|
},
|
||||||
"businessSettings": {
|
"businessSettings": {
|
||||||
"title": "Business Settings",
|
"title": "Business Settings",
|
||||||
|
|
@ -553,18 +348,14 @@
|
||||||
"marketPositioning": "Market Positioning",
|
"marketPositioning": "Market Positioning",
|
||||||
"coreValue": "Core Value",
|
"coreValue": "Core Value",
|
||||||
"categoryDefinition": "Category Definition",
|
"categoryDefinition": "Category Definition",
|
||||||
"targetPersona": "Key Customer Type",
|
"targetPersona": "Target Persona",
|
||||||
"ageSuffix": "years old",
|
"ageSuffix": "years old",
|
||||||
"sellingPoints": "Key Selling Points",
|
"sellingPoints": "Unique Selling Points (USP)",
|
||||||
"recommendedKeywords": "Recommended Target Keywords",
|
"recommendedKeywords": "Recommended Target Keywords",
|
||||||
"generateContent": "Generate Content",
|
"generateContent": "Generate Content",
|
||||||
"pageDescBefore": " reveals ",
|
"pageDescBefore": " reveals ",
|
||||||
"pageDescAfter": "'s core strategy.",
|
"pageDescAfter": "'s core strategy.",
|
||||||
"loadingTitle": "Analyzing Brand",
|
"loadingTitle": "Analyzing Brand"
|
||||||
"loadingStep1": "Collecting brand information...",
|
|
||||||
"loadingStep2": "Analyzing collected data...",
|
|
||||||
"loadingStep3": "Deriving key strategies...",
|
|
||||||
"loadingStep4": "Organizing results..."
|
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
|
|
@ -576,74 +367,14 @@
|
||||||
"required": "*",
|
"required": "*",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown"
|
||||||
},
|
},
|
||||||
"contentCalendar": {
|
|
||||||
"title": "Content Calendar",
|
|
||||||
"tabs": {
|
|
||||||
"all": "All",
|
|
||||||
"completed": "Done",
|
|
||||||
"scheduled": "Planned",
|
|
||||||
"failed": "Failed"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"completed": "Done",
|
|
||||||
"scheduled": "Planned",
|
|
||||||
"failed": "Failed"
|
|
||||||
},
|
|
||||||
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
|
||||||
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
|
|
||||||
"yearMonth": "{{month}} {{year}}",
|
|
||||||
"monthDay": "{{month}} {{day}}",
|
|
||||||
"loading": "Loading...",
|
|
||||||
"noResults": "No recent results",
|
|
||||||
"noResultsDesc": "Upload your content to social channels",
|
|
||||||
"myContents": "My Contents",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"retry": "Retry",
|
|
||||||
"cancelConfirm": "Are you sure you want to cancel this reservation?",
|
|
||||||
"cancelFailed": "Failed to cancel.",
|
|
||||||
"retryFailed": "Failed to retry.",
|
|
||||||
"completedCount": "Done {{count}}",
|
|
||||||
"scheduledCount": "Sched {{count}}",
|
|
||||||
"failedCount": "Fail {{count}}"
|
|
||||||
},
|
|
||||||
"loginPrompt": {
|
|
||||||
"title": "Login Required",
|
|
||||||
"loginBtn": "Login"
|
|
||||||
},
|
|
||||||
"videoDetail": {
|
|
||||||
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
|
||||||
"kakaoDefaultTitle": "ADO2 Video",
|
|
||||||
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
|
|
||||||
"kakaoButtonTitle": "Watch Video",
|
|
||||||
"deletedComment": "(This comment has been deleted.)",
|
|
||||||
"closeAriaLabel": "Close",
|
|
||||||
"share": "Share",
|
|
||||||
"copied": "Copied!",
|
|
||||||
"shareKakao": "KakaoTalk",
|
|
||||||
"shareFacebook": "Facebook",
|
|
||||||
"shareTwitter": "X (Twitter)",
|
|
||||||
"copyUrl": "Copy URL",
|
|
||||||
"commentsTitle": "Comments",
|
|
||||||
"changeAvatarTitle": "Click to change avatar",
|
|
||||||
"nicknamePlaceholder": "Author name",
|
|
||||||
"commentPlaceholder": "Write a comment...",
|
|
||||||
"commentLoginRequired": "Please log in to write a comment",
|
|
||||||
"commentSubmitting": "Submitting",
|
|
||||||
"commentSubmit": "Submit",
|
|
||||||
"noComments": "No comments yet.",
|
|
||||||
"anonymous": "Anonymous",
|
|
||||||
"deleteComment": "Delete",
|
|
||||||
"loadMoreComments": "Load more comments",
|
|
||||||
"loadingComments": "Loading..."
|
|
||||||
},
|
|
||||||
"app": {
|
"app": {
|
||||||
"loginProcessing": "Processing login...",
|
"loginProcessing": "Processing login...",
|
||||||
"loginFailed": "Login processing failed. Please try again.",
|
"loginFailed": "Login processing failed. Please try again.",
|
||||||
"kakaoLoginFailed": "Kakao login failed. Please try again.",
|
"kakaoLoginFailed": "Kakao login failed. Please try again.",
|
||||||
"loginUrlFailed": "Failed to get login URL. Please try again.",
|
"loginUrlFailed": "Failed to get login URL. Please try again.",
|
||||||
"invalidUrl": "Invalid URL. Please enter a Naver Map URL.",
|
"invalidUrl": "Invalid URL. Please enter a Naver Map URL.",
|
||||||
"analysisError": "No results found. Please check your input and try again.",
|
"analysisError": "An error occurred during analysis. Please try again.",
|
||||||
"autocompleteError": "No results found. Please check your input and try again.",
|
"autocompleteError": "Failed to retrieve business information.",
|
||||||
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
||||||
"pageComingSoon": "{{page}} page is coming soon."
|
"pageComingSoon": "{{page}} page is coming soon."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
{
|
{
|
||||||
"wizardSteps": {
|
|
||||||
"brandAnalysis": "분석",
|
|
||||||
"asset": "이미지",
|
|
||||||
"sound": "사운드",
|
|
||||||
"video": "영상"
|
|
||||||
},
|
|
||||||
"header": {
|
"header": {
|
||||||
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
||||||
"start": "시작하기",
|
"start": "시작하기",
|
||||||
|
|
@ -18,95 +12,16 @@
|
||||||
"myContents": "내 콘텐츠",
|
"myContents": "내 콘텐츠",
|
||||||
"myInfo": "내 정보",
|
"myInfo": "내 정보",
|
||||||
"defaultUser": "사용자",
|
"defaultUser": "사용자",
|
||||||
"credits": "보유 크레딧: {{count}}",
|
|
||||||
"loggingOut": "로그아웃 중...",
|
"loggingOut": "로그아웃 중...",
|
||||||
"logout": "로그아웃",
|
"logout": "로그아웃"
|
||||||
"tutorialRestart": "튜토리얼 다시 보기",
|
|
||||||
"tutorial": "튜토리얼",
|
|
||||||
"tutorialOn": "튜토리얼 켜기",
|
|
||||||
"tutorialOff": "튜토리얼 끄기",
|
|
||||||
"inquiry": "고객의견"
|
|
||||||
},
|
|
||||||
"tutorial": {
|
|
||||||
"skip": "건너뛰기",
|
|
||||||
"next": "다음",
|
|
||||||
"prev": "이전",
|
|
||||||
"finish": "완료",
|
|
||||||
"landing": {
|
|
||||||
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
|
|
||||||
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
|
|
||||||
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
|
|
||||||
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
|
|
||||||
},
|
|
||||||
"asset": {
|
|
||||||
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
|
|
||||||
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
|
|
||||||
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
|
|
||||||
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
|
|
||||||
},
|
|
||||||
"sound": {
|
|
||||||
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요.", "note": "배경음악은 이후 오픈 예정입니다." },
|
|
||||||
"language": { "title": "언어 선택", "desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
|
|
||||||
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."},
|
|
||||||
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
|
|
||||||
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
|
|
||||||
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
|
|
||||||
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
|
|
||||||
},
|
|
||||||
"completion": {
|
|
||||||
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요." },
|
|
||||||
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
|
|
||||||
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
|
|
||||||
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
|
|
||||||
},
|
|
||||||
"myInfo": {
|
|
||||||
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
|
|
||||||
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." },
|
|
||||||
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
|
|
||||||
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
|
|
||||||
},
|
|
||||||
"ado2": {
|
|
||||||
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
|
|
||||||
"download": { "title": "다운로드", "desc": "영상을 다운로드 할 수 있어요." },
|
|
||||||
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
|
|
||||||
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
|
|
||||||
},
|
|
||||||
"upload": {
|
|
||||||
"seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
|
|
||||||
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
|
|
||||||
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
|
|
||||||
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
|
|
||||||
},
|
|
||||||
"dashboard": {
|
|
||||||
"metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요." },
|
|
||||||
"chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." },
|
|
||||||
"more": { "title": "더 많은 통계", "desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요." }
|
|
||||||
},
|
|
||||||
"contentCalendar": {
|
|
||||||
"grid": { "title": "콘텐츠 캘린더", "desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?" },
|
|
||||||
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
|
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
|
|
||||||
"title": "고객의견",
|
|
||||||
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
|
|
||||||
},
|
|
||||||
"restart": {
|
|
||||||
"title": "튜토리얼을 다시 시작할까요?",
|
|
||||||
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
|
|
||||||
"confirm": "시작하기",
|
|
||||||
"cancel": "취소"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company":"㈜에이아이오투오",
|
"company":"㈜에이아이오투오",
|
||||||
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
||||||
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
"headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
||||||
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
"researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
||||||
"phone": "전화 : 070-4260-8310 | 010-2755-6463",
|
"phone": "전화 : 070-4260-8310 | 010-2755-6463",
|
||||||
"email": "이메일 : o2oteam@o2o.kr",
|
"email": "이메일 : o2oteam@o2o.kr"
|
||||||
"privacyPolicy": "개인정보처리방침",
|
|
||||||
"termsOfService": "서비스 약관"
|
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
"title": "소셜 미디어 포스팅",
|
"title": "소셜 미디어 포스팅",
|
||||||
|
|
@ -140,31 +55,9 @@
|
||||||
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
|
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
|
||||||
"uploadStartFailed": "업로드 시작에 실패했습니다.",
|
"uploadStartFailed": "업로드 시작에 실패했습니다.",
|
||||||
"uploadFailed": "업로드에 실패했습니다.",
|
"uploadFailed": "업로드에 실패했습니다.",
|
||||||
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
|
"autoSeoTitle": "자동으로 작성중입니다. 기다려주세요.",
|
||||||
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
|
"autoSeoDescription": "자동으로 작성중입니다. 기다려주세요.",
|
||||||
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)",
|
"autoSeoTags": "자동으로 작성중입니다. 기다려주세요."
|
||||||
"youtubeCallback": {
|
|
||||||
"connecting": "YouTube 연결 중...",
|
|
||||||
"pleaseWait": "잠시만 기다려주세요.",
|
|
||||||
"failed": "연결 실패",
|
|
||||||
"errorAuthCancelled": "Google 인증이 취소되었거나 실패했습니다.",
|
|
||||||
"errorNoCode": "인증 코드를 받지 못했습니다.",
|
|
||||||
"errorProcessing": "YouTube 연결 처리 중 오류가 발생했습니다."
|
|
||||||
},
|
|
||||||
"connectError": {
|
|
||||||
"title": "{{platform}} 연결 실패",
|
|
||||||
"defaultError": "알 수 없는 오류가 발생했습니다.",
|
|
||||||
"detail": "다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.",
|
|
||||||
"retry": "다시 시도"
|
|
||||||
},
|
|
||||||
"connectSuccess": {
|
|
||||||
"title": "{{name}} 연결 완료",
|
|
||||||
"messageWithChannel": "{{platform}} 계정이 성공적으로 연결되었습니다.",
|
|
||||||
"messageWithoutChannel": "계정이 성공적으로 연결되었습니다.",
|
|
||||||
"verified": "인증됨",
|
|
||||||
"countdown": "{{count}}초 후 자동으로 이동합니다...",
|
|
||||||
"goNow": "지금 이동"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "YouTube 업로드",
|
"title": "YouTube 업로드",
|
||||||
|
|
@ -180,42 +73,31 @@
|
||||||
"viewOnYoutube": "YouTube에서 보기",
|
"viewOnYoutube": "YouTube에서 보기",
|
||||||
"confirm": "확인",
|
"confirm": "확인",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요."
|
||||||
"goToCalendar": "캘린더에서 확인",
|
},
|
||||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
|
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"searchTypeLabel": "업체명 | URL",
|
"searchTypeBusinessName": "업체명",
|
||||||
"placeholderCombined": "업체명 또는 URL을 입력하세요.",
|
"placeholderBusinessName": "업체명을 입력하세요",
|
||||||
"guideCombined": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 정보를 가져옵니다.",
|
"guideUrl": "URL에서 가져온 정보로 영상이 자동 생성됩니다.",
|
||||||
"errorInputRequired": "업체명 또는 URL을 입력해주세요.",
|
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||||
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://naver.me/abcdef)",
|
"errorUrlRequired": "URL을 입력해주세요.",
|
||||||
|
"errorNameRequired": "업체명을 입력해주세요.",
|
||||||
|
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
|
||||||
"analyzeButton": "브랜드 분석",
|
"analyzeButton": "브랜드 분석",
|
||||||
"scrollMore": "스크롤하여 더 보기",
|
"scrollMore": "스크롤하여 더 보기",
|
||||||
"searching": "검색 중...",
|
"testDataLoading": "로딩 중...",
|
||||||
"searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기",
|
"testData": "테스트 데이터",
|
||||||
"manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.",
|
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
|
||||||
"manualModalTitle": "업체 정보 직접 입력",
|
"searching": "검색 중..."
|
||||||
"manualModalSubtitle": "네이버에 등록되지 않은 업체도 아래 정보만 입력하면 가사·음악·영상 제작까지 할 수 있어요.",
|
|
||||||
"manualAnalyzeButton": "시작하기",
|
|
||||||
"manualLabelName": "업체명",
|
|
||||||
"manualLabelAddress": "주소",
|
|
||||||
"manualLabelRegion": "지역",
|
|
||||||
"manualLabelDetail": "상세 주소",
|
|
||||||
"manualLabelCategory": "업종",
|
|
||||||
"manualPlaceholderName": "업체명을 입력하세요.",
|
|
||||||
"manualPlaceholderAddress": "주소를 입력하세요.",
|
|
||||||
"manualPlaceholderRegion": "지역을 선택하세요.",
|
|
||||||
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
|
|
||||||
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
|
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
||||||
"subtitle": "분석, 제작, 배포까지 콘텐츠 마케팅의 전과정을 자동화",
|
"subtitle": "분석, 제작, 배포까지 콘텐츠 마케팅의 전과정을 자동화",
|
||||||
"feature1Title": "비즈니스 핵심 정보 분석",
|
"feature1Title": "비즈니스 핵심 정보 분석",
|
||||||
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요.",
|
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요",
|
||||||
"feature2Title": "홍보 콘텐츠 자동 제작",
|
"feature2Title": "홍보 콘텐츠 자동 제작",
|
||||||
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 영상을\n자동으로 제작해요",
|
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 노래, 영상을\n자동으로 제작해요",
|
||||||
"feature3Title": "멀티채널 자동 배포",
|
"feature3Title": "멀티채널 자동 배포",
|
||||||
"feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요"
|
"feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요"
|
||||||
},
|
},
|
||||||
|
|
@ -232,27 +114,13 @@
|
||||||
},
|
},
|
||||||
"urlInput": {
|
"urlInput": {
|
||||||
"searchTypeBusinessName": "업체명",
|
"searchTypeBusinessName": "업체명",
|
||||||
"placeholderBusinessName": "업체명을 입력하세요.",
|
"placeholderBusinessName": "업체명을 입력하세요",
|
||||||
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
|
"guideUrl": "URL에서 가져온 정보로 영상이 자동 생성됩니다.",
|
||||||
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||||
"searchButton": "검색하기",
|
"searchButton": "검색하기",
|
||||||
"searching": "검색 중...",
|
"searching": "검색 중...",
|
||||||
"searchTypeManual": "직접 입력",
|
"testDataLoading": "로딩 중...",
|
||||||
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
|
"testData": "테스트 데이터"
|
||||||
"errorUrlRequired": "URL을 입력해주세요.",
|
|
||||||
"errorNameRequired": "업체명을 입력해주세요.",
|
|
||||||
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
|
|
||||||
"manualModalTitle": "업체 정보 입력",
|
|
||||||
"manualLabelName": "업체명",
|
|
||||||
"manualLabelAddress": "주소",
|
|
||||||
"manualLabelRegion": "지역",
|
|
||||||
"manualLabelDetail": "상세 주소",
|
|
||||||
"manualLabelCategory": "업종",
|
|
||||||
"manualPlaceholderName": "업체명을 입력하세요.",
|
|
||||||
"manualPlaceholderAddress": "주소를 입력하세요.",
|
|
||||||
"manualPlaceholderRegion": "지역을 선택하세요.",
|
|
||||||
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
|
|
||||||
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
|
|
||||||
},
|
},
|
||||||
"assetManagement": {
|
"assetManagement": {
|
||||||
"title": "브랜드 에셋",
|
"title": "브랜드 에셋",
|
||||||
|
|
@ -260,7 +128,7 @@
|
||||||
"imageAlt": "이미지",
|
"imageAlt": "이미지",
|
||||||
"uploadBadge": "업로드",
|
"uploadBadge": "업로드",
|
||||||
"imageUpload": "이미지 업로드",
|
"imageUpload": "이미지 업로드",
|
||||||
"dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드",
|
"dragAndDrop": "이미지를 드래그하여\n업로드",
|
||||||
"videoRatio": "영상 비율",
|
"videoRatio": "영상 비율",
|
||||||
"minImages": "최소 5장",
|
"minImages": "최소 5장",
|
||||||
"youtubeShorts": "유튜브 쇼츠",
|
"youtubeShorts": "유튜브 쇼츠",
|
||||||
|
|
@ -268,7 +136,7 @@
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
"loadMore": "더보기",
|
"loadMore": "더보기",
|
||||||
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
||||||
"uploading": "업로드 중 (30~60초 소요)",
|
"uploading": "업로드 중...",
|
||||||
"nextStep": "다음 단계"
|
"nextStep": "다음 단계"
|
||||||
},
|
},
|
||||||
"soundStudio": {
|
"soundStudio": {
|
||||||
|
|
@ -281,23 +149,20 @@
|
||||||
"genreLabel": "장르 선택",
|
"genreLabel": "장르 선택",
|
||||||
"genreAuto": "자동 선택",
|
"genreAuto": "자동 선택",
|
||||||
"genreBallad": "발라드",
|
"genreBallad": "발라드",
|
||||||
"languageLabel": "언어 선택",
|
"languageLabel": "언어",
|
||||||
"languageKorean": "한국어",
|
"languageKorean": "한국어",
|
||||||
"lyricsColumn": "가사",
|
"lyricsColumn": "가사",
|
||||||
"lyricsHint": "가사 영역을 선택해서 수정 가능해요",
|
"lyricsHint": "가사 영역을 선택해서 수정 가능해요",
|
||||||
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
|
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
|
||||||
"lyricsPlaceholderBGM": "배경음악은 가사 없이 생성됩니다.",
|
|
||||||
"generateButton": "사운드 생성",
|
"generateButton": "사운드 생성",
|
||||||
"regenerateButton": "재생성 하기",
|
|
||||||
"regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.",
|
|
||||||
"generating": "생성 중...",
|
"generating": "생성 중...",
|
||||||
"generateVideo": "영상 생성하기",
|
"generateVideo": "영상 생성하기",
|
||||||
"videoGenerating": "영상 생성 중",
|
"videoGenerating": "영상 생성 중",
|
||||||
"noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.",
|
"noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.",
|
||||||
"noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.",
|
"noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.",
|
||||||
"generatingLyrics": "가사를 생성하고 있습니다. (30~60초 소요)",
|
"generatingLyrics": "가사를 생성하고 있습니다...",
|
||||||
"generatingSong": "음악을 생성하고 있습니다. (1~2분 소요)",
|
"generatingSong": "노래를 생성하고 있습니다...",
|
||||||
"songQueued": "음악 생성 대기 중...",
|
"songQueued": "노래 생성 대기 중...",
|
||||||
"retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})",
|
"retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})",
|
||||||
"lyricGenerationFailed": "가사 생성 요청에 실패했습니다.",
|
"lyricGenerationFailed": "가사 생성 요청에 실패했습니다.",
|
||||||
"lyricNotReceived": "가사를 받지 못했습니다.",
|
"lyricNotReceived": "가사를 받지 못했습니다.",
|
||||||
|
|
@ -307,10 +172,7 @@
|
||||||
"musicGenerationFailed": "음악 생성에 실패했습니다.",
|
"musicGenerationFailed": "음악 생성에 실패했습니다.",
|
||||||
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
|
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
|
||||||
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
|
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
|
||||||
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다.",
|
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다."
|
||||||
"creditsRemaining": "잔여 {{count}}",
|
|
||||||
"creditsExhausted": "크레딧이 부족합니다.",
|
|
||||||
"chargeCredits": "크레딧 충전하기"
|
|
||||||
},
|
},
|
||||||
"completion": {
|
"completion": {
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
|
|
@ -318,8 +180,6 @@
|
||||||
"titleError": "영상 생성 실패",
|
"titleError": "영상 생성 실패",
|
||||||
"titleComplete": "콘텐츠 제작 완료",
|
"titleComplete": "콘텐츠 제작 완료",
|
||||||
"imageAndVideo": "이미지 및 영상",
|
"imageAndVideo": "이미지 및 영상",
|
||||||
"checkingSubtitle": "자막 생성 상태를 확인하고 있습니다...",
|
|
||||||
"waitingSubtitle": "자막을 생성하고 있습니다...",
|
|
||||||
"requestingGeneration": "영상 생성을 요청하고 있습니다...",
|
"requestingGeneration": "영상 생성을 요청하고 있습니다...",
|
||||||
"generatingVideo": "영상을 생성하고 있습니다...",
|
"generatingVideo": "영상을 생성하고 있습니다...",
|
||||||
"processingAfterRefresh": "영상을 처리하고 있습니다... (새로고침 후 복구됨)",
|
"processingAfterRefresh": "영상을 처리하고 있습니다... (새로고침 후 복구됨)",
|
||||||
|
|
@ -331,7 +191,7 @@
|
||||||
"statusPlanned": "예약됨",
|
"statusPlanned": "예약됨",
|
||||||
"statusWaiting": "대기 중",
|
"statusWaiting": "대기 중",
|
||||||
"statusTranscribing": "트랜스크립션 중",
|
"statusTranscribing": "트랜스크립션 중",
|
||||||
"statusRendering": "생성 중 (2~3분 소요)",
|
"statusRendering": "렌더링 중",
|
||||||
"statusSucceeded": "완료",
|
"statusSucceeded": "완료",
|
||||||
"statusDefault": "처리 중...",
|
"statusDefault": "처리 중...",
|
||||||
"aiOptimization": "AI 최적화",
|
"aiOptimization": "AI 최적화",
|
||||||
|
|
@ -353,18 +213,7 @@
|
||||||
"videoNotReady": "영상이 아직 준비되지 않았습니다.",
|
"videoNotReady": "영상이 아직 준비되지 않았습니다.",
|
||||||
"youtubeUploadMessage": "YouTube 채널 \"{{channelName}}\"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)",
|
"youtubeUploadMessage": "YouTube 채널 \"{{channelName}}\"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)",
|
||||||
"deployComingSoon": "선택한 소셜 채널에 배포 기능이 준비 중입니다.",
|
"deployComingSoon": "선택한 소셜 채널에 배포 기능이 준비 중입니다.",
|
||||||
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.",
|
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
|
||||||
"contentComplete": "콘텐츠 제작 완료",
|
|
||||||
"contentCompleteDesc": "AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다",
|
|
||||||
"contentInfo": "파일명",
|
|
||||||
"genre": "장르",
|
|
||||||
"resolution": "규격",
|
|
||||||
"lyrics": "가사",
|
|
||||||
"sampleLyrics": "가사를 불러오는 중...",
|
|
||||||
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
|
|
||||||
"downloading": "다운로드 중...",
|
|
||||||
"download": "다운로드",
|
|
||||||
"uploadToSocial": "업로드"
|
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "대시보드",
|
"title": "대시보드",
|
||||||
|
|
@ -437,44 +286,13 @@
|
||||||
"stayIntroReel": "스테이 머뭄 소개 릴스",
|
"stayIntroReel": "스테이 머뭄 소개 릴스",
|
||||||
"newYearEvent": "신년 특가 이벤트 안내",
|
"newYearEvent": "신년 특가 이벤트 안내",
|
||||||
"nightTimelapse": "펜션 야경 타임랩스"
|
"nightTimelapse": "펜션 야경 타임랩스"
|
||||||
},
|
}
|
||||||
"metricTotalViews": "조회수",
|
|
||||||
"metricWatchTime": "시청시간",
|
|
||||||
"metricAvgViewDuration": "평균 시청시간",
|
|
||||||
"metricNewSubscribers": "신규 구독자",
|
|
||||||
"metricUploadedVideos": "업로드 영상",
|
|
||||||
"unitHours": "시간",
|
|
||||||
"unitMinutes": "분",
|
|
||||||
"noDataInPeriod": "이 기간에 데이터가 없습니다.",
|
|
||||||
"dataLoading": "데이터 로딩 중...",
|
|
||||||
"noUploadsTitle": "아직 업로드된 영상이 없습니다.",
|
|
||||||
"noUploadsDesc": "ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다.",
|
|
||||||
"reconnectButton": "재연동하러 가기 →",
|
|
||||||
"connectButton": "연동하러 가기 →",
|
|
||||||
"retryButton": "재시도 →",
|
|
||||||
"selectAccount": "계정을 선택하세요",
|
|
||||||
"modeAnnual": "연간",
|
|
||||||
"modeMonthly": "월간",
|
|
||||||
"chartThisYear": "올해",
|
|
||||||
"chartLastYear": "작년",
|
|
||||||
"chartThisMonth": "이번달",
|
|
||||||
"chartLastMonth": "지난달",
|
|
||||||
"monthOverMonth": "전월 대비 성장",
|
|
||||||
"noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
|
|
||||||
"mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
|
|
||||||
"dataDelayTitle": "데이터 준비 중",
|
|
||||||
"dataDelayDesc": "유튜브 정책에 따라 분석 데이터는 영상 업로드 후 최대 48시간이 지나야 반영됩니다. 나중에 다시 확인해 주세요.",
|
|
||||||
"sampleHide": "Sample 숨기기",
|
|
||||||
"sampleShow": "Sample 보기",
|
|
||||||
"recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",
|
|
||||||
"channelStatsDesc": "선택한 채널의 통계가 표시됩니다.",
|
|
||||||
"youtubeApiFailed": "YouTube Analytics API 호출에 실패했습니다."
|
|
||||||
},
|
},
|
||||||
"myInfo": {
|
"myInfo": {
|
||||||
"title": "내 정보",
|
"title": "내 정보",
|
||||||
"tabBasic": "기본 정보",
|
"tabBasic": "기본 정보",
|
||||||
"tabPayment": "결제 정보",
|
"tabPayment": "결제 정보",
|
||||||
"tabBusiness": "소셜 채널 관리",
|
"tabBusiness": "내 비즈니스 & 소셜 채널 관리",
|
||||||
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
||||||
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
||||||
"myBusiness": "내 비즈니스",
|
"myBusiness": "내 비즈니스",
|
||||||
|
|
@ -489,22 +307,7 @@
|
||||||
"connected": "연결됨",
|
"connected": "연결됨",
|
||||||
"disconnectAccount": "연결 해제",
|
"disconnectAccount": "연결 해제",
|
||||||
"loadingAccounts": "계정 정보를 불러오는 중...",
|
"loadingAccounts": "계정 정보를 불러오는 중...",
|
||||||
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.",
|
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
|
||||||
"creditsTitle": "크레딧 현황",
|
|
||||||
"creditsLoading": "로딩 중...",
|
|
||||||
"creditsLabel": "보유 크레딧",
|
|
||||||
"creditsUnit": "크레딧",
|
|
||||||
"creditsDesc": "크레딧이 부족하면 관리자에게 충전 요청할 수 있습니다.",
|
|
||||||
"creditsChargeBtn": "충전하기",
|
|
||||||
"chargePopupTitle": "크레딧 충전 요청",
|
|
||||||
"chargeAmountLabel": "충전 크레딧",
|
|
||||||
"chargeNoteLabel": "기타 내용",
|
|
||||||
"chargeNotePlaceholder": "관리자에게 전달할 내용을 입력해주세요",
|
|
||||||
"chargeCancel": "취소",
|
|
||||||
"chargeSubmit": "요청하기",
|
|
||||||
"chargeSubmitting": "요청 중...",
|
|
||||||
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
|
||||||
"chargeConfirm": "확인"
|
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 콘텐츠",
|
"title": "ADO2 콘텐츠",
|
||||||
|
|
@ -523,14 +326,7 @@
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"previous": "이전",
|
"previous": "이전",
|
||||||
"next": "다음",
|
"next": "다음",
|
||||||
"uploadToSocial": "업로드",
|
"uploadToSocial": "소셜 미디어에 업로드"
|
||||||
"sortLatest": "최신순",
|
|
||||||
"sortOldest": "오래된순",
|
|
||||||
"sortLikes": "좋아요 많은순",
|
|
||||||
"sortComments": "댓글 많은순",
|
|
||||||
"regionPlaceholder": "지역 선택",
|
|
||||||
"searchPlaceholder": "업체명 검색",
|
|
||||||
"searchBtn": "검색"
|
|
||||||
},
|
},
|
||||||
"businessSettings": {
|
"businessSettings": {
|
||||||
"title": "비즈니스 설정",
|
"title": "비즈니스 설정",
|
||||||
|
|
@ -559,11 +355,7 @@
|
||||||
"generateContent": "콘텐츠 생성",
|
"generateContent": "콘텐츠 생성",
|
||||||
"pageDescBefore": "을 통해 도출된 ",
|
"pageDescBefore": "을 통해 도출된 ",
|
||||||
"pageDescAfter": "의 핵심 전략입니다.",
|
"pageDescAfter": "의 핵심 전략입니다.",
|
||||||
"loadingTitle": "브랜드 분석 중",
|
"loadingTitle": "브랜드 분석 중"
|
||||||
"loadingStep1": "브랜드 정보를 수집하는 중...",
|
|
||||||
"loadingStep2": "수집한 데이터를 분석하는 중...",
|
|
||||||
"loadingStep3": "핵심 전략을 도출하는 중...",
|
|
||||||
"loadingStep4": "결과를 정리하는 중..."
|
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
|
|
@ -575,74 +367,14 @@
|
||||||
"required": "*",
|
"required": "*",
|
||||||
"unknown": "알 수 없음"
|
"unknown": "알 수 없음"
|
||||||
},
|
},
|
||||||
"contentCalendar": {
|
|
||||||
"title": "콘텐츠 캘린더",
|
|
||||||
"tabs": {
|
|
||||||
"all": "전체",
|
|
||||||
"completed": "완료",
|
|
||||||
"scheduled": "예약",
|
|
||||||
"failed": "실패"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"completed": "완료",
|
|
||||||
"scheduled": "예약",
|
|
||||||
"failed": "실패"
|
|
||||||
},
|
|
||||||
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
|
||||||
"days": ["일","월","화","수","목","금","토"],
|
|
||||||
"yearMonth": "{{year}}년 {{month}}",
|
|
||||||
"monthDay": "{{month}} {{day}}일",
|
|
||||||
"loading": "불러오는 중...",
|
|
||||||
"noResults": "최근 결과 없음",
|
|
||||||
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
|
||||||
"myContents": "내 콘텐츠",
|
|
||||||
"cancel": "취소",
|
|
||||||
"retry": "재시도",
|
|
||||||
"cancelConfirm": "예약을 취소하시겠습니까?",
|
|
||||||
"cancelFailed": "취소에 실패했습니다.",
|
|
||||||
"retryFailed": "재시도에 실패했습니다.",
|
|
||||||
"completedCount": "완료 {{count}}",
|
|
||||||
"scheduledCount": "예약 {{count}}",
|
|
||||||
"failedCount": "실패 {{count}}"
|
|
||||||
},
|
|
||||||
"loginPrompt": {
|
|
||||||
"title": "로그인이 필요합니다.",
|
|
||||||
"loginBtn": "로그인"
|
|
||||||
},
|
|
||||||
"videoDetail": {
|
|
||||||
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
|
||||||
"kakaoDefaultTitle": "ADO2 영상",
|
|
||||||
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
|
|
||||||
"kakaoButtonTitle": "영상 보기",
|
|
||||||
"deletedComment": "(삭제된 댓글입니다.)",
|
|
||||||
"closeAriaLabel": "닫기",
|
|
||||||
"share": "공유하기",
|
|
||||||
"copied": "복사됨!",
|
|
||||||
"shareKakao": "카카오톡",
|
|
||||||
"shareFacebook": "페이스북",
|
|
||||||
"shareTwitter": "X (트위터)",
|
|
||||||
"copyUrl": "URL 복사",
|
|
||||||
"commentsTitle": "댓글",
|
|
||||||
"changeAvatarTitle": "클릭하여 아바타 변경",
|
|
||||||
"nicknamePlaceholder": "작성자 이름",
|
|
||||||
"commentPlaceholder": "댓글을 입력하세요...",
|
|
||||||
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
|
||||||
"commentSubmitting": "작성 중",
|
|
||||||
"commentSubmit": "작성",
|
|
||||||
"noComments": "아직 댓글이 없습니다.",
|
|
||||||
"anonymous": "익명",
|
|
||||||
"deleteComment": "삭제",
|
|
||||||
"loadMoreComments": "댓글 더 보기",
|
|
||||||
"loadingComments": "불러오는 중..."
|
|
||||||
},
|
|
||||||
"app": {
|
"app": {
|
||||||
"loginProcessing": "로그인 처리 중...",
|
"loginProcessing": "로그인 처리 중...",
|
||||||
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
||||||
"kakaoLoginFailed": "카카오 로그인에 실패했습니다. 다시 시도해주세요.",
|
"kakaoLoginFailed": "카카오 로그인에 실패했습니다. 다시 시도해주세요.",
|
||||||
"loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
"loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
||||||
"invalidUrl": "유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.",
|
"invalidUrl": "유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.",
|
||||||
"analysisError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
|
"analysisError": "분석 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||||
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
|
"autocompleteError": "업체 정보 조회에 실패했습니다.",
|
||||||
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||||
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CrawlingResponse, TargetPersona } from '../../types/api';
|
import { CrawlingResponse, TargetPersona } from '../../types/api';
|
||||||
import { GeometricChart } from './GeometricChart';
|
import { GeometricChart } from './GeometricChart';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
|
||||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
|
||||||
|
|
||||||
interface AnalysisResultSectionProps {
|
interface AnalysisResultSectionProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
|
@ -14,19 +12,8 @@ interface AnalysisResultSectionProps {
|
||||||
|
|
||||||
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tutorial = useTutorial();
|
|
||||||
const { processed_info, marketing_analysis } = data;
|
const { processed_info, marketing_analysis } = data;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.ANALYSIS)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.ANALYSIS);
|
|
||||||
}
|
|
||||||
}, 600);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const brandIdentity = marketing_analysis?.brand_identity;
|
const brandIdentity = marketing_analysis?.brand_identity;
|
||||||
const marketPositioning = marketing_analysis?.market_positioning;
|
const marketPositioning = marketing_analysis?.market_positioning;
|
||||||
const targetPersonas = marketing_analysis?.target_persona || [];
|
const targetPersonas = marketing_analysis?.target_persona || [];
|
||||||
|
|
@ -37,7 +24,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="bi2-page">
|
<div className="bi2-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bi2-header">
|
<div className="bi2-header">
|
||||||
|
|
@ -189,18 +175,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tutorial.isActive && (
|
|
||||||
<TutorialOverlay
|
|
||||||
hints={tutorial.hints}
|
|
||||||
currentIndex={tutorial.currentHintIndex}
|
|
||||||
onNext={tutorial.nextHint}
|
|
||||||
onPrev={tutorial.prevHint}
|
|
||||||
onSkip={tutorial.skipTutorial}
|
|
||||||
groupProgress={tutorial.groupProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,15 @@
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface LoadingSectionProps {
|
const LoadingSection: React.FC = () => {
|
||||||
onComplete?: () => void;
|
|
||||||
isComplete?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
setProgress(prev => {
|
|
||||||
if (prev >= 100) {
|
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
|
|
||||||
return Math.min(prev + increment, 100);
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isComplete) return;
|
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
||||||
setProgress(100);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
onComplete?.();
|
|
||||||
}, 400);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [isComplete]);
|
|
||||||
|
|
||||||
const getStepMessage = (p: number) => {
|
|
||||||
if (p < 30) return t('analysis.loadingStep1');
|
|
||||||
if (p < 50) return t('analysis.loadingStep2');
|
|
||||||
if (p < 80) return t('analysis.loadingStep3');
|
|
||||||
return t('analysis.loadingStep4');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="loading-container">
|
<div className="loading-container">
|
||||||
<div className="loading-content">
|
<div className="loading-content">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="loading-logo">
|
<div className="loading-logo">
|
||||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
<img src="/assets/images/ado2-loading-logo.svg" alt="ADO2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading Spinner and Text */}
|
{/* Loading Spinner and Text */}
|
||||||
|
|
@ -64,17 +23,6 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
||||||
className="loading-spinner-icon"
|
className="loading-spinner-icon"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="loading-progress-wrapper">
|
|
||||||
<div className="loading-progress-bar">
|
|
||||||
<div
|
|
||||||
className="loading-progress-fill"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="loading-progress-text">{Math.floor(progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<p className="loading-step-message">{getStepMessage(progress)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1,148 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getAllVideos, isLoggedIn } from '../../utils/api';
|
import { getVideosList, deleteVideo } from '../../utils/api';
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
|
||||||
import CitySelectModal from '../../components/CitySelectModal';
|
|
||||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
|
||||||
|
|
||||||
interface ADO2ContentsPageProps {
|
interface ADO2ContentsPageProps {
|
||||||
onBack?: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const authed = isLoggedIn();
|
|
||||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
|
||||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(authed);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasNext, setHasNext] = useState(false);
|
const [hasNext, setHasNext] = useState(false);
|
||||||
const [hasPrev, setHasPrev] = useState(false);
|
const [hasPrev, setHasPrev] = useState(false);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||||
|
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
||||||
|
|
||||||
const pageSize = 20;
|
const pageSize = 12;
|
||||||
const [sortBy, setSortBy] = useState<'created_at' | 'like_count' | 'comment_count'>('created_at');
|
|
||||||
const [order, setOrder] = useState<'desc' | 'asc'>('desc');
|
|
||||||
const [searchInput, setSearchInput] = useState('');
|
|
||||||
const [storeName, setStoreName] = useState('');
|
|
||||||
const [region, setRegion] = useState('');
|
|
||||||
const [showCityModal, setShowCityModal] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed) return;
|
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
}, [page, sortBy, order, storeName, region]);
|
}, [page]);
|
||||||
|
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await getAllVideos(page, pageSize, sortBy, storeName, order, region);
|
const response = await getVideosList(page, pageSize);
|
||||||
setVideos(response.items);
|
console.log('[ADO2] API response:', response);
|
||||||
|
console.log('[ADO2] First video item:', response.items[0]);
|
||||||
|
// result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외)
|
||||||
|
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
|
||||||
|
setVideos(validVideos);
|
||||||
setTotal(response.total);
|
setTotal(response.total);
|
||||||
setTotalPages(response.total_pages);
|
setTotalPages(response.total_pages);
|
||||||
setHasNext(response.has_next);
|
setHasNext(response.has_next);
|
||||||
setHasPrev(response.has_prev);
|
setHasPrev(response.has_prev);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch all videos:', err);
|
console.error('Failed to fetch videos:', err);
|
||||||
setError(t('ado2Contents.loadFailed'));
|
setError(t('ado2Contents.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardClick = (videoId: number) => {
|
|
||||||
setSelectedVideoId(videoId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
return `${year}.${month}.${day}`;
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}.${month}.${day}・${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTitle = (storeName: string, dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (videoUrl: string, storeName: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(videoUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${storeName}.mp4`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed:', err);
|
||||||
|
alert(t('ado2Contents.downloadFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (videoId: number) => {
|
||||||
|
setDeleteTargetId(videoId);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadClick = (video: VideoListItem) => {
|
||||||
|
console.log('[ADO2] Upload clicked - video object:', video);
|
||||||
|
console.log('[ADO2] video.video_id:', video.video_id);
|
||||||
|
setUploadTargetVideo(video);
|
||||||
|
setUploadModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadModalClose = () => {
|
||||||
|
setUploadModalOpen(false);
|
||||||
|
setUploadTargetVideo(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTargetId) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteVideo(deleteTargetId);
|
||||||
|
|
||||||
|
// 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
|
||||||
|
// fetchVideos()를 호출하지 않음 - 서버 캐시 또는 동기화 지연으로 인해
|
||||||
|
// 삭제된 항목이 다시 나타날 수 있기 때문
|
||||||
|
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
|
||||||
|
setTotal(prev => Math.max(0, prev - 1));
|
||||||
|
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err);
|
||||||
|
alert(t('ado2Contents.deleteFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ado2-contents-page">
|
<div className="ado2-contents-page">
|
||||||
|
{/* Header */}
|
||||||
<div className="ado2-contents-header">
|
<div className="ado2-contents-header">
|
||||||
<h1 className="ado2-contents-title">{t('sidebar.ado2Contents')}</h1>
|
<h1 className="ado2-contents-title">{t('ado2Contents.title')}</h1>
|
||||||
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ado2-contents-filters">
|
{/* Content Grid */}
|
||||||
<select
|
|
||||||
className="ado2-filter-select"
|
|
||||||
value={`${sortBy}__${order}`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const [sb, ord] = e.target.value.split('__') as [typeof sortBy, typeof order];
|
|
||||||
setSortBy(sb);
|
|
||||||
setOrder(ord);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="created_at__desc">{t('ado2Contents.sortLatest')}</option>
|
|
||||||
<option value="created_at__asc">{t('ado2Contents.sortOldest')}</option>
|
|
||||||
<option value="like_count__desc">{t('ado2Contents.sortLikes')}</option>
|
|
||||||
<option value="comment_count__desc">{t('ado2Contents.sortComments')}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`ado2-region-pill ${region ? 'active' : ''}`}
|
|
||||||
onClick={() => setShowCityModal(true)}
|
|
||||||
>
|
|
||||||
{region || t('ado2Contents.regionPlaceholder')}
|
|
||||||
{region && (
|
|
||||||
<span className="ado2-region-clear" onClick={(e) => { e.stopPropagation(); setRegion(''); setPage(1); }}>✕</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="ado2-filter-search"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setStoreName(searchInput);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="ado2-filter-input"
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
placeholder={t('ado2Contents.searchPlaceholder')}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="ado2-filter-btn">
|
|
||||||
{t('ado2Contents.searchBtn')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="ado2-contents-loading">
|
<div className="ado2-contents-loading">
|
||||||
<div className="loading-spinner"></div>
|
<div className="loading-spinner"></div>
|
||||||
|
|
@ -141,62 +161,89 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||||
<>
|
<>
|
||||||
<div className="ado2-contents-grid">
|
<div className="ado2-contents-grid">
|
||||||
{videos.map((video) => (
|
{videos.map((video) => (
|
||||||
<div
|
<div key={video.task_id} className="ado2-content-card">
|
||||||
key={video.video_id}
|
{/* Video Thumbnail */}
|
||||||
className="ado2-content-card"
|
<div className="content-card-thumbnail">
|
||||||
style={{ cursor: 'pointer' }}
|
{video.result_movie_url ? (
|
||||||
onClick={() => handleCardClick(video.video_id)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
|
|
||||||
>
|
|
||||||
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
|
||||||
{video.thumbnail_url ? (
|
|
||||||
<img
|
|
||||||
src={video.thumbnail_url}
|
|
||||||
alt={video.store_name}
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
) : video.result_movie_url ? (
|
|
||||||
<video
|
<video
|
||||||
src={video.result_movie_url}
|
src={video.result_movie_url}
|
||||||
className="content-video-preview"
|
className="content-video-preview"
|
||||||
preload="metadata"
|
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
|
onMouseEnter={(e) => e.currentTarget.play()}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.pause();
|
||||||
|
e.currentTarget.currentTime = 0;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="content-no-video" />
|
<div className="content-no-video">
|
||||||
)}
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
{/* 호버 오버레이 */}
|
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
||||||
<div className="ado2-gallery-overlay">
|
<line x1="7" y1="2" x2="7" y2="22"/>
|
||||||
<div className="ado2-gallery-play-btn">
|
<line x1="17" y1="2" x2="17" y2="22"/>
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||||
<polygon points="5,3 19,12 5,21"/>
|
<line x1="2" y1="7" x2="7" y2="7"/>
|
||||||
|
<line x1="2" y1="17" x2="7" y2="17"/>
|
||||||
|
<line x1="17" y1="17" x2="22" y2="17"/>
|
||||||
|
<line x1="17" y1="7" x2="22" y2="7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Card Info */}
|
||||||
<div className="content-card-info">
|
<div className="content-card-info">
|
||||||
<div className="content-card-text">
|
<div className="content-card-text">
|
||||||
<h3 className="content-card-title">{video.store_name}</h3>
|
<h3 className="content-card-title">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
{formatTitle(video.store_name, video.created_at)}
|
||||||
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
</h3>
|
||||||
<ContentCardSocialActions
|
<p className="content-card-date">
|
||||||
videoId={video.video_id}
|
{formatDate(video.created_at)}
|
||||||
storeName={video.store_name}
|
</p>
|
||||||
region={video.region}
|
|
||||||
commentCount={video.comment_count ?? 0}
|
|
||||||
initialLikeCount={video.like_count ?? 0}
|
|
||||||
initialIsLiked={video.is_liked_by_me}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="content-card-actions">
|
||||||
|
<button
|
||||||
|
className="content-download-btn"
|
||||||
|
onClick={() => handleUploadClick(video)}
|
||||||
|
disabled={!video.result_movie_url}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
||||||
|
<path d="M3 15v2h14v-2"/>
|
||||||
|
</svg>
|
||||||
|
<span>{t('ado2Contents.uploadToSocial')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="content-upload-btn"
|
||||||
|
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
|
||||||
|
disabled={!video.result_movie_url}
|
||||||
|
title={t('ado2Contents.download')}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
|
||||||
|
<path d="M3 15v2h14v-2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="content-delete-btn"
|
||||||
|
onClick={() => handleDeleteClick(video.video_id)}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||||
|
<line x1="8" y1="8" x2="8" y2="14"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination - 항상 표시 */}
|
||||||
<div className="ado2-contents-pagination">
|
<div className="ado2-contents-pagination">
|
||||||
<button
|
<button
|
||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
|
|
@ -217,24 +264,38 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!authed && (
|
{/* 삭제 확인 모달 */}
|
||||||
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
|
{deleteModalOpen && (
|
||||||
|
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
||||||
|
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||||
|
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
||||||
|
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
||||||
|
<div className="delete-modal-actions">
|
||||||
|
<button
|
||||||
|
className="delete-modal-btn cancel"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{t('ado2Contents.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="delete-modal-btn confirm"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedVideoId !== null && (
|
{/* 소셜 미디어 업로드 모달 */}
|
||||||
<VideoDetailModal
|
<SocialPostingModal
|
||||||
videoId={String(selectedVideoId)}
|
isOpen={uploadModalOpen}
|
||||||
onClose={() => setSelectedVideoId(null)}
|
onClose={handleUploadModalClose}
|
||||||
|
video={uploadTargetVideo}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{showCityModal && (
|
|
||||||
<CitySelectModal
|
|
||||||
selected={region}
|
|
||||||
onSelect={(city) => { setRegion(city); setPage(1); }}
|
|
||||||
onClose={() => setShowCityModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
|
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
|
||||||
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
|
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
|
||||||
|
|
@ -44,23 +43,15 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getImageSrc = (item: ImageItem): string => {
|
const getImageSrc = (item: ImageItem): string => {
|
||||||
return item.type === 'url' ? item.preview_url : item.preview;
|
return item.type === 'url' ? item.url : item.preview;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextWithUpload = async () => {
|
const handleNextWithUpload = async () => {
|
||||||
if (imageList.length === 0) return;
|
if (imageList.length === 0) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadProgress(0);
|
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setUploadProgress(prev => {
|
|
||||||
if (prev >= 99) return prev;
|
|
||||||
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlImages: ImageUrlItem[] = imageList
|
const urlImages: ImageUrlItem[] = imageList
|
||||||
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
|
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
|
||||||
|
|
@ -72,23 +63,13 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
|
|
||||||
if (urlImages.length > 0 || fileImages.length > 0) {
|
if (urlImages.length > 0 || fileImages.length > 0) {
|
||||||
const response = await uploadImages(urlImages, fileImages);
|
const response = await uploadImages(urlImages, fileImages);
|
||||||
clearInterval(interval);
|
|
||||||
setUploadProgress(100);
|
|
||||||
// 이미지 변경 시 이전 음악/영상 생성 결과 초기화 — 반드시 재생성하도록 강제
|
|
||||||
localStorage.removeItem('castad_song_generation');
|
|
||||||
localStorage.removeItem('castad_song_completion');
|
|
||||||
localStorage.removeItem('castad_song_task_id');
|
|
||||||
localStorage.removeItem('castad_video_generation');
|
|
||||||
localStorage.removeItem('castad_video_complete');
|
|
||||||
onNext(response.task_id);
|
onNext(response.task_id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearInterval(interval);
|
|
||||||
console.error('Image upload failed:', error);
|
console.error('Image upload failed:', error);
|
||||||
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
|
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setUploadProgress(0);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -123,29 +104,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="asset-page">
|
<main className="asset-page">
|
||||||
{isUploading && (
|
|
||||||
<div className="asset-upload-overlay">
|
|
||||||
<div className="asset-upload-overlay-content">
|
|
||||||
<div className="loading-spinner">
|
|
||||||
<div className="loading-ring"></div>
|
|
||||||
<div className="loading-dot">
|
|
||||||
<div className="loading-dot-inner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="comp2-loading-text">{t('assetManagement.uploading')}</p>
|
|
||||||
<div className="loading-progress-wrapper">
|
|
||||||
<div className="loading-progress-bar">
|
|
||||||
<div
|
|
||||||
className="loading-progress-fill"
|
|
||||||
style={{ width: `${uploadProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="loading-progress-text">{Math.floor(uploadProgress)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fixed Header - 뒤로가기 버튼 */}
|
{/* Fixed Header - 뒤로가기 버튼 */}
|
||||||
<div className="asset-sticky-header">
|
<div className="asset-sticky-header">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
|
|
@ -187,7 +145,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
<img
|
<img
|
||||||
src={getImageSrc(item)}
|
src={getImageSrc(item)}
|
||||||
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
/>
|
||||||
{item.type === 'file' && (
|
{item.type === 'file' && (
|
||||||
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Sidebar from '../../components/Sidebar';
|
||||||
|
import AssetManagementContent from './AssetManagementContent';
|
||||||
|
|
||||||
|
interface AssetManagementSectionProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetManagementSection: React.FC<AssetManagementSectionProps> = ({ onBack }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full h-screen bg-[#002224] text-white overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<AssetManagementContent onBack={onBack} onNext={() => {}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssetManagementSection;
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete } from '../../utils/api';
|
import { generateVideo, waitForVideoComplete } from '../../utils/api';
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
|
||||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
|
||||||
|
|
||||||
interface CompletionContentProps {
|
interface CompletionContentProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
songTaskId: string | null;
|
songTaskId: string | null;
|
||||||
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
|
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
|
||||||
onVideoProgressChange?: (progress: number) => void;
|
onVideoProgressChange?: (progress: number) => void;
|
||||||
onGoToCalendar?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
||||||
|
|
@ -33,8 +29,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
onBack,
|
onBack,
|
||||||
songTaskId,
|
songTaskId,
|
||||||
onVideoStatusChange,
|
onVideoStatusChange,
|
||||||
onVideoProgressChange,
|
onVideoProgressChange
|
||||||
onGoToCalendar,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
||||||
|
|
@ -42,25 +37,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [renderProgress, setRenderProgress] = useState(0);
|
const [renderProgress, setRenderProgress] = useState(0);
|
||||||
const [displayProgress, setDisplayProgress] = useState(0);
|
|
||||||
const hasStartedGeneration = useRef(false);
|
const hasStartedGeneration = useRef(false);
|
||||||
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const tutorial = useTutorial();
|
|
||||||
const tutorialIsActiveRef = useRef(tutorial.isActive);
|
|
||||||
tutorialIsActiveRef.current = tutorial.isActive;
|
|
||||||
|
|
||||||
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
|
|
||||||
useEffect(() => {
|
|
||||||
const isComplete = videoStatus === 'complete';
|
|
||||||
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
|
|
||||||
|
|
||||||
if (isProcessing && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
|
|
||||||
} else if (isComplete && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
|
|
||||||
}
|
|
||||||
}, [videoStatus]);
|
|
||||||
|
|
||||||
// 소셜 미디어 포스팅 모달
|
// 소셜 미디어 포스팅 모달
|
||||||
const [showSocialModal, setShowSocialModal] = useState(false);
|
const [showSocialModal, setShowSocialModal] = useState(false);
|
||||||
|
|
@ -89,31 +66,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
}
|
}
|
||||||
}, [renderProgress, onVideoProgressChange]);
|
}, [renderProgress, onVideoProgressChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (displayIntervalRef.current) clearInterval(displayIntervalRef.current);
|
|
||||||
|
|
||||||
if (renderProgress === 100) {
|
|
||||||
setDisplayProgress(100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderProgress에 도달한 후에도 99%까지 서서히 크리핑
|
|
||||||
const CREEP_MAX = 99;
|
|
||||||
|
|
||||||
displayIntervalRef.current = setInterval(() => {
|
|
||||||
setDisplayProgress(prev => {
|
|
||||||
const target = Math.max(prev, renderProgress);
|
|
||||||
if (prev >= CREEP_MAX) return prev;
|
|
||||||
const increment = prev < 70 ? 0.2 : prev < 90 ? 0.04 : 0.01;
|
|
||||||
return Math.min(prev + increment, Math.max(target, Math.min(prev + increment, CREEP_MAX)));
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (displayIntervalRef.current) clearInterval(displayIntervalRef.current);
|
|
||||||
};
|
|
||||||
}, [renderProgress]);
|
|
||||||
|
|
||||||
const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null, dbId?: number) => {
|
const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null, dbId?: number) => {
|
||||||
const data: SavedVideoState = {
|
const data: SavedVideoState = {
|
||||||
videoTaskId,
|
videoTaskId,
|
||||||
|
|
@ -174,19 +126,10 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
|
|
||||||
hasStartedGeneration.current = true;
|
hasStartedGeneration.current = true;
|
||||||
setVideoStatus('generating');
|
setVideoStatus('generating');
|
||||||
setStatusMessage(t('completion.checkingSubtitle'));
|
setStatusMessage(t('completion.requestingGeneration'));
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 자막 완료 여부 확인 후 미완료면 폴링
|
|
||||||
const subtitleStatus = await getSubtitleStatus(songTaskId);
|
|
||||||
if (subtitleStatus.status !== 'completed') {
|
|
||||||
setStatusMessage(t('completion.waitingSubtitle'));
|
|
||||||
await waitForSubtitleComplete(songTaskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatusMessage(t('completion.requestingGeneration'));
|
|
||||||
|
|
||||||
const savedRatio = localStorage.getItem('castad_video_ratio');
|
const savedRatio = localStorage.getItem('castad_video_ratio');
|
||||||
const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical';
|
const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical';
|
||||||
|
|
||||||
|
|
@ -294,7 +237,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
setVideoUrl(completeVideo.videoUrl);
|
setVideoUrl(completeVideo.videoUrl);
|
||||||
if (completeVideo.videoDbId) setVideoDbId(completeVideo.videoDbId);
|
if (completeVideo.videoDbId) setVideoDbId(completeVideo.videoDbId);
|
||||||
setVideoStatus('complete');
|
setVideoStatus('complete');
|
||||||
setShowComplete(true);
|
|
||||||
hasStartedGeneration.current = true;
|
hasStartedGeneration.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -306,7 +248,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
setVideoUrl(savedState.videoUrl);
|
setVideoUrl(savedState.videoUrl);
|
||||||
if (savedState.videoDbId) setVideoDbId(savedState.videoDbId);
|
if (savedState.videoDbId) setVideoDbId(savedState.videoDbId);
|
||||||
setVideoStatus('complete');
|
setVideoStatus('complete');
|
||||||
setShowComplete(true);
|
|
||||||
hasStartedGeneration.current = true;
|
hasStartedGeneration.current = true;
|
||||||
} else if (savedState.status === 'polling') {
|
} else if (savedState.status === 'polling') {
|
||||||
setVideoStatus('polling');
|
setVideoStatus('polling');
|
||||||
|
|
@ -338,32 +279,15 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const handleDownload = () => {
|
||||||
|
if (videoUrl) {
|
||||||
const handleDownload = async () => {
|
|
||||||
if (!videoUrl || isDownloading) return;
|
|
||||||
setIsDownloading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(videoUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = getFileName();
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch {
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = videoUrl;
|
link.href = videoUrl;
|
||||||
link.download = getFileName();
|
link.download = 'castad_video.mp4';
|
||||||
link.target = '_blank';
|
link.target = '_blank';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
} finally {
|
|
||||||
setIsDownloading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -380,20 +304,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
setVideoStatus('idle');
|
setVideoStatus('idle');
|
||||||
setVideoUrl(null);
|
setVideoUrl(null);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setShowComplete(false);
|
|
||||||
clearStorage();
|
clearStorage();
|
||||||
startVideoGeneration();
|
startVideoGeneration();
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showComplete, setShowComplete] = useState(false);
|
const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (displayProgress < 100) return;
|
|
||||||
const timer = setTimeout(() => setShowComplete(true), 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [displayProgress]);
|
|
||||||
|
|
||||||
const isLoading = videoStatus === 'generating' || videoStatus === 'polling' || (videoStatus === 'complete' && !showComplete);
|
|
||||||
|
|
||||||
// 비디오 해상도 계산
|
// 비디오 해상도 계산
|
||||||
const getVideoResolution = () => {
|
const getVideoResolution = () => {
|
||||||
|
|
@ -409,18 +324,18 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="comp2-page">
|
<main className="comp2-page">
|
||||||
{/* <div className="comp2-header">
|
<div className="comp2-header">
|
||||||
<button onClick={onBack} className="comp2-back-btn">
|
<button onClick={onBack} className="comp2-back-btn">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M15 18l-6-6 6-6" />
|
<path d="M15 18l-6-6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{t('completion.back')}</span>
|
<span>{t('completion.back')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
<div className="comp2-title-row">
|
<div className="comp2-title-row">
|
||||||
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
|
<h1 className="comp2-page-title">{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}</h1>
|
||||||
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</p>
|
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="comp2-container">
|
<div className="comp2-container">
|
||||||
|
|
@ -437,15 +352,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="comp2-loading-text">{statusMessage}</p>
|
<p className="comp2-loading-text">{statusMessage}</p>
|
||||||
<div className="loading-progress-wrapper">
|
|
||||||
<div className="loading-progress-bar">
|
|
||||||
<div
|
|
||||||
className="loading-progress-fill"
|
|
||||||
style={{ width: `${displayProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="loading-progress-text">{Math.floor(displayProgress)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : videoStatus === 'error' ? (
|
) : videoStatus === 'error' ? (
|
||||||
<div className="comp2-video-error">
|
<div className="comp2-video-error">
|
||||||
|
|
@ -474,54 +380,27 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
{/* 오른쪽: 콘텐츠 정보 */}
|
{/* 오른쪽: 콘텐츠 정보 */}
|
||||||
<div className="comp2-info-section">
|
<div className="comp2-info-section">
|
||||||
<div className="comp2-info-header">
|
<div className="comp2-info-header">
|
||||||
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
|
<span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '콘텐츠 정보' })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="comp2-info-content">
|
||||||
<div className="comp2-file-info">
|
<div className="comp2-file-info">
|
||||||
<h3 className="comp2-filename">{getFileName()}</h3>
|
<h3 className="comp2-filename">{getFileName()}</h3>
|
||||||
{/* <p className="comp2-filesize">19.6MB</p> */}
|
{/* <p className="comp2-filesize">19.6MB</p> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="comp2-info-content">
|
|
||||||
<div className="comp2-meta-grid">
|
<div className="comp2-meta-grid">
|
||||||
<div className="comp2-meta-item">
|
<div className="comp2-meta-item">
|
||||||
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
|
<span className="comp2-meta-label">{t('completion.genre', { defaultValue: '장르' })} : {songCompletionData?.genre || 'K-POP'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="comp2-meta-divider"></div>
|
<div className="comp2-meta-divider"></div>
|
||||||
<div className="comp2-meta-item">
|
<div className="comp2-meta-item">
|
||||||
<span className="comp2-meta-label">{t('completion.resolution')} : {getVideoResolution()}</span>
|
<span className="comp2-meta-label">{t('completion.resolution', { defaultValue: '규격' })} : {getVideoResolution()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="comp2-meta-divider"></div>
|
<div className="comp2-meta-divider"></div>
|
||||||
<div className="comp2-lyrics-section">
|
<div className="comp2-lyrics-section">
|
||||||
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
|
<span className="comp2-meta-label">{t('completion.lyrics', { defaultValue: '가사' })}</span>
|
||||||
{(() => {
|
<p className="comp2-lyrics-text">
|
||||||
if (!songCompletionData) {
|
{songCompletionData?.lyrics || t('completion.sampleLyrics', { defaultValue: '가사를 불러오는 중...' })}
|
||||||
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>;
|
</p>
|
||||||
}
|
|
||||||
if (!songCompletionData.lyrics) {
|
|
||||||
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
|
||||||
}
|
|
||||||
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
|
|
||||||
const sections: { tag: string | null; lines: string[] }[] = [];
|
|
||||||
lines.forEach((line: string) => {
|
|
||||||
const tagMatch = line.trim().match(/^\[(.+)\]$/);
|
|
||||||
if (tagMatch) {
|
|
||||||
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
|
|
||||||
} else if (sections.length === 0) {
|
|
||||||
sections.push({ tag: null, lines: [line] });
|
|
||||||
} else {
|
|
||||||
sections[sections.length - 1].lines.push(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div className="comp2-lyrics-paragraphs">
|
|
||||||
{sections.filter(s => s.lines.length > 0).map((section, i) => (
|
|
||||||
<div key={i} className="comp2-lyrics-para-section">
|
|
||||||
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
|
||||||
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -529,17 +408,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
<div className="comp2-buttons">
|
<div className="comp2-buttons">
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
|
disabled={videoStatus !== 'complete' || !videoUrl}
|
||||||
className="comp2-btn comp2-btn-secondary"
|
className="comp2-btn comp2-btn-secondary"
|
||||||
>
|
>
|
||||||
{isDownloading ? t('completion.downloading') : t('completion.download')}
|
{t('completion.download', { defaultValue: '다운로드' })}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenSocialConnect}
|
onClick={handleOpenSocialConnect}
|
||||||
disabled={videoStatus !== 'complete' || !videoDbId}
|
disabled={videoStatus !== 'complete' || !videoDbId}
|
||||||
className="comp2-btn comp2-btn-primary"
|
className="comp2-btn comp2-btn-primary"
|
||||||
>
|
>
|
||||||
{t('completion.uploadToSocial')}
|
{t('completion.uploadToSocial', { defaultValue: '소셜 채널 업로드' })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -550,7 +429,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
<SocialPostingModal
|
<SocialPostingModal
|
||||||
isOpen={showSocialModal}
|
isOpen={showSocialModal}
|
||||||
onClose={handleCloseSocialConnect}
|
onClose={handleCloseSocialConnect}
|
||||||
onGoToCalendar={onGoToCalendar}
|
|
||||||
video={videoUrl && videoDbId ? {
|
video={videoUrl && videoDbId ? {
|
||||||
video_id: videoDbId,
|
video_id: videoDbId,
|
||||||
store_name: songCompletionData?.businessName || '',
|
store_name: songCompletionData?.businessName || '',
|
||||||
|
|
@ -558,21 +436,8 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
task_id: songTaskId || '',
|
task_id: songTaskId || '',
|
||||||
result_movie_url: videoUrl,
|
result_movie_url: videoUrl,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
like_count: 0,
|
|
||||||
comment_count: 0,
|
|
||||||
} : null}
|
} : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{tutorial.isActive && (
|
|
||||||
<TutorialOverlay
|
|
||||||
hints={tutorial.hints}
|
|
||||||
currentIndex={tutorial.currentHintIndex}
|
|
||||||
onNext={tutorial.nextHint}
|
|
||||||
onPrev={tutorial.prevHint}
|
|
||||||
onSkip={tutorial.skipTutorial}
|
|
||||||
groupProgress={tutorial.groupProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { UploadHistoryItem } from '../../types/api';
|
import { UploadHistoryItem } from '../../types/api';
|
||||||
import { getUploadHistory, cancelUpload, retryUpload } from '../../utils/api';
|
import { getUploadHistory, cancelUpload, retryUpload } from '../../utils/api';
|
||||||
|
|
||||||
|
|
@ -54,13 +53,13 @@ const statusColor = (status: UploadStatus) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 상태 라벨 (컴포넌트 내부에서 t()로 처리)
|
// 상태 라벨
|
||||||
const statusLabelKey = (status: UploadStatus) => {
|
const statusLabel = (status: UploadStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed': return 'contentCalendar.status.completed';
|
case 'completed': return '완료';
|
||||||
case 'scheduled':
|
case 'scheduled':
|
||||||
case 'pending': return 'contentCalendar.status.scheduled';
|
case 'pending': return '예약';
|
||||||
case 'failed': return 'contentCalendar.status.failed';
|
case 'failed': return '실패';
|
||||||
default: return status;
|
default: return status;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -70,7 +69,6 @@ interface ContentCalendarContentProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => {
|
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [year, setYear] = useState(today.getFullYear());
|
const [year, setYear] = useState(today.getFullYear());
|
||||||
const [month, setMonth] = useState(today.getMonth());
|
const [month, setMonth] = useState(today.getMonth());
|
||||||
|
|
@ -205,7 +203,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
|
|
||||||
// 취소
|
// 취소
|
||||||
const handleCancel = async (uploadId: number) => {
|
const handleCancel = async (uploadId: number) => {
|
||||||
if (!window.confirm(t('contentCalendar.cancelConfirm'))) return;
|
if (!window.confirm('예약을 취소하시겠습니까?')) return;
|
||||||
// 낙관적 업데이트: 먼저 UI에서 제거
|
// 낙관적 업데이트: 먼저 UI에서 제거
|
||||||
setAllItems(prev => prev.filter(i => i.upload_id !== uploadId));
|
setAllItems(prev => prev.filter(i => i.upload_id !== uploadId));
|
||||||
setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId));
|
setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId));
|
||||||
|
|
@ -214,7 +212,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
} catch {
|
} catch {
|
||||||
// 실패 시 다시 불러오기
|
// 실패 시 다시 불러오기
|
||||||
refreshAll();
|
refreshAll();
|
||||||
alert(t('contentCalendar.cancelFailed'));
|
alert('취소에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -224,7 +222,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
await retryUpload(uploadId);
|
await retryUpload(uploadId);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
} catch {
|
} catch {
|
||||||
alert(t('contentCalendar.retryFailed'));
|
alert('재시도에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -236,11 +234,10 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 날짜 표시
|
// 날짜 표시 (M월 D)
|
||||||
const formatDateLabel = (key: string) => {
|
const formatDateLabel = (key: string) => {
|
||||||
const [, m, d] = key.split('-');
|
const [, m, d] = key.split('-');
|
||||||
const months = t('contentCalendar.months', { returnObjects: true }) as string[];
|
return `${parseInt(m)}월 ${parseInt(d)}`;
|
||||||
return t('contentCalendar.monthDay', { month: months[parseInt(m) - 1], day: parseInt(d) });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCalendarDays = (): CalendarDay[] => {
|
const buildCalendarDays = (): CalendarDay[] => {
|
||||||
|
|
@ -269,15 +266,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarDays = buildCalendarDays();
|
const calendarDays = buildCalendarDays();
|
||||||
const monthNames = t('contentCalendar.months', { returnObjects: true }) as string[];
|
const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
|
||||||
const dayLabels = t('contentCalendar.days', { returnObjects: true }) as string[];
|
const dayLabels = ['일','월','화','수','목','금','토'];
|
||||||
const tabs: TabType[] = ['전체','완료','예약','실패'];
|
const tabs: TabType[] = ['전체','완료','예약','실패'];
|
||||||
const tabLabels: Record<TabType, string> = {
|
|
||||||
'전체': t('contentCalendar.tabs.all'),
|
|
||||||
'완료': t('contentCalendar.tabs.completed'),
|
|
||||||
'예약': t('contentCalendar.tabs.scheduled'),
|
|
||||||
'실패': t('contentCalendar.tabs.failed'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 캘린더 그리드 ──────────────────────────────────────────────
|
// ── 캘린더 그리드 ──────────────────────────────────────────────
|
||||||
const renderCalendarGrid = () => {
|
const renderCalendarGrid = () => {
|
||||||
|
|
@ -397,7 +388,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
|
||||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||||
{t('contentCalendar.completedCount', { count: summary.completed })}
|
완료 {summary.completed}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -405,7 +396,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
|
||||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||||
{t('contentCalendar.scheduledCount', { count: summary.scheduled })}
|
예약 {summary.scheduled}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -413,7 +404,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
|
||||||
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
||||||
{t('contentCalendar.failedCount', { count: summary.failed })}
|
실패 {summary.failed}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -441,7 +432,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap',
|
letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabLabels[tab]}
|
{tab}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -451,9 +442,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
const renderStats = () => (
|
const renderStats = () => (
|
||||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||||
{[
|
{[
|
||||||
{ label: t('contentCalendar.tabs.scheduled'), value: totalStats.scheduled },
|
{ label: '예약', value: totalStats.scheduled },
|
||||||
{ label: t('contentCalendar.tabs.completed'), value: totalStats.completed },
|
{ label: '완료', value: totalStats.completed },
|
||||||
{ label: t('contentCalendar.tabs.failed'), value: totalStats.failed },
|
{ label: '실패', value: totalStats.failed },
|
||||||
].map((item, idx) => (
|
].map((item, idx) => (
|
||||||
<React.Fragment key={item.label}>
|
<React.Fragment key={item.label}>
|
||||||
{idx > 0 && <div style={{ width: 0, height: 14, borderLeft: '1px solid #379599' }} />}
|
{idx > 0 && <div style={{ width: 0, height: 14, borderLeft: '1px solid #379599' }} />}
|
||||||
|
|
@ -492,7 +483,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px',
|
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px',
|
||||||
margin: 0, whiteSpace: 'nowrap',
|
margin: 0, whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
{t('contentCalendar.yearMonth', { year, month: monthNames[month] })}
|
{year}년 {monthNames[month]}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleNextMonth}
|
onClick={handleNextMonth}
|
||||||
|
|
@ -538,7 +529,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
|
||||||
color: statusColor(item.status as UploadStatus), lineHeight: 1,
|
color: statusColor(item.status as UploadStatus), lineHeight: 1,
|
||||||
}}>
|
}}>
|
||||||
{t(statusLabelKey(item.status as UploadStatus))}
|
{statusLabel(item.status as UploadStatus)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
|
||||||
|
|
@ -548,19 +539,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 채널 아이콘 + 채널명 */}
|
{/* 채널 아이콘 + 제목 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<PlatformIcon platform={item.platform} size={16} />
|
<PlatformIcon platform={item.platform} size={18} />
|
||||||
<span style={{
|
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
|
|
||||||
color: '#9bcacc', lineHeight: 1.4,
|
|
||||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
|
||||||
}}>
|
|
||||||
{item.platform_username || item.platform_user_id || item.channel_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 제목 */}
|
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
|
||||||
color: '#e5f1f2', lineHeight: 1.4,
|
color: '#e5f1f2', lineHeight: 1.4,
|
||||||
|
|
@ -569,6 +550,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
}}>
|
}}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 실패 메시지 */}
|
{/* 실패 메시지 */}
|
||||||
{isFailed && item.error_message && (
|
{isFailed && item.error_message && (
|
||||||
|
|
@ -593,7 +575,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
color: '#9bcacc', cursor: 'pointer',
|
color: '#9bcacc', cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('contentCalendar.cancel')}
|
취소
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isFailed && (
|
{isFailed && (
|
||||||
|
|
@ -606,7 +588,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
color: '#ffffff', cursor: 'pointer',
|
color: '#ffffff', cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('contentCalendar.retry')}
|
재시도
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -620,7 +602,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
if (panelLoading) {
|
if (panelLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
|
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -635,17 +617,17 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16,
|
||||||
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0,
|
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0,
|
||||||
}}>
|
}}>
|
||||||
{t('contentCalendar.noResults')}
|
최근 결과 없음
|
||||||
</p>
|
</p>
|
||||||
<p style={{
|
<p style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
|
||||||
color: '#9bcacc', letterSpacing: '-0.072px', lineHeight: 1, margin: 0,
|
color: '#9bcacc', letterSpacing: '-0.072px', lineHeight: 1, margin: 0,
|
||||||
}}>
|
}}>
|
||||||
{t('contentCalendar.noResultsDesc')}
|
제작한 콘텐츠를 소셜 채널에 업로드해 보세요
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate?.('내 콘텐츠')}
|
onClick={() => onNavigate?.('ADO2 콘텐츠')}
|
||||||
style={{
|
style={{
|
||||||
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
|
height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
|
||||||
backgroundColor: '#a65eff', cursor: 'pointer',
|
backgroundColor: '#a65eff', cursor: 'pointer',
|
||||||
|
|
@ -653,7 +635,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('contentCalendar.myContents')}
|
ADO2 콘텐츠
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -662,7 +644,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="calendar-panel-scroll"
|
className="calendar-panel-scroll"
|
||||||
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 700 }}
|
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 900 }}
|
||||||
>
|
>
|
||||||
{sortedDateKeys.map(dateKey => (
|
{sortedDateKeys.map(dateKey => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -699,7 +681,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||||
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
||||||
}}>
|
}}>
|
||||||
{t('contentCalendar.title')}
|
콘텐츠 캘린더
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -717,7 +699,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 바텀시트 */}
|
{/* 바텀시트 */}
|
||||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 10, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#01393b',
|
backgroundColor: '#01393b',
|
||||||
borderTop: '1px solid #046266',
|
borderTop: '1px solid #046266',
|
||||||
|
|
@ -754,7 +736,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
padding: '32px',
|
padding: '80px 32px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minWidth: 1400,
|
minWidth: 1400,
|
||||||
height: '100%', boxSizing: 'border-box',
|
height: '100%', boxSizing: 'border-box',
|
||||||
|
|
@ -764,7 +746,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||||
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
||||||
}}>
|
}}>
|
||||||
{t('contentCalendar.title')}
|
콘텐츠 캘린더
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -776,7 +758,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
flex: 1, minHeight: 0,
|
flex: 1, minHeight: 0,
|
||||||
}}>
|
}}>
|
||||||
{/* 캘린더 영역 */}
|
{/* 캘린더 영역 */}
|
||||||
<div className="calendar-grid-area" style={{
|
<div style={{
|
||||||
gridColumn: '1 / span 8',
|
gridColumn: '1 / span 8',
|
||||||
backgroundColor: '#01393b',
|
backgroundColor: '#01393b',
|
||||||
borderRadius: 20, padding: 16,
|
borderRadius: 20, padding: 16,
|
||||||
|
|
@ -792,13 +774,13 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
|
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
) : renderCalendarGrid()}
|
) : renderCalendarGrid()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽 패널 */}
|
{/* 오른쪽 패널 */}
|
||||||
<div className="calendar-side-panel" style={{
|
<div style={{
|
||||||
gridColumn: '9 / span 1',
|
gridColumn: '9 / span 1',
|
||||||
backgroundColor: '#01393b', borderRadius: 20,
|
backgroundColor: '#01393b', borderRadius: 20,
|
||||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||||
|
|
|
||||||
|
|
@ -81,29 +81,29 @@ interface ConnectedAccount {
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
const MOCK_CONTENT_METRICS: ContentMetric[] = [
|
const MOCK_CONTENT_METRICS: ContentMetric[] = [
|
||||||
{ id: 'total-views', label: 'dashboard.metricTotalViews', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' },
|
{ id: 'total-views', label: '조회수', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' },
|
||||||
{ id: 'total-watch-time', label: 'dashboard.metricWatchTime', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' },
|
{ id: 'total-watch-time', label: '시청시간', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' },
|
||||||
{ id: 'avg-view-duration',label: 'dashboard.metricAvgViewDuration', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' },
|
{ id: 'avg-view-duration',label: '평균 시청시간', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' },
|
||||||
{ id: 'new-subscribers', label: 'dashboard.metricNewSubscribers', value: 483, unit: 'count', trend: 50, trendDirection: 'up' },
|
{ id: 'new-subscribers', label: '신규 구독자', value: 483, unit: 'count', trend: 50, trendDirection: 'up' },
|
||||||
{ id: 'likes', label: 'dashboard.metricLikes', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' },
|
{ id: 'likes', label: '좋아요', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' },
|
||||||
{ id: 'comments', label: 'dashboard.metricComments', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' },
|
{ id: 'comments', label: '댓글', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' },
|
||||||
{ id: 'shares', label: 'dashboard.metricShares', value: 840, unit: 'count', trend: 15, trendDirection: 'up' },
|
{ id: 'shares', label: '공유', value: 840, unit: 'count', trend: 15, trendDirection: 'up' },
|
||||||
{ id: 'uploaded-videos', label: 'dashboard.metricUploadedVideos', value: 17, unit: 'count', trend: 4, trendDirection: 'up' },
|
{ id: 'uploaded-videos', label: '업로드 영상', value: 17, unit: 'count', trend: 4, trendDirection: 'up' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_MONTHLY_DATA: MonthlyData[] = [
|
const MOCK_MONTHLY_DATA: MonthlyData[] = [
|
||||||
{ month: 'dashboard.months.jan', thisYear: 18000, lastYear: 14500 },
|
{ month: '1월', thisYear: 18000, lastYear: 14500 },
|
||||||
{ month: 'dashboard.months.feb', thisYear: 19500, lastYear: 15800 },
|
{ month: '2월', thisYear: 19500, lastYear: 15800 },
|
||||||
{ month: 'dashboard.months.mar', thisYear: 21000, lastYear: 17200 },
|
{ month: '3월', thisYear: 21000, lastYear: 17200 },
|
||||||
{ month: 'dashboard.months.apr', thisYear: 18500, lastYear: 16800 },
|
{ month: '4월', thisYear: 18500, lastYear: 16800 },
|
||||||
{ month: 'dashboard.months.may', thisYear: 24000, lastYear: 19500 },
|
{ month: '5월', thisYear: 24000, lastYear: 19500 },
|
||||||
{ month: 'dashboard.months.jun', thisYear: 27500, lastYear: 21000 },
|
{ month: '6월', thisYear: 27500, lastYear: 21000 },
|
||||||
{ month: 'dashboard.months.jul', thisYear: 32000, lastYear: 23500 },
|
{ month: '7월', thisYear: 32000, lastYear: 23500 },
|
||||||
{ month: 'dashboard.months.aug', thisYear: 29500, lastYear: 24800 },
|
{ month: '8월', thisYear: 29500, lastYear: 24800 },
|
||||||
{ month: 'dashboard.months.sep', thisYear: 31000, lastYear: 26200 },
|
{ month: '9월', thisYear: 31000, lastYear: 26200 },
|
||||||
{ month: 'dashboard.months.oct', thisYear: 28500, lastYear: 25500 },
|
{ month: '10월', thisYear: 28500, lastYear: 25500 },
|
||||||
{ month: 'dashboard.months.nov', thisYear: 34000, lastYear: 27800 },
|
{ month: '11월', thisYear: 34000, lastYear: 27800 },
|
||||||
{ month: 'dashboard.months.dec', thisYear: 38000, lastYear: 29500 },
|
{ month: '12월', thisYear: 38000, lastYear: 29500 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => {
|
const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => {
|
||||||
|
|
@ -201,16 +201,18 @@ const formatNumber = (num: number): string => {
|
||||||
return num.toString();
|
return num.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatValue = (value: number, unit: string, unitHours: string, unitMinutes: string): string => {
|
// unit에 따라 value를 표시용 문자열로 변환 (언어별 suffix는 호출부에서 처리)
|
||||||
if (unit === 'hours') return value.toFixed(1) + unitHours;
|
const formatValue = (value: number, unit: string): string => {
|
||||||
if (unit === 'minutes') return Math.round(value) + unitMinutes;
|
if (unit === 'hours') return value.toFixed(1) + '시간';
|
||||||
|
if (unit === 'minutes') return Math.round(value) + '분';
|
||||||
return formatNumber(Math.round(value));
|
return formatNumber(Math.round(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTrend = (trend: number, unit: string, unitHours: string, unitMinutes: string): string => {
|
// unit에 따라 trend 절댓값을 표시용 문자열로 변환
|
||||||
|
const formatTrend = (trend: number, unit: string): string => {
|
||||||
const abs = Math.abs(trend);
|
const abs = Math.abs(trend);
|
||||||
if (unit === 'hours') return abs.toFixed(1) + unitHours;
|
if (unit === 'hours') return abs.toFixed(1) + '시간';
|
||||||
if (unit === 'minutes') return Math.round(abs) + unitMinutes;
|
if (unit === 'minutes') return Math.round(abs) + '분';
|
||||||
return formatNumber(Math.round(abs));
|
return formatNumber(Math.round(abs));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -237,22 +239,19 @@ const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => (
|
||||||
);
|
);
|
||||||
|
|
||||||
const StatCard: React.FC<ContentMetric> = ({ label, value, unit, trend, trendDirection }) => {
|
const StatCard: React.FC<ContentMetric> = ({ label, value, unit, trend, trendDirection }) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const unitHours = t('dashboard.unitHours');
|
|
||||||
const unitMinutes = t('dashboard.unitMinutes');
|
|
||||||
const isNeutral = trend === 0 || trendDirection === '-';
|
const isNeutral = trend === 0 || trendDirection === '-';
|
||||||
const isUp = trendDirection === 'up';
|
const isUp = trendDirection === 'up';
|
||||||
return (
|
return (
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<span className="stat-label">{t(label)}</span>
|
<span className="stat-label">{label}</span>
|
||||||
<h3 className="stat-value">{formatValue(value, unit, unitHours, unitMinutes)}</h3>
|
<h3 className="stat-value">{formatValue(value, unit)}</h3>
|
||||||
<div className="stat-trend-wrapper">
|
<div className="stat-trend-wrapper">
|
||||||
{isNeutral ? (
|
{isNeutral ? (
|
||||||
<span className="stat-trend neutral">─</span>
|
<span className="stat-trend neutral">─</span>
|
||||||
) : (
|
) : (
|
||||||
<span className={`stat-trend ${isUp ? 'up' : 'down'}`}>
|
<span className={`stat-trend ${isUp ? 'up' : 'down'}`}>
|
||||||
<TrendIcon direction={isUp ? 'up' : 'down'} />
|
<TrendIcon direction={isUp ? 'up' : 'down'} />
|
||||||
{isUp ? '+' : '-'}{formatTrend(trend, unit, unitHours, unitMinutes)}
|
{isUp ? '+' : '-'}{formatTrend(trend, unit)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -277,12 +276,11 @@ const YearOverYearChart: React.FC<{
|
||||||
currentLabel: string;
|
currentLabel: string;
|
||||||
previousLabel: string;
|
previousLabel: string;
|
||||||
mode: 'day' | 'month';
|
mode: 'day' | 'month';
|
||||||
noDataLabel: string;
|
}> = ({ data, currentLabel, previousLabel, mode }) => {
|
||||||
}> = ({ data, currentLabel, previousLabel, mode, noDataLabel }) => {
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', opacity: 0.4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', opacity: 0.4 }}>
|
||||||
<span>{noDataLabel}</span>
|
<span>이 기간에 데이터가 없습니다.</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -364,14 +362,14 @@ const YearOverYearChart: React.FC<{
|
||||||
// Icon Components
|
// Icon Components
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
const YouTubeIcon: React.FC<{ className?: string; style?: React.CSSProperties }> = ({ className, style }) => (
|
const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
<svg className={className || "platform-tab-icon"} style={style} viewBox="0 0 24 24" fill="currentColor">
|
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const InstagramIcon: React.FC<{ className?: string; style?: React.CSSProperties }> = ({ className, style }) => (
|
const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
<svg className={className || "platform-tab-icon"} style={style} viewBox="0 0 24 24" fill="currentColor">
|
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" />
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
@ -575,7 +573,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (errorData.code === 'YOUTUBE_API_FAILED') {
|
if (errorData.code === 'YOUTUBE_API_FAILED') {
|
||||||
setError({ code: 'YOUTUBE_API_FAILED', message: t('dashboard.youtubeApiFailed') });
|
setError({ code: 'YOUTUBE_API_FAILED', message: 'YouTube Analytics API 호출에 실패했습니다.' });
|
||||||
setDashboardData(null);
|
setDashboardData(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -608,7 +606,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-screen">
|
<div className="flex justify-center items-center h-screen">
|
||||||
<div className="text-lg">{t('dashboard.dataLoading')}</div>
|
<div className="text-lg">{t('데이터 로딩 중...')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -616,13 +614,12 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
// hasUploads === false이고 error 없음: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너
|
// hasUploads === false이고 error 없음: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너
|
||||||
const isEmptyState = dashboardData?.hasUploads === false && !dashboardData?.error;
|
const isEmptyState = dashboardData?.hasUploads === false && !dashboardData?.error;
|
||||||
|
|
||||||
|
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음
|
||||||
|
const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData || !!error;
|
||||||
|
|
||||||
// showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백
|
// showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백
|
||||||
const useReal = !showMockData && !isEmptyState;
|
const useReal = !showMockData && !isEmptyState;
|
||||||
const hasRealContentMetrics = useReal && !!dashboardData?.contentMetrics?.some((m: ContentMetric) => m.value > 0);
|
const contentMetrics = (useReal && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS;
|
||||||
const contentMetrics = hasRealContentMetrics ? dashboardData!.contentMetrics : MOCK_CONTENT_METRICS;
|
|
||||||
|
|
||||||
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음 5)실제 지표 없음
|
|
||||||
const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData || !!error || !hasRealContentMetrics;
|
|
||||||
const topContent = (useReal && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT;
|
const topContent = (useReal && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT;
|
||||||
const hasRealAgeGroups = useReal && !!dashboardData?.audienceData?.ageGroups?.some(g => g.percentage > 0);
|
const hasRealAgeGroups = useReal && !!dashboardData?.audienceData?.ageGroups?.some(g => g.percentage > 0);
|
||||||
const hasRealGender = useReal && ((dashboardData?.audienceData?.gender?.male ?? 0) + (dashboardData?.audienceData?.gender?.female ?? 0)) > 0;
|
const hasRealGender = useReal && ((dashboardData?.audienceData?.gender?.male ?? 0) + (dashboardData?.audienceData?.gender?.female ?? 0)) > 0;
|
||||||
|
|
@ -634,15 +631,15 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
const chartData: ChartDataPoint[] = mode === 'month'
|
const chartData: ChartDataPoint[] = mode === 'month'
|
||||||
? ((useReal && dashboardData?.monthlyData?.length)
|
? ((useReal && dashboardData?.monthlyData?.length)
|
||||||
? dashboardData.monthlyData.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear }))
|
? dashboardData.monthlyData.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear }))
|
||||||
: MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: t(d.month), current: d.thisYear, previous: d.lastYear })))
|
: MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear })))
|
||||||
: ((useReal && dashboardData?.dailyData?.length)
|
: ((useReal && dashboardData?.dailyData?.length)
|
||||||
? dashboardData.dailyData.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod }))
|
? dashboardData.dailyData.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod }))
|
||||||
: MOCK_DAILY_DATA.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod })));
|
: MOCK_DAILY_DATA.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod })));
|
||||||
|
|
||||||
// mode별 차트 레이블
|
// mode별 차트 레이블
|
||||||
const chartCurrentLabel = mode === 'month' ? t('dashboard.chartThisYear') : t('dashboard.chartThisMonth');
|
const chartCurrentLabel = mode === 'month' ? '올해' : '이번달';
|
||||||
const chartPreviousLabel = mode === 'month' ? t('dashboard.chartLastYear') : t('dashboard.chartLastMonth');
|
const chartPreviousLabel = mode === 'month' ? '작년' : '지난달';
|
||||||
const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : t('dashboard.monthOverMonth');
|
const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : '전월 대비 성장';
|
||||||
|
|
||||||
const lastUpdated = new Date().toLocaleDateString('ko-KR', {
|
const lastUpdated = new Date().toLocaleDateString('ko-KR', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
@ -660,24 +657,8 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
<path d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p style={{ color: '#a6ffea', fontSize: '20px', fontWeight: 600, marginBottom: '2px' }}>{t('dashboard.noUploadsTitle')}</p>
|
<p style={{ color: '#a6ffea', fontSize: '20px', fontWeight: 600, marginBottom: '2px' }}>아직 업로드된 영상이 없습니다.</p>
|
||||||
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '16px' }}>{t('dashboard.noUploadsDesc')}</p>
|
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '16px' }}>ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 48시간 지연 안내 배너 */}
|
|
||||||
{dashboardData && !isEmptyState && !hasRealContentMetrics && !showMockData && (
|
|
||||||
<div className="mb-4 p-4 border-l-4 rounded" style={{ background: 'rgba(255,210,100,0.06)', borderColor: '#ffd264' }}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ffd264" strokeWidth="2" style={{ flexShrink: 0 }}>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M12 6v6l4 2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p style={{ color: '#ffd264', fontSize: '16px', fontWeight: 600, marginBottom: '2px' }}>{t('dashboard.dataDelayTitle')}</p>
|
|
||||||
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '14px' }}>{t('dashboard.dataDelayDesc')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -698,7 +679,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
onClick={() => onNavigate?.('내 정보')}
|
onClick={() => onNavigate?.('내 정보')}
|
||||||
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
|
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{t('dashboard.reconnectButton')}
|
재연동하러 가기 →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{error.code === 'YOUTUBE_NOT_CONNECTED' && (
|
{error.code === 'YOUTUBE_NOT_CONNECTED' && (
|
||||||
|
|
@ -706,7 +687,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
onClick={() => onNavigate?.('내 정보')}
|
onClick={() => onNavigate?.('내 정보')}
|
||||||
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
|
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{t('dashboard.connectButton')}
|
연동하러 가기 →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(error.code === 'YOUTUBE_API_FAILED' || error.code === 'DASHBOARD_DATA_ERROR') && (
|
{(error.code === 'YOUTUBE_API_FAILED' || error.code === 'DASHBOARD_DATA_ERROR') && (
|
||||||
|
|
@ -714,7 +695,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
onClick={() => { setError(null); setRetryTrigger((n: number) => n + 1); }}
|
onClick={() => { setError(null); setRetryTrigger((n: number) => n + 1); }}
|
||||||
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
|
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{t('dashboard.retryButton')}
|
재시도 →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -757,7 +738,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}>{t('dashboard.selectAccount')}</span>
|
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}>계정을 선택하세요</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="2" style={{ flexShrink: 0, marginLeft: 'auto' }}>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="2" style={{ flexShrink: 0, marginLeft: 'auto' }}>
|
||||||
|
|
@ -819,20 +800,20 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.contentPerformance')}</h2>
|
<h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.contentPerformance')}</h2>
|
||||||
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>{t('dashboard.recentVideosDesc')}</p>
|
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mode-toggle">
|
<div className="mode-toggle">
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${mode === 'month' ? 'active' : ''}`}
|
className={`mode-btn ${mode === 'month' ? 'active' : ''}`}
|
||||||
onClick={() => setMode('month')}
|
onClick={() => setMode('month')}
|
||||||
>
|
>
|
||||||
{t('dashboard.modeAnnual')}
|
연간
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${mode === 'day' ? 'active' : ''}`}
|
className={`mode-btn ${mode === 'day' ? 'active' : ''}`}
|
||||||
onClick={() => setMode('day')}
|
onClick={() => setMode('day')}
|
||||||
>
|
>
|
||||||
{t('dashboard.modeMonthly')}
|
월간
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -869,7 +850,6 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
currentLabel={chartCurrentLabel}
|
currentLabel={chartCurrentLabel}
|
||||||
previousLabel={chartPreviousLabel}
|
previousLabel={chartPreviousLabel}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
noDataLabel={t('dashboard.noDataInPeriod')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -894,7 +874,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
<AnimatedSection delay={1000} className="audience-section">
|
<AnimatedSection delay={1000} className="audience-section">
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.audienceInsights')}</h2>
|
<h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.audienceInsights')}</h2>
|
||||||
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>{t('dashboard.channelStatsDesc')}</p>
|
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>선택한 채널의 통계가 표시됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<div className="audience-cards" style={!hasRealAudienceData && !showMockData ? { filter: 'blur(4px)', pointerEvents: 'none', userSelect: 'none' } : {}}>
|
<div className="audience-cards" style={!hasRealAudienceData && !showMockData ? { filter: 'blur(4px)', pointerEvents: 'none', userSelect: 'none' } : {}}>
|
||||||
|
|
@ -927,7 +907,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
padding: '20px 28px',
|
padding: '20px 28px',
|
||||||
}}>
|
}}>
|
||||||
<p style={{ color: 'rgba(255,255,255,0.85)', fontSize: '16px', fontWeight: 500, margin: 0, textAlign: 'center' }}>
|
<p style={{ color: 'rgba(255,255,255,0.85)', fontSize: '16px', fontWeight: 500, margin: 0, textAlign: 'center' }}>
|
||||||
{t('dashboard.noAudienceData')}
|
누적 데이터가 부족하여 실제 시청자 정보가 없습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -963,7 +943,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></>
|
: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></>
|
||||||
}
|
}
|
||||||
</svg>
|
</svg>
|
||||||
{showMockData ? t('dashboard.sampleHide') : t('dashboard.sampleShow')}
|
{showMockData ? 'Sample 숨기기' : 'Sample 보기'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
import AssetManagementContent from './AssetManagementContent';
|
import AssetManagementContent from './AssetManagementContent';
|
||||||
|
|
@ -9,24 +9,18 @@ import DashboardContent from './DashboardContent';
|
||||||
import BusinessSettingsContent from './BusinessSettingsContent';
|
import BusinessSettingsContent from './BusinessSettingsContent';
|
||||||
import UrlInputContent from './UrlInputContent';
|
import UrlInputContent from './UrlInputContent';
|
||||||
import ADO2ContentsPage from './ADO2ContentsPage';
|
import ADO2ContentsPage from './ADO2ContentsPage';
|
||||||
import MyContentsPage from './MyContentsPage';
|
|
||||||
import MyInfoContent from './MyInfoContent';
|
import MyInfoContent from './MyInfoContent';
|
||||||
import ContentCalendarContent from './ContentCalendarContent';
|
import ContentCalendarContent from './ContentCalendarContent';
|
||||||
import LoadingSection from '../Analysis/LoadingSection';
|
import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
|
||||||
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
|
||||||
import WizardStepper from '../../components/WizardStepper';
|
|
||||||
|
|
||||||
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
||||||
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
||||||
const SONG_TASK_ID_KEY = 'castad_song_task_id';
|
const SONG_TASK_ID_KEY = 'castad_song_task_id';
|
||||||
const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
|
const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
|
||||||
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||||
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
|
|
||||||
|
|
||||||
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
||||||
const SONG_GENERATION_KEY = 'castad_song_generation';
|
const SONG_GENERATION_KEY = 'castad_song_generation';
|
||||||
|
|
@ -52,7 +46,7 @@ interface BusinessInfo {
|
||||||
interface GenerationFlowProps {
|
interface GenerationFlowProps {
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
initialActiveItem?: string;
|
initialActiveItem?: string;
|
||||||
initialImageList?: ImageListItem[];
|
initialImageList?: string[];
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
initialAnalysisData?: CrawlingResponse | null;
|
initialAnalysisData?: CrawlingResponse | null;
|
||||||
}
|
}
|
||||||
|
|
@ -117,35 +111,14 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
const [videoGenerationProgress, setVideoGenerationProgress] = useState(0);
|
const [videoGenerationProgress, setVideoGenerationProgress] = useState(0);
|
||||||
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
|
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
|
||||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
|
||||||
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
||||||
const [credits, setCredits] = useState<number | null>(null);
|
|
||||||
const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined);
|
|
||||||
const tutorial = useTutorial();
|
|
||||||
|
|
||||||
const refreshCredits = useCallback(async () => {
|
// 로그인 직후 사용자 정보 조회
|
||||||
try {
|
|
||||||
const { credits } = await getUserCredits();
|
|
||||||
setCredits(credits);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to refresh credits:', e);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 사운드 스튜디오, 영상 완성 진입 시 크레딧 갱신
|
|
||||||
useEffect(() => {
|
|
||||||
if (wizardStep === 2 || wizardStep === 3) {
|
|
||||||
refreshCredits();
|
|
||||||
}
|
|
||||||
}, [wizardStep]);
|
|
||||||
|
|
||||||
// 로그인 직후 사용자 정보 + 크레딧 조회
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserInfo = async () => {
|
const fetchUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
|
const data = await getUserMe();
|
||||||
setUserInfo(data);
|
setUserInfo(data);
|
||||||
setCredits(creditsData.credits);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch user info:', error);
|
console.error('Failed to fetch user info:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -169,9 +142,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
||||||
const getInitialImageList = (): ImageItem[] => {
|
const getInitialImageList = (): ImageItem[] => {
|
||||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||||
return analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
|
return analysisData.image_list.map(url => ({ type: 'url', url }));
|
||||||
}
|
}
|
||||||
return initialImageList.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
|
return initialImageList.map(url => ({ type: 'url', url }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
||||||
|
|
@ -183,7 +156,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
||||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||||
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
|
if (prevAnalysisMIdRef.current !== analysisData.m_id) {
|
||||||
setImageList(analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })));
|
setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevAnalysisMIdRef.current = analysisData?.m_id;
|
prevAnalysisMIdRef.current = analysisData?.m_id;
|
||||||
|
|
@ -231,7 +204,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// 업체명 자동완성으로 분석 시작
|
// 업체명 자동완성으로 분석 시작
|
||||||
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
||||||
goToWizardStep(-1); // 로딩 상태로
|
goToWizardStep(-1); // 로딩 상태로
|
||||||
setIsAnalysisComplete(false);
|
|
||||||
setAnalysisError(null);
|
setAnalysisError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -239,11 +211,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
console.log('[Autocomplete] Response m_id:', data.m_id);
|
console.log('[Autocomplete] Response m_id:', data.m_id);
|
||||||
|
|
||||||
// 기본값 보장
|
// 기본값 보장
|
||||||
// if (data.marketing_analysis) {
|
if (data.marketing_analysis) {
|
||||||
// data.marketing_analysis.tags = data.marketing_analysis.tags || [];
|
data.marketing_analysis.tags = data.marketing_analysis.tags || [];
|
||||||
// data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
|
data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
|
||||||
// data.marketing_analysis.report = data.marketing_analysis.report || '';
|
data.marketing_analysis.report = data.marketing_analysis.report || '';
|
||||||
// }
|
}
|
||||||
if (data.processed_info) {
|
if (data.processed_info) {
|
||||||
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
||||||
data.processed_info.region = data.processed_info.region || '';
|
data.processed_info.region = data.processed_info.region || '';
|
||||||
|
|
@ -253,40 +225,25 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
|
|
||||||
setAnalysisData(data);
|
setAnalysisData(data);
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||||
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
|
goToWizardStep(0); // 브랜드 분석 결과로
|
||||||
setIsAnalysisComplete(true);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Autocomplete error:', err);
|
console.error('Autocomplete error:', err);
|
||||||
setAnalysisError(t('app.autocompleteError'));
|
setAnalysisError(err instanceof Error ? err.message : t('app.autocompleteError'));
|
||||||
goToWizardStep(-2); // URL 입력으로 돌아가기
|
goToWizardStep(-2); // URL 입력으로 돌아가기
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
// 테스트용 랜덤 m_id 생성 (99 ~ 300)
|
||||||
const handleManualInput = async (businessName: string, address: string, category: string) => {
|
const generateRandomMId = (): number => {
|
||||||
goToWizardStep(-1);
|
return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
|
||||||
setIsAnalysisComplete(false);
|
};
|
||||||
setAnalysisError(null);
|
|
||||||
|
|
||||||
try {
|
// 테스트 데이터로 브랜드 분석 페이지 이동
|
||||||
const data = await marketingAnalysis(businessName, address, category);
|
const handleTestData = (data: CrawlingResponse) => {
|
||||||
|
const tagged = { ...data, m_id: generateRandomMId(), _isTestData: true };
|
||||||
if (data.processed_info) {
|
setAnalysisData(tagged);
|
||||||
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||||
data.processed_info.region = data.processed_info.region || '';
|
goToWizardStep(0); // 브랜드 분석 결과로
|
||||||
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
|
|
||||||
}
|
|
||||||
data.image_list = data.image_list || [];
|
|
||||||
|
|
||||||
setAnalysisData(data);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
|
||||||
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
|
|
||||||
setIsAnalysisComplete(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Marketing analysis error:', err);
|
|
||||||
setAnalysisError(t('app.autocompleteError'));
|
|
||||||
goToWizardStep(-2);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 언어 변경 시 테스트 데이터 다시 로드
|
// 언어 변경 시 테스트 데이터 다시 로드
|
||||||
|
|
@ -301,7 +258,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then((data: CrawlingResponse) => {
|
.then((data: CrawlingResponse) => {
|
||||||
// 언어 변경 시에는 기존 m_id 유지
|
// 언어 변경 시에는 기존 m_id 유지
|
||||||
const tagged = { ...data, m_id: parsed.m_id, _isTestData: true };
|
const tagged = { ...data, m_id: parsed.m_id || generateRandomMId(), _isTestData: true };
|
||||||
setAnalysisData(tagged);
|
setAnalysisData(tagged);
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||||
})
|
})
|
||||||
|
|
@ -314,7 +271,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
if (!url.trim()) return;
|
if (!url.trim()) return;
|
||||||
|
|
||||||
goToWizardStep(-1); // 로딩 상태로
|
goToWizardStep(-1); // 로딩 상태로
|
||||||
setIsAnalysisComplete(false);
|
|
||||||
setAnalysisError(null);
|
setAnalysisError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -322,11 +278,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
console.log('[Crawl] Response m_id:', data.m_id);
|
console.log('[Crawl] Response m_id:', data.m_id);
|
||||||
|
|
||||||
// 기본값 보장
|
// 기본값 보장
|
||||||
// if (data.marketing_analysis) {
|
if (data.marketing_analysis) {
|
||||||
// data.marketing_analysis.tags = data.marketing_analysis.tags || [];
|
data.marketing_analysis.tags = data.marketing_analysis.tags || [];
|
||||||
// data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
|
data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
|
||||||
// data.marketing_analysis.report = data.marketing_analysis.report || '';
|
data.marketing_analysis.report = data.marketing_analysis.report || '';
|
||||||
// }
|
}
|
||||||
if (data.processed_info) {
|
if (data.processed_info) {
|
||||||
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
||||||
data.processed_info.region = data.processed_info.region || '';
|
data.processed_info.region = data.processed_info.region || '';
|
||||||
|
|
@ -336,11 +292,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
|
|
||||||
setAnalysisData(data);
|
setAnalysisData(data);
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||||
saveSearchHistory({ type: 'url', value: url });
|
goToWizardStep(0); // 브랜드 분석 결과로
|
||||||
setIsAnalysisComplete(true);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Crawling failed:', err);
|
console.error('Crawling failed:', err);
|
||||||
setAnalysisError(t('app.analysisError'));
|
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
|
||||||
|
setAnalysisError(errorMessage);
|
||||||
goToWizardStep(-2); // URL 입력으로 돌아가기
|
goToWizardStep(-2); // URL 입력으로 돌아가기
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -360,44 +316,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
||||||
}, [activeItem]);
|
}, [activeItem]);
|
||||||
|
|
||||||
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
|
|
||||||
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
|
|
||||||
useEffect(() => {
|
|
||||||
if (tutorial.isActive) tutorial.skipTutorial();
|
|
||||||
if (SIDEBAR_ITEMS.includes(activeItem)) return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.ASSET);
|
|
||||||
} else if (wizardStep === 2 && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.SOUND);
|
|
||||||
}
|
|
||||||
}, 600);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [wizardStep, activeItem]);
|
|
||||||
|
|
||||||
// activeItem 변경 시 튜토리얼 트리거
|
|
||||||
useEffect(() => {
|
|
||||||
if (tutorial.isActive) tutorial.skipTutorial();
|
|
||||||
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
|
|
||||||
} else if (activeItem === '내 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
|
|
||||||
} else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD);
|
|
||||||
} else if (activeItem === '콘텐츠 캘린더' && !tutorial.hasSeen(TUTORIAL_KEYS.CONTENT_CALENDAR)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.CONTENT_CALENDAR);
|
|
||||||
}
|
|
||||||
}, [activeItem]);
|
|
||||||
|
|
||||||
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
|
|
||||||
const handleGoToPayment = () => {
|
|
||||||
setMyInfoInitialTab('payment');
|
|
||||||
setActiveItem('내 정보');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
|
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
|
||||||
const handleNavigate = (item: string) => {
|
const handleNavigate = (item: string) => {
|
||||||
setMyInfoInitialTab(undefined);
|
|
||||||
if (item === '새 프로젝트 만들기') {
|
if (item === '새 프로젝트 만들기') {
|
||||||
// 기존 프로젝트 데이터 초기화
|
// 기존 프로젝트 데이터 초기화
|
||||||
clearAllProjectStorage();
|
clearAllProjectStorage();
|
||||||
|
|
@ -414,7 +334,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
setAnalysisError(null);
|
setAnalysisError(null);
|
||||||
}
|
}
|
||||||
setActiveItem(item);
|
setActiveItem(item);
|
||||||
refreshCredits();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
||||||
|
|
@ -426,18 +345,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
<UrlInputContent
|
<UrlInputContent
|
||||||
onAnalyze={handleStartAnalysis}
|
onAnalyze={handleStartAnalysis}
|
||||||
onAutocomplete={handleAutocomplete}
|
onAutocomplete={handleAutocomplete}
|
||||||
onManualInput={handleManualInput}
|
onTestData={handleTestData}
|
||||||
error={analysisError}
|
error={analysisError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case -1:
|
case -1:
|
||||||
// 로딩 단계
|
// 로딩 단계
|
||||||
return (
|
return <LoadingSection />;
|
||||||
<LoadingSection
|
|
||||||
isComplete={isAnalysisComplete}
|
|
||||||
onComplete={() => goToWizardStep(0)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 0:
|
case 0:
|
||||||
// 브랜드 분석 결과 단계
|
// 브랜드 분석 결과 단계
|
||||||
if (!analysisData) {
|
if (!analysisData) {
|
||||||
|
|
@ -483,17 +397,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
businessInfo={currentBusinessInfo}
|
businessInfo={currentBusinessInfo}
|
||||||
imageTaskId={imageTaskId}
|
imageTaskId={imageTaskId}
|
||||||
mId={analysisData?.m_id ?? 0}
|
mId={analysisData?.m_id ?? 0}
|
||||||
industry={analysisData?.industry ?? ''}
|
|
||||||
videoGenerationStatus={videoGenerationStatus}
|
videoGenerationStatus={videoGenerationStatus}
|
||||||
videoGenerationProgress={videoGenerationProgress}
|
videoGenerationProgress={videoGenerationProgress}
|
||||||
onGoToPayment={handleGoToPayment}
|
|
||||||
onStatusChange={(status: string) => {
|
|
||||||
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
|
|
||||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
|
|
||||||
} else if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
|
|
||||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO), 400);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
|
|
@ -511,7 +416,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
songTaskId={songTaskId}
|
songTaskId={songTaskId}
|
||||||
onVideoStatusChange={setVideoGenerationStatus}
|
onVideoStatusChange={setVideoGenerationStatus}
|
||||||
onVideoProgressChange={setVideoGenerationProgress}
|
onVideoProgressChange={setVideoGenerationProgress}
|
||||||
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|
@ -526,35 +430,22 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
case '비즈니스 설정':
|
case '비즈니스 설정':
|
||||||
return <BusinessSettingsContent />;
|
return <BusinessSettingsContent />;
|
||||||
case 'ADO2 콘텐츠':
|
case 'ADO2 콘텐츠':
|
||||||
return <ADO2ContentsPage />;
|
|
||||||
case '내 콘텐츠':
|
|
||||||
return (
|
return (
|
||||||
<MyContentsPage
|
<ADO2ContentsPage
|
||||||
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
||||||
onNavigate={handleNavigate}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case '콘텐츠 캘린더':
|
case '콘텐츠 캘린더':
|
||||||
return <ContentCalendarContent onNavigate={handleNavigate} />;
|
return <ContentCalendarContent onNavigate={handleNavigate} />;
|
||||||
case '내 정보':
|
case '내 정보':
|
||||||
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
return <MyInfoContent />;
|
||||||
case '새 프로젝트 만들기':
|
case '새 프로젝트 만들기':
|
||||||
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
|
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
|
||||||
if (wizardStep === -1 || wizardStep === -2) {
|
if (wizardStep === 0 || wizardStep === -1) {
|
||||||
return renderWizardContent();
|
return renderWizardContent();
|
||||||
}
|
}
|
||||||
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
|
|
||||||
if (wizardStep === 0) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<WizardStepper currentStep={wizardStep} />
|
|
||||||
{renderWizardContent()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="wizard-page-container">
|
<div className="wizard-page-container">
|
||||||
<WizardStepper currentStep={wizardStep} />
|
|
||||||
{renderWizardContent()}
|
{renderWizardContent()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -573,69 +464,18 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
||||||
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
||||||
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
|
||||||
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
|
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
|
||||||
|
|
||||||
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
|
|
||||||
const getCurrentTutorialKey = (): string | null => {
|
|
||||||
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
|
|
||||||
if (activeItem === '내 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
|
|
||||||
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
|
|
||||||
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
|
|
||||||
if (activeItem === '새 프로젝트 만들기') {
|
|
||||||
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
|
|
||||||
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tutorialUI = (
|
|
||||||
<>
|
|
||||||
{getCurrentTutorialKey() && (
|
|
||||||
<button
|
|
||||||
className={`tutorial-toggle-fab ${tutorial.isEnabled ? 'active' : ''}`}
|
|
||||||
onClick={() => tutorial.toggleTutorial(getCurrentTutorialKey())}
|
|
||||||
title={tutorial.isEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<path d="M12 8v4l3 3"/>
|
|
||||||
</svg>
|
|
||||||
<span>{t('sidebar.tutorial')}</span>
|
|
||||||
<span className={`tutorial-toggle-badge ${tutorial.isEnabled ? 'on' : 'off'}`}>
|
|
||||||
{tutorial.isEnabled ? 'ON' : 'OFF'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{tutorial.isActive && (
|
|
||||||
<TutorialOverlay
|
|
||||||
hints={tutorial.hints}
|
|
||||||
currentIndex={tutorial.currentHintIndex}
|
|
||||||
onNext={tutorial.nextHint}
|
|
||||||
onPrev={tutorial.prevHint}
|
|
||||||
onSkip={tutorial.skipTutorial}
|
|
||||||
groupProgress={tutorial.groupProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{tutorial.isRestartPopupVisible && (
|
|
||||||
<TutorialRestartPopup
|
|
||||||
onConfirm={tutorial.confirmRestart}
|
|
||||||
onCancel={tutorial.cancelRestart}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 브랜드 분석일 때는 전체 화면 스크롤
|
// 브랜드 분석일 때는 전체 화면 스크롤
|
||||||
if (isBrandAnalysis) {
|
if (isBrandAnalysis) {
|
||||||
return (
|
return (
|
||||||
<div className="analysis-page-wrapper">
|
<div className="analysis-page-wrapper">
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
||||||
)}
|
)}
|
||||||
<main className="analysis-page-main">
|
<main className="analysis-page-main">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</main>
|
</main>
|
||||||
{tutorialUI}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -643,10 +483,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
||||||
)}
|
)}
|
||||||
{tutorialUI}
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,398 +0,0 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { getVideosList, deleteVideo } from '../../utils/api';
|
|
||||||
import { VideoListItem } from '../../types/api';
|
|
||||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
|
||||||
import VideoDetailModal from '../../components/VideoDetailModal';
|
|
||||||
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
|
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
|
||||||
|
|
||||||
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const isPlayingRef = useRef(false);
|
|
||||||
const pendingPlayRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
video.preload = 'auto';
|
|
||||||
} else {
|
|
||||||
video.preload = 'none';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(video);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const safePlay = useCallback((video: HTMLVideoElement) => {
|
|
||||||
const playPromise = video.play();
|
|
||||||
if (playPromise !== undefined) {
|
|
||||||
playPromise
|
|
||||||
.then(() => { isPlayingRef.current = true; })
|
|
||||||
.catch((error) => {
|
|
||||||
if (error.name !== 'AbortError') console.error(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCanPlay = useCallback(() => {
|
|
||||||
if (!pendingPlayRef.current || !videoRef.current) return;
|
|
||||||
pendingPlayRef.current = false;
|
|
||||||
safePlay(videoRef.current);
|
|
||||||
}, [safePlay]);
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
if (video.readyState >= 3) {
|
|
||||||
safePlay(video);
|
|
||||||
} else {
|
|
||||||
pendingPlayRef.current = true;
|
|
||||||
}
|
|
||||||
}, [safePlay]);
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
pendingPlayRef.current = false;
|
|
||||||
if (isPlayingRef.current) {
|
|
||||||
video.pause();
|
|
||||||
video.currentTime = 0;
|
|
||||||
isPlayingRef.current = false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={src}
|
|
||||||
className={className}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="none"
|
|
||||||
onCanPlay={handleCanPlay}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MyContentsPageProps {
|
|
||||||
onBack?: () => void;
|
|
||||||
onNavigate?: (item: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const tutorial = useTutorial();
|
|
||||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [hasNext, setHasNext] = useState(false);
|
|
||||||
const [hasPrev, setHasPrev] = useState(false);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
|
||||||
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
|
||||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const pageSize = 12;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchVideos();
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchVideos = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await getVideosList(page, pageSize);
|
|
||||||
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
|
|
||||||
setVideos(validVideos);
|
|
||||||
setTotal(response.total);
|
|
||||||
setTotalPages(response.total_pages);
|
|
||||||
setHasNext(response.has_next);
|
|
||||||
setHasPrev(response.has_prev);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch videos:', err);
|
|
||||||
setError(t('ado2Contents.loadFailed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}.${month}.${day}・${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTitle = (storeName: string, dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async (videoUrl: string, storeName: string) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(videoUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${storeName}.mp4`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Download failed:', err);
|
|
||||||
alert(t('ado2Contents.downloadFailed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteClick = (videoId: number) => {
|
|
||||||
setDeleteTargetId(videoId);
|
|
||||||
setDeleteModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadClick = (video: VideoListItem) => {
|
|
||||||
setUploadTargetVideo(video);
|
|
||||||
setUploadModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadModalClose = () => {
|
|
||||||
setUploadModalOpen(false);
|
|
||||||
setUploadTargetVideo(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCancel = () => {
|
|
||||||
setDeleteModalOpen(false);
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!deleteTargetId) return;
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
await deleteVideo(deleteTargetId);
|
|
||||||
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
|
|
||||||
setTotal(prev => Math.max(0, prev - 1));
|
|
||||||
setDeleteModalOpen(false);
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Delete failed:', err);
|
|
||||||
alert(t('ado2Contents.deleteFailed'));
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ado2-contents-page">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="ado2-contents-header">
|
|
||||||
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>
|
|
||||||
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Grid */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="ado2-contents-loading">
|
|
||||||
<div className="loading-spinner"></div>
|
|
||||||
<p>{t('ado2Contents.loading')}</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="ado2-contents-error">
|
|
||||||
<p>{error}</p>
|
|
||||||
<button onClick={fetchVideos} className="retry-btn">{t('ado2Contents.retry')}</button>
|
|
||||||
</div>
|
|
||||||
) : videos.length === 0 ? (
|
|
||||||
<div className="ado2-contents-empty">
|
|
||||||
<p>{t('ado2Contents.noContent')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="ado2-contents-grid">
|
|
||||||
{videos.map((video) => (
|
|
||||||
<div key={video.task_id} className="ado2-content-card">
|
|
||||||
{/* Video Thumbnail */}
|
|
||||||
<div
|
|
||||||
className="content-card-thumbnail"
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => setSelectedVideoId(video.video_id)}
|
|
||||||
>
|
|
||||||
{video.result_movie_url ? (
|
|
||||||
<VideoPreviewCard
|
|
||||||
src={video.result_movie_url}
|
|
||||||
className="content-video-preview"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="content-no-video">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
|
||||||
<line x1="7" y1="2" x2="7" y2="22"/>
|
|
||||||
<line x1="17" y1="2" x2="17" y2="22"/>
|
|
||||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
|
||||||
<line x1="2" y1="7" x2="7" y2="7"/>
|
|
||||||
<line x1="2" y1="17" x2="7" y2="17"/>
|
|
||||||
<line x1="17" y1="17" x2="22" y2="17"/>
|
|
||||||
<line x1="17" y1="7" x2="22" y2="7"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card Info */}
|
|
||||||
<div className="content-card-info">
|
|
||||||
<div className="content-card-text">
|
|
||||||
<h3 className="content-card-title">
|
|
||||||
{formatTitle(video.store_name, video.created_at)}
|
|
||||||
</h3>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<p className="content-card-date">
|
|
||||||
{formatDate(video.created_at)}
|
|
||||||
</p>
|
|
||||||
<ContentCardSocialActions
|
|
||||||
videoId={video.video_id}
|
|
||||||
storeName={video.store_name}
|
|
||||||
region={video.region}
|
|
||||||
commentCount={video.comment_count ?? 0}
|
|
||||||
initialLikeCount={video.like_count ?? 0}
|
|
||||||
initialIsLiked={video.is_liked_by_me}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="content-card-actions">
|
|
||||||
<button
|
|
||||||
className="content-download-btn"
|
|
||||||
onClick={() => handleUploadClick(video)}
|
|
||||||
disabled={!video.result_movie_url}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
|
||||||
<path d="M3 15v2h14v-2"/>
|
|
||||||
</svg>
|
|
||||||
<span>{t('ado2Contents.uploadToSocial')}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="content-upload-btn"
|
|
||||||
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
|
|
||||||
disabled={!video.result_movie_url}
|
|
||||||
title={t('ado2Contents.download')}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
|
|
||||||
<path d="M3 15v2h14v-2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="content-delete-btn"
|
|
||||||
onClick={() => handleDeleteClick(video.video_id)}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
|
||||||
<line x1="8" y1="8" x2="8" y2="14"/>
|
|
||||||
<line x1="12" y1="8" x2="12" y2="14"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="ado2-contents-pagination">
|
|
||||||
<button
|
|
||||||
className="pagination-btn"
|
|
||||||
onClick={() => setPage(p => p - 1)}
|
|
||||||
disabled={!hasPrev}
|
|
||||||
>
|
|
||||||
{t('ado2Contents.previous')}
|
|
||||||
</button>
|
|
||||||
<span className="pagination-info">{page} / {totalPages}</span>
|
|
||||||
<button
|
|
||||||
className="pagination-btn"
|
|
||||||
onClick={() => setPage(p => p + 1)}
|
|
||||||
disabled={!hasNext}
|
|
||||||
>
|
|
||||||
{t('ado2Contents.next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
|
||||||
{deleteModalOpen && (
|
|
||||||
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
|
||||||
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
|
||||||
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
|
||||||
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
|
||||||
<div className="delete-modal-actions">
|
|
||||||
<button
|
|
||||||
className="delete-modal-btn cancel"
|
|
||||||
onClick={handleDeleteCancel}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{t('ado2Contents.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="delete-modal-btn confirm"
|
|
||||||
onClick={handleDeleteConfirm}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedVideoId !== null && (
|
|
||||||
<VideoDetailModal
|
|
||||||
videoId={String(selectedVideoId)}
|
|
||||||
onClose={() => setSelectedVideoId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 소셜 미디어 업로드 모달 */}
|
|
||||||
<SocialPostingModal
|
|
||||||
isOpen={uploadModalOpen}
|
|
||||||
onClose={handleUploadModalClose}
|
|
||||||
video={uploadTargetVideo}
|
|
||||||
onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MyContentsPage;
|
|
||||||
|
|
@ -1,53 +1,18 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api';
|
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api';
|
||||||
import { SocialAccount } from '../../types/api';
|
import { SocialAccount } from '../../types/api';
|
||||||
|
|
||||||
type TabType = 'basic' | 'payment' | 'business';
|
type TabType = 'basic' | 'payment' | 'business';
|
||||||
|
|
||||||
interface MyInfoContentProps {
|
const MyInfoContent: React.FC = () => {
|
||||||
initialTab?: TabType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business');
|
const [activeTab, setActiveTab] = useState<TabType>('business');
|
||||||
const [businessUrl, setBusinessUrl] = useState('');
|
const [businessUrl, setBusinessUrl] = useState('');
|
||||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||||
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState<string | null>(null);
|
const [isConnecting, setIsConnecting] = useState<string | null>(null);
|
||||||
const [credits, setCredits] = useState<number | null>(null);
|
|
||||||
const [isLoadingCredits, setIsLoadingCredits] = useState(false);
|
|
||||||
const [showChargePopup, setShowChargePopup] = useState(false);
|
|
||||||
const [chargeAmount, setChargeAmount] = useState('');
|
|
||||||
const [chargeNote, setChargeNote] = useState('');
|
|
||||||
const [isRequesting, setIsRequesting] = useState(false);
|
|
||||||
const [chargeSuccess, setChargeSuccess] = useState(false);
|
|
||||||
|
|
||||||
// initialTab 변경 시 탭 업데이트
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialTab) setActiveTab(initialTab);
|
|
||||||
}, [initialTab]);
|
|
||||||
|
|
||||||
// 크레딧 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'payment') {
|
|
||||||
loadCredits();
|
|
||||||
}
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
const loadCredits = async () => {
|
|
||||||
setIsLoadingCredits(true);
|
|
||||||
try {
|
|
||||||
const data = await getUserCredits();
|
|
||||||
setCredits(data.credits);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load credits:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingCredits(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 소셜 계정 목록 로드
|
// 소셜 계정 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -113,80 +78,12 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
|
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
// { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
|
{ id: 'basic' as TabType, label: t('myInfo.tabBasic') },
|
||||||
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
|
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
|
||||||
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
|
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{showChargePopup && (
|
|
||||||
<div className="myinfo-popup-overlay" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>
|
|
||||||
<div className="myinfo-popup" onClick={e => e.stopPropagation()}>
|
|
||||||
{chargeSuccess ? (
|
|
||||||
<>
|
|
||||||
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
|
|
||||||
<button className="myinfo-popup-close" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeConfirm')}</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h3 className="myinfo-popup-title">{t('myInfo.chargePopupTitle')}</h3>
|
|
||||||
<div className="myinfo-popup-field">
|
|
||||||
<label className="myinfo-popup-label">{t('myInfo.chargeAmountLabel')}</label>
|
|
||||||
<div className="myinfo-popup-counter">
|
|
||||||
<button
|
|
||||||
className="myinfo-popup-counter-btn"
|
|
||||||
onClick={() => setChargeAmount(v => String(Math.max(1, Number(v || 0) - 1)))}
|
|
||||||
>−</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="myinfo-popup-input myinfo-popup-input--center"
|
|
||||||
placeholder="0"
|
|
||||||
value={chargeAmount}
|
|
||||||
onChange={e => setChargeAmount(e.target.value.replace(/[^0-9]/g, ''))}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="myinfo-popup-counter-btn"
|
|
||||||
onClick={() => setChargeAmount(v => String(Number(v || 0) + 1))}
|
|
||||||
>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="myinfo-popup-field">
|
|
||||||
<label className="myinfo-popup-label">{t('myInfo.chargeNoteLabel')}</label>
|
|
||||||
<textarea
|
|
||||||
className="myinfo-popup-textarea"
|
|
||||||
placeholder={t('myInfo.chargeNotePlaceholder')}
|
|
||||||
value={chargeNote}
|
|
||||||
onChange={e => setChargeNote(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="myinfo-popup-actions">
|
|
||||||
<button className="myinfo-popup-cancel" onClick={() => { setShowChargePopup(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeCancel')}</button>
|
|
||||||
<button
|
|
||||||
className="myinfo-popup-close"
|
|
||||||
disabled={!chargeAmount || isRequesting}
|
|
||||||
onClick={async () => {
|
|
||||||
setIsRequesting(true);
|
|
||||||
try {
|
|
||||||
await requestCreditCharge(Number(chargeAmount), chargeNote);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Charge request failed:', e);
|
|
||||||
} finally {
|
|
||||||
setIsRequesting(false);
|
|
||||||
setChargeSuccess(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRequesting ? t('myInfo.chargeSubmitting') : t('myInfo.chargeSubmit')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<main className="myinfo-page">
|
<main className="myinfo-page">
|
||||||
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
|
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
|
||||||
|
|
||||||
|
|
@ -205,42 +102,21 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
{/* 탭 컨텐츠 */}
|
||||||
<div className="myinfo-content">
|
<div className="myinfo-content">
|
||||||
{/* {activeTab === 'basic' && (
|
{activeTab === 'basic' && (
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
|
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
|
|
||||||
{activeTab === 'payment' && (
|
{activeTab === 'payment' && (
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
<h2 className="myinfo-section-title">{t('myInfo.creditsTitle')}</h2>
|
<p className="myinfo-placeholder">{t('myInfo.paymentPlaceholder')}</p>
|
||||||
<div className="myinfo-credits-card">
|
|
||||||
{isLoadingCredits ? (
|
|
||||||
<p className="myinfo-placeholder">{t('myInfo.creditsLoading')}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="myinfo-credits-row">
|
|
||||||
<span className="myinfo-credits-label">{t('myInfo.creditsLabel')}</span>
|
|
||||||
<span className="myinfo-credits-value">{credits !== null ? credits.toLocaleString() : '-'} {t('myInfo.creditsUnit')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="myinfo-credits-row">
|
|
||||||
<p className="myinfo-credits-desc">{t('myInfo.creditsDesc')}</p>
|
|
||||||
<button
|
|
||||||
className="myinfo-credits-charge-btn"
|
|
||||||
onClick={() => setShowChargePopup(true)}
|
|
||||||
>
|
|
||||||
{t('myInfo.creditsChargeBtn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'business' && (
|
{activeTab === 'business' && (
|
||||||
<>
|
<>
|
||||||
{/* 내 비즈니스 섹션
|
{/* 내 비즈니스 섹션 */}
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
<h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2>
|
<h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2>
|
||||||
<div className="myinfo-business-card">
|
<div className="myinfo-business-card">
|
||||||
|
|
@ -261,7 +137,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
{/* 소셜 채널 섹션 */}
|
{/* 소셜 채널 섹션 */}
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
|
|
@ -374,7 +250,6 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
|
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
|
||||||
import { LANGUAGE_MAP } from '../../types/api';
|
import { LANGUAGE_MAP } from '../../types/api';
|
||||||
|
|
@ -16,70 +16,14 @@ interface SoundStudioContentProps {
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
imageTaskId: string | null;
|
imageTaskId: string | null;
|
||||||
mId: number;
|
mId: number;
|
||||||
industry?: string;
|
|
||||||
onStatusChange?: (status: string) => void;
|
|
||||||
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
||||||
videoGenerationProgress?: number;
|
videoGenerationProgress?: number;
|
||||||
onGoToPayment?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
|
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
|
||||||
|
|
||||||
const MAX_RETRY_COUNT = 3;
|
const MAX_RETRY_COUNT = 3;
|
||||||
|
|
||||||
// 생성 완료된 곡 상태를 새로고침 후에도 복원하기 위한 키
|
|
||||||
// GenerationFlow의 clearAllProjectStorage가 이 키를 포함해 정리함
|
|
||||||
const SONG_GENERATION_KEY = 'castad_song_generation';
|
|
||||||
|
|
||||||
const GENRE_VALUES = ['kpop', 'pop', 'ballad', 'hip-hop', 'rnb', 'edm', 'jazz', 'rock'];
|
|
||||||
|
|
||||||
const genreMap: Record<string, string> = {
|
|
||||||
'자동 선택': '', // 런타임에 랜덤 결정
|
|
||||||
'K-POP': 'kpop',
|
|
||||||
'POP': 'pop',
|
|
||||||
'발라드': 'ballad',
|
|
||||||
'Hip-Hop': 'hip-hop',
|
|
||||||
'R&B': 'rnb',
|
|
||||||
'EDM': 'edm',
|
|
||||||
'JAZZ': 'jazz',
|
|
||||||
'ROCK': 'rock',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEffectiveGenre = (selectedGenre: string): string => {
|
|
||||||
if (selectedGenre === '자동 선택') {
|
|
||||||
return GENRE_VALUES[Math.floor(Math.random() * GENRE_VALUES.length)];
|
|
||||||
}
|
|
||||||
return genreMap[selectedGenre] || 'pop';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환
|
|
||||||
const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => {
|
|
||||||
const displayMap: Record<string, string> = {
|
|
||||||
kpop: 'K-POP',
|
|
||||||
pop: 'POP',
|
|
||||||
ballad: t('soundStudio.genreBallad'),
|
|
||||||
'hip-hop': 'Hip-Hop',
|
|
||||||
rnb: 'R&B',
|
|
||||||
edm: 'EDM',
|
|
||||||
jazz: 'JAZZ',
|
|
||||||
rock: 'ROCK',
|
|
||||||
};
|
|
||||||
return displayMap[genreValue] || genreValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeError = (message: string, fallback: string): string => {
|
|
||||||
if (
|
|
||||||
message.includes('Traceback') ||
|
|
||||||
message.includes('File "/') ||
|
|
||||||
message.includes('httpx.') ||
|
|
||||||
message.includes('httpcore.') ||
|
|
||||||
message.length > 200
|
|
||||||
) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LANGUAGE_FLAGS: Record<string, string> = {
|
const LANGUAGE_FLAGS: Record<string, string> = {
|
||||||
'한국어': '🇰🇷',
|
'한국어': '🇰🇷',
|
||||||
'English': '🇺🇸',
|
'English': '🇺🇸',
|
||||||
|
|
@ -95,18 +39,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
businessInfo,
|
businessInfo,
|
||||||
imageTaskId,
|
imageTaskId,
|
||||||
mId,
|
mId,
|
||||||
industry = '',
|
|
||||||
onStatusChange,
|
|
||||||
videoGenerationStatus = 'idle',
|
videoGenerationStatus = 'idle',
|
||||||
videoGenerationProgress = 0,
|
videoGenerationProgress = 0
|
||||||
onGoToPayment,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedType, setSelectedType] = useState('보컬');
|
const [selectedType, setSelectedType] = useState('보컬');
|
||||||
const [selectedLang, setSelectedLang] = useState('한국어');
|
const [selectedLang, setSelectedLang] = useState('한국어');
|
||||||
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
|
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
|
||||||
const [usedGenre, setUsedGenre] = useState('');
|
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [status, setStatus] = useState<GenerationStatus>('idle');
|
const [status, setStatus] = useState<GenerationStatus>('idle');
|
||||||
const [lyrics, setLyrics] = useState('');
|
const [lyrics, setLyrics] = useState('');
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
|
@ -117,41 +58,40 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const [songTaskId, setSongTaskId] = useState<string | null>(null);
|
const [songTaskId, setSongTaskId] = useState<string | null>(null);
|
||||||
|
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const languageDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 완료 데이터를 localStorage에 저장
|
// 완료 데이터를 localStorage에 저장
|
||||||
const saveSongCompletionData = () => {
|
const saveSongCompletionData = () => {
|
||||||
const completionData = {
|
const completionData = {
|
||||||
businessName: businessInfo?.customer_name || '알 수 없음',
|
businessName: businessInfo?.customer_name || '알 수 없음',
|
||||||
// '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장
|
genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre,
|
||||||
genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre,
|
|
||||||
lyrics: lyrics,
|
lyrics: lyrics,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
|
localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 새로고침 후 생성 완료된 곡 상태 복원
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem(SONG_GENERATION_KEY);
|
|
||||||
if (!saved) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return;
|
|
||||||
|
|
||||||
// 저장된 URL로 복원 (public Blob URL이라 만료되지 않음)
|
|
||||||
// 백엔드에 /song/download 엔드포인트가 없으므로 재조회하지 않는다.
|
|
||||||
setSongTaskId(parsed.songTaskId);
|
|
||||||
setAudioUrl(parsed.audioUrl);
|
|
||||||
setLyrics(parsed.lyrics ?? '');
|
|
||||||
setStatus('complete');
|
|
||||||
} catch {
|
|
||||||
// 파싱 실패 시 무시
|
|
||||||
}
|
|
||||||
}, []); // 마운트 1회만 실행
|
|
||||||
|
|
||||||
// Close language dropdown when clicking outside
|
// Close language dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (languageDropdownRef.current && !languageDropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsLanguageDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLanguageDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isLanguageDropdownOpen]);
|
||||||
|
|
||||||
// Auto-navigate to next page when video generation is complete
|
// Auto-navigate to next page when video generation is complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoGenerationStatus === 'complete' && songTaskId) {
|
if (videoGenerationStatus === 'complete' && songTaskId) {
|
||||||
|
|
@ -190,18 +130,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
|
|
||||||
// 새로고침 후에도 복원할 수 있도록 완료된 곡 상태를 저장
|
|
||||||
try {
|
|
||||||
localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({
|
|
||||||
audioUrl: statusResponse.song_result_url,
|
|
||||||
songTaskId: taskId,
|
|
||||||
lyrics: currentLyrics,
|
|
||||||
status: 'complete',
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
// localStorage 쓰기 실패는 무시
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Polling failed:', error);
|
console.error('Polling failed:', error);
|
||||||
|
|
||||||
|
|
@ -218,10 +146,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(sanitizeError(
|
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
|
||||||
error instanceof Error ? error.message : '',
|
|
||||||
t('soundStudio.musicGenerationError')
|
|
||||||
));
|
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -232,14 +157,21 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
|
const genreMap: Record<string, string> = {
|
||||||
const isInstrumental = selectedType === '배경음악';
|
'자동 선택': 'pop',
|
||||||
const effectiveGenre = getEffectiveGenre(selectedGenre);
|
'K-POP': 'kpop',
|
||||||
setUsedGenre(effectiveGenre);
|
'발라드': 'ballad',
|
||||||
|
'Hip-Hop': 'hip-hop',
|
||||||
|
'R&B': 'rnb',
|
||||||
|
'EDM': 'edm',
|
||||||
|
'JAZZ': 'jazz',
|
||||||
|
'ROCK': 'rock',
|
||||||
|
};
|
||||||
|
|
||||||
const songResponse = await generateSong(imageTaskId, {
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: effectiveGenre,
|
genre: genreMap[selectedGenre] || 'pop',
|
||||||
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
|
language,
|
||||||
|
lyrics: currentLyrics,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!songResponse.success) {
|
if (!songResponse.success) {
|
||||||
|
|
@ -251,14 +183,46 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Song regeneration failed:', error);
|
console.error('Song regeneration failed:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(sanitizeError(
|
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.songRegenerationError'));
|
||||||
error instanceof Error ? error.message : '',
|
|
||||||
t('soundStudio.songRegenerationError')
|
|
||||||
));
|
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMove = (clientX: number) => {
|
||||||
|
if (!progressBarRef.current || !audioRef.current) return;
|
||||||
|
const rect = progressBarRef.current.getBoundingClientRect();
|
||||||
|
const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
||||||
|
const newTime = (newProgress / 100) * duration;
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
setProgress(newProgress);
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!audioUrl) return;
|
||||||
|
setIsDragging(true);
|
||||||
|
handleMove(e.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isDragging) handleMove(e.clientX);
|
||||||
|
};
|
||||||
|
const onMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
|
@ -318,9 +282,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거
|
|
||||||
localStorage.removeItem(SONG_GENERATION_KEY);
|
|
||||||
|
|
||||||
setStatus('generating_lyric');
|
setStatus('generating_lyric');
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||||
|
|
@ -330,10 +291,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
const isInstrumental = selectedType === '배경음악';
|
|
||||||
// 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다.
|
|
||||||
// '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다.
|
|
||||||
const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre];
|
|
||||||
|
|
||||||
// 1. 가사 생성 요청
|
// 1. 가사 생성 요청
|
||||||
console.log('[SoundStudio] Sending m_id:', mId);
|
console.log('[SoundStudio] Sending m_id:', mId);
|
||||||
|
|
@ -344,22 +301,13 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
m_id: mId,
|
m_id: mId,
|
||||||
region: businessInfo.region,
|
region: businessInfo.region,
|
||||||
task_id: imageTaskId,
|
task_id: imageTaskId,
|
||||||
industry,
|
|
||||||
orientation,
|
orientation,
|
||||||
...(isInstrumental && { instrumental: true }),
|
|
||||||
...(manualGenre && { genre: manualGenre }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!lyricResponse.success || !lyricResponse.task_id) {
|
if (!lyricResponse.success || !lyricResponse.task_id) {
|
||||||
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
let songLyrics: string | undefined;
|
|
||||||
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다.
|
|
||||||
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다.
|
|
||||||
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre);
|
|
||||||
|
|
||||||
if (!isInstrumental) {
|
|
||||||
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
||||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||||
const lyricDetailResponse = await waitForLyricComplete(
|
const lyricDetailResponse = await waitForLyricComplete(
|
||||||
|
|
@ -381,18 +329,25 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
setLyrics(lyricDetailResponse.lyric_result);
|
setLyrics(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 genreMap: Record<string, string> = {
|
||||||
|
'자동 선택': 'pop',
|
||||||
|
'K-POP': 'kpop',
|
||||||
|
'발라드': 'ballad',
|
||||||
|
'Hip-Hop': 'hip-hop',
|
||||||
|
'R&B': 'rnb',
|
||||||
|
'EDM': 'edm',
|
||||||
|
'JAZZ': 'jazz',
|
||||||
|
'ROCK': 'rock',
|
||||||
|
};
|
||||||
|
|
||||||
const songResponse = await generateSong(imageTaskId, {
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: effectiveGenre,
|
genre: genreMap[selectedGenre] || 'pop',
|
||||||
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
|
language,
|
||||||
|
lyrics: lyricDetailResponse.lyric_result,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!songResponse.success) {
|
if (!songResponse.success) {
|
||||||
|
|
@ -411,15 +366,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
setStatus('polling');
|
setStatus('polling');
|
||||||
setStatusMessage(t('soundStudio.generatingSong'));
|
setStatusMessage(t('soundStudio.generatingSong'));
|
||||||
|
|
||||||
await resumePolling(songResponse.task_id, songResponse.song_id, songLyrics ?? '', 0);
|
await resumePolling(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 0);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Music generation failed:', error);
|
console.error('Music generation failed:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(sanitizeError(
|
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
|
||||||
error instanceof Error ? error.message : '',
|
|
||||||
t('soundStudio.musicGenerationError')
|
|
||||||
));
|
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -437,41 +389,10 @@ 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 변경 시 부모에 알림
|
|
||||||
useEffect(() => {
|
|
||||||
onStatusChange?.(status);
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<main className="sound-studio-page">
|
<main className="sound-studio-page">
|
||||||
{audioUrl && (
|
{audioUrl && (
|
||||||
<audio
|
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||||
ref={audioRef}
|
|
||||||
src={audioUrl}
|
|
||||||
preload="metadata"
|
|
||||||
onLoadedMetadata={() => {
|
|
||||||
if (audioRef.current) setDuration(audioRef.current.duration);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -499,15 +420,14 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
<label className="input-label">{t('soundStudio.soundTypeLabel')}</label>
|
<label className="input-label">{t('soundStudio.soundTypeLabel')}</label>
|
||||||
<div className="sound-type-grid">
|
<div className="sound-type-grid">
|
||||||
{[
|
{[
|
||||||
{ key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false },
|
{ key: '보컬', label: t('soundStudio.soundTypeVocal') },
|
||||||
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: false },
|
{ key: '배경음악', label: t('soundStudio.soundTypeBGM') },
|
||||||
].map(({ key, label, disabled }) => (
|
].map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => !isGenerating && !disabled && setSelectedType(key)}
|
onClick={() => !isGenerating && setSelectedType(key)}
|
||||||
disabled={isGenerating || disabled}
|
disabled={isGenerating}
|
||||||
className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
|
className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
|
||||||
title={disabled ? t('soundStudio.comingSoon', { defaultValue: '이후 오픈 예정입니다' }) : undefined}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -548,7 +468,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="genre-row">
|
<div className="genre-row">
|
||||||
{['JAZZ', 'ROCK', 'POP'].map(genre => (
|
{['JAZZ', 'ROCK'].map(genre => (
|
||||||
<button
|
<button
|
||||||
key={genre}
|
key={genre}
|
||||||
onClick={() => setSelectedGenre(genre)}
|
onClick={() => setSelectedGenre(genre)}
|
||||||
|
|
@ -558,6 +478,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
{genre}
|
{genre}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<div className="genre-btn-placeholder"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -565,31 +486,39 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
{/* Language Selection */}
|
{/* Language Selection */}
|
||||||
<div className="sound-studio-section">
|
<div className="sound-studio-section">
|
||||||
<label className="input-label">{t('soundStudio.languageLabel')}</label>
|
<label className="input-label">{t('soundStudio.languageLabel')}</label>
|
||||||
<div className="genre-grid language-grid">
|
<div className="language-selector-wrapper" ref={languageDropdownRef}>
|
||||||
<div className="genre-row">
|
<button
|
||||||
{['한국어', 'English', '中文'].map((lang) => (
|
onClick={() => setIsLanguageDropdownOpen(!isLanguageDropdownOpen)}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="language-selector"
|
||||||
|
>
|
||||||
|
<div className="language-display">
|
||||||
|
<span className="language-flag">{LANGUAGE_FLAGS[selectedLang]}</span>
|
||||||
|
<span className="language-name">{selectedLang}</span>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src="/assets/images/icon-dropdown.svg"
|
||||||
|
alt=""
|
||||||
|
className={`language-dropdown-icon ${isLanguageDropdownOpen ? 'open' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isLanguageDropdownOpen && (
|
||||||
|
<div className="language-dropdown-menu">
|
||||||
|
{Object.keys(LANGUAGE_MAP).map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
onClick={() => !isGenerating && setSelectedLang(lang)}
|
onClick={() => {
|
||||||
disabled={isGenerating}
|
setSelectedLang(lang);
|
||||||
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
setIsLanguageDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`language-dropdown-item ${selectedLang === lang ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
<span className="language-flag">{LANGUAGE_FLAGS[lang]}</span>
|
||||||
</button>
|
<span className="language-name">{lang}</span>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="genre-row">
|
|
||||||
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
|
|
||||||
<button
|
|
||||||
key={lang}
|
|
||||||
onClick={() => !isGenerating && setSelectedLang(lang)}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -602,16 +531,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
</svg>
|
</svg>
|
||||||
{statusMessage}
|
{statusMessage}
|
||||||
</div>
|
</div>
|
||||||
) : status === 'complete' ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleRegenerate}
|
|
||||||
className="btn-generate-sound"
|
|
||||||
>
|
|
||||||
{t('soundStudio.regenerateButton')}
|
|
||||||
</button>
|
|
||||||
<p className="regenerate-hint">{t('soundStudio.regenerateHint')}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateMusic}
|
onClick={handleGenerateMusic}
|
||||||
|
|
@ -622,30 +541,18 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage && (() => {
|
{errorMessage && (
|
||||||
const isCreditsError = errorMessage.includes('credit');
|
|
||||||
return (
|
|
||||||
<div className="error-message-new">
|
<div className="error-message-new">
|
||||||
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage}
|
{errorMessage}
|
||||||
{isCreditsError && (
|
|
||||||
<a
|
|
||||||
href=""
|
|
||||||
className="charge-credits-link"
|
|
||||||
onClick={e => { e.preventDefault(); onGoToPayment?.(); }}
|
|
||||||
>
|
|
||||||
{t('soundStudio.chargeCredits')}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Lyrics */}
|
{/* Right Column - Lyrics */}
|
||||||
<div className="lyrics-column">
|
<div className="lyrics-column">
|
||||||
<div className="lyrics-header">
|
<div className="lyrics-header">
|
||||||
<h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3>
|
<h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3>
|
||||||
{/* <p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p> */}
|
<p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audio Player */}
|
{/* Audio Player */}
|
||||||
|
|
@ -667,8 +574,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 진행 표시 전용 — Azure Blob 익명 응답에 Accept-Ranges가 없어 seek가 불안정하므로 드래그 미지원 */}
|
|
||||||
<div
|
<div
|
||||||
|
ref={progressBarRef}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
|
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -685,17 +593,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
{/* Lyrics Display */}
|
{/* Lyrics Display */}
|
||||||
<div className="lyrics-display">
|
<div className="lyrics-display">
|
||||||
{lyrics ? (
|
{lyrics ? (
|
||||||
<div className="lyrics-paragraphs">
|
<textarea
|
||||||
{lyricsSections.map((section, i) => (
|
value={lyrics}
|
||||||
<div key={i} className="lyrics-section">
|
onChange={(e) => setLyrics(e.target.value)}
|
||||||
{section.tag && <span className="lyrics-tag">{section.tag}</span>}
|
className="lyrics-textarea"
|
||||||
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
|
placeholder={t('soundStudio.lyricsPlaceholder')}
|
||||||
</div>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="lyrics-placeholder">
|
<div className="lyrics-placeholder">
|
||||||
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
|
{t('soundStudio.lyricsPlaceholder')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -737,8 +643,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,299 @@
|
||||||
import React from 'react';
|
|
||||||
import { AutocompleteRequest } from '../../utils/api';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
|
||||||
|
import { CrawlingResponse } from '../../types/api';
|
||||||
|
|
||||||
|
type SearchType = 'url' | 'name';
|
||||||
|
|
||||||
|
// 환경변수에서 테스트 모드 확인
|
||||||
|
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
||||||
|
|
||||||
interface UrlInputContentProps {
|
interface UrlInputContentProps {
|
||||||
onAnalyze: (value: string, type?: SearchType) => void;
|
onAnalyze: (value: string, type?: SearchType) => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onManualInput?: (businessName: string, address: string, category: string) => void;
|
onTestData?: (data: CrawlingResponse) => void;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => {
|
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [searchType, setSearchType] = useState<SearchType>('url');
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
|
||||||
|
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
||||||
|
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
||||||
|
|
||||||
|
// 테스트 데이터 로드 핸들러
|
||||||
|
const handleTestData = async () => {
|
||||||
|
if (!onTestData) return;
|
||||||
|
setIsLoadingTest(true);
|
||||||
|
try {
|
||||||
|
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
||||||
|
const response = await fetch(jsonFile);
|
||||||
|
const data: CrawlingResponse = await response.json();
|
||||||
|
onTestData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('테스트 데이터 로드 실패:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTest(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const searchTypeOptions = [
|
||||||
|
{ value: 'url' as SearchType, label: 'URL' },
|
||||||
|
{ value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
return searchType === 'url'
|
||||||
|
? 'https://www.castad.com'
|
||||||
|
: t('urlInput.placeholderBusinessName');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGuideText = () => {
|
||||||
|
return searchType === 'url'
|
||||||
|
? t('urlInput.guideUrl')
|
||||||
|
: t('urlInput.guideBusinessName');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 업체명 검색 시 자동완성 (디바운스 적용)
|
||||||
|
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim() || searchType !== 'name') {
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAutocompleteLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await searchAccommodation(query);
|
||||||
|
setAutocompleteResults(response.items || []);
|
||||||
|
setShowAutocomplete(response.items && response.items.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('자동완성 검색 오류:', error);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
} finally {
|
||||||
|
setIsAutocompleteLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchType]);
|
||||||
|
|
||||||
|
// 자동완성 항목 선택 - 업체 정보 저장
|
||||||
|
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
||||||
|
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
||||||
|
setSelectedItem(item); // 선택된 업체 정보 저장
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 키보드 네비게이션 핸들러
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!showAutocomplete || autocompleteResults.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev =>
|
||||||
|
prev < autocompleteResults.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev =>
|
||||||
|
prev > 0 ? prev - 1 : autocompleteResults.length - 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 붙여넣기 시 URL만 추출
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
if (searchType !== 'url') return;
|
||||||
|
const pasted = e.clipboardData.getData('text');
|
||||||
|
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
|
||||||
|
if (urlMatch) {
|
||||||
|
e.preventDefault();
|
||||||
|
setInputValue(urlMatch[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출 처리
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!inputValue.trim()) return;
|
||||||
|
|
||||||
|
if (searchType === 'name' && selectedItem && onAutocomplete) {
|
||||||
|
// 업체명 검색인 경우 autocomplete API 호출
|
||||||
|
const request: AutocompleteRequest = {
|
||||||
|
address: selectedItem.address,
|
||||||
|
roadAddress: selectedItem.roadAddress,
|
||||||
|
title: selectedItem.title,
|
||||||
|
};
|
||||||
|
onAutocomplete(request);
|
||||||
|
} else {
|
||||||
|
// URL 검색인 경우 기존 로직
|
||||||
|
onAnalyze(inputValue.trim(), searchType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="url-input-container">
|
<div className="url-input-container">
|
||||||
<div className="url-input-content">
|
<div className="url-input-content">
|
||||||
|
{/* 로고 */}
|
||||||
<div className="url-input-logo">
|
<div className="url-input-logo">
|
||||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
<img src="/assets/images/ado2-logo.svg" alt="ADO2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SearchInputForm
|
{/* URL 입력 폼 */}
|
||||||
onAnalyze={onAnalyze}
|
<form onSubmit={handleSubmit} className="url-input-form">
|
||||||
onAutocomplete={onAutocomplete}
|
<div className="url-input-wrapper">
|
||||||
onManualInput={onManualInput}
|
{/* 드롭다운 */}
|
||||||
error={error}
|
<div className="url-input-dropdown-container">
|
||||||
/>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="url-input-dropdown-trigger"
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
>
|
||||||
|
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className={`url-input-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="url-input-dropdown-menu">
|
||||||
|
{searchTypeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchType(option.value);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 필드 */}
|
||||||
|
<div className="url-input-field-container" ref={autocompleteRef}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
let value = e.target.value;
|
||||||
|
|
||||||
|
// URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...")
|
||||||
|
if (searchType === 'url') {
|
||||||
|
const urlMatch = value.match(/https?:\/\/\S+/);
|
||||||
|
if (urlMatch && urlMatch[0] !== value) {
|
||||||
|
value = urlMatch[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputValue(value);
|
||||||
|
|
||||||
|
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
||||||
|
if (searchType === 'name') {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
handleAutocompleteSearch(value);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onFocus={() => {
|
||||||
|
if (searchType === 'name' && autocompleteResults.length > 0) {
|
||||||
|
setShowAutocomplete(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={getPlaceholder()}
|
||||||
|
className="url-input-field"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 자동완성 결과 */}
|
||||||
|
{showAutocomplete && searchType === 'name' && (
|
||||||
|
<div className="url-input-autocomplete-dropdown">
|
||||||
|
{isAutocompleteLoading ? (
|
||||||
|
<div className="url-input-autocomplete-loading">{t('urlInput.searching')}</div>
|
||||||
|
) : (
|
||||||
|
autocompleteResults.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
className="url-input-autocomplete-item"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectAutocomplete(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="url-input-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
||||||
|
<div className="url-input-autocomplete-address">{item.roadAddress || item.address}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 버튼 */}
|
||||||
|
<button type="submit" className="url-input-button">
|
||||||
|
{t('landing.hero.analyzeButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<p className="url-input-error">{error}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 안내 텍스트 */}
|
||||||
|
<p className="url-input-guide">
|
||||||
|
{getGuideText()}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// YouTube Shorts 영상 ID들
|
// YouTube Shorts 영상 ID들
|
||||||
const videos = [
|
const videos = [
|
||||||
{ id: 1, videoId: 'M3iuPZ59X1I' },
|
{ id: 1, videoId: 'OZJ8X4P82OA' },
|
||||||
{ id: 2, videoId: 'JxWQxELDHSs' },
|
{ id: 2, videoId: 'hNzMO21O40c' },
|
||||||
{ id: 3, videoId: 'c2ZdwhaB7S4' },
|
{ id: 3, videoId: 'dM8_d6Aud68' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,31 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { CrawlingResponse } from '../../types/api';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
|
||||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
type SearchType = 'url' | 'name';
|
||||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
// 환경변수에서 테스트 모드 확인
|
||||||
import SearchInputForm from '../../components/SearchInputForm';
|
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
||||||
import { SearchType } from '../../components/SearchInputForm';
|
|
||||||
|
interface HeroSectionProps {
|
||||||
|
onAnalyze?: (value: string, type?: SearchType) => void;
|
||||||
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
|
onTestData?: (data: CrawlingResponse) => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
error?: string | null;
|
||||||
|
scrollProgress?: number; // 0 ~ 1 (스크롤 진행률)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidUrl = (string: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(string);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Orb configuration with movement zones to prevent overlap
|
// Orb configuration with movement zones to prevent overlap
|
||||||
interface OrbConfig {
|
interface OrbConfig {
|
||||||
|
|
@ -17,54 +34,159 @@ interface OrbConfig {
|
||||||
initialX: number;
|
initialX: number;
|
||||||
initialY: number;
|
initialY: number;
|
||||||
color: string;
|
color: string;
|
||||||
|
// Movement bounds for each orb
|
||||||
minX: number;
|
minX: number;
|
||||||
maxX: number;
|
maxX: number;
|
||||||
minY: number;
|
minY: number;
|
||||||
maxY: number;
|
maxY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6 orbs distributed in a 3x2 grid pattern with overlapping zones for smooth movement
|
||||||
const orbConfigs: OrbConfig[] = [
|
const orbConfigs: OrbConfig[] = [
|
||||||
|
// Top-left zone
|
||||||
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
|
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
|
||||||
|
// Top-right zone
|
||||||
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
|
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
|
||||||
|
// Middle-left zone
|
||||||
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
|
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
|
||||||
|
// Middle-right zone
|
||||||
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
|
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
|
||||||
|
// Bottom-left zone
|
||||||
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
|
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
|
||||||
|
// Bottom-right zone
|
||||||
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
|
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface HeroSectionProps {
|
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
const { t, i18n } = useTranslation();
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
const [inputValue, setInputValue] = useState('');
|
||||||
onManualInput?: (businessName: string, address: string, category: string) => void;
|
const [searchType, setSearchType] = useState<SearchType>('url');
|
||||||
onNext?: () => void;
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
error?: string | null;
|
const [localError, setLocalError] = useState('');
|
||||||
scrollProgress?: number;
|
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
||||||
}
|
|
||||||
|
|
||||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
|
// 테스트 데이터 로드 핸들러
|
||||||
const { t } = useTranslation();
|
const handleTestData = async () => {
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
if (!onTestData) return;
|
||||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
setIsLoadingTest(true);
|
||||||
|
try {
|
||||||
|
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
||||||
|
const response = await fetch(jsonFile);
|
||||||
|
const data: CrawlingResponse = await response.json();
|
||||||
|
onTestData(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('테스트 데이터 로드 실패:', error);
|
||||||
|
setLocalError(t('landing.hero.testDataLoadFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTest(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
|
||||||
|
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const animationRefs = useRef<number[]>([]);
|
const animationRefs = useRef<number[]>([]);
|
||||||
const tutorial = useTutorial();
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 첫 방문 시 랜딩 튜토리얼 시작
|
const searchTypeOptions = [
|
||||||
|
{ value: 'url' as SearchType, label: 'URL' },
|
||||||
|
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 드롭다운 외부 클릭 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const timer = setTimeout(() => {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
|
setIsDropdownOpen(false);
|
||||||
}, 800);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
|
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Orb 랜덤 이동 애니메이션
|
// 업체명 검색 시 자동완성 (디바운스 적용)
|
||||||
|
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim() || searchType !== 'name') {
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAutocompleteLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await searchAccommodation(query);
|
||||||
|
setAutocompleteResults(response.items || []);
|
||||||
|
setShowAutocomplete(response.items && response.items.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('자동완성 검색 오류:', error);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
} finally {
|
||||||
|
setIsAutocompleteLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchType]);
|
||||||
|
|
||||||
|
// 자동완성 항목 선택 - 업체 정보 저장
|
||||||
|
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
|
||||||
|
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
||||||
|
setSelectedItem(item); // 선택된 업체 정보 저장
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 키보드 네비게이션 핸들러
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
// 자동완성이 표시될 때만 키보드 네비게이션 활성화
|
||||||
|
if (showAutocomplete && autocompleteResults.length > 0) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev =>
|
||||||
|
prev < autocompleteResults.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev =>
|
||||||
|
prev > 0 ? prev - 1 : autocompleteResults.length - 1
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault(); // 항상 Enter 기본 동작 방지
|
||||||
|
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
|
||||||
|
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업체명 검색 모드에서 Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
|
||||||
|
if (e.key === 'Enter' && searchType === 'name') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Random movement for orbs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
||||||
const config = orbConfigs[index];
|
const config = orbConfigs[index];
|
||||||
let currentX = config.initialX;
|
let currentX = config.initialX;
|
||||||
let currentY = config.initialY;
|
let currentY = config.initialY;
|
||||||
|
// 초기 타겟은 현재 위치와 동일하게 설정 (순간이동 방지)
|
||||||
let targetX = currentX;
|
let targetX = currentX;
|
||||||
let targetY = currentY;
|
let targetY = currentY;
|
||||||
let scale = 1;
|
let scale = 1;
|
||||||
|
|
@ -72,13 +194,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
let isFirstMove = true;
|
let isFirstMove = true;
|
||||||
|
|
||||||
const generateNewTarget = () => {
|
const generateNewTarget = () => {
|
||||||
|
// Move within the orb's designated zone
|
||||||
const rangeX = config.maxX - config.minX;
|
const rangeX = config.maxX - config.minX;
|
||||||
const rangeY = config.maxY - config.minY;
|
const rangeY = config.maxY - config.minY;
|
||||||
|
|
||||||
if (isFirstMove) {
|
if (isFirstMove) {
|
||||||
|
// 첫 이동은 현재 위치에서 가까운 곳으로 (자연스러운 시작)
|
||||||
const smallRangeX = rangeX * 0.3;
|
const smallRangeX = rangeX * 0.3;
|
||||||
const smallRangeY = rangeY * 0.3;
|
const smallRangeY = rangeY * 0.3;
|
||||||
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
|
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
|
||||||
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
|
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
|
||||||
|
// 범위 내로 클램핑
|
||||||
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
|
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
|
||||||
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
|
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
|
||||||
isFirstMove = false;
|
isFirstMove = false;
|
||||||
|
|
@ -86,38 +212,94 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
targetX = config.minX + Math.random() * rangeX;
|
targetX = config.minX + Math.random() * rangeX;
|
||||||
targetY = config.minY + Math.random() * rangeY;
|
targetY = config.minY + Math.random() * rangeY;
|
||||||
}
|
}
|
||||||
targetScale = 0.9 + Math.random() * 0.2;
|
targetScale = 0.9 + Math.random() * 0.2; // 0.9 to 1.1 (더 작은 범위)
|
||||||
};
|
};
|
||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
|
// Slow speed - 일정한 속도로 부드럽게
|
||||||
const speed = 0.003;
|
const speed = 0.003;
|
||||||
currentX += (targetX - currentX) * speed;
|
currentX += (targetX - currentX) * speed;
|
||||||
currentY += (targetY - currentY) * speed;
|
currentY += (targetY - currentY) * speed;
|
||||||
scale += (targetScale - scale) * speed;
|
scale += (targetScale - scale) * speed;
|
||||||
|
|
||||||
|
// Apply transform
|
||||||
orb.style.left = `${currentX}%`;
|
orb.style.left = `${currentX}%`;
|
||||||
orb.style.top = `${currentY}%`;
|
orb.style.top = `${currentY}%`;
|
||||||
orb.style.transform = `scale(${scale})`;
|
orb.style.transform = `scale(${scale})`;
|
||||||
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
|
|
||||||
if (distance < 1) generateNewTarget();
|
// Generate new target when close enough
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)
|
||||||
|
);
|
||||||
|
if (distance < 1) {
|
||||||
|
generateNewTarget();
|
||||||
|
}
|
||||||
|
|
||||||
animationRefs.current[index] = requestAnimationFrame(animate);
|
animationRefs.current[index] = requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 첫 타겟 생성 후 애니메이션 시작
|
||||||
generateNewTarget();
|
generateNewTarget();
|
||||||
animate();
|
animate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 모든 공이 동시에 자연스럽게 시작 (딜레이 없이)
|
||||||
orbRefs.current.forEach((orb, index) => {
|
orbRefs.current.forEach((orb, index) => {
|
||||||
if (orb) moveOrb(orb, index);
|
if (orb) {
|
||||||
|
moveOrb(orb, index);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
return () => {
|
return () => {
|
||||||
animationRefs.current.forEach(id => cancelAnimationFrame(id));
|
animationRefs.current.forEach(id => cancelAnimationFrame(id));
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const error = externalError || localError;
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
return searchType === 'url'
|
||||||
|
? 'https://www.castad.com'
|
||||||
|
: t('landing.hero.placeholderBusinessName');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGuideText = () => {
|
||||||
|
return searchType === 'url'
|
||||||
|
? t('landing.hero.guideUrl')
|
||||||
|
: t('landing.hero.guideBusinessName');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
if (!inputValue.trim()) {
|
||||||
|
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
|
||||||
|
setLocalError(t('landing.hero.errorInvalidUrl'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError('');
|
||||||
|
|
||||||
|
if (searchType === 'name' && selectedItem && onAutocomplete) {
|
||||||
|
// 업체명 검색인 경우 autocomplete API 호출
|
||||||
|
const request: AutocompleteRequest = {
|
||||||
|
address: selectedItem.address,
|
||||||
|
roadAddress: selectedItem.roadAddress,
|
||||||
|
title: selectedItem.title,
|
||||||
|
};
|
||||||
|
onAutocomplete(request);
|
||||||
|
} else if (onAnalyze) {
|
||||||
|
// URL 검색인 경우 기존 로직
|
||||||
|
onAnalyze(inputValue, searchType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hero-section">
|
<div className="hero-section">
|
||||||
{/* Animated background orbs */}
|
{/* Animated background orbs - 스크롤에 따라 빠르게 사라짐 */}
|
||||||
<div
|
<div
|
||||||
className="hero-bg-orbs"
|
className="hero-bg-orbs"
|
||||||
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
|
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
|
||||||
|
|
@ -139,61 +321,186 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hero-content">
|
<div className="hero-content">
|
||||||
|
{/* Logo Image */}
|
||||||
<img
|
<img
|
||||||
src="/assets/images/ADO2_with Slogan_white.svg"
|
src="/assets/images/ado2-logo.svg"
|
||||||
alt="ADO2"
|
alt="ADO2"
|
||||||
className="hero-logo"
|
className="hero-logo"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchInputForm
|
|
||||||
onAnalyze={onAnalyze}
|
{/* Input Form */}
|
||||||
onAutocomplete={onAutocomplete}
|
<div className="hero-form">
|
||||||
onManualInput={onManualInput}
|
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
||||||
onManualButtonClick={() => {
|
{/* 드롭다운 */}
|
||||||
if (isLoggedIn()) {
|
<div className="hero-dropdown-container" ref={dropdownRef}>
|
||||||
setIsManualModalOpen(true);
|
<button
|
||||||
} else {
|
type="button"
|
||||||
setIsLoginPromptOpen(true);
|
className="hero-dropdown-trigger"
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
>
|
||||||
|
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="hero-dropdown-menu">
|
||||||
|
{searchTypeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchType(option.value);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-input-container" ref={autocompleteRef}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
let value = e.target.value;
|
||||||
|
|
||||||
|
// URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...")
|
||||||
|
if (searchType === 'url') {
|
||||||
|
const urlMatch = value.match(/https?:\/\/\S+/);
|
||||||
|
if (urlMatch && urlMatch[0] !== value) {
|
||||||
|
value = urlMatch[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputValue(value);
|
||||||
|
setHighlightedIndex(-1); // 입력 시 하이라이트 초기화
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
|
||||||
|
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
||||||
|
if (searchType === 'name') {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
handleAutocompleteSearch(value);
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
error={externalError || null}
|
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
if (searchType !== 'url') return;
|
||||||
|
const pasted = e.clipboardData.getData('text');
|
||||||
|
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
|
||||||
|
if (urlMatch) {
|
||||||
|
e.preventDefault();
|
||||||
|
setInputValue(urlMatch[0]);
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
if (searchType === 'name' && autocompleteResults.length > 0) {
|
||||||
|
setShowAutocomplete(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={getPlaceholder()}
|
||||||
|
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 자동완성 결과 */}
|
||||||
|
{showAutocomplete && searchType === 'name' && (
|
||||||
|
<div className="hero-autocomplete-dropdown">
|
||||||
|
{isAutocompleteLoading ? (
|
||||||
|
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
||||||
|
) : (
|
||||||
|
autocompleteResults.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectAutocomplete(item);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
||||||
|
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{inputValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hero-input-clear"
|
||||||
|
onClick={() => {
|
||||||
|
setInputValue('');
|
||||||
|
setLocalError('');
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="hero-input-hint">{getGuideText()}</span>
|
||||||
|
{error && (
|
||||||
|
<p className="hero-error">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={handleStart} className="hero-button">
|
||||||
|
{t('landing.hero.analyzeButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Indicator */}
|
{/* Footer Indicator */}
|
||||||
<button onClick={onNext} className="scroll-indicator">
|
<button onClick={onNext} className="scroll-indicator">
|
||||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
||||||
<div className="scroll-indicator-icon">
|
<div className="scroll-indicator-icon">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M7 10l5 5 5-5" />
|
<path d="M7 10l5 5 5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{tutorial.isActive && !isManualModalOpen && (
|
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
||||||
<TutorialOverlay
|
{isTestPage && onTestData && (
|
||||||
hints={tutorial.hints}
|
<button
|
||||||
currentIndex={tutorial.currentHintIndex}
|
onClick={handleTestData}
|
||||||
onNext={tutorial.nextHint}
|
disabled={isLoadingTest}
|
||||||
onPrev={tutorial.prevHint}
|
className="test-data-button"
|
||||||
onSkip={tutorial.skipTutorial}
|
>
|
||||||
groupProgress={tutorial.groupProgress}
|
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
|
||||||
/>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
{isManualModalOpen && (
|
|
||||||
<BusinessNameInputModal
|
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
|
||||||
onSubmit={(businessName, address, category) => {
|
|
||||||
if (tutorial.isActive) tutorial.nextHint();
|
|
||||||
setIsManualModalOpen(false);
|
|
||||||
onManualInput?.(businessName, address, category);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoginPromptOpen && (
|
|
||||||
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
||||||
<div className="login-content">
|
<div className="login-content">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="login-logo">
|
<div className="login-logo">
|
||||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
<img src="/assets/images/ado2-login-logo.svg" alt="ADO2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const SocialConnectError: React.FC = () => {
|
const SocialConnectError: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
// URL 파라미터 파싱
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const platform = searchParams.get('platform') || 'unknown';
|
const platform = searchParams.get('platform') || 'unknown';
|
||||||
const errorMessage = searchParams.get('error') || t('social.connectError.defaultError');
|
const errorMessage = searchParams.get('error') || '알 수 없는 오류가 발생했습니다.';
|
||||||
|
|
||||||
const platformName = platform === 'youtube' ? 'YouTube' :
|
const platformName = platform === 'youtube' ? 'YouTube' :
|
||||||
platform === 'instagram' ? 'Instagram' :
|
platform === 'instagram' ? 'Instagram' :
|
||||||
|
|
@ -27,19 +25,19 @@ const SocialConnectError: React.FC = () => {
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">{t('social.connectError.title', { platform: platformName })}</h1>
|
<h1 className="social-connect-title">{platformName} 연결 실패</h1>
|
||||||
<p className="social-connect-message">
|
<p className="social-connect-message">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
<p className="social-connect-detail">
|
<p className="social-connect-detail">
|
||||||
{t('social.connectError.detail')}
|
다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.
|
||||||
</p>
|
</p>
|
||||||
<div className="social-connect-actions">
|
<div className="social-connect-actions">
|
||||||
<button
|
<button
|
||||||
onClick={navigateToHome}
|
onClick={navigateToHome}
|
||||||
className="social-connect-button"
|
className="social-connect-button"
|
||||||
>
|
>
|
||||||
{t('social.connectError.retry')}
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
|
// 연결된 소셜 계정 정보를 저장하는 localStorage 키
|
||||||
const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
|
const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
|
||||||
|
|
||||||
interface ConnectedSocialAccount {
|
interface ConnectedSocialAccount {
|
||||||
|
|
@ -13,9 +13,9 @@ interface ConnectedSocialAccount {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SocialConnectSuccess: React.FC = () => {
|
const SocialConnectSuccess: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [countdown, setCountdown] = useState(3);
|
const [countdown, setCountdown] = useState(3);
|
||||||
|
|
||||||
|
// URL 파라미터 파싱
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const platform = searchParams.get('platform') || 'unknown';
|
const platform = searchParams.get('platform') || 'unknown';
|
||||||
const accountId = searchParams.get('account_id') || '';
|
const accountId = searchParams.get('account_id') || '';
|
||||||
|
|
@ -26,6 +26,7 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
platform === 'instagram' ? 'Instagram' :
|
platform === 'instagram' ? 'Instagram' :
|
||||||
platform === 'facebook' ? 'Facebook' : platform;
|
platform === 'facebook' ? 'Facebook' : platform;
|
||||||
|
|
||||||
|
// 연결된 계정 정보를 localStorage에 저장
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platform && accountId) {
|
if (platform && accountId) {
|
||||||
const connectedAccount: ConnectedSocialAccount = {
|
const connectedAccount: ConnectedSocialAccount = {
|
||||||
|
|
@ -41,6 +42,7 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
}, [platform, accountId, channelName, profileImage]);
|
}, [platform, accountId, channelName, profileImage]);
|
||||||
|
|
||||||
const navigateToHome = () => {
|
const navigateToHome = () => {
|
||||||
|
// URL을 루트로 변경하고 페이지 새로고침하여 generation_flow로 이동
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -62,6 +64,7 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="social-connect-page">
|
<div className="social-connect-page">
|
||||||
<div className="social-connect-card success">
|
<div className="social-connect-card success">
|
||||||
|
{/* 프로필 이미지가 있으면 표시 */}
|
||||||
{profileImage ? (
|
{profileImage ? (
|
||||||
<img
|
<img
|
||||||
src={profileImage}
|
src={profileImage}
|
||||||
|
|
@ -78,13 +81,15 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="social-connect-title">
|
<h1 className="social-connect-title">
|
||||||
{t('social.connectSuccess.title', { name: channelName || platformName })}
|
{channelName || platformName} 연결 완료
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="social-connect-message">
|
<p className="social-connect-message">
|
||||||
{channelName
|
{channelName ? (
|
||||||
? t('social.connectSuccess.messageWithChannel', { platform: platformName })
|
<>{platformName} 계정이 성공적으로 연결되었습니다.</>
|
||||||
: t('social.connectSuccess.messageWithoutChannel')}
|
) : (
|
||||||
|
<>계정이 성공적으로 연결되었습니다.</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{channelName && (
|
{channelName && (
|
||||||
|
|
@ -93,20 +98,20 @@ const SocialConnectSuccess: React.FC = () => {
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
{t('social.connectSuccess.verified')}
|
인증됨
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="social-connect-countdown">
|
<p className="social-connect-countdown">
|
||||||
{t('social.connectSuccess.countdown', { count: countdown })}
|
{countdown}초 후 자동으로 이동합니다...
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={navigateToHome}
|
onClick={navigateToHome}
|
||||||
className="social-connect-button"
|
className="social-connect-button"
|
||||||
>
|
>
|
||||||
{t('social.connectSuccess.goNow')}
|
지금 이동
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
|
|
||||||
const YouTubeOAuthCallback: React.FC = () => {
|
const YouTubeOAuthCallback: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [status, setStatus] = useState<'processing' | 'error'>('processing');
|
const [status, setStatus] = useState<'processing' | 'error'>('processing');
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
|
||||||
|
|
@ -20,7 +18,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('[YouTube OAuth] Error from Google:', error);
|
console.error('[YouTube OAuth] Error from Google:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(t('social.youtubeCallback.errorAuthCancelled'));
|
setErrorMessage('Google 인증이 취소되었거나 실패했습니다.');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
|
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
@ -30,7 +28,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
console.error('[YouTube OAuth] No code received');
|
console.error('[YouTube OAuth] No code received');
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(t('social.youtubeCallback.errorNoCode'));
|
setErrorMessage('인증 코드를 받지 못했습니다.');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=no_code';
|
window.location.href = '/social/connect/error?platform=youtube&error=no_code';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
@ -48,7 +46,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[YouTube OAuth] Failed to process callback:', err);
|
console.error('[YouTube OAuth] Failed to process callback:', err);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMessage(t('social.youtubeCallback.errorProcessing'));
|
setErrorMessage('YouTube 연결 처리 중 오류가 발생했습니다.');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
|
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
@ -68,8 +66,8 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
|
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">{t('social.youtubeCallback.connecting')}</h1>
|
<h1 className="social-connect-title">YouTube 연결 중...</h1>
|
||||||
<p className="social-connect-message">{t('social.youtubeCallback.pleaseWait')}</p>
|
<p className="social-connect-message">잠시만 기다려주세요.</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -80,7 +78,7 @@ const YouTubeOAuthCallback: React.FC = () => {
|
||||||
<line x1="9" y1="9" x2="15" y2="15" />
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="social-connect-title">{t('social.youtubeCallback.failed')}</h1>
|
<h1 className="social-connect-title">연결 실패</h1>
|
||||||
<p className="social-connect-message">{errorMessage}</p>
|
<p className="social-connect-message">{errorMessage}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,947 +0,0 @@
|
||||||
/* =====================================================
|
|
||||||
Layout Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Landing Page Container - Free Scroll */
|
|
||||||
.landing-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100dvh;
|
|
||||||
overflow-y: scroll;
|
|
||||||
overflow-x: hidden;
|
|
||||||
background-color: var(--color-bg-darker);
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-section {
|
|
||||||
background: var(--Color-teal-800, #002224);
|
|
||||||
min-height: 100dvh;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page Container */
|
|
||||||
.page-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
padding: var(--spacing-page);
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: #002224;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.page-container {
|
|
||||||
padding: var(--spacing-page-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page Container - Full Height with Background */
|
|
||||||
.page-container-full {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100dvh;
|
|
||||||
color: var(--color-text-white);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: var(--spacing-page);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.page-container-full {
|
|
||||||
padding: var(--spacing-page-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.page-container-full {
|
|
||||||
padding: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wizard Page Container - 단계별 페이지 전환용 */
|
|
||||||
.wizard-page-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: var(--color-bg-darker);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Content Area */
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
max-width: 72rem;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.main-content {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dashboard Layout */
|
|
||||||
.dashboard-layout {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100dvh;
|
|
||||||
background-color: var(--color-bg-darker);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-content {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
position: relative;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Card Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Base Card */
|
|
||||||
.card {
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-3xl);
|
|
||||||
padding: var(--spacing-page);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.card {
|
|
||||||
padding: var(--spacing-page-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Variants */
|
|
||||||
.card-flex {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-flex-2 {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-overflow-hidden {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Inner (Darker Background) */
|
|
||||||
.card-inner {
|
|
||||||
background-color: rgba(18, 26, 29, 0.5);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Header Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Page Header */
|
|
||||||
.page-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page Title */
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-3xl);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-4xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page Subtitle */
|
|
||||||
.page-subtitle {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.page-subtitle {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section Title (Mint color uppercase) */
|
|
||||||
.section-title {
|
|
||||||
color: #94FBE0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
margin-bottom: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section Title Purple */
|
|
||||||
.section-title-purple {
|
|
||||||
color: #AE72F9;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
margin-bottom: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Button Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Primary Button (Purple) */
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--color-purple);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1rem 4rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
transform: scale(1);
|
|
||||||
box-shadow: var(--shadow-purple);
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--color-purple-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
background-color: #4b5563;
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
cursor: not-allowed;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Secondary Button (Mint) */
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
transform: scale(1);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background-color: var(--color-mint-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:disabled {
|
|
||||||
background-color: rgba(166, 255, 234, 0.5);
|
|
||||||
color: rgba(18, 26, 29, 0.5);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Outline Button */
|
|
||||||
.btn-outline {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--color-border-gray-600);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1rem 4rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
border-color: var(--color-text-gray-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Back Button */
|
|
||||||
.btn-back {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--color-border-gray-600);
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
transition: background-color var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-back:hover {
|
|
||||||
background-color: rgba(31, 41, 55, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Regenerate Button (Mint Outline) */
|
|
||||||
.btn-regenerate {
|
|
||||||
margin-top: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: var(--color-mint-20);
|
|
||||||
color: var(--color-mint);
|
|
||||||
border: 1px solid var(--color-mint-30);
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
transition: background-color var(--transition-normal);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-regenerate:hover {
|
|
||||||
background-color: var(--color-mint-30);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-regenerate:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection Button */
|
|
||||||
.btn-select {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
background-color: rgba(18, 26, 29, 0.4);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-select:hover {
|
|
||||||
border-color: var(--color-border-gray-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-select.active {
|
|
||||||
border-color: var(--color-mint);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-select:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Form Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Select Input */
|
|
||||||
.select-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text Input */
|
|
||||||
.text-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Textarea - Lyrics */
|
|
||||||
.textarea-lyrics {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
background-color: transparent;
|
|
||||||
resize: none;
|
|
||||||
border: none;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
line-height: 1.625;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-lyrics:focus {
|
|
||||||
outline: none;
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Navigation Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Back Button Container */
|
|
||||||
.back-button-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1040px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bottom Button Container */
|
|
||||||
.bottom-button-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 32px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 30;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-button-container > * {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.bottom-button-container {
|
|
||||||
left: 240px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Sidebar Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Sidebar Container */
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
border-right: 1px solid var(--color-border-white-5);
|
|
||||||
transition: all var(--transition-slow);
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.expanded {
|
|
||||||
width: 15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.collapsed {
|
|
||||||
width: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.mobile-open {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.mobile-closed {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.sidebar.mobile-closed {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Header */
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header.collapsed {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Logo */
|
|
||||||
.sidebar-logo {
|
|
||||||
font-family: 'Playfair Display', serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
color: var(--color-text-white);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logo:hover {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Menu */
|
|
||||||
.sidebar-menu {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Menu Item */
|
|
||||||
.sidebar-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.active {
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item:not(.active) {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item:not(.active):not(.disabled):hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.active .sidebar-item-icon {
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item-label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Footer */
|
|
||||||
.sidebar-footer {
|
|
||||||
padding: 1rem;
|
|
||||||
margin-top: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Credit Card */
|
|
||||||
.credit-card {
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-label {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-value {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 0.375rem;
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-upgrade-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem;
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
transition: background-color var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-upgrade-btn:hover {
|
|
||||||
background-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Profile Section */
|
|
||||||
.profile-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-section.collapsed {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar-default {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: var(--color-bg-gray-700);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-credits {
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-top: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Language Switch */
|
|
||||||
.sidebar-language-switch {
|
|
||||||
padding: 0 1rem 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle container */
|
|
||||||
.lang-toggle {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 2px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-option {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 4px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-option.active {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-option:hover:not(.active) {
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sliding indicator */
|
|
||||||
.lang-toggle-slider {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
width: calc(50% - 2px);
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 999px;
|
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-slider.left {
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-slider.right {
|
|
||||||
transform: translateX(calc(100% + 2px));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapsed state - simple button */
|
|
||||||
.lang-toggle-collapsed {
|
|
||||||
padding: 4px 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-toggle-collapsed:hover {
|
|
||||||
color: #ffffff;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Footer Actions */
|
|
||||||
.sidebar-footer-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-footer-actions .logout-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-inquiry-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-inquiry-btn:hover {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-inquiry-btn.collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logout Button */
|
|
||||||
.logout-btn {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn:hover {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn.collapsed {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn-label {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 전용 사이드바 튜토리얼 토글 */
|
|
||||||
.sidebar-tutorial-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.sidebar-tutorial-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
transition: color var(--transition-normal), background-color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-btn:hover {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-btn.active {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-label {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-badge.on {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-tutorial-badge.off {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Menu Button */
|
|
||||||
.mobile-menu-btn {
|
|
||||||
position: fixed;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
z-index: 40;
|
|
||||||
padding: 0.625rem;
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border-white-10);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn:hover {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.mobile-menu-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Overlay */
|
|
||||||
.mobile-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.mobile-overlay {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,503 +0,0 @@
|
||||||
/* =====================================================
|
|
||||||
Business Settings Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Settings Container */
|
|
||||||
.settings-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-container {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-container {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Header */
|
|
||||||
.settings-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
max-width: 42rem;
|
|
||||||
margin-left: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-header {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-title {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-title {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.settings-title {
|
|
||||||
font-size: var(--text-3xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-description {
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1.625;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-description {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-description {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Card */
|
|
||||||
.settings-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 36rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-card-inner {
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-card-inner {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-card-inner {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: var(--radius-3xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-card-title {
|
|
||||||
color: var(--color-mint);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.settings-card-title {
|
|
||||||
font-size: 10px;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.settings-card-title {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Social Items */
|
|
||||||
.social-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-items {
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item {
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
transition: border-color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item:hover {
|
|
||||||
border-color: var(--color-border-white-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-left {
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-icon {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-icon-inner {
|
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-icon-inner {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-name {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-name {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-connect {
|
|
||||||
color: var(--color-mint);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.social-item-connect {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-item-connect:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Footer Component
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Landing Footer */
|
|
||||||
.landing-footer {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--Color-teal-800, #002224);
|
|
||||||
padding: 40px 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.landing-footer {
|
|
||||||
padding: 48px 5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.landing-footer {
|
|
||||||
padding: 60px 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
max-width: 1144px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.footer-content {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-left {
|
|
||||||
min-width: 262px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-logo {
|
|
||||||
height: 24px;
|
|
||||||
width: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-copyright {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #379599;
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
line-height: 1.19;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #379599;
|
|
||||||
text-decoration: none;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link:hover {
|
|
||||||
opacity: 1;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-link-divider {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #379599;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-right {
|
|
||||||
max-width: 600px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-info {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #CEE5E6;
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
line-height: 1.19;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.footer-info {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Header Component
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Landing Header */
|
|
||||||
.landing-header {
|
|
||||||
width: calc(100% - 2rem);
|
|
||||||
max-width: 1280px;
|
|
||||||
padding: 12px 12px 12px 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: fixed;
|
|
||||||
top: 16px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 10100;
|
|
||||||
background: rgba(0, 34, 36, 0.3);
|
|
||||||
border: 1px solid rgba(229, 241, 242, 0.2);
|
|
||||||
border-radius: 999px;
|
|
||||||
backdrop-filter: blur(40px);
|
|
||||||
-webkit-backdrop-filter: blur(40px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.landing-header {
|
|
||||||
width: calc(100% - 10rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-logo img {
|
|
||||||
height: 100%;
|
|
||||||
width: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions .lang-toggle {
|
|
||||||
width: auto;
|
|
||||||
max-width: none;
|
|
||||||
padding: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions .lang-toggle-option {
|
|
||||||
padding: 4px 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-login-btn {
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background-color: #94FBE0;
|
|
||||||
color: #000000;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
line-height: 1.19;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-login-btn:hover {
|
|
||||||
background-color: #7fe8cc;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(148, 251, 224, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-login-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-start-btn {
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background-color: #AE72F9;
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
letter-spacing: -0.006em;
|
|
||||||
line-height: 1.19;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-start-btn:hover {
|
|
||||||
background-color: #9B5DE5;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(174, 114, 249, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-start-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-avatar {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--color-border-gray-700);
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.header-avatar {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-avatar img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Content Safe Area (for Landing Pages)
|
|
||||||
===================================================== */
|
|
||||||
.content-safe-area {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +0,0 @@
|
||||||
/* CASTAD Design System - 도메인별 CSS 파일 매니페스트 */
|
|
||||||
|
|
||||||
@import './tokens.css';
|
|
||||||
@import './base-components.css';
|
|
||||||
@import './media-components.css';
|
|
||||||
@import './generation-flow.css';
|
|
||||||
@import './landing.css';
|
|
||||||
@import './dashboard.css';
|
|
||||||
@import './business-settings.css';
|
|
||||||
@import './studio-assets.css';
|
|
||||||
@import './contents-social.css';
|
|
||||||
@import './modals-overlays.css';
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,593 +0,0 @@
|
||||||
/* =====================================================
|
|
||||||
Player Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Player Bar */
|
|
||||||
.player-bar {
|
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Play Button */
|
|
||||||
.play-btn {
|
|
||||||
color: var(--color-mint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform var(--transition-normal);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-btn:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress Bar */
|
|
||||||
.progress-bar-container {
|
|
||||||
flex: 1;
|
|
||||||
height: 0.375rem;
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-container:disabled,
|
|
||||||
.progress-bar-container.disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-fill {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%) translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-thumb-glow {
|
|
||||||
position: absolute;
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
opacity: 0.2;
|
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-thumb-dot {
|
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
box-shadow: var(--shadow-mint-glow);
|
|
||||||
border: 1px solid rgba(18, 26, 29, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time Display */
|
|
||||||
.time-display {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-text-gray-500);
|
|
||||||
font-family: monospace;
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Image Grid Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Image Grid */
|
|
||||||
.image-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-grid-4 {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Image Item */
|
|
||||||
.image-item {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
background-color: rgba(18, 26, 29, 0.5);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
left: 0.5rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background-color: var(--color-purple-80);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item-remove {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item-remove:hover {
|
|
||||||
background-color: rgba(239, 68, 68, 0.8);
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Upload Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Upload Zone */
|
|
||||||
.upload-zone {
|
|
||||||
flex: 1;
|
|
||||||
border: 2px dashed var(--color-border-gray-600);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone:hover {
|
|
||||||
border-color: rgba(166, 255, 234, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone-icon {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--color-text-gray-500);
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone:hover .upload-zone-icon {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone-title {
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.625;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone-subtitle {
|
|
||||||
color: var(--color-text-gray-500);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Tag Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Tag */
|
|
||||||
.tag {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
background-color: var(--color-bg-card-inner);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: #e5e7eb;
|
|
||||||
border: 1px solid var(--color-border-white-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tag with Dot */
|
|
||||||
.tag-dot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-text-gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-dot::before {
|
|
||||||
content: '';
|
|
||||||
width: 0.375rem;
|
|
||||||
height: 0.375rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Social Card Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Social Card */
|
|
||||||
.social-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
background-color: var(--color-bg-dark);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-card:hover {
|
|
||||||
border-color: var(--color-border-white-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-card.selected {
|
|
||||||
border-color: var(--color-mint);
|
|
||||||
box-shadow: 0 0 15px rgba(166, 255, 234, 0.15);
|
|
||||||
background-color: var(--color-bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-icon {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
transition: transform var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-icon-inner {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-check {
|
|
||||||
position: absolute;
|
|
||||||
top: -0.25rem;
|
|
||||||
right: -0.25rem;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--color-bg-dark);
|
|
||||||
border: 2px solid var(--color-bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-name {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: 700;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-card.selected .social-name {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-card:not(.selected) .social-name {
|
|
||||||
color: var(--color-text-gray-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-email {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--color-purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Video Preview Components
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Video Container */
|
|
||||||
.video-container {
|
|
||||||
flex: 1;
|
|
||||||
background-color: #000;
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-pattern {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0.1;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(45deg, var(--color-bg-dark) 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, var(--color-bg-dark) 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, var(--color-bg-dark) 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, var(--color-bg-dark) 75%);
|
|
||||||
background-size: 40px 40px;
|
|
||||||
background-position: 0 0, 0 20px, 20px -20px, -20px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-controls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
left: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-controls-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-play-btn {
|
|
||||||
color: var(--color-text-white);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-play-btn:hover {
|
|
||||||
color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-progress {
|
|
||||||
flex: 1;
|
|
||||||
height: 0.375rem;
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-mint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Video Player */
|
|
||||||
.video-player {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Video Loading State */
|
|
||||||
.video-loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Video Error State */
|
|
||||||
.video-error {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-error svg {
|
|
||||||
width: 4rem;
|
|
||||||
height: 4rem;
|
|
||||||
color: #ef4444;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Social Card Disabled State */
|
|
||||||
.social-card.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Tags Container
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
.tags-container {
|
|
||||||
margin-top: 1rem;
|
|
||||||
background-color: rgba(18, 26, 29, 0.6);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid var(--color-border-white-5);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Status Messages
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Error Message */
|
|
||||||
.error-message {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
color: #f87171;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Message */
|
|
||||||
.status-message {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: var(--color-mint-10);
|
|
||||||
border: 1px solid var(--color-mint-30);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
color: var(--color-mint);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
Utility Classes
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* Flex utilities */
|
|
||||||
.flex-center {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-between {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-2 {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shrink-0 {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.min-h-0 {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gap utilities */
|
|
||||||
.gap-2 { gap: 0.5rem; }
|
|
||||||
.gap-3 { gap: 0.75rem; }
|
|
||||||
.gap-4 { gap: 1rem; }
|
|
||||||
.gap-6 { gap: 1.5rem; }
|
|
||||||
|
|
||||||
/* Margin utilities */
|
|
||||||
.mb-1 { margin-bottom: 0.25rem; }
|
|
||||||
.mb-2 { margin-bottom: 0.5rem; }
|
|
||||||
.mb-3 { margin-bottom: 0.75rem; }
|
|
||||||
.mb-4 { margin-bottom: 1rem; }
|
|
||||||
.mb-6 { margin-bottom: 1.5rem; }
|
|
||||||
.mt-4 { margin-top: 1rem; }
|
|
||||||
.mt-6 { margin-top: 1.5rem; }
|
|
||||||
|
|
||||||
/* Text utilities */
|
|
||||||
.text-center { text-align: center; }
|
|
||||||
.text-right { text-align: right; }
|
|
||||||
.font-bold { font-weight: 700; }
|
|
||||||
.font-medium { font-weight: 500; }
|
|
||||||
|
|
||||||
/* Overflow utilities */
|
|
||||||
.overflow-hidden { overflow: hidden; }
|
|
||||||
.overflow-y-auto { overflow-y: auto; }
|
|
||||||
|
|
||||||
/* Width utilities */
|
|
||||||
.w-full { width: 100%; }
|
|
||||||
|
|
||||||
/* Animation */
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.2; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse {
|
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid utilities */
|
|
||||||
.grid-cols-3 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Space utilities */
|
|
||||||
.space-y-4 > * + * {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space-y-5 > * + * {
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Min width */
|
|
||||||
.min-w-280 {
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar utility */
|
|
||||||
.scrollbar-hide {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,74 +0,0 @@
|
||||||
/* =====================================================
|
|
||||||
CASTAD Design System - CSS Variables & Component Classes
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
CSS Variables - Design Tokens
|
|
||||||
===================================================== */
|
|
||||||
:root {
|
|
||||||
/* Primary Colors */
|
|
||||||
--color-mint: #a6ffea;
|
|
||||||
--color-mint-hover: #8affda;
|
|
||||||
--color-mint-glow: rgba(166, 255, 234, 0.6);
|
|
||||||
--color-mint-10: rgba(166, 255, 234, 0.1);
|
|
||||||
--color-mint-20: rgba(166, 255, 234, 0.2);
|
|
||||||
--color-mint-30: rgba(166, 255, 234, 0.3);
|
|
||||||
--color-mint-50: rgba(166, 255, 234, 0.5);
|
|
||||||
|
|
||||||
--color-purple: #a682ff;
|
|
||||||
--color-purple-hover: #9570f0;
|
|
||||||
--color-purple-glow: rgba(166, 130, 255, 0.2);
|
|
||||||
--color-purple-80: rgba(166, 130, 255, 0.8);
|
|
||||||
|
|
||||||
/* Background Colors - Teal-600 based */
|
|
||||||
--color-bg-dark: #002224;
|
|
||||||
--color-bg-darker: #001a1c;
|
|
||||||
--color-bg-card: #003538;
|
|
||||||
--color-bg-card-inner: #004548;
|
|
||||||
|
|
||||||
/* Text Colors */
|
|
||||||
--color-text-white: #ffffff;
|
|
||||||
--color-text-gray-300: #d1d5db;
|
|
||||||
--color-text-gray-400: #9ca3af;
|
|
||||||
--color-text-gray-500: #6b7280;
|
|
||||||
|
|
||||||
/* Border Colors */
|
|
||||||
--color-border-white-5: rgba(255, 255, 255, 0.05);
|
|
||||||
--color-border-white-10: rgba(255, 255, 255, 0.1);
|
|
||||||
--color-border-gray-600: #4b5563;
|
|
||||||
--color-border-gray-700: #374151;
|
|
||||||
|
|
||||||
/* Shadows */
|
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
||||||
--shadow-purple: 0 10px 15px -3px rgba(166, 130, 255, 0.2);
|
|
||||||
--shadow-mint-glow: 0 0 10px rgba(166, 255, 234, 0.6);
|
|
||||||
|
|
||||||
/* Spacing */
|
|
||||||
--spacing-page: 1.5rem;
|
|
||||||
--spacing-page-md: 2rem;
|
|
||||||
|
|
||||||
/* Border Radius */
|
|
||||||
--radius-sm: 0.5rem;
|
|
||||||
--radius-md: 0.75rem;
|
|
||||||
--radius-lg: 1rem;
|
|
||||||
--radius-xl: 1.25rem;
|
|
||||||
--radius-2xl: 1rem;
|
|
||||||
--radius-3xl: 1.5rem;
|
|
||||||
--radius-full: 9999px;
|
|
||||||
|
|
||||||
/* Font Sizes */
|
|
||||||
--text-xs: 0.75rem;
|
|
||||||
--text-sm: 0.875rem;
|
|
||||||
--text-base: 1rem;
|
|
||||||
--text-lg: 1.125rem;
|
|
||||||
--text-xl: 1.25rem;
|
|
||||||
--text-2xl: 1.5rem;
|
|
||||||
--text-3xl: 1.875rem;
|
|
||||||
--text-4xl: 2.25rem;
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
--transition-fast: 150ms;
|
|
||||||
--transition-normal: 200ms;
|
|
||||||
--transition-slow: 300ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -32,21 +32,14 @@ export interface MarketingAnalysis {
|
||||||
target_keywords: string[];
|
target_keywords: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageListItem {
|
|
||||||
preview: string; // 미리보기용 CDN 리사이즈 URL
|
|
||||||
original: string; // 업로드용 원본 URL
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CrawlingResponse {
|
export interface CrawlingResponse {
|
||||||
image_list: ImageListItem[] | null;
|
image_list: string[];
|
||||||
image_count: number;
|
image_count: number;
|
||||||
m_id: number;
|
m_id: number;
|
||||||
industry: string;
|
|
||||||
processed_info: {
|
processed_info: {
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
region: string;
|
region: string;
|
||||||
detail_region_info: string;
|
detail_region_info: string;
|
||||||
industry: string;
|
|
||||||
};
|
};
|
||||||
marketing_analysis: MarketingAnalysis;
|
marketing_analysis: MarketingAnalysis;
|
||||||
}
|
}
|
||||||
|
|
@ -54,8 +47,7 @@ export interface CrawlingResponse {
|
||||||
// URL 이미지 (서버에서 가져온 이미지)
|
// URL 이미지 (서버에서 가져온 이미지)
|
||||||
export interface UrlImage {
|
export interface UrlImage {
|
||||||
type: 'url';
|
type: 'url';
|
||||||
url: string; // 업로드용 원본 URL
|
url: string;
|
||||||
preview_url: string; // 미리보기용 CDN 리사이즈 URL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업로드된 파일 이미지
|
// 업로드된 파일 이미지
|
||||||
|
|
@ -75,10 +67,7 @@ export interface LyricGenerateRequest {
|
||||||
m_id: number;
|
m_id: number;
|
||||||
region: string;
|
region: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
industry?: string;
|
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
instrumental?: boolean;
|
|
||||||
genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가사 생성 응답
|
// 가사 생성 응답
|
||||||
|
|
@ -108,16 +97,14 @@ export interface LyricDetailResponse {
|
||||||
status: string;
|
status: string;
|
||||||
lyric_prompt: string;
|
lyric_prompt: string;
|
||||||
lyric_result: string;
|
lyric_result: string;
|
||||||
genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값)
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 노래 생성 요청
|
// 노래 생성 요청
|
||||||
export interface SongGenerateRequest {
|
export interface SongGenerateRequest {
|
||||||
genre: string;
|
genre: string;
|
||||||
language?: string;
|
language: string;
|
||||||
lyrics?: string;
|
lyrics: string;
|
||||||
instrumental?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 노래 생성 응답
|
// 노래 생성 응답
|
||||||
|
|
@ -154,13 +141,6 @@ export interface SongDownloadResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영상 생성 응답
|
// 영상 생성 응답
|
||||||
// 자막 상태 확인 응답
|
|
||||||
export interface SubtitleStatusResponse {
|
|
||||||
task_id: string;
|
|
||||||
status: string; // 'pending' | 'processing' | 'completed' | 'failed' | 'error'
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoGenerateResponse {
|
export interface VideoGenerateResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
|
|
@ -274,33 +254,14 @@ export interface UserMeResponse {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserCreditsResponse {
|
// 비디오 목록 아이템
|
||||||
credits: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비디오 목록 아이템 (갤러리용)
|
|
||||||
export interface VideoListItem {
|
export interface VideoListItem {
|
||||||
video_id: number;
|
video_id: number;
|
||||||
store_name: string;
|
store_name: string;
|
||||||
region: string;
|
region: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
result_movie_url: string;
|
result_movie_url: string;
|
||||||
thumbnail_url?: string;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
like_count: number;
|
|
||||||
comment_count: number;
|
|
||||||
is_liked_by_me?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비디오 상세 아이템
|
|
||||||
export interface VideoDetailItem {
|
|
||||||
video_id: number;
|
|
||||||
result_movie_url: string;
|
|
||||||
store_name: string;
|
|
||||||
region: string;
|
|
||||||
created_at: string;
|
|
||||||
like_count: number;
|
|
||||||
is_liked_by_me: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비디오 목록 응답
|
// 비디오 목록 응답
|
||||||
|
|
@ -314,45 +275,6 @@ export interface VideosListResponse {
|
||||||
has_prev: boolean;
|
has_prev: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 대댓글
|
|
||||||
export interface CommentReply {
|
|
||||||
id: number;
|
|
||||||
nickname: string;
|
|
||||||
content: string | null;
|
|
||||||
is_deleted: boolean;
|
|
||||||
is_mine: boolean;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 댓글 아이템
|
|
||||||
export interface CommentItem {
|
|
||||||
id: number;
|
|
||||||
nickname: string;
|
|
||||||
content: string | null;
|
|
||||||
is_deleted: boolean;
|
|
||||||
is_mine: boolean;
|
|
||||||
created_at: string;
|
|
||||||
replies: CommentReply[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 댓글 목록 응답
|
|
||||||
export interface CommentsResponse {
|
|
||||||
items: CommentItem[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
total_pages: number;
|
|
||||||
has_next: boolean;
|
|
||||||
has_prev: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 좋아요 토글 응답
|
|
||||||
export interface LikeToggleResponse {
|
|
||||||
video_id: number;
|
|
||||||
is_liked: boolean;
|
|
||||||
like_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Social OAuth Types
|
// Social OAuth Types
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -423,11 +345,9 @@ export interface SocialUploadRequest {
|
||||||
// 소셜 업로드 응답
|
// 소셜 업로드 응답
|
||||||
export interface SocialUploadResponse {
|
export interface SocialUploadResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
upload_id: number;
|
upload_id: string;
|
||||||
platform: string;
|
status: 'pending' | 'uploading' | 'completed' | 'failed';
|
||||||
status: string;
|
|
||||||
message: string;
|
message: string;
|
||||||
scheduled_at?: string | null; // 예약 업로드 충돌 시 실제 예약 시간 반환
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 소셜 업로드 상태 조회 응답
|
// 소셜 업로드 상태 조회 응답
|
||||||
|
|
@ -455,8 +375,6 @@ export interface UploadHistoryItem {
|
||||||
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
|
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
|
||||||
title: string;
|
title: string;
|
||||||
channel_name: string;
|
channel_name: string;
|
||||||
platform_user_id: string | null;
|
|
||||||
platform_username: string | null;
|
|
||||||
scheduled_at: string | null;
|
scheduled_at: string | null;
|
||||||
uploaded_at: string | null;
|
uploaded_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
|
||||||
229
src/utils/api.ts
229
src/utils/api.ts
|
|
@ -8,7 +8,6 @@ import {
|
||||||
SongGenerateResponse,
|
SongGenerateResponse,
|
||||||
SongStatusResponse,
|
SongStatusResponse,
|
||||||
SongDownloadResponse,
|
SongDownloadResponse,
|
||||||
SubtitleStatusResponse,
|
|
||||||
VideoGenerateResponse,
|
VideoGenerateResponse,
|
||||||
VideoStatusResponse,
|
VideoStatusResponse,
|
||||||
VideoDownloadResponse,
|
VideoDownloadResponse,
|
||||||
|
|
@ -29,11 +28,6 @@ import {
|
||||||
TokenExpiredErrorResponse,
|
TokenExpiredErrorResponse,
|
||||||
YTAutoSeoRequest,
|
YTAutoSeoRequest,
|
||||||
YTAutoSeoResponse,
|
YTAutoSeoResponse,
|
||||||
UserCreditsResponse,
|
|
||||||
VideoDetailItem,
|
|
||||||
CommentsResponse,
|
|
||||||
CommentItem,
|
|
||||||
LikeToggleResponse,
|
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
|
|
@ -167,8 +161,6 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.json().catch(() => null);
|
|
||||||
console.error('[generateSong] 422 detail:', JSON.stringify(errorBody));
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,52 +242,6 @@ export async function waitForSongComplete(
|
||||||
return poll();
|
return poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자막 상태 확인 API
|
|
||||||
export async function getSubtitleStatus(taskId: string): Promise<SubtitleStatusResponse> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/lyric/subtitle/status/${taskId}`, {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자막 완료까지 폴링 (5초 간격, 10분 타임아웃)
|
|
||||||
const SUBTITLE_POLL_INTERVAL = 5000;
|
|
||||||
const SUBTITLE_POLL_TIMEOUT = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
export async function waitForSubtitleComplete(
|
|
||||||
taskId: string,
|
|
||||||
onStatusChange?: (status: string) => void
|
|
||||||
): Promise<SubtitleStatusResponse> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
const poll = async (): Promise<SubtitleStatusResponse> => {
|
|
||||||
if (Date.now() - startTime > SUBTITLE_POLL_TIMEOUT) {
|
|
||||||
throw new Error('TIMEOUT');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getSubtitleStatus(taskId);
|
|
||||||
onStatusChange?.(response.status);
|
|
||||||
|
|
||||||
if (response.status === 'completed') {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 'failed' || response.status === 'error') {
|
|
||||||
throw new Error(response.message || '자막 생성에 실패했습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, SUBTITLE_POLL_INTERVAL));
|
|
||||||
return poll();
|
|
||||||
};
|
|
||||||
|
|
||||||
return poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 영상 생성 API
|
// 영상 생성 API
|
||||||
export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> {
|
export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> {
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, {
|
const response = await authenticatedFetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, {
|
||||||
|
|
@ -377,99 +323,6 @@ export async function deleteVideo(videoId: number): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전체 사용자 영상 목록 조회 API (ADO2 콘텐츠 갤러리용)
|
|
||||||
export async function getAllVideos(
|
|
||||||
page: number = 1,
|
|
||||||
pageSize: number = 20,
|
|
||||||
sortBy: 'created_at' | 'like_count' | 'comment_count' = 'created_at',
|
|
||||||
storeName: string = '',
|
|
||||||
order: 'desc' | 'asc' = 'desc',
|
|
||||||
region: string = '',
|
|
||||||
): Promise<VideosListResponse> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(page),
|
|
||||||
page_size: String(pageSize),
|
|
||||||
sort_by: sortBy,
|
|
||||||
order,
|
|
||||||
});
|
|
||||||
if (storeName.trim()) params.set('store_name', storeName.trim());
|
|
||||||
if (region.trim()) params.set('region', region.trim());
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 단일 영상 상세 조회 API
|
|
||||||
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 댓글 목록 조회
|
|
||||||
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 댓글 작성
|
|
||||||
export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise<CommentItem> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 댓글 삭제
|
|
||||||
export async function deleteComment(commentId: number): Promise<void> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/comment/${commentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 좋아요 토글
|
|
||||||
export async function toggleVideoLike(videoId: string): Promise<LikeToggleResponse> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미지 업로드 API (multipart/form-data)
|
// 이미지 업로드 API (multipart/form-data)
|
||||||
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
|
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
|
||||||
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;
|
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;
|
||||||
|
|
@ -623,20 +476,14 @@ export async function authenticatedFetch(
|
||||||
|
|
||||||
let response = await fetch(url, { ...options, headers });
|
let response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
// 401 에러 시 에러 코드에 따라 처리
|
// 401 에러 시 토큰 갱신 시도
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
const errorBody = await response.json().catch(() => null);
|
|
||||||
const errorCode = errorBody?.detail?.code;
|
|
||||||
|
|
||||||
if (errorCode !== 'TOKEN_EXPIRED') {
|
|
||||||
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
|
|
||||||
redirectToLogin();
|
|
||||||
throw new Error(errorCode ?? 'Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지)
|
||||||
|
// refreshPromise가 존재하면 재사용, 없으면 새로 생성
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = refreshAccessToken().finally(() => {
|
refreshPromise = refreshAccessToken().finally(() => {
|
||||||
|
// 성공/실패 상관없이 Promise 초기화 (다음 갱신을 위해)
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -653,6 +500,7 @@ export async function authenticatedFetch(
|
||||||
response = await fetch(url, { ...options, headers: newHeaders });
|
response = await fetch(url, { ...options, headers: newHeaders });
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
console.error('Token refresh failed:', refreshError);
|
console.error('Token refresh failed:', refreshError);
|
||||||
|
// 토큰 갱신 실패 시 로그인 페이지로 리다이렉트
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
throw refreshError;
|
throw refreshError;
|
||||||
}
|
}
|
||||||
|
|
@ -803,32 +651,6 @@ export async function getUserMe(): Promise<UserMeResponse> {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 크레딧 조회
|
|
||||||
export async function getUserCredits(): Promise<UserCreditsResponse> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/user/auth/me/credits`, {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 크레딧 충전 요청
|
|
||||||
export async function requestCreditCharge(amount: number, note: string): Promise<void> {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/user/credits/charge-requests`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ requested_amount: amount, message: note }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그인 여부 확인
|
// 로그인 여부 확인
|
||||||
export function isLoggedIn(): boolean {
|
export function isLoggedIn(): boolean {
|
||||||
return !!getAccessToken();
|
return !!getAccessToken();
|
||||||
|
|
@ -844,16 +666,6 @@ export interface AccommodationSearchItem {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 네이버 검색 API는 title에 <b> 태그와 & 같은 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[];
|
||||||
|
|
@ -910,37 +722,6 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업체명·주소 직접 입력으로 마케팅 분석
|
|
||||||
export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise<CrawlingResponse> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(`${API_URL}/marketing`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ store_name: storeName, address, category }),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
|
||||||
throw new Error('요청 시간이 초과되었습니다. 다시 시도해주세요.');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Social OAuth TOKEN_EXPIRED 처리
|
// Social OAuth TOKEN_EXPIRED 처리
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue