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:
40
app/api/packs/list-mine/route.ts
Normal file
40
app/api/packs/list-mine/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user