chore(phase0): packages·subscription 제거 — 페이지/API/cron/vercel.json + 파급(stats·members·saju) 수정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:07:56 +09:00
parent 88fe56163d
commit 8e1cf9b4e1
14 changed files with 13 additions and 534 deletions

View File

@@ -7,7 +7,6 @@ interface Stats {
totalOrders: number;
totalRevenue: number;
pendingContacts: number;
activeSubscribers: number;
monthlyChart: Array<{ month: string; revenue: number }>;
}
@@ -157,17 +156,6 @@ export default function AdminDashboard() {
</svg>
}
/>
<StatCard
label="활성 구독자"
value={`${stats?.activeSubscribers ?? 0}`}
color="bg-amber-500/20 text-amber-400"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
}
/>
<StatCard
label="미처리 문의"
value={`${stats?.pendingContacts ?? 0}`}

View File

@@ -9,15 +9,8 @@ interface Member {
created_at: string;
orderCount: number;
totalPaid: number;
activeSub: { product_id: string; status: string; expires_at: string } | null;
}
const PLAN_LABELS: Record<string, string> = {
lotto_gold: '🥇 골드',
lotto_platinum: '💎 플래티넘',
lotto_diamond: '👑 다이아',
};
export default function AdminMembersPage() {
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
@@ -77,7 +70,6 @@ export default function AdminMembersPage() {
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-right px-5 py-3 text-slate-400 font-medium"> </th>
<th className="text-right px-5 py-3 text-slate-400 font-medium"> </th>
</tr>
@@ -90,16 +82,6 @@ export default function AdminMembersPage() {
<td className="px-5 py-3 text-slate-400">
{new Date(m.created_at).toLocaleDateString('ko-KR')}
</td>
<td className="px-5 py-3">
{m.activeSub ? (
<div>
<span className="text-xs font-semibold text-amber-400">{PLAN_LABELS[m.activeSub.product_id] ?? m.activeSub.product_id}</span>
<div className="text-xs text-slate-500">{new Date(m.activeSub.expires_at).toLocaleDateString('ko-KR')} </div>
</div>
) : (
<span className="text-xs text-slate-600">-</span>
)}
</td>
<td className="px-5 py-3 text-right">
<span className={`px-2 py-0.5 rounded-full text-xs ${m.orderCount > 0 ? 'bg-green-900/40 text-green-400' : 'bg-slate-700 text-slate-500'}`}>
{m.orderCount}
@@ -124,11 +106,6 @@ export default function AdminMembersPage() {
<p className="text-white text-sm font-semibold truncate">{m.email ?? '-'}</p>
<p className="text-slate-400 text-xs mt-0.5">{m.full_name ?? '이름 없음'}</p>
</div>
{m.activeSub && (
<span className="ml-2 flex-shrink-0 text-xs font-semibold text-amber-400 bg-amber-900/20 px-2 py-0.5 rounded-full">
{PLAN_LABELS[m.activeSub.product_id] ?? m.activeSub.product_id}
</span>
)}
</div>
{/* 상세 정보 그리드 */}
@@ -150,12 +127,6 @@ export default function AdminMembersPage() {
</p>
</div>
</div>
{m.activeSub && (
<p className="text-slate-600 text-xs mt-2">
: {new Date(m.activeSub.expires_at).toLocaleDateString('ko-KR')}
</p>
)}
</div>
))}
</div>

View File

@@ -27,14 +27,12 @@ export async function GET() {
// 각 회원의 주문 수 + 결제 금액 집계
const enriched = await Promise.all(
(profiles ?? []).map(async (p: { id: string; email: string; full_name: string; created_at: string }) => {
const [ordersRes, paymentsRes, subsRes] = await Promise.all([
const [ordersRes, paymentsRes] = await Promise.all([
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('user_id', p.id).eq('status', 'paid'),
supabase.from('payments').select('amount').eq('user_id', p.id).eq('status', 'paid'),
supabase.from('subscriptions').select('product_id, status, expires_at').eq('user_id', p.id).eq('status', 'active').order('created_at', { ascending: false }).limit(1),
]);
const totalPaid = (paymentsRes.data ?? []).reduce((s: number, x: { amount: number }) => s + x.amount, 0);
const activeSub = subsRes.data?.[0] ?? null;
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid, activeSub };
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid };
})
);

View File

@@ -54,6 +54,5 @@ const DEFAULT_SERVICES = [
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 },
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
{ id: 'packages', name: 'SaaS 제품 허브(구)', description: '구 /packages 페이지', is_active: false, order_index: 104 },
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
];

View File

@@ -15,20 +15,18 @@ export async function GET() {
const supabase = createAdminClient();
// 병렬 쿼리
const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes, subsRes] = await Promise.all([
const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes] = await Promise.all([
supabase.from('profiles').select('id', { count: 'exact', head: true }),
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('status', 'paid'),
supabase.from('payments').select('amount').eq('status', 'paid'),
supabase.from('contact_requests').select('id', { count: 'exact', head: true }).eq('status', 'pending'),
supabase.from('payments').select('amount, created_at').eq('status', 'paid').order('created_at', { ascending: true }),
supabase.from('subscriptions').select('id', { count: 'exact', head: true }).eq('status', 'active'),
]);
const totalMembers = profilesRes.count ?? 0;
const totalOrders = ordersRes.count ?? 0;
const totalRevenue = (paymentsRes.data ?? []).reduce((sum: number, p: { amount: number }) => sum + p.amount, 0);
const pendingContacts = contactsRes.count ?? 0;
const activeSubscribers = subsRes.count ?? 0;
// 최근 6개월 월별 수익 집계
const monthly: Record<string, number> = {};
@@ -49,5 +47,5 @@ export async function GET() {
const monthlyChart = Object.entries(monthly).map(([month, revenue]) => ({ month, revenue }));
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, activeSubscribers, monthlyChart });
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, monthlyChart });
}

