feat: mock payment flow + flash token page (test mode)
- Mock purchase: order create → mock-pay → FlashToken issued instantly (no real billing) - Flash page (/flash/:token): esp-web-tools integration, token state display, consume on complete - Orders route: create/mock-pay/me/refund with full audit logging - Flash route: GET validate, GET manifest (esp-web-tools compatible), POST consume - MinIO file proxy (/api/files/*): browser CORS solved, firmware served through backend - Schema: chipFamily on Project, flashOffset on ProjectFile - ProjectNew: chipFamily selector + firmware flash offset option - MyOrders: real order list with flash token status and buttons - Dockerfile: prisma db push (no migration files needed for dev) - TOSS_PAYMENT_GUIDE.md: step-by-step guide for real payment after business registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ESP32 DIY 플랫폼</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script type="module" src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import Navbar from './components/Navbar';
|
||||
|
||||
import Home from './pages/Home';
|
||||
import Flash from './pages/Flash';
|
||||
import Projects from './pages/Projects';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
import Shop from './pages/Shop';
|
||||
@@ -51,6 +52,7 @@ function AppRoutes() {
|
||||
<Route path="/dashboard/projects/:id" element={<RequireAuth><ProjectEdit /></RequireAuth>} />
|
||||
<Route path="/dashboard/orders" element={<RequireAuth><MyOrders /></RequireAuth>} />
|
||||
<Route path="/dashboard/sales" element={<RequireAuth><MySales /></RequireAuth>} />
|
||||
<Route path="/flash/:token" element={<RequireAuth><Flash /></RequireAuth>} />
|
||||
|
||||
<Route path="/admin" element={<RequireAdmin><AdminIndex /></RequireAdmin>} />
|
||||
<Route path="/admin/projects" element={<RequireAdmin><AdminProjects /></RequireAdmin>} />
|
||||
|
||||
@@ -1,10 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import api from '../../api/client';
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pending: { label: '결제 대기', color: 'var(--warn)' },
|
||||
paid: { label: '결제 완료', color: 'var(--success)' },
|
||||
refunded: { label: '환불됨', color: 'var(--text2)' },
|
||||
cancelled:{ label: '취소됨', color: 'var(--text2)' },
|
||||
};
|
||||
|
||||
export default function MyOrders() {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/orders/me')
|
||||
.then(r => setOrders(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleRefund(orderId) {
|
||||
if (!confirm('환불 요청하시겠습니까?\n플래시 완료 후에는 환불이 불가능합니다.')) return;
|
||||
try {
|
||||
await api.post(`/orders/${orderId}/refund`, { reason: '사용자 요청' });
|
||||
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: 'refunded' } : o));
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || '환불 처리 실패');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="spinner" />;
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<h2 style={{ marginBottom: 24 }}>구매 내역</h2>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<p className="text-muted">결제 기능은 2단계에서 구현됩니다.</p>
|
||||
</div>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<p className="text-muted">구매 내역이 없습니다.</p>
|
||||
<Link to="/shop" className="btn btn-primary" style={{ marginTop: 16 }}>상점 둘러보기</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{orders.map(order => {
|
||||
const st = STATUS_LABEL[order.status] || { label: order.status, color: 'var(--text2)' };
|
||||
const thumb = order.product?.project?.files?.[0];
|
||||
const ft = order.flashToken;
|
||||
|
||||
return (
|
||||
<div key={order.id} className="card" style={{ display: 'grid', gridTemplateColumns: '60px 1fr auto', gap: 16, alignItems: 'center' }}>
|
||||
{/* 썸네일 */}
|
||||
{thumb
|
||||
? <img src={thumb.thumbnailUrl || thumb.url} alt="" style={{ width: 60, height: 60, objectFit: 'cover', borderRadius: 4 }} />
|
||||
: <div style={{ width: 60, height: 60, background: 'var(--bg3)', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20 }}>📦</div>
|
||||
}
|
||||
|
||||
{/* 정보 */}
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>
|
||||
{order.product?.project?.title || '상품'}
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginBottom: 4 }}>
|
||||
₩{order.amount.toLocaleString()} · {new Date(order.orderedAt).toLocaleDateString('ko-KR')}
|
||||
{' · '}<span style={{ color: st.color }}>{st.label}</span>
|
||||
</div>
|
||||
{ft && (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
{ft.isUsed
|
||||
? <span style={{ color: 'var(--text2)' }}>✅ 플래시 완료</span>
|
||||
: new Date() > new Date(ft.expiresAt)
|
||||
? <span style={{ color: 'var(--danger)' }}>⏰ 토큰 만료</span>
|
||||
: <span style={{ color: 'var(--success)' }}>
|
||||
🔑 토큰 유효 · 만료: {new Date(ft.expiresAt).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end' }}>
|
||||
{ft && !ft.isUsed && order.status === 'paid' && new Date() <= new Date(ft.expiresAt) && (
|
||||
<Link to={`/flash/${ft.token}`} className="btn btn-primary btn-sm">
|
||||
⚡ 플래시
|
||||
</Link>
|
||||
)}
|
||||
{order.status === 'paid' && !ft?.isUsed && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleRefund(order.id)}>
|
||||
환불
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import api from '../../api/client';
|
||||
export default function ProjectNew() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', difficultyLevel: 3, requiredParts: [],
|
||||
title: '', description: '', difficultyLevel: 3, chipFamily: 'ESP32-S3', requiredParts: [],
|
||||
});
|
||||
const [partRow, setPartRow] = useState({ name: '', quantity: '', link: '' });
|
||||
const [files, setFiles] = useState([]);
|
||||
const [fileType, setFileType] = useState('image');
|
||||
const [flashOffset, setOffset] = useState('0x0');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [step, setStep] = useState(1); // 1:기본정보, 2:파일, 3:완료
|
||||
@@ -35,6 +36,7 @@ export default function ProjectNew() {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
difficultyLevel: form.difficultyLevel,
|
||||
chipFamily: form.chipFamily,
|
||||
requiredParts: form.requiredParts,
|
||||
});
|
||||
setProjectId(data.id);
|
||||
@@ -53,6 +55,7 @@ export default function ProjectNew() {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('fileType', fileType);
|
||||
if (fileType === 'firmware') fd.append('flashOffset', flashOffset);
|
||||
files.forEach(f => fd.append('files', f));
|
||||
await api.post(`/projects/${projectId}/files`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
@@ -114,11 +117,24 @@ export default function ProjectNew() {
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="프로젝트 목적, 기능, 특징을 설명해주세요" rows={6} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>난이도</label>
|
||||
<select value={form.difficultyLevel} onChange={e => setForm(f => ({ ...f, difficultyLevel: parseInt(e.target.value) }))}>
|
||||
{[1,2,3,4,5].map(d => <option key={d} value={d}>{d} — {['입문','초급','중급','고급','전문가'][d-1]}</option>)}
|
||||
</select>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="form-group">
|
||||
<label>난이도</label>
|
||||
<select value={form.difficultyLevel} onChange={e => setForm(f => ({ ...f, difficultyLevel: parseInt(e.target.value) }))}>
|
||||
{[1,2,3,4,5].map(d => <option key={d} value={d}>{d} — {['입문','초급','중급','고급','전문가'][d-1]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>ESP 칩 패밀리</label>
|
||||
<select value={form.chipFamily} onChange={e => setForm(f => ({ ...f, chipFamily: e.target.value }))}>
|
||||
<option value="ESP32-S3">ESP32-S3 (권장)</option>
|
||||
<option value="ESP32-S2">ESP32-S2</option>
|
||||
<option value="ESP32-C3">ESP32-C3</option>
|
||||
<option value="ESP32-C6">ESP32-C6</option>
|
||||
<option value="ESP32-H2">ESP32-H2</option>
|
||||
<option value="ESP32">ESP32</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필요 부품 */}
|
||||
@@ -150,15 +166,29 @@ export default function ProjectNew() {
|
||||
{step === 2 && (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: 20 }}>파일 업로드</h3>
|
||||
<div className="form-group">
|
||||
<label>파일 종류</label>
|
||||
<select value={fileType} onChange={e => setFileType(e.target.value)}>
|
||||
<option value="image">이미지 (jpg, png, webp)</option>
|
||||
<option value="video">영상 (mp4, mov)</option>
|
||||
<option value="wiring">배선도 (jpg, png, pdf)</option>
|
||||
<option value="stl">3D 케이스 STL</option>
|
||||
<option value="firmware">펌웨어 (.bin)</option>
|
||||
</select>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: fileType === 'firmware' ? '1fr 1fr' : '1fr', gap: 12 }}>
|
||||
<div className="form-group">
|
||||
<label>파일 종류</label>
|
||||
<select value={fileType} onChange={e => setFileType(e.target.value)}>
|
||||
<option value="image">이미지 (jpg, png, webp)</option>
|
||||
<option value="video">영상 (mp4, mov)</option>
|
||||
<option value="wiring">배선도 (jpg, png, pdf)</option>
|
||||
<option value="stl">3D 케이스 STL</option>
|
||||
<option value="firmware">펌웨어 (.bin)</option>
|
||||
</select>
|
||||
</div>
|
||||
{fileType === 'firmware' && (
|
||||
<div className="form-group">
|
||||
<label>플래시 오프셋</label>
|
||||
<select value={flashOffset} onChange={e => setOffset(e.target.value)}>
|
||||
<option value="0x0">0x0 — merged.bin (권장)</option>
|
||||
<option value="0x10000">0x10000 — app.bin만</option>
|
||||
<option value="0x0000">0x0000 — bootloader</option>
|
||||
<option value="0x8000">0x8000 — partition table</option>
|
||||
<option value="0xe000">0xe000 — boot_app0</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`dropzone${dragging ? ' over' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||
|
||||
184
platform/frontend/src/pages/Flash.jsx
Normal file
184
platform/frontend/src/pages/Flash.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
|
||||
const CHIP_LABELS = {
|
||||
'ESP32-S3': 'ESP32-S3', 'ESP32-S2': 'ESP32-S2', 'ESP32-C3': 'ESP32-C3',
|
||||
'ESP32-C6': 'ESP32-C6', 'ESP32-H2': 'ESP32-H2', 'ESP32': 'ESP32',
|
||||
};
|
||||
|
||||
export default function Flash() {
|
||||
const { token } = useParams();
|
||||
const [info, setInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [flashDone, setFlashDone] = useState(false);
|
||||
const installRef = useRef(null);
|
||||
|
||||
const manifestUrl = `${window.location.origin}/api/flash/${token}/manifest`;
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/flash/${token}`)
|
||||
.then(r => setInfo(r.data))
|
||||
.catch(() => setInfo(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
// esp-web-tools 이벤트 — 플래시 완료/실패 시 서버에 기록
|
||||
useEffect(() => {
|
||||
const btn = installRef.current;
|
||||
if (!btn) return;
|
||||
|
||||
function onSuccess(e) {
|
||||
const mac = e.detail?.device?.macAddress || 'unknown';
|
||||
api.post(`/flash/${token}/consume`, {
|
||||
mac, chipFamily: info?.chipFamily, success: true,
|
||||
}).catch(() => {});
|
||||
setFlashDone(true);
|
||||
}
|
||||
function onFail(e) {
|
||||
api.post(`/flash/${token}/consume`, {
|
||||
mac: 'unknown', chipFamily: info?.chipFamily,
|
||||
success: false, errorMessage: e.detail?.message,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
btn.addEventListener('state-changed', (e) => {
|
||||
if (e.detail?.state === 'finished') onSuccess(e);
|
||||
if (e.detail?.state === 'error') onFail(e);
|
||||
});
|
||||
}, [info, token]);
|
||||
|
||||
if (loading) return <div className="spinner" />;
|
||||
|
||||
// 유효하지 않은 토큰
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 500 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>❌</div>
|
||||
<h2 style={{ marginBottom: 8 }}>유효하지 않은 토큰</h2>
|
||||
<p className="text-muted">토큰을 찾을 수 없습니다.</p>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||
구매 내역으로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 이미 사용된 토큰
|
||||
if (info.isUsed) {
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 500 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
||||
<h2 style={{ marginBottom: 8 }}>이미 플래시됨</h2>
|
||||
<p className="text-muted" style={{ marginBottom: 8 }}>
|
||||
이 토큰은 {info.usedAt ? new Date(info.usedAt).toLocaleString('ko-KR') : ''}에 사용되었습니다.
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>1회용 토큰은 재사용할 수 없습니다.</p>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||
구매 내역으로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 만료된 토큰
|
||||
if (info.expired) {
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 500 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⏰</div>
|
||||
<h2 style={{ marginBottom: 8 }}>만료된 토큰</h2>
|
||||
<p className="text-muted">토큰 유효기간이 지났습니다.</p>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>고객센터에 문의해주세요.</p>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||
구매 내역으로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 펌웨어 없음
|
||||
if (!info.hasFirmware) {
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 500 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
<h2 style={{ marginBottom: 8 }}>펌웨어 파일 없음</h2>
|
||||
<p className="text-muted">판매자가 아직 펌웨어를 업로드하지 않았습니다.</p>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>판매자에게 문의하거나 환불을 요청하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 600 }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 36 }}>⚡</div>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 2 }}>{info.productName}</h2>
|
||||
<span className="text-muted" style={{ fontSize: 13 }}>
|
||||
{CHIP_LABELS[info.chipFamily] || info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flashDone ? (
|
||||
<div className="alert alert-success" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🎉</div>
|
||||
<strong>플래시 완료!</strong>
|
||||
<p style={{ marginTop: 8, fontSize: 13 }}>펌웨어가 성공적으로 ESP32에 기록되었습니다.</p>
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="alert alert-info" style={{ marginBottom: 20 }}>
|
||||
<strong>플래시 전 확인사항</strong>
|
||||
<ul style={{ marginTop: 8, paddingLeft: 16, fontSize: 13, lineHeight: 2 }}>
|
||||
<li>Chrome 또는 Edge 브라우저를 사용하고 있나요?</li>
|
||||
<li>ESP32를 USB 케이블로 PC에 연결했나요?</li>
|
||||
<li>이 토큰은 <strong>1회만</strong> 사용 가능합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: '24px 0' }}>
|
||||
<esp-web-install-button
|
||||
ref={installRef}
|
||||
manifest={manifestUrl}
|
||||
style={{ '--esp-tools-button-color': '#6366f1', '--esp-tools-button-text-color': '#fff' }}
|
||||
>
|
||||
<button slot="activate" className="btn btn-primary"
|
||||
style={{ fontSize: 16, padding: '12px 32px' }}>
|
||||
⚡ ESP32 플래시 시작
|
||||
</button>
|
||||
<span slot="unsupported" style={{ color: 'var(--danger)', fontSize: 14 }}>
|
||||
Chrome 또는 Edge 브라우저가 필요합니다
|
||||
</span>
|
||||
</esp-web-install-button>
|
||||
<p className="text-muted" style={{ fontSize: 12, textAlign: 'center' }}>
|
||||
버튼 클릭 후 팝업에서 ESP32 포트를 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<details style={{ fontSize: 13, color: 'var(--text2)' }}>
|
||||
<summary style={{ cursor: 'pointer', marginBottom: 8 }}>토큰 정보 (고급)</summary>
|
||||
<code style={{ wordBreak: 'break-all', display: 'block', background: 'var(--bg3)', padding: 8, borderRadius: 4 }}>
|
||||
{token}
|
||||
</code>
|
||||
<p style={{ marginTop: 8 }}>매니페스트 URL: <code style={{ fontSize: 11 }}>{manifestUrl}</code></p>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import api from '../api/client';
|
||||
|
||||
@@ -13,6 +13,8 @@ export default function ProductDetail() {
|
||||
const { user } = useAuth();
|
||||
const [product, setProduct] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [buying, setBuying] = useState(false);
|
||||
const [buyError, setBuyError] = useState('');
|
||||
const [imgIdx, setImgIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,6 +24,46 @@ export default function ProductDetail() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
async function handleBuy() {
|
||||
if (!user) {
|
||||
navigate('/auth/login', { state: { from: `/shop/${id}` } });
|
||||
return;
|
||||
}
|
||||
setBuyError('');
|
||||
setBuying(true);
|
||||
try {
|
||||
// 1. 주문 생성
|
||||
let orderId, flashToken;
|
||||
try {
|
||||
const res = await api.post('/orders', { productId: product.id });
|
||||
orderId = res.data.orderId;
|
||||
// 이미 구매한 경우 바로 Flash 페이지로
|
||||
if (res.data.flashToken) {
|
||||
navigate(`/flash/${res.data.flashToken}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// 이미 결제 완료된 주문
|
||||
if (err.response?.data?.flashToken) {
|
||||
navigate(`/flash/${err.response.data.flashToken}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 2. 모의 결제 처리
|
||||
const payRes = await api.post(`/orders/${orderId}/mock-pay`);
|
||||
flashToken = payRes.data.flashToken;
|
||||
|
||||
// 3. Flash 페이지로 이동
|
||||
navigate(`/flash/${flashToken}`);
|
||||
} catch (err) {
|
||||
setBuyError(err.response?.data?.error || '구매 처리 중 오류가 발생했습니다');
|
||||
} finally {
|
||||
setBuying(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="spinner" />;
|
||||
if (!product) return null;
|
||||
|
||||
@@ -31,7 +73,7 @@ export default function ProductDetail() {
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 32 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 32, alignItems: 'start' }}>
|
||||
{/* 왼쪽 */}
|
||||
<div>
|
||||
{images.length > 0 && (
|
||||
@@ -52,7 +94,7 @@ export default function ProductDetail() {
|
||||
|
||||
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{product.project.title}</h1>
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
by {product.project.user.nickname}
|
||||
by {product.project.user.nickname} · 칩: {product.project.chipFamily}
|
||||
</div>
|
||||
{avgRating && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -98,8 +140,8 @@ export default function ProductDetail() {
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 — 구매 패널 */}
|
||||
<div>
|
||||
<div className="card" style={{ position: 'sticky', top: 80 }}>
|
||||
<div style={{ position: 'sticky', top: 80 }}>
|
||||
<div className="card">
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--accent2)', marginBottom: 4 }}>
|
||||
₩{product.price.toLocaleString()}
|
||||
</div>
|
||||
@@ -109,22 +151,34 @@ export default function ProductDetail() {
|
||||
|
||||
{product.isOnSale ? (
|
||||
<>
|
||||
{user ? (
|
||||
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', fontSize: 16 }}
|
||||
onClick={() => alert('결제 기능은 2단계에서 구현됩니다')}>
|
||||
구매하기
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }}
|
||||
onClick={() => navigate('/auth/login', { state: { from: `/shop/${id}` } })}>
|
||||
로그인 후 구매
|
||||
</button>
|
||||
)}
|
||||
{buyError && <div className="alert alert-error" style={{ fontSize: 13 }}>{buyError}</div>}
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center', fontSize: 16, padding: '12px' }}
|
||||
onClick={handleBuy}
|
||||
disabled={buying}
|
||||
>
|
||||
{buying ? '처리 중...' : user ? '지금 구매하기' : '로그인 후 구매'}
|
||||
</button>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* 테스트 모드 안내 */}
|
||||
<div style={{ background: 'rgba(245,158,11,.1)', border: '1px solid rgba(245,158,11,.3)', borderRadius: 'var(--radius)', padding: '10px 12px', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--warn)', fontWeight: 600, marginBottom: 4 }}>
|
||||
🧪 테스트 모드
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||
현재 모의 결제로 동작합니다. 실제 요금이 청구되지 않습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul style={{ color: 'var(--text2)', fontSize: 13, paddingLeft: 16 }}>
|
||||
<li>구매 즉시 플래시 토큰 발급</li>
|
||||
<li>USB 연결 후 브라우저에서 플래시</li>
|
||||
<li>구매 즉시 1회용 플래시 토큰 발급</li>
|
||||
<li>USB 연결 후 브라우저에서 직접 플래시</li>
|
||||
<li>플래시 완료 후 환불 불가</li>
|
||||
<li>토큰 유효기간: 30일</li>
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user