From 26fef53174bb315521f93089fb320f849a49dbaa Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 21:36:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(phase2):=20=EC=82=AC=EC=A3=BC=20AI=20?= =?UTF-8?q?=ED=95=B4=EC=84=9D=20=EB=AC=B4=EB=A3=8C=ED=99=94=20=E2=80=94=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=E2=86=92=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - page.tsx: hasPaid를 orders 'saju_detail' paid 조회 대신 로그인 여부(!!user)로 산출 - SajuAISection: 미로그인 시 "개편 준비 중" 안내를 /login?next= 유도 CTA로 교체 - analyze fetch가 429(일일 무료 횟수 초과)를 받으면 전용 에러 메시지 표시(재시도 버튼 숨김) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/work/saju/result/SajuAISection.tsx | 60 ++++++++++++++++++++------ app/work/saju/result/page.tsx | 10 ++--- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/app/work/saju/result/SajuAISection.tsx b/app/work/saju/result/SajuAISection.tsx index 4f7284a..63b1ed7 100644 --- a/app/work/saju/result/SajuAISection.tsx +++ b/app/work/saju/result/SajuAISection.tsx @@ -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>(new Set([0])); + const [errorMessage, setErrorMessage] = useState(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 (
@@ -312,10 +337,13 @@ export default function SajuAISection({ ))}
-

- AI 상세 해석은 서비스 개편 준비 중입니다 -

-

사주 서비스 개편(Phase 2)에서 무료 제공 예정

+ + 로그인하고 AI 상세 해석 무료로 받기 + +

로그인 회원은 하루 1회 무료 · 저장된 해석은 언제든 다시 보기

); @@ -343,13 +371,17 @@ export default function SajuAISection({ if (status === 'error') { return (
-

AI 해석 생성에 실패했습니다.

- +

+ {errorMessage ?? 'AI 해석 생성에 실패했습니다.'} +

+ {!errorMessage && ( + + )}
); } diff --git a/app/work/saju/result/page.tsx b/app/work/saju/result/page.tsx index 3040bfd..0154502 100644 --- a/app/work/saju/result/page.tsx +++ b/app/work/saju/result/page.tsx @@ -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 포함 정확한 키로 조회