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:
2026-07-09 23:05:17 +09:00
parent c5c9487874
commit 6b6f1dbe0e
3 changed files with 112 additions and 9 deletions

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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>
); );
} }