diff --git a/index.css b/index.css index 33d423b..403428b 100644 --- a/index.css +++ b/index.css @@ -9382,6 +9382,232 @@ border: 1px solid rgba(255, 255, 255, 0.06); } +.myinfo-credits-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.myinfo-credits-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.myinfo-credits-label { + color: rgba(255, 255, 255, 0.6); + font-size: 0.95rem; +} + + +.myinfo-credits-value { + color: #a6ffea; + font-size: 1.4rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.myinfo-credits-desc { + color: rgba(255, 255, 255, 0.4); + font-size: 0.85rem; + margin: 0; + flex: 1; +} + +.myinfo-credits-charge-btn { + padding: 0.75rem 1.25rem; + background: linear-gradient(135deg, #a6ffea 0%, #7ee8cf 100%); + border: none; + border-radius: 8px; + color: #002224; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s ease; +} + +.myinfo-credits-charge-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(166, 255, 234, 0.3); +} + +.myinfo-popup-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.myinfo-popup { + background: #132034; + border: 1px solid rgba(78, 205, 196, 0.3); + border-radius: 16px; + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1.25rem; + width: 380px; + max-width: 90vw; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.myinfo-popup-title { + color: #fff; + font-size: 1.05rem; + font-weight: 700; + margin: 0 0 0.5rem; + align-self: flex-start; +} + +.myinfo-popup-message { + color: #fff; + font-size: 1.1rem; + font-weight: 600; + text-align: center; + margin: 0; +} + +.myinfo-popup-field { + display: flex; + flex-direction: column; + gap: 0.4rem; + width: 100%; +} + +.myinfo-popup-label { + color: rgba(255, 255, 255, 0.6); + font-size: 0.85rem; +} + +.myinfo-popup-counter { + display: flex; + align-items: center; + gap: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + overflow: hidden; +} + +.myinfo-popup-counter-btn { + background: rgba(255, 255, 255, 0.08); + border: none; + color: #fff; + font-size: 1.25rem; + width: 48px; + min-width: 48px; + height: 44px; + cursor: pointer; + transition: background 0.15s; + flex-shrink: 0; +} + +.myinfo-popup-counter-btn:hover { + background: rgba(255, 255, 255, 0.15); +} + +.myinfo-popup-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #fff; + font-size: 0.95rem; + padding: 0.6rem 0.875rem; + outline: none; + width: 100%; + box-sizing: border-box; +} + +.myinfo-popup-input--center { + border: none; + border-left: 1px solid rgba(255, 255, 255, 0.12); + border-right: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0; + text-align: center; + flex: 1; + height: 44px; + padding: 0; +} + +.myinfo-popup-input--center::-webkit-inner-spin-button, +.myinfo-popup-input--center::-webkit-outer-spin-button { + -webkit-appearance: none; +} + +.myinfo-popup-input:focus { + border-color: rgba(78, 205, 196, 0.5); +} + +.myinfo-popup-textarea { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #fff; + font-size: 0.9rem; + padding: 0.6rem 0.875rem; + outline: none; + resize: none; + width: 100%; + box-sizing: border-box; + font-family: inherit; +} + +.myinfo-popup-textarea:focus { + border-color: rgba(78, 205, 196, 0.5); +} + +.myinfo-popup-actions { + display: flex; + gap: 0.75rem; + width: 100%; + justify-content: flex-end; + margin-top: 0.25rem; +} + +.myinfo-popup-cancel { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 0.6rem 1.25rem; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.2s; +} + +.myinfo-popup-cancel:hover { + background: rgba(255, 255, 255, 0.13); +} + +.myinfo-popup-close { + background: linear-gradient(135deg, #a6ffea 0%, #7ee8cf 100%); + color: #002224; + border: none; + border-radius: 8px; + padding: 0.6rem 1.5rem; + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; + transition: opacity 0.2s; +} + +.myinfo-popup-close:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.myinfo-popup-close:not(:disabled):hover { + opacity: 0.85; +} + /* 내 비즈니스 카드 */ .myinfo-business-card { background: rgba(255, 255, 255, 0.03); diff --git a/src/locales/en.json b/src/locales/en.json index fb1fb19..f738be4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -431,7 +431,22 @@ "connected": "Connected", "disconnectAccount": "Disconnect", "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": { "title": "ADO2 Contents", diff --git a/src/locales/ko.json b/src/locales/ko.json index 962d940..cbc5746 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -431,7 +431,22 @@ "connected": "연결됨", "disconnectAccount": "연결 해제", "loadingAccounts": "계정 정보를 불러오는 중...", - "youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다." + "youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.", + "creditsTitle": "크레딧 현황", + "creditsLoading": "로딩 중...", + "creditsLabel": "보유 크레딧", + "creditsUnit": "크레딧", + "creditsDesc": "크레딧이 부족하면 관리자에게 충전 요청할 수 있습니다.", + "creditsChargeBtn": "충전하기", + "chargePopupTitle": "크레딧 충전 요청", + "chargeAmountLabel": "충전 크레딧", + "chargeNoteLabel": "기타 내용", + "chargeNotePlaceholder": "관리자에게 전달할 내용을 입력해주세요", + "chargeCancel": "취소", + "chargeSubmit": "요청하기", + "chargeSubmitting": "요청 중...", + "chargeSuccess": "충전 요청이 완료 되었습니다!", + "chargeConfirm": "확인" }, "ado2Contents": { "title": "ADO2 콘텐츠", diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index 5b0e14f..16b161e 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -12,7 +12,6 @@ interface CompletionContentProps { songTaskId: string | null; onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void; onVideoProgressChange?: (progress: number) => void; - onVideoComplete?: () => void; onGoToCalendar?: () => void; } @@ -35,7 +34,6 @@ const CompletionContent: React.FC = ({ songTaskId, onVideoStatusChange, onVideoProgressChange, - onVideoComplete, onGoToCalendar, }) => { const { t } = useTranslation(); @@ -259,7 +257,6 @@ const CompletionContent: React.FC = ({ setVideoStatus('complete'); setStatusMessage(''); saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId); - onVideoComplete?.(); } else { throw new Error(t('completion.videoUrlMissing')); } diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 1f50839..0709821 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -118,6 +118,7 @@ const GenerationFlow: React.FC = ({ const [isAnalysisComplete, setIsAnalysisComplete] = useState(false); const [userInfo, setUserInfo] = useState(null); const [credits, setCredits] = useState(null); + const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined); const tutorial = useTutorial(); const refreshCredits = useCallback(async () => { @@ -247,7 +248,8 @@ const GenerationFlow: React.FC = ({ setIsAnalysisComplete(true); } catch (err) { console.error('Autocomplete error:', err); - setAnalysisError(err instanceof Error ? err.message : t('app.autocompleteError')); + const msg = err instanceof Error ? err.message : ''; + setAnalysisError(/^HTTP error!/.test(msg) ? t('app.autocompleteError') : (msg || t('app.autocompleteError'))); goToWizardStep(-2); // URL 입력으로 돌아가기 } }; @@ -366,8 +368,15 @@ const GenerationFlow: React.FC = ({ } }, [activeItem]); + // 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용) + const handleGoToPayment = () => { + setMyInfoInitialTab('payment'); + setActiveItem('내 정보'); + }; + // 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화 const handleNavigate = (item: string) => { + setMyInfoInitialTab(undefined); if (item === '새 프로젝트 만들기') { // 기존 프로젝트 데이터 초기화 clearAllProjectStorage(); @@ -455,6 +464,7 @@ const GenerationFlow: React.FC = ({ mId={analysisData?.m_id ?? 0} videoGenerationStatus={videoGenerationStatus} 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); @@ -479,7 +489,6 @@ const GenerationFlow: React.FC = ({ songTaskId={songTaskId} onVideoStatusChange={setVideoGenerationStatus} onVideoProgressChange={setVideoGenerationProgress} - onVideoComplete={refreshCredits} onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')} /> ); @@ -504,7 +513,7 @@ const GenerationFlow: React.FC = ({ case '콘텐츠 캘린더': return ; case '내 정보': - return ; + return ; case '새 프로젝트 만들기': // 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시 if (wizardStep === 0 || wizardStep === -1) { diff --git a/src/pages/Dashboard/MyInfoContent.tsx b/src/pages/Dashboard/MyInfoContent.tsx index 9594bae..102e8a3 100644 --- a/src/pages/Dashboard/MyInfoContent.tsx +++ b/src/pages/Dashboard/MyInfoContent.tsx @@ -1,18 +1,53 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api'; +import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api'; import { SocialAccount } from '../../types/api'; type TabType = 'basic' | 'payment' | 'business'; -const MyInfoContent: React.FC = () => { +interface MyInfoContentProps { + initialTab?: TabType; +} + +const MyInfoContent: React.FC = ({ initialTab }) => { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState('business'); + const [activeTab, setActiveTab] = useState(initialTab || 'business'); const [businessUrl, setBusinessUrl] = useState(''); const [socialAccounts, setSocialAccounts] = useState([]); const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); const [isConnecting, setIsConnecting] = useState(null); + const [credits, setCredits] = useState(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(() => { @@ -84,6 +119,74 @@ const MyInfoContent: React.FC = () => { ]; return ( + <> + {showChargePopup && ( +
{ setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}> +
e.stopPropagation()}> + {chargeSuccess ? ( + <> +

{t('myInfo.chargeSuccess')}

+ + + ) : ( + <> +

{t('myInfo.chargePopupTitle')}

+
+ +
+ + setChargeAmount(e.target.value.replace(/[^0-9]/g, ''))} + min={1} + /> + +
+
+
+ +