feat(mypage): 다운로드 버튼 활성화 (Phase 2) + status 분기

- packFiles state + /api/packs/list-mine fetch (RLS 우회 위해 admin client 라우트)
- handleDownload: /api/packs/sign-link 호출 → window.location 이동
- 카드: 자료 리스트 DB SSOT (PACK_ASSETS.files 폐기)
- order.status === 'completed' 만 다운로드 활성, 그 외는 Phase 1 placeholder 유지
- 4시간 만료 안내 추가

빌드 복구: B3에서 깨진 mypage 빌드를 이번 commit이 복구.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 09:11:08 +09:00
parent c94ec83986
commit 774835a37a
2 changed files with 130 additions and 34 deletions

View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
import { tierIncludes, getPackFilesForTiers } from '@/lib/supabase/pack-files';
export const runtime = 'nodejs';
export async function GET() {
const cookieStore = await cookies();
const supabase = createSSRClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: () => {},
},
},
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ files: [] });
const admin = createAdminClient();
const { data: orders } = await admin
.from('contact_requests')
.select('service, status')
.eq('user_id', user.id)
.eq('status', 'completed');
const tiers = new Set<PackTier>();
for (const o of (orders ?? [])) {
const t = extractPackTier(o.service);
if (t) tierIncludes(t).forEach((x) => tiers.add(x));
}
const files = await getPackFilesForTiers(admin, Array.from(tiers));
return NextResponse.json({ files });
}

View File

