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;
|
||||
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}건`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
// 미로그인 시 무시
|
||||
|
||||
Reference in New Issue
Block a user