/// 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 = { '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 = { 'KO': 'fi-kr', 'EN': 'fi-us', 'JP': 'fi-jp', 'CN': 'fi-cn', 'TH': 'fi-th', 'VN': 'fi-vn' }; const InputForm: React.FC = ({ 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(uiLanguage); // Sync content language with UI language useEffect(() => { setContentLanguage(uiLanguage); }, [uiLanguage]); // 펜션 카테고리 (복수 선택) const [pensionCategories, setPensionCategories] = useState([]); // 상태 관리 const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [address, setAddress] = useState(''); const [category, setCategory] = useState(''); const [images, setImages] = useState([]); const [previews, setPreviews] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [isSearching, setIsSearching] = useState(false); const [searchStatus, setSearchStatus] = useState(''); const [mapLink, setMapLink] = useState(null); const [naverUrl, setNaverUrl] = useState(''); const [isCrawling, setIsCrawling] = useState(false); const [crawlStatus, setCrawlStatus] = useState(''); const [audioMode, setAudioMode] = useState('Song'); const [musicGenre, setMusicGenre] = useState('Auto'); const [musicDuration, setMusicDuration] = useState('Short'); const [activeGenres, setActiveGenres] = useState(GENRE_BY_LANG['KO']); const [ttsConfig, setTTSConfig] = useState({ gender: 'Female', age: 'Young', tone: 'Bright' }); const [visualStyle, setVisualStyle] = useState<'Video' | 'Slideshow'>('Slideshow'); const [transitionEffect, setTransitionEffect] = useState('Mix'); const [aspectRatio, setAspectRatio] = useState('9:16'); const [textEffect, setTextEffect] = useState('Cinematic'); const [customStyle, setCustomStyle] = useState(''); 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) => { 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) => { 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 }) => (
{locked ? : completed ? : step}
); return ( <> {showTour && ( )}
{/* Header */}

{t('appTitle')}

{t('appSubtitle')}

{/* Progress indicator */}
{t('textStyle') === '자막 스타일' ? '진행률' : 'Progress'} {progressPercent}%
handleSubmit(e, 'Full')} className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start"> {/* [LEFT COLUMN] */}
{/* STEP 1: Pension Category */}
{t('pensionCategory')} {t('textStyle') === '자막 스타일' ? '복수 선택 가능' : 'Multiple selection allowed'}
{step1Complete && {t('textStyle') === '자막 스타일' ? '완료' : 'Done'}}
{PENSION_CATEGORIES.map((cat) => { const isSelected = pensionCategories.includes(cat.id); return ( ); })}
{/* STEP 2: Image Upload */}
{t('uploadTitle')} {t('textStyle') === '자막 스타일' ? '최대 10장 (선택사항)' : 'Up to 10 images (optional)'}
{previews.length > 0 && ( {previews.length}/10 )}
{previews.length > 0 && (
{previews.map((src, idx) => (
))}
)} {/* AI Image Option */}

{t('aiImageOption')}

{t('aiImageDesc')}

{/* STEP 3: Auto Data Search */} setOpenSections({...openSections, autoSearch: open})} >
{t('autoInfoTitle')} {t('textStyle') === '자막 스타일' ? '네이버/구글에서 자동 가져오기' : 'Auto-fetch from Naver/Google'}
{/* Naver */}
setNaverUrl(e.target.value)} placeholder="https://naver.me/..." disabled={!step2Complete} className="flex-1 h-9 text-sm" />
{crawlStatus &&

{crawlStatus}

}
{t('textStyle') === '자막 스타일' ? '또는' : 'OR'}
{/* Google */}
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" />
{searchStatus &&

{searchStatus}

}
{/* STEP 4: Basic Info */}
{t('textStyle') === '자막 스타일' ? '기본 정보' : 'Basic Info'} {t('textStyle') === '자막 스타일' ? '펜션 이름과 설명을 입력하세요' : 'Enter pension name and description'}
{step3Complete && {t('textStyle') === '자막 스타일' ? '완료' : 'Done'}}
setName(e.target.value)} placeholder="ex: Galaxy Pension" disabled={!step2Complete} className="h-11 text-base font-medium" />