feat(products): 동적 카탈로그·상세 페이지 + 계좌이체 구매 모달
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
364
app/components/BankTransferModal.tsx
Normal file
364
app/components/BankTransferModal.tsx
Normal file
@@ -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<AuthState>('checking');
|
||||||
|
const [depositorName, setDepositorName] = useState('');
|
||||||
|
const [agreed, setAgreed] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState<SuccessInfo | null>(null);
|
||||||
|
const closeBtnRef = useRef<HTMLButtonElement>(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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||||
|
style={{ background: 'rgba(15,23,42,0.45)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={success ? '주문 접수 완료' : `${product.name} 구매`}
|
||||||
|
onClick={(e) => 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)' }}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div
|
||||||
|
className="sticky top-0 flex items-center justify-between px-6 h-16 border-b"
|
||||||
|
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-base font-bold break-keep"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
|
>
|
||||||
|
{success ? '주문 접수 완료' : '계좌이체 구매'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
ref={closeBtnRef}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="닫기"
|
||||||
|
className="p-2 -mr-2 rounded-lg transition-colors duration-150"
|
||||||
|
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
{/* 상품 요약 */}
|
||||||
|
{!success && (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border px-4 py-3.5 mb-6 flex items-center justify-between gap-3"
|
||||||
|
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-sm font-semibold break-keep"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
|
>
|
||||||
|
{product.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-base font-bold shrink-0"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
|
>
|
||||||
|
{priceLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 세션 확인 중 ── */}
|
||||||
|
{authState === 'checking' && !success && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border-2 border-t-transparent animate-spin"
|
||||||
|
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 미로그인 ── */}
|
||||||
|
{authState === 'guest' && !success && (
|
||||||
|
<div className="text-center py-2">
|
||||||
|
<p
|
||||||
|
className="text-sm leading-relaxed break-keep mb-5"
|
||||||
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
로그인 후 구매할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={loginHref}
|
||||||
|
className="inline-flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
로그인하기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 로그인 상태: 구매 폼 ── */}
|
||||||
|
{authState === 'user' && !success && (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="depositor-name"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
입금자명 <span style={{ color: 'var(--jsm-accent)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="depositor-name"
|
||||||
|
type="text"
|
||||||
|
value={depositorName}
|
||||||
|
onChange={(e) => 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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||||
|
입금자명이 다르면 확인이 늦어질 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-0.5 w-4 h-4 shrink-0 accent-[var(--jsm-accent)]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||||
|
<Link
|
||||||
|
href="/legal/terms"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
style={{ color: 'var(--jsm-accent)' }}
|
||||||
|
>
|
||||||
|
이용약관
|
||||||
|
</Link>
|
||||||
|
과{' '}
|
||||||
|
<Link
|
||||||
|
href="/legal/refund"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
style={{ color: 'var(--jsm-accent)' }}
|
||||||
|
>
|
||||||
|
환불정책
|
||||||
|
</Link>
|
||||||
|
에 동의합니다.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="px-3.5 py-3 rounded-lg text-sm break-keep"
|
||||||
|
style={{ background: '#fef2f2', border: '1px solid #fecaca', color: '#dc2626', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="w-full py-3 rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
style={{
|
||||||
|
background: canSubmit ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)',
|
||||||
|
color: '#ffffff',
|
||||||
|
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||||||
|
...KOR_BODY,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? '처리 중...' : '주문하기'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 성공 화면 ── */}
|
||||||
|
{success && (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="text-lg font-bold mb-2 break-keep"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
|
>
|
||||||
|
{success.reused ? '이미 접수된 주문이 있습니다' : '주문이 접수되었습니다'}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-sm leading-relaxed break-keep mb-5"
|
||||||
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
아래 계좌로 입금해 주세요. 입금이 확인되면 마이페이지에서 다운로드할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl
|
||||||
|
className="rounded-lg border divide-y mb-5"
|
||||||
|
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ k: '입금 계좌', v: `${BANK.name} ${BANK.account}` },
|
||||||
|
{ k: '예금주', v: BANK.holder },
|
||||||
|
{ k: '입금 금액', v: priceLabel },
|
||||||
|
{ k: '입금자명', v: success.depositorName },
|
||||||
|
].map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.k}
|
||||||
|
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||||
|
style={{ borderColor: 'var(--jsm-line)' }}
|
||||||
|
>
|
||||||
|
<dt className="text-xs shrink-0" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||||
|
{row.k}
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className="text-sm font-semibold text-right break-all"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
{row.v}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-xs leading-relaxed break-keep mb-5"
|
||||||
|
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
입금 확인 후 마이페이지 → 내 제품에서 다운로드할 수 있습니다. 최대 24시간 내 처리됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/mypage?tab=products"
|
||||||
|
className="inline-flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
마이페이지로
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ import Link from 'next/link';
|
|||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Suspense } from 'react';
|
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() {
|
function LoginForm() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -15,6 +22,7 @@ function LoginForm() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
const next = safeNext(searchParams.get('next'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get('error')) {
|
if (searchParams.get('error')) {
|
||||||
@@ -22,7 +30,7 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
// 이미 로그인된 경우 리다이렉트
|
// 이미 로그인된 경우 리다이렉트
|
||||||
supabase.auth.getUser().then(({ data }) => {
|
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) {
|
if (error) {
|
||||||
setMessage('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.');
|
setMessage('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.');
|
||||||
} else {
|
} else {
|
||||||
router.push('/mypage');
|
router.push(next);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,9 +74,11 @@ function LoginForm() {
|
|||||||
process.env.NODE_ENV === 'development'
|
process.env.NODE_ENV === 'development'
|
||||||
? window.location.origin
|
? window.location.origin
|
||||||
: (process.env.NEXT_PUBLIC_SITE_URL ?? 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({
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
options: { redirectTo: `${base}/auth/callback` },
|
options: { redirectTo: callbackUrl },
|
||||||
});
|
});
|
||||||
if (error) setMessage('Google 로그인 오류: ' + error.message);
|
if (error) setMessage('Google 로그인 오류: ' + error.message);
|
||||||
};
|
};
|
||||||
|
|||||||
36
app/products/[id]/BuySection.tsx
Normal file
36
app/products/[id]/BuySection.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex items-center justify-center w-full sm:w-auto px-8 py-3.5 rounded-lg text-sm font-semibold transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||||
|
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
구매하기
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<BankTransferModal
|
||||||
|
product={product}
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
app/products/[id]/page.tsx
Normal file
171
app/products/[id]/page.tsx
Normal file
@@ -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<ProductRow | null> {
|
||||||
|
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<Metadata> {
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="shrink-0 mt-0.5"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section style={{ background: 'var(--jsm-bg)' }}>
|
||||||
|
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-14 lg:py-20">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<nav className="mb-8" aria-label="breadcrumb">
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-[var(--jsm-accent)]"
|
||||||
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
소프트웨어
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 제품명 · 가격 */}
|
||||||
|
<header className="pb-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||||
|
<h1
|
||||||
|
className="text-2xl sm:text-3xl lg:text-4xl font-bold break-keep"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
|
>
|
||||||
|
{product.name}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mt-4 text-2xl font-bold"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
|
>
|
||||||
|
₩{product.price.toLocaleString('ko-KR')}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 상세 설명 */}
|
||||||
|
{longText && (
|
||||||
|
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||||
|
<p
|
||||||
|
className="text-base leading-relaxed break-keep whitespace-pre-line"
|
||||||
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
{longText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기능 리스트 */}
|
||||||
|
{features.length > 0 && (
|
||||||
|
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold mb-4 uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--jsm-accent)' }}
|
||||||
|
>
|
||||||
|
주요 기능
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{features.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-start gap-2.5 text-sm sm:text-base break-keep"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--jsm-accent)' }}>
|
||||||
|
<CheckMark />
|
||||||
|
</span>
|
||||||
|
<span>{f}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 구매 안내 + CTA */}
|
||||||
|
<div className="pt-8">
|
||||||
|
<div
|
||||||
|
className="rounded-lg border px-4 py-3.5 mb-6"
|
||||||
|
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||||
|
구매 후 마이페이지에서 즉시 다운로드 (입금 확인 후).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BuySection
|
||||||
|
product={{ id: product.id, name: product.name, price: product.price }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="mt-5 text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||||
|
구매 전{' '}
|
||||||
|
<Link href="/legal/refund" className="underline" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||||
|
환불 정책
|
||||||
|
</Link>
|
||||||
|
을 확인해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Metadata } from 'next';
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: '완성 소프트웨어',
|
title: '완성 소프트웨어',
|
||||||
@@ -10,16 +12,70 @@ export const metadata: Metadata = {
|
|||||||
'쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.',
|
'쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 카탈로그는 항상 최신 상품을 보여주도록 동적 렌더링.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||||
|
|
||||||
const HOW = [
|
const HOW = [
|
||||||
{ n: '01', t: '계좌이체 결제', d: '안내된 계좌로 입금합니다. 이체 확인 후 수동으로 승인합니다.' },
|
{ n: '01', t: '계좌이체 신청', d: '구매할 도구를 고르고 입금자명과 함께 신청합니다.' },
|
||||||
{ n: '02', t: '입금 확인', d: '입금이 확인되면 메일로 안내해 드립니다. 보통 당일 처리됩니다.' },
|
{ n: '02', t: '입금 확인', d: '입금이 확인되면 승인합니다. 최대 24시간 내 처리됩니다.' },
|
||||||
{ n: '03', t: '마이페이지 다운로드', d: '마이페이지에서 구매 내역을 확인하고 파일을 내려받습니다.' },
|
{ n: '03', t: '마이페이지 다운로드', d: '마이페이지의 내 제품에서 파일을 바로 내려받습니다.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ProductsPage() {
|
function ArrowRight() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
<path d="m13 5 7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckMark() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="shrink-0 mt-0.5"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProducts(): Promise<ProductRow[]> {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ─── Hero ─── */}
|
{/* ─── 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"
|
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 }}
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
>
|
>
|
||||||
직접 운영하며 검증한
|
직접 운영하며 검증한 도구를
|
||||||
<br />
|
<br />
|
||||||
도구들을 준비하고 있습니다.
|
그대로 가져가세요.
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
className="text-base sm:text-lg leading-relaxed break-keep"
|
className="text-base sm:text-lg leading-relaxed break-keep"
|
||||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
>
|
>
|
||||||
입금 확인 후 바로 다운로드할 수 있는 형태로 제공됩니다.
|
입금 확인 후 마이페이지에서 바로 다운로드할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ─── 출시 준비 중 안내 ─── */}
|
{/* ─── 카탈로그 / 준비 중 ─── */}
|
||||||
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
{hasProducts ? (
|
||||||
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||||
<div
|
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
||||||
className="rounded-lg border p-8 text-center"
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
style={{
|
{products.map((p) => {
|
||||||
background: 'var(--jsm-surface-alt)',
|
const features = (p.features ?? []).slice(0, 3);
|
||||||
borderColor: 'var(--jsm-line)',
|
return (
|
||||||
}}
|
<Link
|
||||||
>
|
key={p.id}
|
||||||
<p
|
href={`/products/${p.id}`}
|
||||||
className="text-sm font-semibold mb-3"
|
className="group flex flex-col rounded-2xl p-7 lg:p-8 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
|
||||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||||
>
|
>
|
||||||
출시 준비 중
|
<h2
|
||||||
</p>
|
className="text-xl font-bold break-keep"
|
||||||
<p
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
className="text-xl sm:text-2xl font-bold mb-4 break-keep"
|
>
|
||||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
{p.name}
|
||||||
>
|
</h2>
|
||||||
현재 상품을 정비하고 있습니다.
|
{p.description && (
|
||||||
</p>
|
<p
|
||||||
<p
|
className="mt-2.5 text-sm leading-relaxed break-keep"
|
||||||
className="text-sm sm:text-base leading-relaxed break-keep max-w-md mx-auto"
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
>
|
||||||
>
|
{p.description}
|
||||||
로또 분석 도구, 주식 자동매매 유틸리티 등 실제로 운영 중인 도구들을
|
</p>
|
||||||
구매 가능한 형태로 순차 공개할 예정입니다.
|
)}
|
||||||
출시 소식을 먼저 받고 싶다면 아래 링크로 문의해 주세요.
|
|
||||||
</p>
|
{features.length > 0 && (
|
||||||
|
<ul className="mt-5 space-y-2">
|
||||||
|
{features.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-start gap-2 text-sm break-keep"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--jsm-accent)' }}>
|
||||||
|
<CheckMark />
|
||||||
|
</span>
|
||||||
|
<span>{f}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 pt-5 flex items-center justify-between border-t" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||||
|
<span
|
||||||
|
className="text-lg font-bold"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
|
>
|
||||||
|
₩{p.price.toLocaleString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
|
||||||
|
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
자세히 보기
|
||||||
|
<ArrowRight />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
) : (
|
||||||
|
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||||
|
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-8 text-center"
|
||||||
|
style={{
|
||||||
|
background: 'var(--jsm-surface-alt)',
|
||||||
|
borderColor: 'var(--jsm-line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold mb-3"
|
||||||
|
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
출시 준비 중
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xl sm:text-2xl font-bold mb-4 break-keep"
|
||||||
|
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||||
|
>
|
||||||
|
현재 상품을 정비하고 있습니다.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-sm sm:text-base leading-relaxed break-keep max-w-md mx-auto"
|
||||||
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
로또 분석 도구, 주식 자동매매 유틸리티 등 실제로 운영 중인 도구들을
|
||||||
|
구매 가능한 형태로 순차 공개할 예정입니다.
|
||||||
|
출시 소식을 먼저 받고 싶다면 아래 링크로 문의해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ─── 구매 방식 안내 ─── */}
|
{/* ─── 구매 방식 안내 ─── */}
|
||||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||||
@@ -141,7 +265,7 @@ export default function ProductsPage() {
|
|||||||
...KOR_BODY,
|
...KOR_BODY,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
출시 소식 받기
|
{hasProducts ? '맞춤 개발 문의' : '출시 소식 받기'}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/outsourcing"
|
href="/outsourcing"
|
||||||
|
|||||||
Reference in New Issue
Block a user