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:
@@ -7,7 +7,6 @@ interface Stats {
|
|||||||
totalOrders: number;
|
totalOrders: number;
|
||||||
totalRevenue: number;
|
totalRevenue: number;
|
||||||
pendingContacts: number;
|
pendingContacts: number;
|
||||||
activeSubscribers: number;
|
|
||||||
monthlyChart: Array<{ month: string; revenue: number }>;
|
monthlyChart: Array<{ month: string; revenue: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,17 +156,6 @@ export default function AdminDashboard() {
|
|||||||
</svg>
|
</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
|
<StatCard
|
||||||
label="미처리 문의"
|
label="미처리 문의"
|
||||||
value={`${stats?.pendingContacts ?? 0}건`}
|
value={`${stats?.pendingContacts ?? 0}건`}
|
||||||
|
|||||||
@@ -9,15 +9,8 @@ interface Member {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
totalPaid: 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() {
|
export default function AdminMembersPage() {
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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-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>
|
||||||
<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>
|
</tr>
|
||||||
@@ -90,16 +82,6 @@ export default function AdminMembersPage() {
|
|||||||
<td className="px-5 py-3 text-slate-400">
|
<td className="px-5 py-3 text-slate-400">
|
||||||
{new Date(m.created_at).toLocaleDateString('ko-KR')}
|
{new Date(m.created_at).toLocaleDateString('ko-KR')}
|
||||||
</td>
|
</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">
|
<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'}`}>
|
<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}건
|
{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-white text-sm font-semibold truncate">{m.email ?? '-'}</p>
|
||||||
<p className="text-slate-400 text-xs mt-0.5">{m.full_name ?? '이름 없음'}</p>
|
<p className="text-slate-400 text-xs mt-0.5">{m.full_name ?? '이름 없음'}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 상세 정보 그리드 */}
|
{/* 상세 정보 그리드 */}
|
||||||
@@ -150,12 +127,6 @@ export default function AdminMembersPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ export async function GET() {
|
|||||||
// 각 회원의 주문 수 + 결제 금액 집계
|
// 각 회원의 주문 수 + 결제 금액 집계
|
||||||
const enriched = await Promise.all(
|
const enriched = await Promise.all(
|
||||||
(profiles ?? []).map(async (p: { id: string; email: string; full_name: string; created_at: string }) => {
|
(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('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('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 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 };
|
||||||
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid, activeSub };
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,5 @@ const DEFAULT_SERVICES = [
|
|||||||
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 },
|
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 },
|
||||||
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
|
{ 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: '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 },
|
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,20 +15,18 @@ export async function GET() {
|
|||||||
const supabase = createAdminClient();
|
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('profiles').select('id', { count: 'exact', head: true }),
|
||||||
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('status', 'paid'),
|
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('status', 'paid'),
|
||||||
supabase.from('payments').select('amount').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('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('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 totalMembers = profilesRes.count ?? 0;
|
||||||
const totalOrders = ordersRes.count ?? 0;
|
const totalOrders = ordersRes.count ?? 0;
|
||||||
const totalRevenue = (paymentsRes.data ?? []).reduce((sum: number, p: { amount: number }) => sum + p.amount, 0);
|
const totalRevenue = (paymentsRes.data ?? []).reduce((sum: number, p: { amount: number }) => sum + p.amount, 0);
|
||||||
const pendingContacts = contactsRes.count ?? 0;
|
const pendingContacts = contactsRes.count ?? 0;
|
||||||
const activeSubscribers = subsRes.count ?? 0;
|
|
||||||
|
|
||||||
// 최근 6개월 월별 수익 집계
|
// 최근 6개월 월별 수익 집계
|
||||||
const monthly: Record<string, number> = {};
|
const monthly: Record<string, number> = {};
|
||||||
@@ -49,5 +47,5 @@ export async function GET() {
|
|||||||
|
|
||||||
const monthlyChart = Object.entries(monthly).map(([month, revenue]) => ({ month, revenue }));
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 ?? [] });
|
|
||||||
}
|
|
||||||
@@ -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}</>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -110,19 +110,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로또 구독 확인 — subscriptions 테이블 (세션 클라이언트로 RLS select_own 통과)
|
// 로또 이용권 확인 — orders 테이블 (최근 31일 paid 주문)
|
||||||
const { data: lottoSub } = await supabase
|
|
||||||
.from('subscriptions')
|
|
||||||
.select('id')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.eq('status', 'active')
|
|
||||||
.in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual'])
|
|
||||||
.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 thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
const { data: lottoOrder } = await supabase
|
const { data: lottoOrder } = await supabase
|
||||||
.from('orders')
|
.from('orders')
|
||||||
@@ -134,7 +122,6 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
hasLottoSubscription = !!lottoOrder;
|
hasLottoSubscription = !!lottoOrder;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// 미로그인 시 무시
|
// 미로그인 시 무시
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
// SaaS 제품 카탈로그 (/packages)
|
|
||||||
//
|
|
||||||
// 확장 규칙: 새 SaaS 제품을 출시하면 SAAS_CATALOG 배열에 객체 하나만 추가하면
|
|
||||||
// /packages 페이지에 카드가 자동으로 노출된다. 결제는 productId로 lib/products.ts의
|
|
||||||
// PRODUCTS 정의와 subscriptions 인프라에 연결한다.
|
|
||||||
//
|
|
||||||
// 음악(AI 음악 생성 개발 가이드 패키지)은 단품 라인이므로 여기에 넣지 않는다(/music 유지).
|
|
||||||
|
|
||||||
export type SaasStatus = 'available' | 'coming_soon';
|
|
||||||
|
|
||||||
export interface SaasCatalogItem {
|
|
||||||
/** /packages 내 식별자 (향후 /packages/[slug] 상세에 사용) */
|
|
||||||
slug: string;
|
|
||||||
/** 카드 제목 */
|
|
||||||
name: string;
|
|
||||||
/** 한 줄 요약 (카드 상단) */
|
|
||||||
tagline: string;
|
|
||||||
/** 카드 본문 설명 */
|
|
||||||
description: string;
|
|
||||||
/** 가격 표시용 라벨 (예: "월 ₩29,000"). 미정이면 생략 */
|
|
||||||
priceLabel?: string;
|
|
||||||
/** 과금 형태 */
|
|
||||||
billing: 'monthly' | 'one_time';
|
|
||||||
/** 노출 상태 — available: 구매 가능 / coming_soon: 예고 */
|
|
||||||
status: SaasStatus;
|
|
||||||
/** 핵심 기능 목록 */
|
|
||||||
features: string[];
|
|
||||||
/** 분류 라벨 (예: '자동화') */
|
|
||||||
category: string;
|
|
||||||
/** lib/products.ts PRODUCTS 키 참조 (결제 연결, available일 때) */
|
|
||||||
productId?: string;
|
|
||||||
/** available일 때 상세/결제 경로 */
|
|
||||||
href?: string;
|
|
||||||
/** 카드 강조 뱃지 (예: 'NEW') */
|
|
||||||
badge?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 등록된 SaaS 제품 목록.
|
|
||||||
*
|
|
||||||
* 2026-05-31 현재 비어 있다. 메이킹 스페이스에서 검증된 자동화가 1개 확정되면
|
|
||||||
* 아래 형태로 항목을 추가한다:
|
|
||||||
*
|
|
||||||
* {
|
|
||||||
* slug: 'making-verify',
|
|
||||||
* name: '메이킹 검증 자동화',
|
|
||||||
* tagline: '...',
|
|
||||||
* description: '...',
|
|
||||||
* priceLabel: '월 ₩29,000',
|
|
||||||
* billing: 'monthly',
|
|
||||||
* status: 'available',
|
|
||||||
* features: ['...'],
|
|
||||||
* category: '자동화',
|
|
||||||
* productId: 'making_verify_monthly', // lib/products.ts에 함께 추가
|
|
||||||
* href: '/packages/making-verify',
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export const SAAS_CATALOG: SaasCatalogItem[] = [];
|
|
||||||
|
|
||||||
export const getAvailablePackages = () =>
|
|
||||||
SAAS_CATALOG.filter((p) => p.status === 'available');
|
|
||||||
|
|
||||||
export const getComingSoonPackages = () =>
|
|
||||||
SAAS_CATALOG.filter((p) => p.status === 'coming_soon');
|
|
||||||
@@ -3,7 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin';
|
|||||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||||
|
|
||||||
/** 숨김 가능 서비스 id (service_settings.id와 일치) */
|
/** 숨김 가능 서비스 id (service_settings.id와 일치) */
|
||||||
export type HideableService = 'saju' | 'music' | 'gyeol' | 'packages' | 'lotto';
|
export type HideableService = 'saju' | 'music' | 'gyeol' | 'lotto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서비스 노출 여부. admin_token 세션이면 항상 true.
|
* 서비스 노출 여부. admin_token 세션이면 항상 true.
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"crons": [
|
|
||||||
{
|
|
||||||
"path": "/api/cron/subscription-expiry",
|
|
||||||
"schedule": "0 16 * * *"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user