View File

@@ -1,78 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { sendMessage } from '@/lib/telegram';
/**
* GET /api/cron/subscription-expiry
* Vercel Cron: 매일 01:00 KST (UTC 16:00) 실행
* - 만료된 구독 → status='expired'
* - 3일 후 만료 예정 구독 → 텔레그램 알림 발송
*/
export async function GET(req: NextRequest) {
// Vercel Cron 인증
const authHeader = req.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
const supabase = createAdminClient();
const now = new Date().toISOString();
const in3days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
// 1. 만료된 구독 처리
const { data: expired, error: expireError } = await supabase
.from('subscriptions')
.update({ status: 'expired' })
.eq('status', 'active')
.lt('expires_at', now)
.select('id, user_id, product_id');
if (expireError) {
console.error('subscription expiry error:', expireError);
}
// 2. 3일 후 만료 예정 → 텔레그램 알림
const { data: expiringSoon } = await supabase
.from('subscriptions')
.select('id, user_id, product_id, expires_at, profiles!inner(telegram_chat_id)')
.eq('status', 'active')
.eq('auto_renew', false)
.lt('expires_at', in3days)
.gt('expires_at', now);
const PLAN_NAMES: Record<string, string> = {
lotto_gold: '🥇 골드',
lotto_platinum: '💎 플래티넘',
lotto_diamond: '👑 다이아',
};
let notified = 0;
if (expiringSoon) {
for (const sub of expiringSoon) {
const profile = sub.profiles as unknown as { telegram_chat_id: string | null };
const chatId = profile?.telegram_chat_id;
if (!chatId) continue;
const expiresAt = new Date(sub.expires_at).toLocaleDateString('ko-KR');
const planName = PLAN_NAMES[sub.product_id] ?? sub.product_id;
await sendMessage(
chatId,
`⏰ *구독 만료 안내*\n\n` +
`로또 번호 추천 *${planName}* 플랜이\n` +
`*${expiresAt}*에 만료됩니다.\n\n` +
`지속적인 번호 추천을 받으시려면\n` +
`마이페이지에서 구독을 갱신해 주세요.\n\n` +
`👉 https://jaengseung-made.com/mypage`
);
notified++;
}
}
return NextResponse.json({
ok: true,
expired_count: expired?.length ?? 0,
notified_count: notified,
processed_at: now,
});
}

View File

