From 199dae0ee5e927dcec11d01ce8142645429294b6 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 11 Jun 2026 09:07:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(products):=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=C2=B7=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20+=20=EA=B3=84=EC=A2=8C=EC=9D=B4?= =?UTF-8?q?=EC=B2=B4=20=EA=B5=AC=EB=A7=A4=20=EB=AA=A8=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/components/BankTransferModal.tsx | 364 +++++++++++++++++++++++++++ app/login/page.tsx | 16 +- app/products/[id]/BuySection.tsx | 36 +++ app/products/[id]/page.tsx | 171 +++++++++++++ app/products/page.tsx | 208 +++++++++++---- 5 files changed, 750 insertions(+), 45 deletions(-) create mode 100644 app/components/BankTransferModal.tsx create mode 100644 app/products/[id]/BuySection.tsx create mode 100644 app/products/[id]/page.tsx diff --git a/app/components/BankTransferModal.tsx b/app/components/BankTransferModal.tsx new file mode 100644 index 0000000..2f3e000 --- /dev/null +++ b/app/components/BankTransferModal.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import Link from 'next/link'; +import { createClient } from '@/lib/supabase/client'; + +// 계좌이체 구매 모달. +// - 열릴 때 세션 확인 → 미로그인이면 로그인 유도(구매 폼 미노출) +// - 로그인 상태: 입금자명 + 약관 동의 → POST /api/orders +// - 주문 금액은 서버가 DB price로 확정한다. 아래 표시 금액은 안내용일 뿐이다. +// 접근성: role="dialog" aria-modal, Esc/backdrop 닫기, TopNav 드로어 패턴 차용. + +const KOR_TIGHT = { letterSpacing: '-0.02em' } as const; +const KOR_BODY = { letterSpacing: '-0.01em' } as const; + +const BANK = { name: '케이뱅크', account: '100-116-337157', holder: '박재오' }; + +interface Props { + product: { id: string; name: string; price: number }; + isOpen: boolean; + onClose: () => void; +} + +type AuthState = 'checking' | 'guest' | 'user'; + +interface SuccessInfo { + orderId: string; + depositorName: string; + reused: boolean; +} + +export default function BankTransferModal({ product, isOpen, onClose }: Props) { + const [authState, setAuthState] = useState('checking'); + const [depositorName, setDepositorName] = useState(''); + const [agreed, setAgreed] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(null); + const closeBtnRef = useRef(null); + + const priceLabel = `₩${product.price.toLocaleString('ko-KR')}`; + const loginHref = `/login?next=${encodeURIComponent(`/products/${product.id}`)}`; + + // 열릴 때마다 상태 초기화 + 세션 확인 + useEffect(() => { + if (!isOpen) return; + let mounted = true; + setAuthState('checking'); + setDepositorName(''); + setAgreed(false); + setSubmitting(false); + setError(''); + setSuccess(null); + + const supabase = createClient(); + supabase.auth + .getSession() + .then(({ data }) => { + if (mounted) setAuthState(data.session?.user ? 'user' : 'guest'); + }) + .catch(() => { + if (mounted) setAuthState('guest'); + }); + + return () => { + mounted = false; + }; + }, [isOpen]); + + // Esc 닫기 + body 스크롤 잠금 + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + }; + }, [isOpen, onClose]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const name = depositorName.trim(); + if (!name || !agreed || submitting) return; + setSubmitting(true); + setError(''); + try { + const res = await fetch('/api/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId: product.id, depositorName: name }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error || '주문 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + setSubmitting(false); + return; + } + setSuccess({ + orderId: data.orderId as string, + depositorName: name, + reused: Boolean(data.reused), + }); + } catch { + setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + } finally { + setSubmitting(false); + } + }, + [depositorName, agreed, submitting, product.id], + ); + + if (!isOpen) return null; + + const canSubmit = depositorName.trim().length > 0 && agreed && !submitting; + + return ( +
+
e.stopPropagation()} + className="w-full sm:max-w-md max-h-[92vh] overflow-y-auto rounded-t-2xl sm:rounded-2xl shadow-xl" + style={{ background: 'var(--jsm-surface)' }} + > + {/* 헤더 */} +
+

