feat: ESP32 DIY platform Phase 1 — marketplace with mock payment & flash token flow
- React+Vite frontend (dark theme, role-based routing: admin/seller/buyer) - Express+Prisma+PostgreSQL backend with JWT auth and audit logging - MinIO object storage with backend proxy for CORS-free firmware delivery - Mock payment flow (order → mock-pay → FlashToken) for pre-business testing - FlashToken lifecycle: issue → validate → esp-web-tools manifest → consume - Admin approval workflow for project/product submissions - Toss Payments integration guide (TOSS_PAYMENT_GUIDE.md) for live keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
frontend/nginx.conf
Normal file
36
frontend/nginx.conf
Normal file
@@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 512M;
|
||||
|
||||
# React SPA — 모든 경로를 index.html로 fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 백엔드 API 프록시
|
||||
location /api/ {
|
||||
proxy_pass http://platform-backend:3201/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# 정적 에셋 캐시
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# gzip 압축
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "platform-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
76
frontend/src/App.jsx
Normal file
76
frontend/src/App.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
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';
|
||||
import ProductDetail from './pages/ProductDetail';
|
||||
import Login from './pages/Auth/Login';
|
||||
import Register from './pages/Auth/Register';
|
||||
import Dashboard from './pages/Dashboard/Index';
|
||||
import ProjectNew from './pages/Dashboard/ProjectNew';
|
||||
import ProjectEdit from './pages/Dashboard/ProjectEdit';
|
||||
import MyOrders from './pages/Dashboard/MyOrders';
|
||||
import MySales from './pages/Dashboard/MySales';
|
||||
import AdminIndex from './pages/Admin/Index';
|
||||
import AdminProjects from './pages/Admin/Projects';
|
||||
import AdminUsers from './pages/Admin/Users';
|
||||
import AdminLogs from './pages/Admin/Logs';
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="spinner" />;
|
||||
if (!user) return <Navigate to="/auth/login" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function RequireAdmin({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="spinner" />;
|
||||
if (!user || user.role !== 'admin') return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="/shop" element={<Shop />} />
|
||||
<Route path="/shop/:id" element={<ProductDetail />} />
|
||||
<Route path="/auth/login" element={<Login />} />
|
||||
<Route path="/auth/register" element={<Register />} />
|
||||
|
||||
<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />
|
||||
<Route path="/dashboard/projects/new" element={<RequireAuth><ProjectNew /></RequireAuth>} />
|
||||
<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>} />
|
||||
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
|
||||
<Route path="/admin/logs" element={<RequireAdmin><AdminLogs /></RequireAdmin>} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
28
frontend/src/api/client.js
Normal file
28
frontend/src/api/client.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// 요청마다 JWT 토큰 자동 첨부
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
// 401 응답 시 로그아웃 처리
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/auth/login';
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
43
frontend/src/components/Navbar.jsx
Normal file
43
frontend/src/components/Navbar.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="navbar-inner">
|
||||
<NavLink to="/" className="navbar-brand">
|
||||
ESP32 DIY <span>플랫폼</span>
|
||||
</NavLink>
|
||||
<div className="navbar-links">
|
||||
<NavLink to="/projects">프로젝트</NavLink>
|
||||
<NavLink to="/shop">상점</NavLink>
|
||||
{user && <NavLink to="/dashboard">대시보드</NavLink>}
|
||||
{user?.role === 'admin' && <NavLink to="/admin">관리자</NavLink>}
|
||||
</div>
|
||||
<div className="navbar-right">
|
||||
{user ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--text2)', fontSize: 13, alignSelf: 'center' }}>
|
||||
{user.nickname}
|
||||
</span>
|
||||
<button className="btn btn-outline btn-sm" onClick={handleLogout}>로그아웃</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavLink to="/auth/login" className="btn btn-outline btn-sm">로그인</NavLink>
|
||||
<NavLink to="/auth/register" className="btn btn-primary btn-sm">회원가입</NavLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
2
frontend/src/hooks/useAuth.js
Normal file
2
frontend/src/hooks/useAuth.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// JSX가 없는 순수 re-export — Vite build-import-analysis 통과용
|
||||
export { AuthProvider, useAuth } from './useAuth.jsx';
|
||||
53
frontend/src/hooks/useAuth.jsx
Normal file
53
frontend/src/hooks/useAuth.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import api from '../api/client';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('user')); } catch { return null; }
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) { setLoading(false); return; }
|
||||
api.get('/auth/me')
|
||||
.then(r => { setUser(r.data); localStorage.setItem('user', JSON.stringify(r.data)); })
|
||||
.catch(() => { localStorage.removeItem('token'); localStorage.removeItem('user'); setUser(null); })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function login(email, password) {
|
||||
const { data } = await api.post('/auth/login', { email, password });
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
}
|
||||
|
||||
async function register(email, password, nickname) {
|
||||
const { data } = await api.post('/auth/register', { email, password, nickname });
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await api.post('/auth/logout'); } catch {}
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
237
frontend/src/index.css
Normal file
237
frontend/src/index.css
Normal file
@@ -0,0 +1,237 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg2: #1a1d2e;
|
||||
--bg3: #252840;
|
||||
--border: #2e3150;
|
||||
--text: #e2e8f0;
|
||||
--text2: #94a3b8;
|
||||
--accent: #6366f1;
|
||||
--accent2: #818cf8;
|
||||
--success: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--radius: 8px;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: var(--accent2); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Layout */
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 0 16px; }
|
||||
.page { padding: 32px 0; }
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.navbar-inner {
|
||||
display: flex; align-items: center; gap: 24px;
|
||||
max-width: 1100px; margin: 0 auto; padding: 0 16px;
|
||||
height: 56px;
|
||||
}
|
||||
.navbar-brand { font-weight: 700; font-size: 18px; color: var(--accent2); }
|
||||
.navbar-brand span { color: var(--text2); font-weight: 400; font-size: 13px; }
|
||||
.navbar-links { display: flex; gap: 8px; flex: 1; }
|
||||
.navbar-links a {
|
||||
padding: 6px 12px; border-radius: var(--radius);
|
||||
color: var(--text2); font-size: 14px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.navbar-links a:hover, .navbar-links a.active {
|
||||
background: var(--bg3); color: var(--text); text-decoration: none;
|
||||
}
|
||||
.navbar-right { display: flex; gap: 8px; margin-left: auto; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: var(--radius);
|
||||
font-size: 14px; font-weight: 500; border: none;
|
||||
cursor: pointer; transition: opacity .15s, transform .1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { opacity: .85; }
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-success { background: var(--success); color: #fff; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 13px; }
|
||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.project-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: border-color .2s, transform .2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.project-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.project-card img {
|
||||
width: 100%; height: 180px; object-fit: cover;
|
||||
background: var(--bg3);
|
||||
}
|
||||
.project-card-body { padding: 16px; }
|
||||
.project-card-title { font-weight: 600; margin-bottom: 6px; }
|
||||
.project-card-meta { color: var(--text2); font-size: 13px; display: flex; gap: 12px; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text2); }
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 100px; }
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.alert-error { background: rgba(239,68,68,.15); border: 1px solid var(--danger); color: #fca5a5; }
|
||||
.alert-success { background: rgba(34,197,94,.15); border: 1px solid var(--success); color: #86efac; }
|
||||
.alert-warn { background: rgba(245,158,11,.15); border: 1px solid var(--warn); color: #fcd34d; }
|
||||
.alert-info { background: rgba(99,102,241,.15); border: 1px solid var(--accent); color: var(--accent2); }
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-pending { background: rgba(245,158,11,.2); color: var(--warn); }
|
||||
.badge-approved { background: rgba(34,197,94,.2); color: var(--success); }
|
||||
.badge-rejected { background: rgba(239,68,68,.2); color: var(--danger); }
|
||||
.badge-draft { background: rgba(148,163,184,.2); color: var(--text2); }
|
||||
.badge-suspended { background: rgba(239,68,68,.2); color: var(--danger); }
|
||||
|
||||
/* Table */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
th { color: var(--text2); font-weight: 500; background: var(--bg3); }
|
||||
tr:hover td { background: rgba(255,255,255,.02); }
|
||||
|
||||
/* Pagination */
|
||||
.pagination { display: flex; gap: 6px; margin-top: 24px; justify-content: center; }
|
||||
.pagination button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button.active { background: var(--accent); border-color: var(--accent); }
|
||||
.pagination button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* Misc */
|
||||
.stars { color: var(--warn); letter-spacing: 2px; }
|
||||
.divider { height: 1px; background: var(--border); margin: 20px 0; }
|
||||
.text-muted { color: var(--text2); }
|
||||
.text-center { text-align: center; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
.mt-24 { margin-top: 24px; }
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.gap-8 { gap: 8px; }
|
||||
.gap-16 { gap: 16px; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
width: 36px; height: 36px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
margin: 40px auto;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* File Drop Zone */
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.dropzone:hover, .dropzone.over { border-color: var(--accent); }
|
||||
.dropzone p { color: var(--text2); margin-top: 8px; font-size: 13px; }
|
||||
|
||||
/* Sidebar layout */
|
||||
.layout-2col { display: grid; grid-template-columns: 220px 1fr; gap: 24px; }
|
||||
.sidebar {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 0;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
}
|
||||
.sidebar a {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar a:hover, .sidebar a.active {
|
||||
background: var(--bg3);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout-2col { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; display: flex; flex-wrap: wrap; }
|
||||
.card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
57
frontend/src/pages/Admin/Index.jsx
Normal file
57
frontend/src/pages/Admin/Index.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../../api/client';
|
||||
|
||||
export default function AdminIndex() {
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/admin/stats').then(r => setStats(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const cards = stats ? [
|
||||
{ label: '총 사용자', value: stats.users.toLocaleString(), icon: '👤' },
|
||||
{ label: '전체 프로젝트', value: stats.projects.total.toLocaleString(), icon: '📁' },
|
||||
{ label: '승인 대기', value: stats.projects.pending.toLocaleString(), icon: '⏳', warn: stats.projects.pending > 0 },
|
||||
{ label: '총 매출', value: `₩${(stats.revenue.total || 0).toLocaleString()}`, icon: '💰' },
|
||||
{ label: '총 주문', value: stats.revenue.orders.toLocaleString(), icon: '🛒' },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<h2 style={{ marginBottom: 24 }}>관리자 대시보드</h2>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16, marginBottom: 32 }}>
|
||||
{cards.map(c => (
|
||||
<div key={c.label} className="card" style={{ borderColor: c.warn ? 'var(--warn)' : undefined }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>{c.icon}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: c.warn ? 'var(--warn)' : 'var(--text)' }}>{c.value}</div>
|
||||
<div className="text-muted" style={{ fontSize: 13 }}>{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빠른 메뉴 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||
{[
|
||||
{ to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' },
|
||||
{ to: '/admin/users', icon: '👥', title: '사용자 관리', desc: '사용자 목록, 역할 변경, 계정 비활성화' },
|
||||
{ to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' },
|
||||
].map(m => (
|
||||
<Link key={m.to} to={m.to} style={{ textDecoration: 'none' }}>
|
||||
<div className="card" style={{ cursor: 'pointer', transition: 'border-color .2s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>{m.icon}</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{m.title}</div>
|
||||
<div className="text-muted" style={{ fontSize: 13 }}>{m.desc}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/pages/Admin/Logs.jsx
Normal file
95
frontend/src/pages/Admin/Logs.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../api/client';
|
||||
|
||||
const ACTION_COLORS = {
|
||||
REGISTER: 'var(--success)', LOGIN: 'var(--success)', LOGIN_FAIL: 'var(--danger)',
|
||||
LOGOUT: 'var(--text2)', PROJECT_CREATE: 'var(--accent2)', PROJECT_SUBMIT: 'var(--warn)',
|
||||
PROJECT_DELETE: 'var(--danger)', ADMIN_APPROVE: 'var(--success)', ADMIN_REJECT: 'var(--danger)',
|
||||
ADMIN_USER_DEACTIVATE: 'var(--danger)', ORDER_CREATE: 'var(--accent2)', PAYMENT_CONFIRM: 'var(--success)',
|
||||
};
|
||||
|
||||
export default function AdminLogs() {
|
||||
const [data, setData] = useState({ logs: [], total: 0, pages: 1 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [filter, setFilter] = useState({ action: '', userId: '' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page, limit: 50 });
|
||||
if (filter.action) params.set('action', filter.action);
|
||||
if (filter.userId) params.set('userId', filter.userId);
|
||||
api.get(`/admin/logs?${params}`)
|
||||
.then(r => setData(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, [page]);
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2>감사 로그</h2>
|
||||
<span className="text-muted" style={{ fontSize: 13 }}>총 {data.total.toLocaleString()}건</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<input placeholder="Action 필터 (예: LOGIN)" value={filter.action}
|
||||
onChange={e => setFilter(f => ({ ...f, action: e.target.value }))} style={{ maxWidth: 200 }} />
|
||||
<input placeholder="User ID" value={filter.userId}
|
||||
onChange={e => setFilter(f => ({ ...f, userId: e.target.value }))} style={{ maxWidth: 200 }} />
|
||||
<button className="btn btn-outline" onClick={() => { setPage(1); load(); }}>검색</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="spinner" /> : (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>시간</th><th>Action</th><th>사용자</th><th>대상</th><th>IP</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.logs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.createdAt).toLocaleString('ko-KR')}
|
||||
</td>
|
||||
<td>
|
||||
<code style={{ fontSize: 12, color: ACTION_COLORS[log.action] || 'var(--text)', background: 'var(--bg3)', padding: '2px 6px', borderRadius: 4 }}>
|
||||
{log.action}
|
||||
</code>
|
||||
</td>
|
||||
<td style={{ fontSize: 12 }}>
|
||||
{log.user ? `${log.user.nickname} (${log.user.email})` : '-'}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||
{log.targetType ? `${log.targetType} ${log.targetId?.slice(0, 8)}...` : '-'}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text2)' }}>{log.ipAddress}</td>
|
||||
<td>
|
||||
{log.responseStatus && (
|
||||
<span style={{ fontSize: 12, color: log.responseStatus < 400 ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{log.responseStatus}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.pages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||
{Array.from({ length: Math.min(data.pages, 10) }, (_, i) => (
|
||||
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>{i + 1}</button>
|
||||
))}
|
||||
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/pages/Admin/Projects.jsx
Normal file
132
frontend/src/pages/Admin/Projects.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../api/client';
|
||||
|
||||
export default function AdminProjects() {
|
||||
const [pending, setPending] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [approveForm, setApproveForm] = useState({ price: '', commissionRate: '0.1' });
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/admin/projects/pending')
|
||||
.then(r => setPending(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleApprove() {
|
||||
if (!approveForm.price) { setMsg('판매가를 입력해주세요'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/admin/projects/${selected.id}/approve`, {
|
||||
price: parseInt(approveForm.price),
|
||||
commissionRate: parseFloat(approveForm.commissionRate),
|
||||
});
|
||||
setPending(p => p.filter(x => x.id !== selected.id));
|
||||
setSelected(null);
|
||||
setMsg('승인되었습니다');
|
||||
} catch (err) {
|
||||
setMsg(err.response?.data?.error || '오류 발생');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!rejectNote.trim()) { setMsg('반려 사유를 입력해주세요'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/admin/projects/${selected.id}/reject`, { adminNote: rejectNote });
|
||||
setPending(p => p.filter(x => x.id !== selected.id));
|
||||
setSelected(null);
|
||||
setRejectNote('');
|
||||
setMsg('반려 처리되었습니다');
|
||||
} catch (err) {
|
||||
setMsg(err.response?.data?.error || '오류 발생');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="spinner" />;
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<h2 style={{ marginBottom: 8 }}>프로젝트 승인 관리</h2>
|
||||
<p className="text-muted" style={{ marginBottom: 24 }}>검토 대기 중인 프로젝트: {pending.length}건</p>
|
||||
|
||||
{msg && <div className="alert alert-info" onClick={() => setMsg('')}>{msg}</div>}
|
||||
|
||||
{pending.length === 0 ? (
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<p className="text-muted">검토 대기 중인 프로젝트가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 1fr' : '1fr', gap: 20 }}>
|
||||
{/* 목록 */}
|
||||
<div>
|
||||
{pending.map(p => (
|
||||
<div key={p.id} className="card" style={{ marginBottom: 12, cursor: 'pointer', borderColor: selected?.id === p.id ? 'var(--accent)' : 'var(--border)' }}
|
||||
onClick={() => { setSelected(p); setApproveForm({ price: '', commissionRate: '0.1' }); setRejectNote(''); }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
{p.files[0] && (
|
||||
<img src={p.files[0].thumbnailUrl || p.files[0].url} alt=""
|
||||
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{p.title}</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
by {p.user.nickname} · 파일 {p._count.files}개 ·{' '}
|
||||
{new Date(p.updatedAt).toLocaleDateString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 상세 / 승인 패널 */}
|
||||
{selected && (
|
||||
<div className="card" style={{ height: 'fit-content', position: 'sticky', top: 80 }}>
|
||||
<h3 style={{ marginBottom: 16 }}>{selected.title}</h3>
|
||||
<p className="text-muted" style={{ fontSize: 13, marginBottom: 16, maxHeight: 120, overflow: 'auto' }}>
|
||||
{selected.description}
|
||||
</p>
|
||||
|
||||
<div className="divider" />
|
||||
<h4 style={{ marginBottom: 12 }}>승인</h4>
|
||||
<div className="form-group">
|
||||
<label>판매가 (원)</label>
|
||||
<input type="number" min="100" placeholder="예: 9900"
|
||||
value={approveForm.price} onChange={e => setApproveForm(f => ({ ...f, price: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>수수료율 (0.0 ~ 1.0)</label>
|
||||
<input type="number" min="0" max="1" step="0.01"
|
||||
value={approveForm.commissionRate}
|
||||
onChange={e => setApproveForm(f => ({ ...f, commissionRate: e.target.value }))} />
|
||||
</div>
|
||||
<button className="btn btn-success" onClick={handleApprove} disabled={saving} style={{ width: '100%', marginBottom: 12, justifyContent: 'center' }}>
|
||||
{saving ? '처리 중...' : '✓ 승인하기'}
|
||||
</button>
|
||||
|
||||
<div className="divider" />
|
||||
<h4 style={{ marginBottom: 12 }}>반려</h4>
|
||||
<div className="form-group">
|
||||
<label>반려 사유</label>
|
||||
<textarea value={rejectNote} onChange={e => setRejectNote(e.target.value)} rows={3}
|
||||
placeholder="수정이 필요한 내용을 구체적으로 작성해주세요" />
|
||||
</div>
|
||||
<button className="btn btn-danger" onClick={handleReject} disabled={saving} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{saving ? '처리 중...' : '✕ 반려하기'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
frontend/src/pages/Admin/Users.jsx
Normal file
104
frontend/src/pages/Admin/Users.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../api/client';
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [data, setData] = useState({ users: [], total: 0, pages: 1 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page, limit: 30 });
|
||||
if (q) params.set('q', q);
|
||||
api.get(`/admin/users?${params}`)
|
||||
.then(r => setData(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, [page]);
|
||||
|
||||
async function toggleActive(userId) {
|
||||
await api.put(`/admin/users/${userId}/toggle`);
|
||||
load();
|
||||
}
|
||||
|
||||
async function changeRole(userId, role) {
|
||||
if (!confirm(`역할을 "${role}"로 변경하시겠습니까?`)) return;
|
||||
await api.put(`/admin/users/${userId}/role`, { role });
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<h2 style={{ marginBottom: 20 }}>사용자 관리</h2>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<input placeholder="이메일 또는 닉네임 검색" value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { setPage(1); load(); } }}
|
||||
style={{ maxWidth: 280 }} />
|
||||
<button className="btn btn-outline" onClick={() => { setPage(1); load(); }}>검색</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="spinner" /> : (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>이메일</th><th>닉네임</th><th>역할</th><th>상태</th><th>가입일</th><th>최근 로그인</th><th>역할 변경</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td style={{ fontSize: 12 }}>{u.email}</td>
|
||||
<td>{u.nickname}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${u.role === 'admin' ? 'approved' : u.role === 'seller' ? 'pending' : 'draft'}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ color: u.isActive ? 'var(--success)' : 'var(--danger)', fontSize: 12 }}>
|
||||
{u.isActive ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-muted" style={{ fontSize: 12 }}>{new Date(u.createdAt).toLocaleDateString('ko-KR')}</td>
|
||||
<td className="text-muted" style={{ fontSize: 12 }}>
|
||||
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<select defaultValue={u.role} onChange={e => changeRole(u.id, e.target.value)}
|
||||
style={{ width: 90, padding: '4px 8px', fontSize: 12 }}>
|
||||
<option value="buyer">buyer</option>
|
||||
<option value="seller">seller</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.role !== 'admin' && (
|
||||
<button className={`btn btn-sm ${u.isActive ? 'btn-danger' : 'btn-success'}`}
|
||||
onClick={() => toggleActive(u.id)}>
|
||||
{u.isActive ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.pages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||
{Array.from({ length: data.pages }, (_, i) => (
|
||||
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>{i + 1}</button>
|
||||
))}
|
||||
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/pages/Auth/Login.jsx
Normal file
54
frontend/src/pages/Auth/Login.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export default function Login() {
|
||||
const [form, setForm] = useState({ email: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const from = location.state?.from || '/dashboard';
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(form.email, form.password);
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '로그인에 실패했습니다');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 400 }}>
|
||||
<div className="card">
|
||||
<h2 style={{ marginBottom: 24 }}>로그인</h2>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>이메일</label>
|
||||
<input type="email" required value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>비밀번호</label>
|
||||
<input type="password" required value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} />
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-muted mt-16 text-center" style={{ fontSize: 13 }}>
|
||||
계정이 없으신가요? <Link to="/auth/register">회원가입</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
frontend/src/pages/Auth/Register.jsx
Normal file
61
frontend/src/pages/Auth/Register.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export default function Register() {
|
||||
const [form, setForm] = useState({ email: '', password: '', nickname: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (form.password.length < 8) {
|
||||
setError('비밀번호는 8자 이상이어야 합니다');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(form.email, form.password, form.nickname);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '회원가입에 실패했습니다');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 400 }}>
|
||||
<div className="card">
|
||||
<h2 style={{ marginBottom: 24 }}>회원가입</h2>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>이메일</label>
|
||||
<input type="email" required value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>닉네임 (2~30자)</label>
|
||||
<input type="text" required minLength={2} maxLength={30} value={form.nickname}
|
||||
onChange={e => setForm(f => ({ ...f, nickname: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>비밀번호 (8자 이상)</label>
|
||||
<input type="password" required minLength={8} value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} />
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? '처리 중...' : '회원가입'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-muted mt-16 text-center" style={{ fontSize: 13 }}>
|
||||
이미 계정이 있으신가요? <Link to="/auth/login">로그인</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/pages/Dashboard/Index.jsx
Normal file
75
frontend/src/pages/Dashboard/Index.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import api from '../../api/client';
|
||||
|
||||
const STATUS_BADGE = {
|
||||
draft: <span className="badge badge-draft">임시저장</span>,
|
||||
pending: <span className="badge badge-pending">검토 대기</span>,
|
||||
approved: <span className="badge badge-approved">승인됨</span>,
|
||||
rejected: <span className="badge badge-rejected">반려됨</span>,
|
||||
suspended: <span className="badge badge-suspended">정지됨</span>,
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/projects/my/list')
|
||||
.then(r => setProjects(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 22 }}>내 대시보드</h2>
|
||||
<p className="text-muted" style={{ marginTop: 4 }}>안녕하세요, {user?.nickname}님</p>
|
||||
</div>
|
||||
<Link to="/dashboard/projects/new" className="btn btn-primary">+ 새 프로젝트</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<Link to="/dashboard/projects/new" className="btn btn-outline btn-sm">프로젝트 등록</Link>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
|
||||
<Link to="/dashboard/sales" className="btn btn-outline btn-sm">판매 내역</Link>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 16 }}>내 프로젝트</h3>
|
||||
{loading ? <div className="spinner" /> : (
|
||||
projects.length === 0 ? (
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<p className="text-muted">등록한 프로젝트가 없습니다.</p>
|
||||
<Link to="/dashboard/projects/new" className="btn btn-primary" style={{ marginTop: 16 }}>첫 프로젝트 등록</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>제목</th><th>상태</th><th>판매가</th><th>판매수</th><th>날짜</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.title}</td>
|
||||
<td>{STATUS_BADGE[p.status] || p.status}</td>
|
||||
<td>{p.product ? `₩${p.product.price.toLocaleString()}` : '-'}</td>
|
||||
<td>{p.product?.totalSales ?? '-'}</td>
|
||||
<td className="text-muted">{new Date(p.createdAt).toLocaleDateString('ko-KR')}</td>
|
||||
<td>
|
||||
<Link to={`/dashboard/projects/${p.id}`} className="btn btn-outline btn-sm">관리</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
frontend/src/pages/Dashboard/MyOrders.jsx
Normal file
103
frontend/src/pages/Dashboard/MyOrders.jsx
Normal file
@@ -0,0 +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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/pages/Dashboard/MySales.jsx
Normal file
10
frontend/src/pages/Dashboard/MySales.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function MySales() {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
frontend/src/pages/Dashboard/ProjectEdit.jsx
Normal file
175
frontend/src/pages/Dashboard/ProjectEdit.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import api from '../../api/client';
|
||||
|
||||
const STATUS_LABEL = {
|
||||
draft: '임시저장', pending: '검토 대기', approved: '승인됨',
|
||||
rejected: '반려됨', suspended: '정지됨',
|
||||
};
|
||||
|
||||
export default function ProjectEdit() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState(null);
|
||||
const [form, setForm] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [files, setFiles] = useState([]);
|
||||
const [fileType, setFileType] = useState('image');
|
||||
const fileRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/projects/${id}`)
|
||||
.then(r => {
|
||||
setProject(r.data);
|
||||
setForm({ title: r.data.title, description: r.data.description, difficultyLevel: r.data.difficultyLevel });
|
||||
})
|
||||
.catch(() => navigate('/dashboard'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/projects/${id}`, form);
|
||||
setMsg('저장되었습니다');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '저장 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!files.length) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('fileType', fileType);
|
||||
files.forEach(f => fd.append('files', f));
|
||||
const { data } = await api.post(`/projects/${id}/files`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
setProject(p => ({ ...p, files: [...(p.files || []), ...data] }));
|
||||
setFiles([]);
|
||||
setMsg('파일이 업로드되었습니다');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '업로드 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFile(fileId) {
|
||||
if (!confirm('파일을 삭제하시겠습니까?')) return;
|
||||
await api.delete(`/projects/${id}/files/${fileId}`);
|
||||
setProject(p => ({ ...p, files: p.files.filter(f => f.id !== fileId) }));
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/projects/${id}/submit`);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '제출 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="spinner" />;
|
||||
const canEdit = ['draft', 'rejected'].includes(project?.status);
|
||||
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 700 }}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 24 }}>
|
||||
<h2>프로젝트 관리</h2>
|
||||
<span className={`badge badge-${project.status}`}>{STATUS_LABEL[project.status]}</span>
|
||||
</div>
|
||||
|
||||
{project.adminNote && (
|
||||
<div className="alert alert-warn">
|
||||
<strong>관리자 메모:</strong> {project.adminNote}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{msg && <div className="alert alert-success">{msg}</div>}
|
||||
|
||||
{/* 기본 정보 수정 */}
|
||||
<form className="card" onSubmit={handleSave} style={{ marginBottom: 20 }}>
|
||||
<h3 style={{ marginBottom: 16 }}>기본 정보</h3>
|
||||
<div className="form-group">
|
||||
<label>제목</label>
|
||||
<input required disabled={!canEdit} value={form.title || ''}
|
||||
onChange={e => setForm(f => ({ ...f, title: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>설명</label>
|
||||
<textarea disabled={!canEdit} value={form.description || ''}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={6} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary" disabled={!canEdit || saving}>
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
{['draft', 'rejected'].includes(project.status) && (
|
||||
<button type="button" className="btn btn-success" onClick={handleSubmit} disabled={saving}>
|
||||
검토 요청
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 파일 관리 */}
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: 16 }}>파일 관리</h3>
|
||||
{project.files?.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 8, marginBottom: 20 }}>
|
||||
{project.files.map(f => (
|
||||
<div key={f.id} style={{ position: 'relative', borderRadius: 4, overflow: 'hidden', background: 'var(--bg3)' }}>
|
||||
{f.fileType === 'image'
|
||||
? <img src={f.thumbnailUrl || f.url} alt="" style={{ width: '100%', height: 80, objectFit: 'cover' }} />
|
||||
: <div style={{ height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24 }}>
|
||||
{f.fileType === 'stl' ? '🖨️' : f.fileType === 'firmware' ? '💾' : '📄'}
|
||||
</div>
|
||||
}
|
||||
<button onClick={() => handleDeleteFile(f.id)}
|
||||
style={{ position: 'absolute', top: 2, right: 2, background: 'var(--danger)', border: 'none',
|
||||
borderRadius: 4, color: '#fff', cursor: 'pointer', fontSize: 11, padding: '2px 5px' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<select value={fileType} onChange={e => setFileType(e.target.value)} style={{ width: 160 }}>
|
||||
<option value="image">이미지</option>
|
||||
<option value="video">영상</option>
|
||||
<option value="wiring">배선도</option>
|
||||
<option value="stl">STL</option>
|
||||
<option value="firmware">펌웨어</option>
|
||||
</select>
|
||||
<button className="btn btn-outline" onClick={() => fileRef.current.click()}>파일 선택</button>
|
||||
<input ref={fileRef} type="file" multiple hidden
|
||||
onChange={e => setFiles(Array.from(e.target.files))} />
|
||||
{files.length > 0 && (
|
||||
<button className="btn btn-primary" onClick={handleUpload} disabled={saving}>
|
||||
{saving ? '업로드 중...' : `${files.length}개 업로드`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
{files.map(f => f.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
frontend/src/pages/Dashboard/ProjectNew.jsx
Normal file
242
frontend/src/pages/Dashboard/ProjectNew.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api/client';
|
||||
|
||||
export default function ProjectNew() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({
|
||||
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:완료
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [projectId, setProjectId] = useState(null);
|
||||
const fileRef = useRef();
|
||||
|
||||
function addPart() {
|
||||
if (!partRow.name) return;
|
||||
setForm(f => ({ ...f, requiredParts: [...f.requiredParts, { ...partRow }] }));
|
||||
setPartRow({ name: '', quantity: '', link: '' });
|
||||
}
|
||||
function removePart(i) {
|
||||
setForm(f => ({ ...f, requiredParts: f.requiredParts.filter((_, idx) => idx !== i) }));
|
||||
}
|
||||
|
||||
async function handleStep1(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data } = await api.post('/projects', {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
difficultyLevel: form.difficultyLevel,
|
||||
chipFamily: form.chipFamily,
|
||||
requiredParts: form.requiredParts,
|
||||
});
|
||||
setProjectId(data.id);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '저장 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!files.length) { setStep(3); return; }
|
||||
setError('');
|
||||
setSaving(true);
|
||||
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' },
|
||||
});
|
||||
setFiles([]);
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '업로드 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForReview() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/submit`);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '제출 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
setFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 700 }}>
|
||||
{/* 스텝 표시 */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 32 }}>
|
||||
{['기본 정보', '파일 업로드', '검토 제출'].map((label, i) => (
|
||||
<div key={i} style={{ flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 'var(--radius)',
|
||||
background: step === i + 1 ? 'var(--accent)' : step > i + 1 ? 'var(--bg3)' : 'var(--bg2)',
|
||||
border: '1px solid var(--border)', color: step === i + 1 ? '#fff' : 'var(--text2)', fontSize: 13 }}>
|
||||
{i + 1}. {label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{/* 스텝 1 */}
|
||||
{step === 1 && (
|
||||
<form className="card" onSubmit={handleStep1}>
|
||||
<h3 style={{ marginBottom: 20 }}>기본 정보</h3>
|
||||
<div className="form-group">
|
||||
<label>프로젝트 제목 *</label>
|
||||
<input required value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
placeholder="예: ESP32-S3 CAN FD 로거" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>설명 *</label>
|
||||
<textarea required value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="프로젝트 목적, 기능, 특징을 설명해주세요" rows={6} />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 필요 부품 */}
|
||||
<div className="form-group">
|
||||
<label>필요 부품 (선택)</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 1fr auto', gap: 8, marginBottom: 8 }}>
|
||||
<input placeholder="부품명" value={partRow.name} onChange={e => setPartRow(p => ({ ...p, name: e.target.value }))} />
|
||||
<input placeholder="수량" value={partRow.quantity} onChange={e => setPartRow(p => ({ ...p, quantity: e.target.value }))} />
|
||||
<input placeholder="알리/쿠팡 링크" value={partRow.link} onChange={e => setPartRow(p => ({ ...p, link: e.target.value }))} />
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={addPart}>추가</button>
|
||||
</div>
|
||||
{form.requiredParts.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, padding: '4px 0' }}>
|
||||
<span style={{ flex: 1 }}>{p.name}</span>
|
||||
<span className="text-muted">{p.quantity}</span>
|
||||
{p.link && <a href={p.link} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>링크</a>}
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removePart(i)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '저장 중...' : '다음 단계 →'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 스텝 2 */}
|
||||
{step === 2 && (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: 20 }}>파일 업로드</h3>
|
||||
<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); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => fileRef.current.click()}>
|
||||
<input ref={fileRef} type="file" multiple hidden
|
||||
onChange={e => setFiles(prev => [...prev, ...Array.from(e.target.files)])} />
|
||||
<p>파일을 드래그하거나 클릭하여 선택</p>
|
||||
<p>이미지 최대 20MB · 영상 최대 500MB · 펌웨어 최대 64MB</p>
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<ul style={{ marginTop: 12, fontSize: 13, color: 'var(--text2)' }}>
|
||||
{files.map((f, i) => (
|
||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||
<span>{f.name}</span>
|
||||
<span>{(f.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 20 }}>
|
||||
<button className="btn btn-outline" onClick={() => setStep(1)}>← 이전</button>
|
||||
<button className="btn btn-primary" onClick={handleUpload} disabled={saving}>
|
||||
{saving ? '업로드 중...' : files.length > 0 ? '업로드 후 다음 →' : '건너뛰기 →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스텝 3 */}
|
||||
{step === 3 && (
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>📋</div>
|
||||
<h3 style={{ marginBottom: 12 }}>관리자 검토 요청</h3>
|
||||
<p className="text-muted" style={{ marginBottom: 24 }}>
|
||||
프로젝트 정보와 파일이 저장되었습니다.<br />
|
||||
관리자 검토 요청을 보내면 승인 후 판매가 시작됩니다.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/dashboard')}>나중에</button>
|
||||
<button className="btn btn-primary" onClick={submitForReview} disabled={saving}>
|
||||
{saving ? '제출 중...' : '검토 요청 제출'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="alert alert-error" style={{ marginTop: 16 }}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
frontend/src/pages/Flash.jsx
Normal file
184
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>
|
||||
);
|
||||
}
|
||||
102
frontend/src/pages/Home.jsx
Normal file
102
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
|
||||
function Stars({ rating }) {
|
||||
if (!rating) return <span className="text-muted">리뷰 없음</span>;
|
||||
return <span className="stars">{'★'.repeat(Math.round(rating))}{'☆'.repeat(5 - Math.round(rating))}</span>;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/products?sort=popular&limit=6')
|
||||
.then(r => setProducts(r.data.products || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<div style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)', padding: '60px 0' }}>
|
||||
<div className="container text-center">
|
||||
<h1 style={{ fontSize: 36, marginBottom: 16, color: 'var(--accent2)' }}>
|
||||
ESP32 DIY 플랫폼
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text2)', fontSize: 16, marginBottom: 32, maxWidth: 500, margin: '0 auto 32px' }}>
|
||||
직접 만든 ESP32 프로젝트를 공유하고 판매하세요.<br />
|
||||
펌웨어 구매 후 브라우저에서 바로 플래시까지.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||
<Link to="/shop" className="btn btn-primary">상점 둘러보기</Link>
|
||||
<Link to="/projects" className="btn btn-outline">프로젝트 탐색</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 인기 상품 */}
|
||||
<div className="container page">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: 20 }}>인기 상품</h2>
|
||||
<Link to="/shop" className="text-muted" style={{ fontSize: 13 }}>전체 보기 →</Link>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="spinner" /> : (
|
||||
<div className="card-grid">
|
||||
{products.map(p => (
|
||||
<div key={p.id} className="project-card" onClick={() => navigate(`/shop/${p.id}`)}>
|
||||
{p.project.files[0]
|
||||
? <img src={p.project.files[0].thumbnailUrl || p.project.files[0].url} alt={p.project.title} />
|
||||
: <div style={{ height: 180, background: 'var(--bg3)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text2)' }}>이미지 없음</div>
|
||||
}
|
||||
<div className="project-card-body">
|
||||
<div className="project-card-title">{p.project.title}</div>
|
||||
<div className="project-card-meta">
|
||||
<span>{p.project.user.nickname}</span>
|
||||
<Stars rating={p.avgRating} />
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--accent2)', fontWeight: 600 }}>
|
||||
₩{p.price.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length === 0 && !loading && (
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<p className="text-muted">아직 등록된 상품이 없습니다.</p>
|
||||
<Link to="/dashboard/projects/new" className="btn btn-primary" style={{ marginTop: 16 }}>
|
||||
첫 프로젝트 등록하기
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 특징 소개 */}
|
||||
<div style={{ background: 'var(--bg2)', borderTop: '1px solid var(--border)', padding: '48px 0' }}>
|
||||
<div className="container">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 24 }}>
|
||||
{[
|
||||
{ icon: '🔧', title: '프로젝트 공유', desc: '회로도, STL, 부품 목록을 함께 공유' },
|
||||
{ icon: '💸', title: '수익화', desc: '내 프로젝트에서 펌웨어 판매 수익 창출' },
|
||||
{ icon: '⚡', title: '브라우저 플래시', desc: '구매 후 USB 연결만으로 즉시 플래시' },
|
||||
{ icon: '⭐', title: '리뷰 시스템', desc: '완성품 사진·영상으로 제품 평가' },
|
||||
].map(f => (
|
||||
<div key={f.title} className="card" style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>{f.icon}</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{f.title}</div>
|
||||
<div className="text-muted" style={{ fontSize: 13 }}>{f.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
frontend/src/pages/ProductDetail.jsx
Normal file
192
frontend/src/pages/ProductDetail.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import api from '../api/client';
|
||||
|
||||
function Stars({ rating }) {
|
||||
return <span className="stars">{'★'.repeat(rating)}{'☆'.repeat(5 - rating)}</span>;
|
||||
}
|
||||
|
||||
export default function ProductDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
api.get(`/products/${id}`)
|
||||
.then(r => setProduct(r.data))
|
||||
.catch(() => navigate('/shop'))
|
||||
.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;
|
||||
|
||||
const images = product.project.files.filter(f => f.fileType === 'image');
|
||||
const reviews = product.reviews || [];
|
||||
const avgRating = product.avgRating;
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 32, alignItems: 'start' }}>
|
||||
{/* 왼쪽 */}
|
||||
<div>
|
||||
{images.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<img src={images[imgIdx].url} alt="" style={{ width: '100%', maxHeight: 480, objectFit: 'cover', borderRadius: 'var(--radius)', background: 'var(--bg3)' }} />
|
||||
{images.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
{images.map((img, i) => (
|
||||
<img key={img.id} src={img.thumbnailUrl || img.url} alt=""
|
||||
onClick={() => setImgIdx(i)}
|
||||
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 4, cursor: 'pointer', border: i === imgIdx ? '2px solid var(--accent)' : '2px solid transparent' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{product.project.title}</h1>
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
by {product.project.user.nickname} · 칩: {product.project.chipFamily}
|
||||
</div>
|
||||
{avgRating && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Stars rating={Math.round(avgRating)} />
|
||||
<span className="text-muted" style={{ fontSize: 13 }}> {avgRating} ({product.reviewCount}개 리뷰)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<p style={{ color: 'var(--text2)', lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>
|
||||
{product.project.description}
|
||||
</p>
|
||||
|
||||
{/* 리뷰 */}
|
||||
{reviews.length > 0 && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<h3 style={{ marginBottom: 16 }}>구매자 리뷰</h3>
|
||||
{reviews.map(r => (
|
||||
<div key={r.id} className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="flex items-center gap-8" style={{ marginBottom: 8 }}>
|
||||
<strong style={{ fontSize: 13 }}>{r.user.nickname}</strong>
|
||||
<Stars rating={r.rating} />
|
||||
<span className="text-muted" style={{ fontSize: 12, marginLeft: 'auto' }}>
|
||||
{new Date(r.createdAt).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{r.title}</div>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>{r.content}</p>
|
||||
{r.media?.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
{r.media.map(m => (
|
||||
m.mediaType === 'image'
|
||||
? <img key={m.id} src={m.thumbnailUrl || m.url} alt="" style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: 4 }} />
|
||||
: <a key={m.id} href={m.url} target="_blank" rel="noreferrer" className="btn btn-outline btn-sm">영상 보기</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 — 구매 패널 */}
|
||||
<div style={{ position: 'sticky', top: 80 }}>
|
||||
<div className="card">
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--accent2)', marginBottom: 4 }}>
|
||||
₩{product.price.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 20 }}>
|
||||
총 판매 {product.totalSales.toLocaleString()}건
|
||||
</div>
|
||||
|
||||
{product.isOnSale ? (
|
||||
<>
|
||||
{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>구매 즉시 1회용 플래시 토큰 발급</li>
|
||||
<li>USB 연결 후 브라우저에서 직접 플래시</li>
|
||||
<li>플래시 완료 후 환불 불가</li>
|
||||
<li>토큰 유효기간: 30일</li>
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<div className="alert alert-warn">현재 판매 중지 상태입니다</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
frontend/src/pages/ProjectDetail.jsx
Normal file
130
frontend/src/pages/ProjectDetail.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
|
||||
const FILE_LABELS = { image: '이미지', video: '영상', stl: 'STL', wiring: '배선도', firmware: '펌웨어' };
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [imgIdx, setImgIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/projects/${id}`)
|
||||
.then(r => setProject(r.data))
|
||||
.catch(() => navigate('/projects'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="spinner" />;
|
||||
if (!project) return null;
|
||||
|
||||
const images = project.files.filter(f => f.fileType === 'image');
|
||||
const others = project.files.filter(f => f.fileType !== 'image');
|
||||
const product = project.product;
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 32 }}>
|
||||
{/* 왼쪽 */}
|
||||
<div>
|
||||
{/* 이미지 갤러리 */}
|
||||
{images.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<img src={images[imgIdx].url} alt="" style={{ width: '100%', maxHeight: 480, objectFit: 'cover', borderRadius: 'var(--radius)', background: 'var(--bg3)' }} />
|
||||
{images.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
{images.map((img, i) => (
|
||||
<img key={img.id} src={img.thumbnailUrl || img.url} alt=""
|
||||
onClick={() => setImgIdx(i)}
|
||||
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 4, cursor: 'pointer', border: i === imgIdx ? '2px solid var(--accent)' : '2px solid transparent' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{project.title}</h1>
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 20 }}>
|
||||
by {project.user.nickname} · 난이도 {'⭐'.repeat(project.difficultyLevel)}
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<h3 style={{ marginBottom: 12 }}>프로젝트 설명</h3>
|
||||
<p style={{ color: 'var(--text2)', lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>{project.description}</p>
|
||||
|
||||
{/* 필요 부품 */}
|
||||
{project.requiredParts?.length > 0 && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<h3 style={{ marginBottom: 12 }}>필요 부품</h3>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>부품명</th><th>수량</th><th>구매처</th></tr></thead>
|
||||
<tbody>
|
||||
{project.requiredParts.map((p, i) => (
|
||||
<tr key={i}>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.quantity || '-'}</td>
|
||||
<td>{p.link ? <a href={p.link} target="_blank" rel="noreferrer">링크</a> : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 첨부 파일 */}
|
||||
{others.length > 0 && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<h3 style={{ marginBottom: 12 }}>첨부 파일</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{others.map(f => (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer"
|
||||
className="card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px' }}>
|
||||
<span style={{ fontSize: 20 }}>
|
||||
{f.fileType === 'stl' ? '🖨️' : f.fileType === 'wiring' ? '🔌' : f.fileType === 'video' ? '🎬' : '📄'}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13 }}>{f.originalName || FILE_LABELS[f.fileType] || f.fileType}</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>{(f.fileSize / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 — 구매 */}
|
||||
<div>
|
||||
{product && product.isOnSale ? (
|
||||
<div className="card" style={{ position: 'sticky', top: 80 }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent2)', marginBottom: 8 }}>
|
||||
₩{product.price.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 20 }}>
|
||||
판매 {product.totalSales.toLocaleString()}건
|
||||
</div>
|
||||
<Link to={`/shop/${product.id}`} className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }}>
|
||||
구매하기 →
|
||||
</Link>
|
||||
<p className="text-muted mt-8" style={{ fontSize: 12, textAlign: 'center' }}>
|
||||
구매 후 브라우저에서 바로 플래시 가능
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<p className="text-muted text-center">이 프로젝트는 현재 판매 중이 아닙니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/pages/Projects.jsx
Normal file
80
frontend/src/pages/Projects.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
|
||||
const DIFFICULTY = ['', '⭐ 입문', '⭐⭐ 초급', '⭐⭐⭐ 중급', '⭐⭐⭐⭐ 고급', '⭐⭐⭐⭐⭐ 전문가'];
|
||||
|
||||
export default function Projects() {
|
||||
const [data, setData] = useState({ projects: [], total: 0, pages: 1 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [difficulty, setDiff] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page, limit: 18 });
|
||||
if (difficulty) params.set('difficulty', difficulty);
|
||||
api.get(`/projects?${params}`)
|
||||
.then(r => setData(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, difficulty]);
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: 20 }}>ESP32 프로젝트</h2>
|
||||
<select style={{ width: 160 }} value={difficulty} onChange={e => { setDiff(e.target.value); setPage(1); }}>
|
||||
<option value="">난이도 전체</option>
|
||||
{[1,2,3,4,5].map(d => <option key={d} value={d}>{DIFFICULTY[d]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="spinner" /> : (
|
||||
<>
|
||||
<div className="card-grid">
|
||||
{data.projects.map(p => (
|
||||
<div key={p.id} className="project-card" onClick={() => navigate(`/projects/${p.id}`)}>
|
||||
{p.files[0]
|
||||
? <img src={p.files[0].thumbnailUrl || p.files[0].url} alt={p.title} />
|
||||
: <div style={{ height: 180, background: 'var(--bg3)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text2)' }}>이미지 없음</div>
|
||||
}
|
||||
<div className="project-card-body">
|
||||
<div className="project-card-title">{p.title}</div>
|
||||
<div className="project-card-meta">
|
||||
<span>{p.user.nickname}</span>
|
||||
<span>{DIFFICULTY[p.difficultyLevel]}</span>
|
||||
{p.product && (
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--accent2)', fontWeight: 600 }}>
|
||||
₩{p.product.price.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.projects.length === 0 && (
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<p className="text-muted">등록된 프로젝트가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.pages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||
{Array.from({ length: data.pages }, (_, i) => (
|
||||
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/pages/Shop.jsx
Normal file
88
frontend/src/pages/Shop.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
|
||||
function Stars({ rating, count }) {
|
||||
if (!rating) return <span className="text-muted" style={{ fontSize: 12 }}>리뷰 없음</span>;
|
||||
return (
|
||||
<span style={{ fontSize: 12 }}>
|
||||
<span className="stars">{'★'.repeat(Math.round(rating))}</span>
|
||||
<span className="text-muted"> {rating} ({count})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Shop() {
|
||||
const [data, setData] = useState({ products: [], total: 0, pages: 1 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [sort, setSort] = useState('popular');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api.get(`/products?page=${page}&limit=18&sort=${sort}`)
|
||||
.then(r => setData(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, sort]);
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: 20 }}>ESP32 상점</h2>
|
||||
<select style={{ width: 160 }} value={sort} onChange={e => { setSort(e.target.value); setPage(1); }}>
|
||||
<option value="popular">인기순</option>
|
||||
<option value="newest">최신순</option>
|
||||
<option value="price_asc">가격 낮은순</option>
|
||||
<option value="price_desc">가격 높은순</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="spinner" /> : (
|
||||
<>
|
||||
<div className="card-grid">
|
||||
{data.products.map(p => (
|
||||
<div key={p.id} className="project-card" onClick={() => navigate(`/shop/${p.id}`)}>
|
||||
{p.project.files[0]
|
||||
? <img src={p.project.files[0].thumbnailUrl || p.project.files[0].url} alt={p.project.title} />
|
||||
: <div style={{ height: 180, background: 'var(--bg3)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text2)' }}>이미지 없음</div>
|
||||
}
|
||||
<div className="project-card-body">
|
||||
<div className="project-card-title">{p.project.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
|
||||
by {p.project.user.nickname}
|
||||
</div>
|
||||
<div className="project-card-meta">
|
||||
<Stars rating={p.avgRating} count={p.reviewCount} />
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--accent2)', fontWeight: 700, fontSize: 15 }}>
|
||||
₩{p.price.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.products.length === 0 && (
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<p className="text-muted">등록된 상품이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.pages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||
{Array.from({ length: data.pages }, (_, i) => (
|
||||
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react({
|
||||
include: /\.(jsx|js|tsx|ts)$/,
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3201',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user