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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 포함 정확한 키로 조회
|
||||||
|
|||||||
Reference in New Issue
Block a user