+ {success ? '주문 접수 완료' : '계좌이체 구매'} +

+ +
+ +
+ {/* 상품 요약 */} + {!success && ( +
+ + {product.name} + + + {priceLabel} + +
+ )} + + {/* ── 세션 확인 중 ── */} + {authState === 'checking' && !success && ( +
+
+
+ )} + + {/* ── 미로그인 ── */} + {authState === 'guest' && !success && ( +
+

+ 로그인 후 구매할 수 있습니다. +

+ + 로그인하기 + +
+ )} + + {/* ── 로그인 상태: 구매 폼 ── */} + {authState === 'user' && !success && ( +
+
+ + setDepositorName(e.target.value)} + placeholder="입금하실 분의 성함" + required + maxLength={40} + disabled={submitting} + className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]" + style={{ + background: 'var(--jsm-surface)', + border: '1px solid var(--jsm-line)', + color: 'var(--jsm-ink)', + }} + /> +

+ 입금자명이 다르면 확인이 늦어질 수 있습니다. +

+
+ + + + {error && ( +
+ {error} +
+ )} + + +
+ )} + + {/* ── 성공 화면 ── */} + {success && ( +
+

+ {success.reused ? '이미 접수된 주문이 있습니다' : '주문이 접수되었습니다'} +

+

+ 아래 계좌로 입금해 주세요. 입금이 확인되면 마이페이지에서 다운로드할 수 있습니다. +

+ +
+ {[ + { k: '입금 계좌', v: `${BANK.name} ${BANK.account}` }, + { k: '예금주', v: BANK.holder }, + { k: '입금 금액', v: priceLabel }, + { k: '입금자명', v: success.depositorName }, + ].map((row) => ( +
+
+ {row.k} +
+
+ {row.v} +
+
+ ))} +
+ +

+ 입금 확인 후 마이페이지 → 내 제품에서 다운로드할 수 있습니다. 최대 24시간 내 처리됩니다. +

+ + + 마이페이지로 + +
+ )} +
+
+
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 8976fbb..1723fee 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -6,6 +6,13 @@ import Link from 'next/link'; import { createClient } from '@/lib/supabase/client'; import { Suspense } from 'react'; +// next 파라미터가 안전한 내부 경로(`/`로 시작, `//`·`/\` 프로토콜-상대 아님)일 때만 허용. +function safeNext(raw: string | null): string { + if (!raw) return '/mypage'; + if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return '/mypage'; + return raw; +} + function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -15,6 +22,7 @@ function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const supabase = createClient(); + const next = safeNext(searchParams.get('next')); useEffect(() => { if (searchParams.get('error')) { @@ -22,7 +30,7 @@ function LoginForm() { } // 이미 로그인된 경우 리다이렉트 supabase.auth.getUser().then(({ data }) => { - if (data.user) router.push('/mypage'); + if (data.user) router.push(next); }); }, []); @@ -52,7 +60,7 @@ function LoginForm() { if (error) { setMessage('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.'); } else { - router.push('/mypage'); + router.push(next); router.refresh(); } } @@ -66,9 +74,11 @@ function LoginForm() { process.env.NODE_ENV === 'development' ? window.location.origin : (process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin); + // next는 /auth/callback에서 read해 로그인 후 목적지로 리다이렉트 (기본 /mypage) + const callbackUrl = `${base}/auth/callback?next=${encodeURIComponent(next)}`; const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', - options: { redirectTo: `${base}/auth/callback` }, + options: { redirectTo: callbackUrl }, }); if (error) setMessage('Google 로그인 오류: ' + error.message); }; diff --git a/app/products/[id]/BuySection.tsx b/app/products/[id]/BuySection.tsx new file mode 100644 index 0000000..d9445b0 --- /dev/null +++ b/app/products/[id]/BuySection.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useState } from 'react'; +import BankTransferModal from '@/app/components/BankTransferModal'; + +// 상세 페이지의 구매 버튼 + 모달 트리거 (클라이언트 경계). +// 서버 페이지가 product 요약만 넘겨주고, 주문 금액은 서버(API)가 DB로 확정한다. + +const KOR_BODY = { letterSpacing: '-0.01em' } as const; + +interface Props { + product: { id: string; name: string; price: number }; +} + +export default function BuySection({ product }: Props) { + const [open, setOpen] = useState(false); + + return ( + <> + + + setOpen(false)} + /> + + ); +} diff --git a/app/products/[id]/page.tsx b/app/products/[id]/page.tsx new file mode 100644 index 0000000..41f6b50 --- /dev/null +++ b/app/products/[id]/page.tsx @@ -0,0 +1,171 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { getProductById, type ProductRow } from '@/lib/supabase/product-files'; +import BuySection from './BuySection'; + +// 완성 소프트웨어 상세 (서버 컴포넌트). +// 비노출/비활성/존재하지 않음/DB 예외 → notFound() 로 일관 처리해 500을 내지 않는다. + +export const dynamic = 'force-dynamic'; + +const KOR_TIGHT = { letterSpacing: '-0.02em' } as const; +const KOR_BODY = { letterSpacing: '-0.01em' } as const; + +interface Props { + params: Promise<{ id: string }>; +} + +async function loadProduct(id: string): Promise { + try { + return await getProductById(createAdminClient(), id); + } catch (err) { + // DB 장애·마이그레이션 미적용 등 — 상세 페이지는 404로 폴백 + console.error('[ProductDetail] getProductById failed:', err); + return null; + } +} + +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const product = await loadProduct(id); + if (!product || !product.is_listed || !product.is_active) { + return { title: '완성 소프트웨어' }; + } + return { + title: product.name, + description: + product.description ?? + `${product.name} — 쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어. 입금 확인 후 마이페이지에서 즉시 다운로드.`, + }; +} + +function CheckMark() { + return ( + + + + ); +} + +export default async function ProductDetailPage({ params }: Props) { + const { id } = await params; + const product = await loadProduct(id); + + if (!product || !product.is_listed || !product.is_active) { + notFound(); + } + + const features = product.features ?? []; + const longText = product.description_long ?? product.description ?? ''; + + return ( +
+
+ {/* 브레드크럼 */} + + + {/* 제품명 · 가격 */} +
+