@@ -6,7 +6,8 @@ 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 { PACK_ASSETS, extractPackTier, type PackTier } from '@/lib/pack-assets'; import { PACK_TIER_NAMES, extractPackTier, type PackTier } from '@/lib/pack-assets';
import type { PackFile } from '@/lib/supabase/pack-files';
function buildSajuResultUrl(rec: SajuRecord) { function buildSajuResultUrl(rec: SajuRecord) {
const { birth_year, birth_month, birth_day, birth_hour, gender } = rec.saju_data; const { birth_year, birth_month, birth_day, birth_hour, gender } = rec.saju_data;
@@ -92,6 +93,8 @@ export default function MyPage() {
const [linkToken, setLinkToken] = useState(''); const [linkToken, setLinkToken] = useState('');
const [linking, setLinking] = useState(false); const [linking, setLinking] = useState(false);
const [linkMessage, setLinkMessage] = useState(''); const [linkMessage, setLinkMessage] = useState('');
const [packFiles, setPackFiles] = useState<PackFile[]>([]);
const [downloading, setDownloading] = useState<string | null>(null);
// 텔레그램 연동 상태 // 텔레그램 연동 상태
const [telegramChatId, setTelegramChatId] = useState<string | null>(null); const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
@@ -158,6 +161,13 @@ export default function MyPage() {
setProjects(projData.projects ?? []); setProjects(projData.projects ?? []);
} }
// 구매한 팩 자료 파일 조회
const filesRes = await fetch('/api/packs/list-mine');
if (filesRes.ok) {
const { files } = await filesRes.json();
setPackFiles(files ?? []);
}
setLoading(false); setLoading(false);
} }
init(); init();
@@ -240,6 +250,26 @@ export default function MyPage() {
setTelegramLinkState('idle'); setTelegramLinkState('idle');
}; };
async function handleDownload(fileId: string) {
setDownloading(fileId);
try {
const res = await fetch('/api/packs/sign-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId }),
});
const data = await res.json();
if (!res.ok || !data.url) {
throw new Error(data.error ?? '링크 발급 실패');
}
window.location.href = data.url;
} catch (e) {
alert(e instanceof Error ? e.message : '다운로드 준비 중 오류가 발생했습니다');
} finally {
setDownloading(null);
}
}
const handleLinkProject = async (e: React.FormEvent) => { const handleLinkProject = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!linkToken.trim()) return; if (!linkToken.trim()) return;
@@ -777,7 +807,6 @@ export default function MyPage() {
/> />
) : ( ) : (
packOrders.map(({ order, tier }) => { packOrders.map(({ order, tier }) => {
const asset = PACK_ASSETS[tier];
const statusLabel = const statusLabel =
order.status === 'completed' ? '자료 발송 완료' : order.status === 'completed' ? '자료 발송 완료' :
order.status === 'in_progress' ? '결제 처리 중' : order.status === 'in_progress' ? '결제 처리 중' :
@@ -791,7 +820,7 @@ export default function MyPage() {
<div key={order.id} className="bg-white rounded-2xl border border-slate-200 p-6"> <div key={order.id} className="bg-white rounded-2xl border border-slate-200 p-6">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div> <div>
<div className="font-bold text-slate-900 text-base">{asset.name}</div> <div className="font-bold text-slate-900 text-base">{PACK_TIER_NAMES[tier]}</div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 mt-1">
{new Date(order.created_at).toLocaleDateString('ko-KR')} {new Date(order.created_at).toLocaleDateString('ko-KR')}
</div> </div>
@@ -801,38 +830,65 @@ export default function MyPage() {
</span> </span>
</div> </div>
<div className="border-t border-slate-100 pt-4"> {/* 자료 리스트 — DB가 SSOT */}
<div className="text-sm font-semibold text-slate-700 mb-3"> {(() => {
📦 ({asset.files.length}) const filesForTier = packFiles.filter((pf) => {
</div> if (tier === 'starter') return pf.min_tier === 'starter';
<ul className="space-y-2 mb-5"> if (tier === 'pro') return pf.min_tier === 'starter' || pf.min_tier === 'pro';
{asset.files.map((file, i) => ( return true; // master
<li key={i} className="flex items-center gap-2 text-sm text-slate-600"> });
<span className="text-slate-400">·</span>
<span>{file}</span>
</li>
))}
</ul>
<button return (
disabled <div className="border-t border-slate-100 pt-4">
className="w-full py-3 rounded-xl text-sm font-bold bg-slate-100 text-slate-400 cursor-not-allowed" <div className="text-sm font-semibold text-slate-700 mb-3">
> 📦 ({filesForTier.length})
</div>
</button> {filesForTier.length === 0 ? (
<p className="text-xs text-slate-500 mt-2 text-center leading-relaxed"> <p className="text-xs text-slate-500"> . 1:1로 .</p>
1:1로 . . ) : (
<br /> <ul className="space-y-2 mb-3">
<a {filesForTier.map((f) => (
href="https://open.kakao.com/o/s9stoNvb" <li key={f.id} className="flex items-center justify-between gap-2 text-sm">
target="_blank" <span className="text-slate-700 flex-1">{f.label}</span>
rel="noopener noreferrer" {order.status === 'completed' ? (
className="text-violet-600 hover:underline font-semibold" <button
> onClick={() => handleDownload(f.id)}
disabled={downloading === f.id}
</a> className="px-3 py-1.5 rounded-lg text-xs font-bold bg-violet-600 hover:bg-violet-500 disabled:bg-slate-300 text-white transition"
</p> >
</div> {downloading === f.id ? '준비중...' : '다운로드'}
</button>
) : (
<span className="text-xs text-slate-400"> </span>
)}
</li>
))}
</ul>
)}
{order.status === 'completed' && filesForTier.length > 0 && (
<p className="text-xs text-slate-500 leading-relaxed">
4 .
</p>
)}
{order.status !== 'completed' && (
<p className="text-xs text-slate-500 mt-2 text-center leading-relaxed">
{order.status === 'in_progress' ? '결제 처리 중. 자료는 결제 확인 후 활성화됩니다.' : '입금 대기 중. 카톡 1:1로 안내드립니다.'}
<br />
<a
href="https://open.kakao.com/o/s9stoNvb"
target="_blank"
rel="noopener noreferrer"
className="text-violet-600 hover:underline font-semibold"
>
</a>
</p>
)}
</div>
);
})()}
</div> </div>
); );
}) })