feat(phase3b): 마이페이지 음악 영상화 신청·상태 + 모달 트랙 연결
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
This commit is contained in:
@@ -41,7 +41,7 @@ export async function GET() {
|
|||||||
// 세션 클라이언트로 본인 것만(RLS music_select_own)
|
// 세션 클라이언트로 본인 것만(RLS music_select_own)
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('music_tracks')
|
.from('music_tracks')
|
||||||
.select('id, title, story, lyrics, style, audio_url, task_id, created_at')
|
.select('id, title, story, lyrics, style, audio_url, task_id, created_at, video_status, video_url')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface Props {
|
|||||||
product: { id: string; name: string; price: number };
|
product: { id: string; name: string; price: number };
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
musicTrackId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthState = 'checking' | 'guest' | 'user';
|
type AuthState = 'checking' | 'guest' | 'user';
|
||||||
@@ -29,7 +30,7 @@ interface SuccessInfo {
|
|||||||
reused: boolean;
|
reused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BankTransferModal({ product, isOpen, onClose }: Props) {
|
export default function BankTransferModal({ product, isOpen, onClose, musicTrackId }: Props) {
|
||||||
const [authState, setAuthState] = useState<AuthState>('checking');
|
const [authState, setAuthState] = useState<AuthState>('checking');
|
||||||
const [depositorName, setDepositorName] = useState('');
|
const [depositorName, setDepositorName] = useState('');
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
@@ -98,7 +99,11 @@ export default function BankTransferModal({ product, isOpen, onClose }: Props) {
|
|||||||
const res = await fetch('/api/orders', {
|
const res = await fetch('/api/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ productId: product.id, depositorName: name }),
|
body: JSON.stringify({
|
||||||
|
productId: product.id,
|
||||||
|
depositorName: name,
|
||||||
|
...(musicTrackId ? { musicTrackId } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -122,7 +127,7 @@ export default function BankTransferModal({ product, isOpen, onClose }: Props) {
|
|||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[depositorName, agreed, submitting, product.id],
|
[depositorName, agreed, submitting, product.id, musicTrackId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import type { User } from '@supabase/supabase-js';
|
import type { User } from '@supabase/supabase-js';
|
||||||
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
||||||
|
import BankTransferModal from '@/app/components/BankTransferModal';
|
||||||
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
||||||
import { findCard } from '@/lib/tarot/cards';
|
import { findCard } from '@/lib/tarot/cards';
|
||||||
import {
|
import {
|
||||||
@@ -75,6 +76,8 @@ type MusicTrackRow = {
|
|||||||
story: string | null;
|
story: string | null;
|
||||||
audio_url: string | null;
|
audio_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
video_status?: 'none' | 'requested' | 'in_progress' | 'delivered';
|
||||||
|
video_url?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언
|
// AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언
|
||||||
@@ -167,6 +170,9 @@ function MyPageContent() {
|
|||||||
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
|
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
|
||||||
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
|
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
|
||||||
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
|
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
|
||||||
|
// 음악 영상화 신청 — 계좌이체 모달에 전달할 상품·대상 트랙
|
||||||
|
const [videoProduct, setVideoProduct] = useState<{ id: string; name: string; price: number } | null>(null);
|
||||||
|
const [videoModalTrackId, setVideoModalTrackId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadProjects = useCallback(async () => {
|
const loadProjects = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -202,6 +208,23 @@ function MyPageContent() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 음악 트랙 영상화 신청 — 상품 정보 로드 후 계좌이체 모달 오픈
|
||||||
|
const handleRequestVideo = useCallback(async (trackId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/studio/video-product');
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn('영상화 상품을 불러오지 못했습니다', res.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.product) return;
|
||||||
|
setVideoProduct(data.product);
|
||||||
|
setVideoModalTrackId(trackId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('영상화 상품 조회 중 오류', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
@@ -965,7 +988,11 @@ function MyPageContent() {
|
|||||||
onToggle={() => toggleAiCard(item.data.id)}
|
onToggle={() => toggleAiCard(item.data.id)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MusicAiCard key={`music-${item.data.id}`} track={item.data} />
|
<MusicAiCard
|
||||||
|
key={`music-${item.data.id}`}
|
||||||
|
track={item.data}
|
||||||
|
onRequestVideo={handleRequestVideo}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -973,6 +1000,17 @@ function MyPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 음악 영상화 신청 — 계좌이체 모달 (영상화 상품 로드 완료 시에만 오픈) */}
|
||||||
|
<BankTransferModal
|
||||||
|
isOpen={!!videoModalTrackId && !!videoProduct}
|
||||||
|
product={videoProduct ?? { id: '', name: '', price: 0 }}
|
||||||
|
musicTrackId={videoModalTrackId ?? undefined}
|
||||||
|
onClose={() => {
|
||||||
|
setVideoModalTrackId(null);
|
||||||
|
loadAiRecords();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1594,8 +1632,14 @@ function TarotAiCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시).
|
// AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시) + 영상화 신청/상태.
|
||||||
function MusicAiCard({ track }: { track: MusicTrackRow }) {
|
function MusicAiCard({
|
||||||
|
track,
|
||||||
|
onRequestVideo,
|
||||||
|
}: {
|
||||||
|
track: MusicTrackRow;
|
||||||
|
onRequestVideo: (trackId: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card compact>
|
<Card compact>
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
@@ -1624,14 +1668,68 @@ function MusicAiCard({ track }: { track: MusicTrackRow }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{track.audio_url ? (
|
{track.audio_url ? (
|
||||||
<audio controls className="w-full" style={{ height: 36 }}>
|
<audio controls className="w-full mb-3" style={{ height: 36 }}>
|
||||||
<source src={track.audio_url} />
|
<source src={track.audio_url} />
|
||||||
</audio>
|
</audio>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
|
<p className="text-xs mb-3" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||||
생성 준비 중입니다. 잠시 후 다시 확인해주세요.
|
생성 준비 중입니다. 잠시 후 다시 확인해주세요.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="pt-3 border-t" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||||
|
{(!track.video_status || track.video_status === 'none') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRequestVideo(track.id)}
|
||||||
|
className="px-4 py-2 rounded-lg text-xs font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||||
|
style={{ background: 'var(--jsm-accent)' }}
|
||||||
|
>
|
||||||
|
영상화 신청
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track.video_status === 'requested' && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||||
|
>
|
||||||
|
영상화 접수됨 · 입금 확인 중
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track.video_status === 'in_progress' && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||||
|
>
|
||||||
|
영상 제작 중
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track.video_status === 'delivered' && (
|
||||||
|
track.video_url ? (
|
||||||
|
<div>
|
||||||
|
<video controls src={track.video_url} className="w-full rounded-lg mb-2" />
|
||||||
|
<a
|
||||||
|
href={track.video_url}
|
||||||
|
download
|
||||||
|
className="text-xs font-semibold underline"
|
||||||
|
style={{ color: 'var(--jsm-accent)' }}
|
||||||
|
>
|
||||||
|
영상 다운로드
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||||
|
>
|
||||||
|
영상 완료
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user