+ {product.name} +

+

+ ₩{product.price.toLocaleString('ko-KR')} +

+
+ + {/* 상세 설명 */} + {longText && ( +
+

+ {longText} +

+
+ )} + + {/* 기능 리스트 */} + {features.length > 0 && ( +
+

+ 주요 기능 +

+
    + {features.map((f) => ( +
  • + + + + {f} +
  • + ))} +
+
+ )} + + {/* 구매 안내 + CTA */} +
+
+

+ 구매 후 마이페이지에서 즉시 다운로드 (입금 확인 후). +

+
+ + + +

+ 구매 전{' '} + + 환불 정책 + + 을 확인해 주세요. +

+
+
+
+ ); +} diff --git a/app/products/page.tsx b/app/products/page.tsx index 2c0862e..7d23b3b 100644 --- a/app/products/page.tsx +++ b/app/products/page.tsx @@ -1,8 +1,10 @@ import Link from 'next/link'; import type { Metadata } from 'next'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files'; -// TODO(Phase 2): products 테이블 연동 후 동적 카탈로그로 교체 예정. -// 현재는 404 방지용 정적 스텁 페이지입니다. +// 완성 소프트웨어 동적 카탈로그 (서버 컴포넌트). +// DB 장애·마이그레이션 미적용 시 빈 배열로 폴백해 페이지가 항상 200으로 생존한다. export const metadata: Metadata = { title: '완성 소프트웨어', @@ -10,16 +12,70 @@ export const metadata: Metadata = { '쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.', }; +// 카탈로그는 항상 최신 상품을 보여주도록 동적 렌더링. +export const dynamic = 'force-dynamic'; + const KOR_TIGHT = { letterSpacing: '-0.02em' } as const; const KOR_BODY = { letterSpacing: '-0.01em' } as const; const HOW = [ - { n: '01', t: '계좌이체 결제', d: '안내된 계좌로 입금합니다. 이체 확인 후 수동으로 승인합니다.' }, - { n: '02', t: '입금 확인', d: '입금이 확인되면 메일로 안내해 드립니다. 보통 당일 처리됩니다.' }, - { n: '03', t: '마이페이지 다운로드', d: '마이페이지에서 구매 내역을 확인하고 파일을 내려받습니다.' }, + { n: '01', t: '계좌이체 신청', d: '구매할 도구를 고르고 입금자명과 함께 신청합니다.' }, + { n: '02', t: '입금 확인', d: '입금이 확인되면 승인합니다. 최대 24시간 내 처리됩니다.' }, + { n: '03', t: '마이페이지 다운로드', d: '마이페이지의 내 제품에서 파일을 바로 내려받습니다.' }, ]; -export default function ProductsPage() { +function ArrowRight() { + return ( + + + + + ); +} + +function CheckMark() { + return ( + + + + ); +} + +async function loadProducts(): Promise { + try { + return await getListedProducts(createAdminClient()); + } catch (err) { + // DB 장애·컬럼 미존재(마이그레이션 미적용) 등 — 페이지는 준비 중 폴백으로 생존 + console.error('[Products] getListedProducts failed, falling back to empty:', err); + return []; + } +} + +export default async function ProductsPage() { + const products = await loadProducts(); + const hasProducts = products.length > 0; + return ( <> {/* ─── Hero ─── */} @@ -40,53 +96,121 @@ export default function ProductsPage() { className="text-3xl sm:text-4xl lg:text-5xl font-bold leading-[1.2] break-keep mb-5" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }} > - 직접 운영하며 검증한 + 직접 운영하며 검증한 도구를
- 도구들을 준비하고 있습니다. + 그대로 가져가세요.