@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
/**
* PATCH /api/subscription/[id]
* action: 'cancel' | 'toggle_autorenew'
*
* cancel — 구독 즉시 해지 (status='cancelled', auto_renew=false)
* toggle_autorenew — 자동갱신 on/off 전환
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
const { id } = await params;
let body: { action?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 });
}
const { action } = body;
// 본인 구독인지 확인
const { data: sub, error: fetchError } = await supabase
.from('subscriptions')
.select('id, status, auto_renew, expires_at')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle();
if (fetchError || !sub) {
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
}
if (action === 'cancel') {
if (sub.status === 'cancelled') {
return NextResponse.json({ error: 'ALREADY_CANCELLED' }, { status: 400 });
}
const { error } = await supabase
.from('subscriptions')
.update({
status: 'cancelled',
auto_renew: false,
cancelled_at: new Date().toISOString(),
})
.eq('id', id);
if (error) {
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
}
return NextResponse.json({
ok: true,
message: '구독이 해지되었습니다. 만료일까지는 서비스를 계속 이용할 수 있습니다.',
expires_at: sub.expires_at,
});
}
if (action === 'toggle_autorenew') {
if (sub.status === 'cancelled' || sub.status === 'expired') {
return NextResponse.json({ error: 'SUBSCRIPTION_NOT_ACTIVE' }, { status: 400 });
}
const newValue = !sub.auto_renew;
const { error } = await supabase
.from('subscriptions')
.update({ auto_renew: newValue })
.eq('id', id);
if (error) {
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
}
return NextResponse.json({ ok: true, auto_renew: newValue });
}
return NextResponse.json({ error: 'INVALID_ACTION' }, { status: 400 });
}

View File

@@ -1,31 +0,0 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
/**
* GET /api/subscription
* 내 활성/만료 구독 목록 조회
* - auth 검증은 anon client, DB 조회는 admin client (RLS 우회)
*/
export async function GET() {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
// admin client로 RLS 우회 (subscriptions 테이블 SELECT 정책 없을 때도 동작)
const admin = createAdminClient();
const { data, error } = await admin
.from('subscriptions')
.select('id, product_id, status, auto_renew, started_at, expires_at, cancelled_at')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
if (error) {
return NextResponse.json({ error: 'DB_ERROR', detail: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true, subscriptions: data ?? [] });
}

View File

@@ -1,21 +0,0 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { isServiceVisible } from '@/lib/service-visibility';
export const metadata: Metadata = {
title: 'SaaS 제품 · 월 구독 패키지',
description:
'현직 엔지니어가 실제 운영하며 검증한 자동화를 월 구독 SaaS 제품으로 제공합니다. 첫 제품 준비 중 — 출시 알림을 신청하세요.',
keywords: ['SaaS', '자동화 구독', '월 구독 자동화', 'AI 자동화 제품', '쟁승메이드'],
openGraph: {
title: 'SaaS 제품 · 월 구독 패키지 | 쟁승메이드',
description:
'검증된 자동화를 SaaS로. 현직 엔지니어가 직접 운영·검증한 자동화 제품 카탈로그.',
url: 'https://jaengseung-made.com/packages',
},
};
export default async function PackagesLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('packages'))) notFound();
return <>{children}</>;
}

View File

