feat(phase2): 사주 AI 해석 무료화 — 결제 게이트 → 로그인 게이트

- page.tsx: hasPaid를 orders 'saju_detail' paid 조회 대신 로그인 여부(!!user)로 산출
- SajuAISection: 미로그인 시 "개편 준비 중" 안내를 /login?next= 유도 CTA로 교체
- analyze fetch가 429(일일 무료 횟수 초과)를 받으면 전용 에러 메시지 표시(재시도 버튼 숨김)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:36:13 +09:00
parent 5fd7ab8872
commit 26fef53174
2 changed files with 49 additions and 21 deletions

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@@ -187,6 +188,11 @@ export default function SajuAISection({
currentUrl, currentUrl,
engineData, engineData,
}: SajuAISectionProps) { }: SajuAISectionProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const search = searchParams.toString();
const loginHref = `/login?next=${encodeURIComponent(`${pathname}${search ? `?${search}` : ''}`)}`;
// 저장된 해석이 mock 데이터면 재생성 필요 // 저장된 해석이 mock 데이터면 재생성 필요
const isMock = isMockInterpretation(savedInterpretation); const isMock = isMockInterpretation(savedInterpretation);
const validSaved = savedInterpretation && !isMock ? savedInterpretation : null; const validSaved = savedInterpretation && !isMock ? savedInterpretation : null;
@@ -196,6 +202,7 @@ export default function SajuAISection({
); );
const [interpretation, setInterpretation] = useState(validSaved ?? ''); const [interpretation, setInterpretation] = useState(validSaved ?? '');
const [openSections, setOpenSections] = useState<Set<number>>(new Set([0])); const [openSections, setOpenSections] = useState<Set<number>>(new Set([0]));
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const called = useRef(false); const called = useRef(false);
const sections = parseInterpretation(interpretation); const sections = parseInterpretation(interpretation);
@@ -221,13 +228,22 @@ export default function SajuAISection({
setTimeout(() => { setTimeout(() => {
called.current = false; called.current = false;
setStatus('loading'); setStatus('loading');
setErrorMessage(null);
fetch('/api/saju/analyze', { fetch('/api/saju/analyze', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }), body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
}) })
.then(r => r.json()) .then(async r => {
if (r.status === 429) return { __rateLimited: true };
return r.json();
})
.then(data => { .then(data => {
if (data.__rateLimited) {
setErrorMessage('오늘 무료 횟수를 모두 사용했습니다. 내일 다시 시도해주세요.');
setStatus('error');
return;
}
if (data.interpretation && !isMockInterpretation(data.interpretation)) { if (data.interpretation && !isMockInterpretation(data.interpretation)) {
setInterpretation(data.interpretation); setInterpretation(data.interpretation);
setStatus('done'); setStatus('done');
@@ -253,14 +269,23 @@ export default function SajuAISection({
if (!hasPaid || validSaved || called.current) return; if (!hasPaid || validSaved || called.current) return;
called.current = true; called.current = true;
setStatus('loading'); setStatus('loading');
setErrorMessage(null);
fetch('/api/saju/analyze', { fetch('/api/saju/analyze', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }), body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
}) })
.then(r => r.json()) .then(async r => {
if (r.status === 429) return { __rateLimited: true };
return r.json();
})
.then(data => { .then(data => {
if (data.__rateLimited) {
setErrorMessage('오늘 무료 횟수를 모두 사용했습니다. 내일 다시 시도해주세요.');
setStatus('error');
return;
}
if (data.interpretation) { if (data.interpretation) {
setInterpretation(data.interpretation); setInterpretation(data.interpretation);
setStatus('done'); setStatus('done');
@@ -286,7 +311,7 @@ export default function SajuAISection({
.catch(() => setStatus('error')); .catch(() => setStatus('error'));
}, [hasPaid]); }, [hasPaid]);
// ── 미결제 ────────────────────────────────────────────────────────── // ── 미로그인 ────────────────────────────────────────────────────────
if (!hasPaid) { if (!hasPaid) {
return ( return (
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] rounded-2xl border border-[#1a3a7a] p-7 text-center relative overflow-hidden"> <div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] rounded-2xl border border-[#1a3a7a] p-7 text-center relative overflow-hidden">
@@ -312,10 +337,13 @@ export default function SajuAISection({
))} ))}
</div> </div>
<p className="inline-flex items-center gap-2 bg-white/10 text-blue-100/80 font-semibold px-7 py-3 rounded-xl"> <a
AI href={loginHref}
</p> className="inline-flex items-center gap-2 bg-white/10 hover:bg-white/20 text-blue-100/80 font-semibold px-7 py-3 rounded-xl transition-colors"
<p className="text-blue-200/40 text-xs mt-3"> (Phase 2) </p> >
AI
</a>
<p className="text-blue-200/40 text-xs mt-3"> 1 · </p>
</div> </div>
</div> </div>
); );
@@ -343,13 +371,17 @@ export default function SajuAISection({
if (status === 'error') { if (status === 'error') {
return ( return (
<div className="bg-white rounded-2xl border border-red-200 p-6 text-center"> <div className="bg-white rounded-2xl border border-red-200 p-6 text-center">
<p className="text-red-500 text-sm font-medium mb-3">AI .</p> <p className="text-red-500 text-sm font-medium mb-3">
{errorMessage ?? 'AI 해석 생성에 실패했습니다.'}
</p>
{!errorMessage && (
<button <button
onClick={() => { called.current = false; setStatus('idle'); }} onClick={() => { called.current = false; setStatus('idle'); }}
className="text-xs text-blue-600 underline" className="text-xs text-blue-600 underline"
> >
</button> </button>
)}
</div> </div>
); );
} }

View File

@@ -74,7 +74,8 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
const solarTermIndex = getCurrentSolarTerm(yearNum, monthNum, dayNum); const solarTermIndex = getCurrentSolarTerm(yearNum, monthNum, dayNum);
const solarTermName = getSolarTermName(solarTermIndex); const solarTermName = getSolarTermName(solarTermIndex);
// ── 결제 여부 + 저장된 AI 해석 + 로또 구독 확인 ───────────────────── // ── 로그인 여부 + 저장된 AI 해석 + 로또 구독 확인 ─────────────────────
// Phase 2: AI 상세 해석은 결제 게이트 → 로그인 게이트로 전환(무료화, 일일 제한은 API에서 강제)
let hasPaid = false; let hasPaid = false;
let savedInterpretation: string | null = null; let savedInterpretation: string | null = null;
let hasLottoSubscription = false; let hasLottoSubscription = false;
@@ -82,12 +83,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
const supabase = await createClient(); const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
if (user) { if (user) {
// 사주 결제 확인 (anon client — 본인 orders는 RLS 허용 가정) hasPaid = !!user;
const { data: order } = await supabase
.from('orders').select('id')
.eq('user_id', user.id).eq('product_id', 'saju_detail').eq('status', 'paid')
.maybeSingle();
hasPaid = !!order;
if (hasPaid) { if (hasPaid) {
// 1차: birth_hour 포함 정확한 키로 조회 // 1차: birth_hour 포함 정확한 키로 조회