- 입금 확인 후 바로 다운로드할 수 있는 형태로 제공됩니다. + 입금 확인 후 마이페이지에서 바로 다운로드할 수 있습니다.

- {/* ─── 출시 준비 중 안내 ─── */} -
-
-
-

- 출시 준비 중 -

-

- 현재 상품을 정비하고 있습니다. -

-

- 로또 분석 도구, 주식 자동매매 유틸리티 등 실제로 운영 중인 도구들을 - 구매 가능한 형태로 순차 공개할 예정입니다. - 출시 소식을 먼저 받고 싶다면 아래 링크로 문의해 주세요. -

+ {/* ─── 카탈로그 / 준비 중 ─── */} + {hasProducts ? ( +
+
+
+ {products.map((p) => { + const features = (p.features ?? []).slice(0, 3); + return ( + +

+ {p.name} +

+ {p.description && ( +

+ {p.description} +

+ )} + + {features.length > 0 && ( +
    + {features.map((f) => ( +
  • + + + + {f} +
  • + ))} +
+ )} + +
+ + ₩{p.price.toLocaleString('ko-KR')} + + + 자세히 보기 + + +
+ + ); + })} +
-
-
+ + ) : ( +
+
+
+

+ 출시 준비 중 +

+

+ 현재 상품을 정비하고 있습니다. +

+

+ 로또 분석 도구, 주식 자동매매 유틸리티 등 실제로 운영 중인 도구들을 + 구매 가능한 형태로 순차 공개할 예정입니다. + 출시 소식을 먼저 받고 싶다면 아래 링크로 문의해 주세요. +

+
+
+
+ )} {/* ─── 구매 방식 안내 ─── */}
@@ -141,7 +265,7 @@ export default function ProductsPage() { ...KOR_BODY, }} > - 출시 소식 받기 + {hasProducts ? '맞춤 개발 문의' : '출시 소식 받기'}