@@ -1,173 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import ContactModal from '@/app/components/ContactModal';
import { trackCTAClick } from '@/lib/gtag';
import {
getAvailablePackages,
getComingSoonPackages,
type SaasCatalogItem,
} from '@/lib/saas-catalog';
const WAITLIST_SERVICE = 'SaaS 출시 알림 신청';
function PackageCard({ pkg, dimmed }: { pkg: SaasCatalogItem; dimmed?: boolean }) {
const inner = (
<>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] uppercase tracking-widest text-white/50">
{pkg.category}
</p>
{pkg.badge && (
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border border-white/30 text-white/80">
{pkg.badge}
</span>
)}
{dimmed && !pkg.badge && (
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border border-white/20 text-white/50">
Coming Soon
</span>
)}
</div>
<h3 className="kx-display text-xl font-bold text-white mb-1.5">{pkg.name}</h3>
<p className="text-sm text-white/70 mb-3">{pkg.tagline}</p>
<p className="text-xs text-white/55 leading-relaxed mb-4 flex-1">{pkg.description}</p>
<ul className="space-y-2 mb-5">
{pkg.features.map((f) => (
<li key={f} className="flex gap-2 text-xs text-white/70">
<span className="text-white/40">·</span>
<span className="leading-relaxed">{f}</span>
</li>
))}
</ul>
<div className="mt-auto flex items-center justify-between">
{pkg.priceLabel ? (
<span className="font-mono text-sm text-white">{pkg.priceLabel}</span>
) : (
<span className="font-mono text-xs text-white/40"> </span>
)}
{!dimmed && <span aria-hidden className="text-white/50 text-sm"></span>}
</div>
</>
);
const base =
'group rounded-2xl border p-6 flex flex-col transition';
if (dimmed) {
return (
<div className={`${base} border-white/10 bg-white/[0.01] opacity-60`}>{inner}</div>
);
}
return (
<Link
href={pkg.href ?? '#'}
onClick={() => trackCTAClick(`packages_card_${pkg.slug}`)}
className={`${base} border-white/15 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05]`}
style={{ textDecoration: 'none' }}
>
{inner}
</Link>
);
}
export default function PackagesPage() {
const [modalOpen, setModalOpen] = useState(false);
const available = getAvailablePackages();
const comingSoon = getComingSoonPackages();
const isEmpty = available.length === 0;
return (
<div className="min-h-screen bg-black text-white">
<ContactModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
service={WAITLIST_SERVICE}
checklist={['관심 있는 업무·자동화 분야', '연락받을 이메일', '현재 겪는 반복 업무(선택)']}
/>
{/* Hero */}
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 border-b border-white/10">
<div className="absolute inset-0 bg-gradient-to-b from-[#0a0618] to-black pointer-events-none" />
<div className="relative z-10 max-w-3xl mx-auto text-center">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4">
SaaS Products
</p>
<h1
className="kx-display text-4xl md:text-6xl font-bold mb-5"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
<br />SaaS로 .
</h1>
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
.
{isEmpty ? ' 첫 제품을 준비하고 있습니다.' : ''}
</p>
{isEmpty && (
<button
onClick={() => {
trackCTAClick('packages_waitlist_hero');
setModalOpen(true);
}}
className="kx-btn-primary inline-flex items-center px-7 py-3 rounded-full text-sm mt-8"
>
</button>
)}
</div>
</section>
{/* Available 카탈로그 */}
{available.length > 0 && (
<section className="py-20 px-6">
<div className="max-w-6xl mx-auto grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{available.map((pkg) => (
<PackageCard key={pkg.slug} pkg={pkg} />
))}
</div>
</section>
)}
{/* Coming Soon 예고 */}
{comingSoon.length > 0 && (
<section className="py-20 px-6 bg-white/[0.02] border-t border-white/10">
<div className="max-w-6xl mx-auto">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4 text-center">
Coming Soon
</p>
<h2 className="kx-display text-2xl md:text-3xl font-bold text-center mb-10">
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{comingSoon.map((pkg) => (
<PackageCard key={pkg.slug} pkg={pkg} dimmed />
))}
</div>
</div>
</section>
)}
{/* 출시 알림 CTA — 항상 노출(빈 상태 아닐 때도 대기자 수집) */}
<section className="py-20 px-6 border-t border-white/10">
<div className="max-w-3xl mx-auto text-center">
<h2 className="kx-display text-2xl md:text-4xl font-bold mb-5">
?
</h2>
<p className="text-base text-white/70 mb-8">
. .
</p>
<button
onClick={() => {
trackCTAClick('packages_waitlist_cta');
setModalOpen(true);
}}
className="kx-btn-primary inline-flex items-center px-7 py-3 rounded-full text-sm"
>
</button>
</div>
</section>
</div>
);
}

View File

@@ -110,30 +110,17 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
}
}
// 로또 구독 확인 — subscriptions 테이블 (세션 클라이언트로 RLS select_own 통과)
const { data: lottoSub } = await supabase
.from('subscriptions')
.select('id')
// 로또 이용권 확인 — orders 테이블 (최근 31일 paid 주문)
const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
const { data: lottoOrder } = await supabase
.from('orders')
.select('id, created_at')
.eq('user_id', user.id)
.eq('status', 'active')
.eq('status', 'paid')
.in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual'])
.gte('created_at', thirtyOneDaysAgo)
.maybeSingle();
hasLottoSubscription = !!lottoSub;
// subscriptions에서 못 찾으면 orders 테이블로 폴백 (구독 마이그레이션 전 데이터)
if (!hasLottoSubscription) {
const now = new Date().toISOString();
const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
const { data: lottoOrder } = await supabase
.from('orders')
.select('id, created_at')
.eq('user_id', user.id)
.eq('status', 'paid')
.in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual'])
.gte('created_at', thirtyOneDaysAgo)
.maybeSingle();
hasLottoSubscription = !!lottoOrder;
}
hasLottoSubscription = !!lottoOrder;
}
} catch {
// 미로그인 시 무시