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

View File

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