CASTAD-v0.1/components/InputForm.tsx

1170 lines
52 KiB
TypeScript

/// <reference lib="dom" />
import React, { useState, ChangeEvent, useEffect } from 'react';
import { Upload, Music, Sparkles, X, Volume2, Search, MapPin, Loader2, Type, Globe, Video, Monitor, Wand2, Layers, FileText, Home, Waves, Mountain, Heart, Users, Dog, Tent, Building, CheckCircle, Lock, ChevronDown, Image as ImageIcon, ExternalLink } from 'lucide-react';
import { BusinessInfo, MusicGenre, MusicDuration, AudioMode, TTSConfig, TTSGender, TTSAge, TTSTone, TextEffect, AspectRatio, Language, TransitionEffect, PensionCategory } from '../types';
import { filterBestImages, enrichDescriptionWithReviews, extractTextEffectFromImage } from '../services/geminiService';
import { crawlNaverPlace } from '../services/naverService';
import { searchPlaceDetails, fetchPlacePhoto } from '../services/googlePlacesService';
import { useLanguage } from '../src/contexts/LanguageContext';
import OnboardingTour from './OnboardingTour';
// shadcn/ui components
import { cn } from '../src/lib/utils';
import { Button } from '../src/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../src/components/ui/card';
import { Input } from '../src/components/ui/input';
import { Label } from '../src/components/ui/label';
import { Textarea } from '../src/components/ui/textarea';
import { Badge } from '../src/components/ui/badge';
import { Separator } from '../src/components/ui/separator';
import { Switch } from '../src/components/ui/switch';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../src/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../src/components/ui/tooltip';
interface InputFormProps {
onSubmit: (info: BusinessInfo) => void;
isSubmitting: boolean;
}
// 국가별 장르 매핑
const GENRE_BY_LANG: Record<Language, MusicGenre[]> = {
'KO': ['Auto', 'K-Pop', 'Trot', 'Ballad', 'Hip-Hop', 'R&B', 'EDM', 'Jazz', 'Rock'],
'EN': ['Auto', 'Pop', 'Country', 'Hip-Hop', 'R&B', 'EDM', 'Jazz', 'Rock', 'Ballad'],
'JP': ['Auto', 'J-Pop', 'Enka', 'Anime', 'Rock', 'Jazz', 'Ballad', 'EDM', 'Hip-Hop'],
'CN': ['Auto', 'C-Pop', 'Mandopop', 'Traditional CN', 'Ballad', 'Hip-Hop', 'EDM', 'Jazz', 'Rock'],
'TH': ['Auto', 'T-Pop', 'Luk Thung', 'Pop', 'Hip-Hop', 'Rock', 'EDM', 'Jazz', 'Ballad'],
'VN': ['Auto', 'V-Pop', 'Bolero', 'Pop', 'Hip-Hop', 'Rock', 'EDM', 'Jazz', 'Ballad']
};
// 펜션 카테고리 옵션
const PENSION_CATEGORIES: { id: PensionCategory; icon: any; labelKey: string }[] = [
{ id: 'PoolVilla', icon: Waves, labelKey: 'catPoolVilla' },
{ id: 'OceanView', icon: Waves, labelKey: 'catOceanView' },
{ id: 'Mountain', icon: Mountain, labelKey: 'catMountain' },
{ id: 'Private', icon: Home, labelKey: 'catPrivate' },
{ id: 'Couple', icon: Heart, labelKey: 'catCouple' },
{ id: 'Family', icon: Users, labelKey: 'catFamily' },
{ id: 'Pet', icon: Dog, labelKey: 'catPet' },
{ id: 'Glamping', icon: Tent, labelKey: 'catGlamping' },
{ id: 'Traditional', icon: Building, labelKey: 'catTraditional' },
];
const FLAG_CLASSES: Record<Language, string> = {
'KO': 'fi-kr',
'EN': 'fi-us',
'JP': 'fi-jp',
'CN': 'fi-cn',
'TH': 'fi-th',
'VN': 'fi-vn'
};
const InputForm: React.FC<InputFormProps> = ({ onSubmit, isSubmitting }) => {
const { language: uiLanguage, t } = useLanguage();
// 온보딩 투어 상태
const [showTour, setShowTour] = useState(false);
useEffect(() => {
const hasSeenTour = localStorage.getItem('castadTourCompleted');
if (!hasSeenTour) {
setTimeout(() => setShowTour(true), 1000);
}
}, []);
// 콘텐츠 생성 언어 - UI 언어와 동기화
const [contentLanguage, setContentLanguage] = useState<Language>(uiLanguage);
// Sync content language with UI language
useEffect(() => {
setContentLanguage(uiLanguage);
}, [uiLanguage]);
// 펜션 카테고리 (복수 선택)
const [pensionCategories, setPensionCategories] = useState<PensionCategory[]>([]);
// 상태 관리
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [address, setAddress] = useState('');
const [category, setCategory] = useState('');
const [images, setImages] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [searchStatus, setSearchStatus] = useState('');
const [mapLink, setMapLink] = useState<string | null>(null);
const [naverUrl, setNaverUrl] = useState('');
const [isCrawling, setIsCrawling] = useState(false);
const [crawlStatus, setCrawlStatus] = useState('');
const [audioMode, setAudioMode] = useState<AudioMode>('Song');
const [musicGenre, setMusicGenre] = useState<MusicGenre>('Auto');
const [musicDuration, setMusicDuration] = useState<MusicDuration>('Short');
const [activeGenres, setActiveGenres] = useState<MusicGenre[]>(GENRE_BY_LANG['KO']);
const [ttsConfig, setTTSConfig] = useState<TTSConfig>({
gender: 'Female',
age: 'Young',
tone: 'Bright'
});
const [visualStyle, setVisualStyle] = useState<'Video' | 'Slideshow'>('Slideshow');
const [transitionEffect, setTransitionEffect] = useState<TransitionEffect>('Mix');
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('9:16');
const [textEffect, setTextEffect] = useState<TextEffect>('Cinematic');
const [customStyle, setCustomStyle] = useState<string>('');
const [isAnalyzingStyle, setIsAnalyzingStyle] = useState(false);
const [useAiImages, setUseAiImages] = useState(false);
// Collapsible states
const [openSections, setOpenSections] = useState({
category: true,
images: true,
autoSearch: false,
basicInfo: true,
settings: true,
textStyle: true,
audio: true
});
useEffect(() => {
setActiveGenres(GENRE_BY_LANG[contentLanguage]);
setMusicGenre('Auto');
}, [contentLanguage]);
const textEffects: { id: TextEffect; label: string; desc: string; previewClass?: string }[] = [
{ id: 'Cinematic', label: t('textStyle') === '자막 스타일' ? '영화 같은' : 'Cinematic', desc: 'Fade', previewClass: 'font-serif tracking-widest' },
{ id: 'Neon', label: t('textStyle') === '자막 스타일' ? '네온 사인' : 'Neon', desc: 'Glow', previewClass: 'effect-neon font-bold text-pink-500' },
{ id: 'Glitch', label: t('textStyle') === '자막 스타일' ? '글리치' : 'Glitch', desc: 'Noise', previewClass: 'effect-glitch font-mono font-bold' },
{ id: 'Typewriter', label: t('textStyle') === '자막 스타일' ? '타자기' : 'Typewriter', desc: 'Cursor', previewClass: 'font-mono border-r-2 border-white pr-1' },
{ id: 'Bold', label: t('textStyle') === '자막 스타일' ? '임팩트' : 'Bold', desc: 'Strong', previewClass: 'font-black uppercase italic' },
{ id: 'Motion', label: t('textStyle') === '자막 스타일' ? '모션' : 'Motion', desc: 'Dynamic', previewClass: 'effect-motion font-bold italic' },
{ id: 'LineReveal', label: t('textStyle') === '자막 스타일' ? '라인 등장' : 'Line Reveal', desc: 'Minimal', previewClass: 'border-t border-b border-white py-1' },
{ id: 'Boxed', label: t('textStyle') === '자막 스타일' ? '박스' : 'Boxed', desc: 'Frame', previewClass: 'border border-white p-1' },
{ id: 'Elegant', label: t('textStyle') === '자막 스타일' ? '우아함' : 'Elegant', desc: 'Classic', previewClass: 'font-serif italic' },
{ id: 'BlockReveal', label: t('textStyle') === '자막 스타일' ? '블록 등장' : 'Block Reveal', desc: 'Pop', previewClass: 'bg-white text-black px-1' },
{ id: 'Custom', label: t('textStyle') === '자막 스타일' ? '나만의 스타일' : 'Custom', desc: 'AI', previewClass: '' },
];
const transitionOptions: { id: TransitionEffect; labelKey: any }[] = [
{ id: 'Mix', labelKey: 'transMix' },
{ id: 'Zoom', labelKey: 'transZoom' },
{ id: 'Slide', labelKey: 'transSlide' },
{ id: 'Wipe', labelKey: 'transWipe' },
];
// Helper Functions
const updateImages = (newFiles: File[]) => {
const combinedFiles = [...images, ...newFiles];
const limitedFiles = combinedFiles.slice(0, 10);
setImages(limitedFiles);
const newPreviews: string[] = [];
let processed = 0;
if (limitedFiles.length === 0) {
setPreviews([]);
return;
}
limitedFiles.forEach(file => {
const reader = new FileReader();
reader.onloadend = () => {
newPreviews.push(reader.result as string);
processed++;
if (processed === limitedFiles.length) {
setPreviews([...newPreviews]);
}
};
reader.readAsDataURL(file);
});
};
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
updateImages(Array.from(e.target.files));
}
};
const removeImage = (index: number) => {
const newImages = [...images];
newImages.splice(index, 1);
setImages(newImages);
if (newImages.length === 0) {
setPreviews([]);
} else {
const newPreviews: string[] = [];
let processed = 0;
newImages.forEach(file => {
const reader = new FileReader();
reader.onloadend = () => {
newPreviews.push(reader.result as string);
processed++;
if (processed === newImages.length) setPreviews(newPreviews);
};
reader.readAsDataURL(file);
});
}
};
const handleSearch = async () => {
if (!searchQuery) return;
setIsSearching(true);
setSearchStatus('Processing...');
setMapLink(null);
let finalQuery = searchQuery;
if (searchQuery.includes('google') && searchQuery.includes('/maps/place/')) {
try {
const match = searchQuery.match(/\/maps\/place\/([^\/]+)/);
if (match && match[1]) {
finalQuery = decodeURIComponent(match[1]).replace(/\+/g, ' ');
}
} catch (e) { console.warn("URL Error"); }
}
try {
const apiKey = import.meta.env.VITE_GEMINI_API_KEY;
if (!apiKey) throw new Error("API Key Missing");
const details = await searchPlaceDetails(finalQuery);
if (!details) {
alert("Place not found");
setIsSearching(false);
return;
}
setName(details.displayName.text);
setAddress(details.formattedAddress);
if (details.photos && details.photos.length > 0) {
setSearchStatus(`Fetching ${details.photos.length} photos...`);
const photoPromises = details.photos.slice(0, 10).map(p => fetchPlacePhoto(p.name));
const fetchedPhotos = (await Promise.all(photoPromises)).filter((f): f is File => f !== null);
updateImages(fetchedPhotos);
}
setSearchStatus('Analyzing reviews...');
let generatedDesc = details.formattedAddress;
const reviewsText = details.reviews?.map(r => r.text.text) || [];
if (reviewsText.length > 0 || details.rating) {
generatedDesc = await enrichDescriptionWithReviews(
details.displayName.text,
details.formattedAddress,
reviewsText,
details.rating || 0,
apiKey
);
}
setDescription(generatedDesc);
setSearchStatus('Done!');
setTimeout(() => setSearchStatus(''), 2000);
} catch (error) {
console.error("Search failed:", error);
alert("Failed to fetch info.");
setSearchStatus('');
} finally {
setIsSearching(false);
}
};
const handleNaverCrawl = async () => {
if (!naverUrl) return;
const apiKey = import.meta.env.VITE_GEMINI_API_KEY;
if (!apiKey) {
alert("API Key Required");
return;
}
setIsCrawling(true);
setCrawlStatus('Connecting...');
try {
const result: any = await crawlNaverPlace(naverUrl, (msg) => setCrawlStatus(msg));
if (result.name) setName(result.name);
if (result.description) setDescription(result.description);
if (result.address) setAddress(result.address);
if (result.category) setCategory(result.category);
if (result.images && result.images.length > 0) {
setCrawlStatus(`Fetched ${result.images.length} photos.`);
updateImages(result.images);
} else {
setCrawlStatus('No photos found.');
}
setTimeout(() => setCrawlStatus(''), 2000);
} catch (e: any) {
console.error("Crawl failed:", e);
alert(e.message || "Failed to crawl Naver Place.");
setCrawlStatus('');
} finally {
setIsCrawling(false);
}
};
const handleStyleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const file = e.target.files[0];
const apiKey = import.meta.env.VITE_GEMINI_API_KEY;
if (!apiKey) { alert("API Key Required"); return; }
setIsAnalyzingStyle(true);
try {
const cssCode = await extractTextEffectFromImage(file, apiKey);
let styleTag = document.getElementById('custom-text-style');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'custom-text-style';
document.head.appendChild(styleTag);
}
styleTag.textContent = cssCode;
setCustomStyle(cssCode);
setTextEffect('Custom');
alert("Style analysis complete!");
} catch (e) {
console.error("Style analysis failed:", e);
alert("Failed to analyze style.");
} finally {
setIsAnalyzingStyle(false);
}
};
const handleSubmit = (e: React.FormEvent | null, mode: 'Full' | 'AudioOnly' = 'Full') => {
if (e) e.preventDefault();
if (name && description) {
onSubmit({
name, description, images, audioMode, musicGenre, musicDuration, ttsConfig,
textEffect, transitionEffect, visualStyle, aspectRatio, sourceUrl: naverUrl || mapLink || undefined,
customStyleCSS: customStyle, creationMode: mode, address, category,
language: contentLanguage,
useAiImages,
pensionCategories
});
} else {
alert(t('brandName') + " & " + t('brandDesc') + " required.");
}
};
// 단계별 완료 조건
const step1Complete = pensionCategories.length > 0;
const step2Complete = step1Complete;
const step3Complete = step2Complete && name.length > 0 && description.length > 0;
const step4Complete = step3Complete && contentLanguage !== undefined;
const step5Complete = step4Complete && textEffect !== undefined;
const step6Complete = step5Complete;
const canSubmit = step6Complete;
// Progress calculation
const completedSteps = [step1Complete, step2Complete, step3Complete, step4Complete, step5Complete, step6Complete].filter(Boolean).length;
const progressPercent = Math.round((completedSteps / 6) * 100);
// 온보딩 투어 스텝
const tourSteps = [
{
target: '[data-tour="step1"]',
title: '1단계: 펜션 유형 선택',
description: '운영하시는 펜션의 유형을 선택해주세요. 복수 선택 가능하며, AI가 이 정보를 바탕으로 맞춤형 콘텐츠를 생성합니다.',
placement: 'bottom' as const,
},
{
target: '[data-tour="step2"]',
title: '2단계: 사진 업로드',
description: '펜션 사진을 업로드하세요. 사진이 부족하면 AI가 자동으로 생성할 수도 있습니다. (선택사항)',
placement: 'bottom' as const,
},
{
target: '[data-tour="step3"]',
title: '3단계: 정보 자동 입력',
description: '네이버 플레이스 URL 또는 Google Maps에서 검색하여 펜션 정보를 자동으로 가져올 수 있습니다.',
placement: 'bottom' as const,
},
{
target: '[data-tour="step4"]',
title: '4단계: 기본 정보 입력',
description: '펜션 이름과 설명을 입력해주세요. 자동 입력을 사용했다면 이미 채워져 있을 거예요!',
placement: 'bottom' as const,
},
{
target: '[data-tour="step5"]',
title: '5단계: 언어 및 영상 설정',
description: '영상 생성 언어, 비율(인스타/유튜브), 비주얼 스타일을 선택하세요.',
placement: 'left' as const,
},
{
target: '[data-tour="step6"]',
title: '6단계: 자막 스타일',
description: '영상에 표시될 자막의 스타일을 선택하세요. 다양한 효과를 미리 볼 수 있습니다.',
placement: 'top' as const,
},
{
target: '[data-tour="step7"]',
title: '7단계: 오디오 설정',
description: '노래, BGM, 성우 중 선택하고, 장르나 음성 설정을 커스터마이징하세요.',
placement: 'top' as const,
},
{
target: '[data-tour="submit"]',
title: '마지막: 영상 생성!',
description: '모든 설정이 완료되면 이 버튼을 눌러 AI 영상 생성을 시작하세요!',
placement: 'top' as const,
},
];
const handleTourComplete = () => {
localStorage.setItem('castadTourCompleted', 'true');
setShowTour(false);
};
const handleTourSkip = () => {
localStorage.setItem('castadTourCompleted', 'true');
setShowTour(false);
};
// Step indicator component
const StepIndicator = ({ step, completed, active, locked }: { step: number; completed: boolean; active: boolean; locked: boolean }) => (
<div className={cn(
"w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all",
completed && "bg-green-600 text-white",
active && !completed && "bg-primary text-primary-foreground",
locked && "bg-muted text-muted-foreground",
!completed && !active && !locked && "bg-muted text-muted-foreground"
)}>
{locked ? <Lock className="w-3.5 h-3.5" /> : completed ? <CheckCircle className="w-4 h-4" /> : step}
</div>
);
return (
<>
{showTour && (
<OnboardingTour
steps={tourSteps}
onComplete={handleTourComplete}
onSkip={handleTourSkip}
/>
)}
<div className="w-full pb-20 pt-6 relative">
{/* Header */}
<div className="text-center mb-8">
<h1 className="heading-2 gradient-text mb-2">
{t('appTitle')}
</h1>
<p className="text-muted-foreground">{t('appSubtitle')}</p>
{/* Progress indicator */}
<div className="mt-6 max-w-md mx-auto">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-2">
<span>{t('textStyle') === '자막 스타일' ? '진행률' : 'Progress'}</span>
<span className="font-medium">{progressPercent}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-accent transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
</div>
<form onSubmit={(e) => handleSubmit(e, 'Full')} className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* [LEFT COLUMN] */}
<div className="lg:col-span-5 space-y-4 lg:sticky lg:top-24">
{/* STEP 1: Pension Category */}
<Card
data-tour="step1"
className={cn(
"transition-all",
step1Complete && "border-green-500/50 bg-green-500/5"
)}
>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<StepIndicator step={1} completed={step1Complete} active={!step1Complete} locked={false} />
<div className="flex-1">
<CardTitle className="text-base">{t('pensionCategory')}</CardTitle>
<CardDescription className="text-xs">
{t('textStyle') === '자막 스타일' ? '복수 선택 가능' : 'Multiple selection allowed'}
</CardDescription>
</div>
{step1Complete && <Badge variant="outline" className="text-green-500 border-green-500/50">{t('textStyle') === '자막 스타일' ? '완료' : 'Done'}</Badge>}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-2">
{PENSION_CATEGORIES.map((cat) => {
const isSelected = pensionCategories.includes(cat.id);
return (
<button
key={cat.id}
type="button"
onClick={() => {
if (isSelected) {
setPensionCategories(pensionCategories.filter(c => c !== cat.id));
} else {
setPensionCategories([...pensionCategories, cat.id]);
}
}}
className={cn(
"flex flex-col items-center gap-1.5 p-3 rounded-lg border text-xs font-medium transition-all",
isSelected
? "bg-primary/10 border-primary text-foreground shadow-sm"
: "bg-card border-border text-muted-foreground hover:bg-muted hover:border-muted-foreground/30"
)}
>
<cat.icon className={cn("w-5 h-5", isSelected ? "text-primary" : "text-muted-foreground")} />
<span className="truncate w-full text-center">{t(cat.labelKey as any)}</span>
</button>
);
})}
</div>
</CardContent>
</Card>
{/* STEP 2: Image Upload */}
<Card
data-tour="step2"
className={cn(
"transition-all",
!step1Complete && "opacity-60"
)}
>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<StepIndicator step={2} completed={false} active={step1Complete} locked={!step1Complete} />
<div className="flex-1">
<CardTitle className="text-base">{t('uploadTitle')}</CardTitle>
<CardDescription className="text-xs">
{t('textStyle') === '자막 스타일' ? '최대 10장 (선택사항)' : 'Up to 10 images (optional)'}
</CardDescription>
</div>
{previews.length > 0 && (
<Badge variant="secondary">{previews.length}/10</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative group">
<input
type="file"
accept="image/*"
multiple
onChange={handleImageChange}
className="hidden"
id="image-upload"
disabled={!step1Complete}
/>
<label
htmlFor="image-upload"
className={cn(
"block w-full aspect-video rounded-lg border-2 border-dashed transition-all cursor-pointer",
"flex flex-col items-center justify-center",
previews.length > 0
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/30 hover:border-primary hover:bg-muted/50",
!step1Complete && "pointer-events-none"
)}
>
<Upload className="w-8 h-8 text-muted-foreground mb-2 group-hover:text-primary transition-colors" />
<p className="text-sm font-medium text-foreground">{t('uploadDesc')}</p>
<p className="text-xs text-muted-foreground mt-1">{t('uploadMax')}</p>
</label>
</div>
{previews.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{previews.map((src, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden border border-border group">
<img src={src} alt="" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => removeImage(idx)}
className="absolute top-1 right-1 p-1 bg-background/80 rounded-full opacity-0 group-hover:opacity-100 hover:bg-destructive transition-all"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{/* AI Image Option */}
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 border border-border">
<div className="flex items-center gap-3">
<Sparkles className={cn("w-5 h-5", useAiImages ? "text-primary" : "text-muted-foreground")} />
<div>
<p className="text-sm font-medium">{t('aiImageOption')}</p>
<p className="text-xs text-muted-foreground">{t('aiImageDesc')}</p>
</div>
</div>
<Switch
checked={useAiImages}
onCheckedChange={setUseAiImages}
disabled={!step1Complete}
/>
</div>
</CardContent>
</Card>
{/* STEP 3: Auto Data Search */}
<Collapsible
open={openSections.autoSearch}
onOpenChange={(open) => setOpenSections({...openSections, autoSearch: open})}
>
<Card
data-tour="step3"
className={cn(
"transition-all",
!step2Complete && "opacity-60"
)}
>
<CollapsibleTrigger asChild>
<CardHeader className="pb-3 cursor-pointer hover:bg-muted/30 transition-colors rounded-t-lg">
<div className="flex items-center gap-3">
<StepIndicator step={3} completed={false} active={step2Complete} locked={!step2Complete} />
<div className="flex-1">
<CardTitle className="text-base">{t('autoInfoTitle')}</CardTitle>
<CardDescription className="text-xs">
{t('textStyle') === '자막 스타일' ? '네이버/구글에서 자동 가져오기' : 'Auto-fetch from Naver/Google'}
</CardDescription>
</div>
<ChevronDown className={cn("w-4 h-4 transition-transform", openSections.autoSearch && "rotate-180")} />
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-3 pt-0">
{/* Naver */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground flex items-center gap-1">
<span className="w-4 h-4 rounded bg-[#03C75A] flex items-center justify-center text-white text-[8px] font-bold">N</span>
{t('naverPlace')}
</Label>
<div className="flex gap-2">
<Input
type="text"
value={naverUrl}
onChange={(e) => setNaverUrl(e.target.value)}
placeholder="https://naver.me/..."
disabled={!step2Complete}
className="flex-1 h-9 text-sm"
/>
<Button
type="button"
onClick={handleNaverCrawl}
disabled={isCrawling || !naverUrl || !step2Complete}
size="sm"
className="bg-[#03C75A] hover:bg-[#02b351]"
>
{isCrawling ? <Loader2 className="w-4 h-4 animate-spin" /> : t('fetchBtn')}
</Button>
</div>
{crawlStatus && <p className="text-xs text-muted-foreground animate-pulse">{crawlStatus}</p>}
</div>
<div className="relative">
<Separator className="my-2" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
{t('textStyle') === '자막 스타일' ? '또는' : 'OR'}
</span>
</div>
{/* Google */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground flex items-center gap-1">
<Search className="w-3 h-3" />
{t('googleSearch')}
</Label>
<div className="flex gap-2">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleSearch())}
placeholder="Search place name..."
disabled={!step2Complete}
className="flex-1 h-9 text-sm"
/>
<Button
type="button"
onClick={handleSearch}
disabled={isSearching || !searchQuery || !step2Complete}
size="sm"
>
{isSearching ? <Loader2 className="w-4 h-4 animate-spin" /> : t('searchBtn')}
</Button>
</div>
{searchStatus && <p className="text-xs text-primary animate-pulse">{searchStatus}</p>}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* STEP 4: Basic Info */}
<Card
data-tour="step4"
className={cn(
"transition-all",
!step2Complete && "opacity-60",
step3Complete && "border-green-500/50 bg-green-500/5"
)}
>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<StepIndicator step={4} completed={step3Complete} active={step2Complete && !step3Complete} locked={!step2Complete} />
<div className="flex-1">
<CardTitle className="text-base">
{t('textStyle') === '자막 스타일' ? '기본 정보' : 'Basic Info'}
</CardTitle>
<CardDescription className="text-xs">
{t('textStyle') === '자막 스타일' ? '펜션 이름과 설명을 입력하세요' : 'Enter pension name and description'}
</CardDescription>
</div>
{step3Complete && <Badge variant="outline" className="text-green-500 border-green-500/50">{t('textStyle') === '자막 스타일' ? '완료' : 'Done'}</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-xs font-medium">{t('brandName')}</Label>
<Input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="ex: Galaxy Pension"
disabled={!step2Complete}
className="h-11 text-base font-medium"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-medium">{t('brandDesc')}</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('textStyle') === '자막 스타일' ? '펜션의 특징, 분위기, 시설 등을 설명해주세요...' : 'Describe features, atmosphere, facilities...'}
rows={4}
disabled={!step2Complete}
className="resize-none"
/>
</div>
</CardContent>
</Card>
</div>
{/* [RIGHT COLUMN] */}
<div className="lg:col-span-7 space-y-4">
{/* STEP 5: Content Language & Settings */}
<Card
data-tour="step5"
className={cn(
"transition-all",
!step3Complete && "opacity-60"
)}
>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<StepIndicator step={5} completed={step4Complete} active={step3Complete && !step4Complete} locked={!step3Complete} />
<div className="flex-1">
<CardTitle className="text-base">
{t('textStyle') === '자막 스타일' ? '언어 및 영상 설정' : 'Language & Video Settings'}
</CardTitle>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Content Language Select */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-2">
<Globe className="w-4 h-4" />
{t('textStyle') === '자막 스타일' ? '생성 언어 (결과물)' : 'Output Language'}
</Label>
<div className="flex gap-2 flex-wrap">
{(['KO', 'EN', 'JP', 'CN', 'TH', 'VN'] as Language[]).map((lang) => (
<button
key={lang}
type="button"
onClick={() => setContentLanguage(lang)}
disabled={!step3Complete}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all",
contentLanguage === lang
? "bg-primary/10 border-primary text-foreground"
: "bg-card border-border text-muted-foreground hover:bg-muted",
!step3Complete && "pointer-events-none"
)}
>
<span className={cn("fi rounded", FLAG_CLASSES[lang])}></span>
{lang}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Ratio */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<Monitor className="w-3.5 h-3.5" /> {t('ratio')}
</Label>
<div className="flex gap-2">
{(['16:9', '9:16'] as AspectRatio[]).map((r) => (
<button
key={r}
type="button"
onClick={() => setAspectRatio(r)}
disabled={!step3Complete}
className={cn(
"flex-1 py-2.5 rounded-lg border text-sm font-medium transition-all",
aspectRatio === r
? "bg-primary/10 border-primary text-foreground"
: "bg-card border-border text-muted-foreground hover:bg-muted",
!step3Complete && "pointer-events-none"
)}
>
{r}
</button>
))}
</div>
</div>
{/* Visual Style */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<Video className="w-3.5 h-3.5" /> {t('visualStyle')}
</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setVisualStyle('Slideshow')}
disabled={!step3Complete}
className={cn(
"flex-1 py-2.5 rounded-lg border text-sm font-medium transition-all",
visualStyle === 'Slideshow'
? "bg-primary/10 border-primary text-foreground"
: "bg-card border-border text-muted-foreground hover:bg-muted",
!step3Complete && "pointer-events-none"
)}
>
{t('visualSlide')}
</button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => { if(prompt('Admin Password:')==='1234') setVisualStyle('Video'); }}
disabled={!step3Complete}
className={cn(
"flex-1 py-2.5 rounded-lg border text-sm font-medium transition-all",
visualStyle === 'Video'
? "bg-primary/10 border-primary text-foreground"
: "bg-card border-border text-muted-foreground hover:bg-muted",
!step3Complete && "pointer-events-none"
)}
>
{t('visualVideo')}
</button>
</TooltipTrigger>
<TooltipContent>
<p>Admin only</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</CardContent>
</Card>
{/* STEP 6: Text & Transition */}
<div className="grid md:grid-cols-2 gap-4">
<Card
data-tour="step6"
className={cn(
"transition-all",
!step4Complete && "opacity-60"
)}
>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<StepIndicator step={6} completed={step5Complete} active={step4Complete && !step5Complete} locked={!step4Complete} />
<div className="flex-1">
<CardTitle className="text-base flex items-center gap-2">
<Type className="w-4 h-4" /> {t('textStyle')}
</CardTitle>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-2">
{textEffects.map((effect) => (
<button
key={effect.id}
type="button"
onClick={() => setTextEffect(effect.id)}
disabled={!step4Complete}
className={cn(
"p-2 rounded-lg text-left border transition-all relative overflow-hidden",
textEffect === effect.id
? "bg-primary/10 border-primary text-foreground ring-1 ring-primary"
: "bg-card border-border text-muted-foreground hover:bg-muted",
!step4Complete && "pointer-events-none"
)}
>
<div className="text-[10px] font-medium mb-1 truncate">{effect.label}</div>
<div className={cn("text-sm leading-none", effect.previewClass || '')}>Aa</div>
</button>
))}
</div>
<label className={cn(
"flex items-center justify-center gap-2 w-full p-2.5 border border-dashed rounded-lg cursor-pointer transition-all text-xs",
"border-muted-foreground/30 hover:border-primary hover:bg-primary/5",
!step4Complete && "pointer-events-none opacity-50"
)}>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleStyleImageUpload}
disabled={isAnalyzingStyle || !step4Complete}
/>
{isAnalyzingStyle
? <><Loader2 className="w-3 h-3 animate-spin" /> Analyzing...</>
: <><Sparkles className="w-3 h-3 text-yellow-500" /> {t('customStyle')}</>
}
</label>
{visualStyle === 'Slideshow' && (
<div className="pt-2">
<Label className="text-xs font-medium flex items-center gap-2 mb-2">
<Layers className="w-3.5 h-3.5" /> {t('transitionEffect')}
</Label>
<div className="grid grid-cols-2 gap-2">
{transitionOptions.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setTransitionEffect(opt.id)}
disabled={!step4Complete}
className={cn(
"p-2 rounded-lg text-center border text-xs font-medium transition-all",
transitionEffect === opt.id
? "bg-primary/10 border-primary text-foreground"
: "bg-card border-border text-muted-foreground hover:bg-muted",
!step4Complete && "pointer-events-none"
)}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* STEP 7: Audio Settings */}
<Card
data-tour="step7"
className={cn(
"transition-all",
!step5Complete && "opacity-60"
)}
>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<StepIndicator step={7} completed={step6Complete} active={step5Complete && !step6Complete} locked={!step5Complete} />
<div className="flex-1">
<CardTitle className="text-base flex items-center gap-2">
<Volume2 className="w-4 h-4" /> {t('audioSettings')}
</CardTitle>
</div>
</div>
</CardHeader>
<CardContent>
{/* Audio Mode Tabs */}
<div className="flex bg-muted p-1 rounded-lg mb-4">
{(['Song', 'Instrumental', 'Narration'] as AudioMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setAudioMode(mode)}
disabled={!step5Complete}
className={cn(
"flex-1 py-2 rounded-md text-xs font-medium transition-all",
audioMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
!step5Complete && "pointer-events-none"
)}
>
{mode === 'Song' ? t('song') : mode === 'Instrumental' ? t('bgm') : t('narration')}
</button>
))}
</div>
{audioMode === 'Narration' ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground">{t('gender')}</Label>
<div className="flex bg-muted rounded-lg p-1">
{(['Female', 'Male'] as TTSGender[]).map(g => (
<button
key={g}
type="button"
onClick={() => setTTSConfig({...ttsConfig, gender: g})}
disabled={!step5Complete}
className={cn(
"flex-1 py-1.5 rounded text-xs font-medium transition-all",
ttsConfig.gender === g
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground"
)}
>
{g === 'Female' ? 'F' : 'M'}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground">{t('age')}</Label>
<div className="flex bg-muted rounded-lg p-1">
{(['Young', 'Middle'] as TTSAge[]).map(a => (
<button
key={a}
type="button"
onClick={() => setTTSConfig({...ttsConfig, age: a})}
disabled={!step5Complete}
className={cn(
"flex-1 py-1.5 rounded text-xs font-medium transition-all",
ttsConfig.age === a
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground"
)}
>
{a}
</button>
))}
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground">{t('tone')}</Label>
<div className="grid grid-cols-4 gap-1">
{(['Bright', 'Calm', 'Energetic', 'Professional'] as TTSTone[]).map(tone => (
<button
key={tone}
type="button"
onClick={() => setTTSConfig({...ttsConfig, tone})}
disabled={!step5Complete}
className={cn(
"py-1.5 rounded text-[10px] border font-medium transition-all",
ttsConfig.tone === tone
? "bg-primary/10 border-primary text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-muted"
)}
>
{tone}
</button>
))}
</div>
</div>
</div>
) : (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Genre</Label>
<div className="grid grid-cols-3 gap-2">
{activeGenres.map((g) => (
<button
key={g}
type="button"
onClick={() => setMusicGenre(g)}
disabled={!step5Complete}
className={cn(
"p-2.5 rounded-lg text-sm font-medium border transition-all",
musicGenre === g
? "bg-primary/10 border-primary text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-muted",
!step5Complete && "pointer-events-none"
)}
>
{g === 'Auto'
? <span className="flex items-center justify-center gap-1"><Sparkles className="w-3.5 h-3.5" /> Auto</span>
: g
}
</button>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Submit Buttons */}
<div data-tour="submit" className="flex flex-col sm:flex-row gap-3 pt-2">
<Button
type="button"
variant="outline"
size="lg"
onClick={() => handleSubmit(null, 'AudioOnly')}
disabled={isSubmitting || !canSubmit}
className="flex-1 h-14"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin mr-2" />
) : canSubmit ? (
<Music className="w-5 h-5 mr-2" />
) : (
<Lock className="w-5 h-5 mr-2" />
)}
{t('submitAudio')}
</Button>
<Button
type="submit"
size="lg"
disabled={isSubmitting || !canSubmit}
className={cn(
"flex-[2] h-14 text-lg font-bold",
canSubmit && "bg-gradient-to-r from-primary to-accent hover:opacity-90"
)}
>
{isSubmitting ? (
<Loader2 className="w-6 h-6 animate-spin mr-2" />
) : canSubmit ? (
<Sparkles className="w-6 h-6 mr-2" />
) : (
<Lock className="w-6 h-6 mr-2" />
)}
{isSubmitting
? t('processing')
: canSubmit
? t('submitVideo')
: (t('textStyle') === '자막 스타일' ? '모든 단계를 완료하세요' : 'Complete all steps')
}
</Button>
</div>
</div>
</form>
</div>
</>
);
};
export default InputForm;