feat: mock payment flow + flash token page (test mode)
- Mock purchase: order create → mock-pay → FlashToken issued instantly (no real billing) - Flash page (/flash/:token): esp-web-tools integration, token state display, consume on complete - Orders route: create/mock-pay/me/refund with full audit logging - Flash route: GET validate, GET manifest (esp-web-tools compatible), POST consume - MinIO file proxy (/api/files/*): browser CORS solved, firmware served through backend - Schema: chipFamily on Project, flashOffset on ProjectFile - ProjectNew: chipFamily selector + firmware flash offset option - MyOrders: real order list with flash token status and buttons - Dockerfile: prisma db push (no migration files needed for dev) - TOSS_PAYMENT_GUIDE.md: step-by-step guide for real payment after business registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
|
# ──────────────────────────────────────────────────────
|
||||||
|
# ESP32 DIY 플랫폼 환경 변수
|
||||||
|
# cp .env.example .env 후 각 값을 채워주세요
|
||||||
|
# ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
DB_PASSWORD=change_this_strong_password
|
DB_PASSWORD=change_this_strong_password
|
||||||
|
|
||||||
@@ -8,12 +13,17 @@ MINIO_PASSWORD=change_this_strong_password
|
|||||||
# JWT (openssl rand -hex 64 로 생성 권장)
|
# JWT (openssl rand -hex 64 로 생성 권장)
|
||||||
JWT_SECRET=change_this_very_long_random_secret_string
|
JWT_SECRET=change_this_very_long_random_secret_string
|
||||||
|
|
||||||
# 배포 URL (DNS 설정 후 변경)
|
# 서비스 외부 접근 URL (DNS 설정 후 변경, 파일 URL에 사용됨)
|
||||||
|
# 로컬 테스트: http://localhost:3200
|
||||||
|
# 실 서비스: https://your-domain.com
|
||||||
BASE_URL=http://localhost:3200
|
BASE_URL=http://localhost:3200
|
||||||
|
|
||||||
# 토스페이먼츠 (https://developers.tosspayments.com 에서 발급)
|
# ──────────────────────────────────────────────────────
|
||||||
TOSS_CLIENT_KEY=test_ck_YOUR_KEY
|
# 토스페이먼츠 (현재 테스트 모드 — 값 없어도 동작함)
|
||||||
TOSS_SECRET_KEY=test_sk_YOUR_KEY
|
# 실결제 전환 시 TOSS_PAYMENT_GUIDE.md 참고
|
||||||
|
# ──────────────────────────────────────────────────────
|
||||||
|
TOSS_CLIENT_KEY=test_ck_placeholder
|
||||||
|
TOSS_SECRET_KEY=test_sk_placeholder
|
||||||
|
|
||||||
# webflash 연동 내부 토큰 (임의 UUID)
|
# webflash 연동 내부 토큰 (임의 UUID)
|
||||||
WEBFLASH_INTERNAL_TOKEN=change_this_token
|
WEBFLASH_INTERNAL_TOKEN=change_this_token
|
||||||
|
|||||||
267
platform/TOSS_PAYMENT_GUIDE.md
Normal file
267
platform/TOSS_PAYMENT_GUIDE.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# 토스페이먼츠 실전 적용 가이드
|
||||||
|
|
||||||
|
> 현재 플랫폼은 **테스트(모의 결제) 모드**로 동작합니다.
|
||||||
|
> 사업자 등록 완료 후 아래 절차를 따라 실제 결제로 전환하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1단계 — 사업자 등록
|
||||||
|
|
||||||
|
토스페이먼츠는 사업자 등록증이 필요합니다.
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 개인사업자 | 홈택스에서 사업자 등록 (즉시 발급 가능) |
|
||||||
|
| 법인사업자 | 법인 설립 후 사업자 등록 |
|
||||||
|
| 필요 서류 | 사업자등록증, 통장 사본, 신분증 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2단계 — 토스페이먼츠 계정 및 API 키 발급
|
||||||
|
|
||||||
|
### 2-1. 개발자 센터 가입
|
||||||
|
|
||||||
|
1. [https://developers.tosspayments.com](https://developers.tosspayments.com) 접속
|
||||||
|
2. 회원가입 후 **내 개발 정보** → **API 키** 탭
|
||||||
|
3. **테스트 키** (무료, 사업자 없어도 사용 가능):
|
||||||
|
- `test_ck_...` — 클라이언트 키
|
||||||
|
- `test_sk_...` — 시크릿 키
|
||||||
|
|
||||||
|
### 2-2. 실 결제 키 발급 (사업자 등록 후)
|
||||||
|
|
||||||
|
1. 토스페이먼츠 파트너 신청: [https://www.tosspayments.com/contact](https://www.tosspayments.com/contact)
|
||||||
|
2. 심사 완료 후 **라이브 키** 발급:
|
||||||
|
- `live_ck_...` — 클라이언트 키
|
||||||
|
- `live_sk_...` — 시크릿 키
|
||||||
|
3. 수수료: 카드 기준 약 **2.2%** (매출 규모에 따라 협의 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3단계 — 플랫폼에 토스 SDK 적용
|
||||||
|
|
||||||
|
### 3-1. 환경 변수 업데이트
|
||||||
|
|
||||||
|
```env
|
||||||
|
# platform/.env
|
||||||
|
TOSS_CLIENT_KEY=live_ck_YOUR_LIVE_KEY # test_ck_ → live_ck_ 로 교체
|
||||||
|
TOSS_SECRET_KEY=live_sk_YOUR_LIVE_KEY # test_sk_ → live_sk_ 로 교체
|
||||||
|
BASE_URL=https://your-domain.com # 실제 도메인으로 변경
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-2. 프론트엔드 — 토스 SDK 로드 (`platform/frontend/index.html`)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 기존 -->
|
||||||
|
<!-- 없음 -->
|
||||||
|
|
||||||
|
<!-- 추가 -->
|
||||||
|
<script src="https://js.tosspayments.com/v2/standard"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-3. 프론트엔드 — 결제창 호출 (`ProductDetail.jsx`)
|
||||||
|
|
||||||
|
현재 `handleBuy` 함수의 `mock-pay` 부분을 아래로 교체하세요:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ❌ 기존 모의 결제 코드
|
||||||
|
const payRes = await api.post(`/orders/${orderId}/mock-pay`);
|
||||||
|
navigate(`/flash/${payRes.data.flashToken}`);
|
||||||
|
|
||||||
|
// ✅ 토스 실결제 코드로 교체
|
||||||
|
const tossPayments = TossPayments(import.meta.env.VITE_TOSS_CLIENT_KEY);
|
||||||
|
const payment = tossPayments.payment({ customerKey: user.id });
|
||||||
|
|
||||||
|
await payment.requestPayment({
|
||||||
|
method: 'CARD',
|
||||||
|
amount: {
|
||||||
|
currency: 'KRW',
|
||||||
|
value: product.price,
|
||||||
|
},
|
||||||
|
orderId, // 서버에서 생성한 주문 ID
|
||||||
|
orderName: product.project.title,
|
||||||
|
successUrl: `${window.location.origin}/payment/success`,
|
||||||
|
failUrl: `${window.location.origin}/payment/fail`,
|
||||||
|
customerEmail: user.email,
|
||||||
|
customerName: user.nickname,
|
||||||
|
});
|
||||||
|
// 위 코드 실행 시 토스 결제창으로 리다이렉트됨
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-4. Vite 환경변수 추가 (`platform/frontend/.env`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_TOSS_CLIENT_KEY=live_ck_YOUR_LIVE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-5. 결제 성공 페이지 추가 (`pages/PaymentSuccess.jsx`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// platform/frontend/src/pages/PaymentSuccess.jsx
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
export default function PaymentSuccess() {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const paymentKey = params.get('paymentKey');
|
||||||
|
const orderId = params.get('orderId');
|
||||||
|
const amount = params.get('amount');
|
||||||
|
|
||||||
|
// 서버에 결제 승인 요청
|
||||||
|
api.post('/payments/toss/confirm', { paymentKey, orderId, amount })
|
||||||
|
.then(r => navigate(`/flash/${r.data.flashToken}`))
|
||||||
|
.catch(() => navigate('/payment/fail'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div className="spinner" />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-6. 백엔드 — 토스 결제 승인 라우트 추가 (`routes/payments.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// platform/backend/src/routes/payments.js
|
||||||
|
const router = require('express').Router();
|
||||||
|
const prisma = require('../config/db');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
// POST /api/payments/toss/confirm
|
||||||
|
router.post('/toss/confirm', requireAuth, async (req, res) => {
|
||||||
|
const { paymentKey, orderId, amount } = req.body;
|
||||||
|
|
||||||
|
// 1. DB에서 주문 확인
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { tossOrderId: orderId, buyerId: req.user.id, status: 'pending' },
|
||||||
|
});
|
||||||
|
if (!order || order.amount !== parseInt(amount)) {
|
||||||
|
return res.status(400).json({ error: '주문 정보가 일치하지 않습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 토스 서버에 결제 승인 요청
|
||||||
|
const tossRes = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// 시크릿 키를 Base64로 인코딩
|
||||||
|
Authorization: `Basic ${Buffer.from(process.env.TOSS_SECRET_KEY + ':').toString('base64')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ paymentKey, orderId, amount }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tossRes.ok) {
|
||||||
|
const err = await tossRes.json();
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 주문 상태 업데이트 + FlashToken 발급
|
||||||
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const [, flashToken] = await prisma.$transaction([
|
||||||
|
prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: 'paid', paidAt: new Date(), paymentKey },
|
||||||
|
}),
|
||||||
|
prisma.flashToken.create({
|
||||||
|
data: { orderId: order.id, expiresAt },
|
||||||
|
}),
|
||||||
|
prisma.product.update({
|
||||||
|
where: { id: order.productId },
|
||||||
|
data: { totalSales: { increment: 1 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ flashToken: flashToken.token });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/payments/toss/webhook — 토스 웹훅 (결제 상태 변경 알림)
|
||||||
|
router.post('/toss/webhook', async (req, res) => {
|
||||||
|
// 웹훅 서명 검증 (토스 대시보드에서 시크릿 설정)
|
||||||
|
const signature = req.headers['toss-payments-signature'];
|
||||||
|
// TODO: HMAC-SHA256 서명 검증 구현
|
||||||
|
|
||||||
|
const { eventType, data } = req.body;
|
||||||
|
if (eventType === 'PAYMENT_STATUS_CHANGED' && data.status === 'CANCELED') {
|
||||||
|
await prisma.order.updateMany({
|
||||||
|
where: { paymentKey: data.paymentKey },
|
||||||
|
data: { status: 'cancelled' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-7. `index.js`에 payments 라우트 추가
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// platform/backend/src/index.js 에 추가
|
||||||
|
app.use('/api/payments', require('./routes/payments'));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4단계 — 환불 처리 (토스 API)
|
||||||
|
|
||||||
|
현재 모의 환불은 DB만 변경합니다. 실결제 환불 시:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// routes/orders.js refund 엔드포인트에 추가
|
||||||
|
const tossRefundRes = await fetch(
|
||||||
|
`https://api.tosspayments.com/v1/payments/${order.paymentKey}/cancel`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Basic ${Buffer.from(process.env.TOSS_SECRET_KEY + ':').toString('base64')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ cancelReason: req.body.reason || '사용자 요청' }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!tossRefundRes.ok) throw new Error('토스 환불 API 실패');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5단계 — 토스 대시보드 설정
|
||||||
|
|
||||||
|
실키 발급 후 [토스페이먼츠 대시보드](https://app.tosspayments.com)에서:
|
||||||
|
|
||||||
|
| 설정 항목 | 값 |
|
||||||
|
|----------|-----|
|
||||||
|
| 결제 성공 URL | `https://your-domain.com/payment/success` |
|
||||||
|
| 결제 실패 URL | `https://your-domain.com/payment/fail` |
|
||||||
|
| 웹훅 URL | `https://your-domain.com/api/payments/toss/webhook` |
|
||||||
|
| 허용 도메인 | `your-domain.com` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 → 운영 전환 요약
|
||||||
|
|
||||||
|
| 항목 | 테스트 모드 (현재) | 운영 모드 (전환 후) |
|
||||||
|
|------|-----------------|----------------|
|
||||||
|
| 결제 처리 | mock-pay (즉시) | 토스 결제창 → confirm API |
|
||||||
|
| API 키 | 없음 | live_ck_, live_sk_ |
|
||||||
|
| 실제 청구 | 없음 | 있음 |
|
||||||
|
| 환불 | DB만 변경 | 토스 cancel API 호출 |
|
||||||
|
| 코드 변경 | - | ProductDetail.jsx, payments.js 추가, index.js 라우트 등록 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 해외 결제 (Stripe) — 차후 적용
|
||||||
|
|
||||||
|
Stripe는 사업자 없이도 개인으로 가입 가능합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install stripe
|
||||||
|
```
|
||||||
|
|
||||||
|
주요 차이점:
|
||||||
|
- Stripe는 USD 기준 → 원화 환율 처리 필요
|
||||||
|
- Payment Intent 방식 사용
|
||||||
|
- 수수료: 약 2.9% + $0.30/건
|
||||||
|
|
||||||
|
별도 가이드 예정.
|
||||||
@@ -15,5 +15,5 @@ COPY src ./src
|
|||||||
|
|
||||||
EXPOSE 3201
|
EXPOSE 3201
|
||||||
|
|
||||||
# 마이그레이션 후 서버 시작
|
# prisma db push: 마이그레이션 파일 없이 스키마를 DB에 직접 동기화 (개발용)
|
||||||
CMD ["sh", "-c", "npx prisma migrate deploy && node src/index.js"]
|
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node src/index.js"]
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ model Project {
|
|||||||
title String
|
title String
|
||||||
description String @db.Text
|
description String @db.Text
|
||||||
difficultyLevel Int @default(3)
|
difficultyLevel Int @default(3)
|
||||||
|
chipFamily String @default("ESP32-S3")
|
||||||
requiredParts Json?
|
requiredParts Json?
|
||||||
status ProjectStatus @default(draft)
|
status ProjectStatus @default(draft)
|
||||||
adminNote String?
|
adminNote String?
|
||||||
@@ -81,6 +82,7 @@ model ProjectFile {
|
|||||||
fileSize Int
|
fileSize Int
|
||||||
mimeType String
|
mimeType String
|
||||||
originalName String?
|
originalName String?
|
||||||
|
flashOffset String @default("0x0")
|
||||||
displayOrder Int @default(0)
|
displayOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,27 +14,15 @@ async function ensureBucket() {
|
|||||||
const exists = await client.bucketExists(BUCKET);
|
const exists = await client.bucketExists(BUCKET);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await client.makeBucket(BUCKET);
|
await client.makeBucket(BUCKET);
|
||||||
// 공개 읽기 정책 (이미지·영상 직접 접근용)
|
|
||||||
const policy = JSON.stringify({
|
|
||||||
Version: '2012-10-17',
|
|
||||||
Statement: [{
|
|
||||||
Effect: 'Allow',
|
|
||||||
Principal: { AWS: ['*'] },
|
|
||||||
Action: ['s3:GetObject'],
|
|
||||||
Resource: [`arn:aws:s3:::${BUCKET}/*`],
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
await client.setBucketPolicy(BUCKET, policy);
|
|
||||||
console.log(`MinIO bucket "${BUCKET}" created`);
|
console.log(`MinIO bucket "${BUCKET}" created`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 업로드 후 공개 URL 반환
|
// 파일 URL — 플랫폼 백엔드 프록시를 통해 서빙 (CORS 문제 없음)
|
||||||
|
// objectName: "images/uuid.webp" 형식
|
||||||
function publicUrl(objectName) {
|
function publicUrl(objectName) {
|
||||||
const proto = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
const base = (process.env.BASE_URL || 'http://localhost:3200').replace(/\/$/, '');
|
||||||
const host = process.env.MINIO_ENDPOINT || 'localhost';
|
return `${base}/api/files/${objectName}`;
|
||||||
const port = process.env.MINIO_PORT || '9000';
|
|
||||||
return `${proto}://${host}:${port}/${BUCKET}/${objectName}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { client, BUCKET, ensureBucket, publicUrl };
|
module.exports = { client, BUCKET, ensureBucket, publicUrl };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
|
const path = require('path');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { ensureBucket } = require('./config/minio');
|
const { ensureBucket, client, BUCKET } = require('./config/minio');
|
||||||
const prisma = require('./config/db');
|
const prisma = require('./config/db');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -10,7 +11,7 @@ const PORT = process.env.PORT || 3201;
|
|||||||
|
|
||||||
// ── 기본 미들웨어 ────────────────────────────────────────────────────────────
|
// ── 기본 미들웨어 ────────────────────────────────────────────────────────────
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
app.use(helmet());
|
app.use(helmet({ crossOriginResourcePolicy: false }));
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: process.env.ALLOWED_ORIGIN || '*',
|
origin: process.env.ALLOWED_ORIGIN || '*',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -20,8 +21,8 @@ app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
|||||||
|
|
||||||
// ── Rate Limiting ────────────────────────────────────────────────────────────
|
// ── Rate Limiting ────────────────────────────────────────────────────────────
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15분
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 200,
|
max: 300,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
|
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
|
||||||
@@ -33,13 +34,45 @@ const authLimiter = rateLimit({
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/', limiter);
|
app.use('/api/', limiter);
|
||||||
app.use('/api/auth/login', authLimiter);
|
app.use('/api/auth/login', authLimiter);
|
||||||
app.use('/api/auth/register', authLimiter);
|
app.use('/api/auth/register', authLimiter);
|
||||||
|
|
||||||
|
// ── MinIO 파일 프록시 ─────────────────────────────────────────────────────────
|
||||||
|
// /api/files/images/xxx.webp → MinIO BUCKET/images/xxx.webp 스트리밍
|
||||||
|
// esp-web-tools가 bin 파일을 직접 fetch할 때 CORS 헤더 포함
|
||||||
|
const MIME = {
|
||||||
|
'.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||||
|
'.bin': 'application/octet-stream', '.stl': 'model/stl',
|
||||||
|
'.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/files/*', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const objectName = req.params[0];
|
||||||
|
if (!objectName) return res.status(400).end();
|
||||||
|
|
||||||
|
const ext = path.extname(objectName).toLowerCase();
|
||||||
|
const contentType = MIME[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
|
||||||
|
const stream = await client.getObject(BUCKET, objectName);
|
||||||
|
stream.pipe(res);
|
||||||
|
stream.on('error', () => res.status(404).end());
|
||||||
|
} catch {
|
||||||
|
res.status(404).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── 라우트 ───────────────────────────────────────────────────────────────────
|
// ── 라우트 ───────────────────────────────────────────────────────────────────
|
||||||
app.use('/api/auth', require('./routes/auth'));
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
app.use('/api/projects', require('./routes/projects'));
|
app.use('/api/projects', require('./routes/projects'));
|
||||||
app.use('/api/products', require('./routes/products'));
|
app.use('/api/products', require('./routes/products'));
|
||||||
|
app.use('/api/orders', require('./routes/orders'));
|
||||||
|
app.use('/api/flash', require('./routes/flash'));
|
||||||
app.use('/api/admin', require('./routes/admin'));
|
app.use('/api/admin', require('./routes/admin'));
|
||||||
|
|
||||||
// 헬스 체크
|
// 헬스 체크
|
||||||
|
|||||||
142
platform/backend/src/routes/flash.js
Normal file
142
platform/backend/src/routes/flash.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const prisma = require('../config/db');
|
||||||
|
const { writeAuditLog } = require('../middleware/audit');
|
||||||
|
|
||||||
|
// FlashToken 포함 조회 헬퍼
|
||||||
|
async function findToken(token) {
|
||||||
|
return prisma.flashToken.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: {
|
||||||
|
order: {
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true, title: true, chipFamily: true,
|
||||||
|
files: {
|
||||||
|
where: { fileType: 'firmware' },
|
||||||
|
orderBy: { displayOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/flash/:token — 토큰 유효성 확인 (Flash 페이지, webflash 둘 다 사용)
|
||||||
|
router.get('/:token', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ft = await findToken(req.params.token);
|
||||||
|
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
|
||||||
|
|
||||||
|
const expired = new Date() > new Date(ft.expiresAt);
|
||||||
|
const project = ft.order.product.project;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
valid: !expired && !ft.isUsed,
|
||||||
|
isUsed: ft.isUsed,
|
||||||
|
expired,
|
||||||
|
expiresAt: ft.expiresAt,
|
||||||
|
productName: project.title,
|
||||||
|
chipFamily: project.chipFamily,
|
||||||
|
hasFirmware: project.files.length > 0,
|
||||||
|
usedAt: ft.usedAt,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/flash/:token/manifest — esp-web-tools 매니페스트 반환
|
||||||
|
// 브라우저에서 직접 fetch하므로 CORS 허용
|
||||||
|
router.get('/:token/manifest', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ft = await findToken(req.params.token);
|
||||||
|
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
|
||||||
|
|
||||||
|
if (ft.isUsed) {
|
||||||
|
return res.status(410).json({ error: '이미 플래시에 사용된 토큰입니다' });
|
||||||
|
}
|
||||||
|
if (new Date() > new Date(ft.expiresAt)) {
|
||||||
|
return res.status(410).json({ error: '만료된 토큰입니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = ft.order.product.project;
|
||||||
|
if (!project.files.length) {
|
||||||
|
return res.status(404).json({ error: '펌웨어 파일이 없습니다. 판매자에게 문의하세요.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
name: project.title,
|
||||||
|
new_install_improv_wait_ms: 0,
|
||||||
|
builds: [{
|
||||||
|
chipFamily: project.chipFamily,
|
||||||
|
parts: project.files.map(f => ({
|
||||||
|
path: f.url,
|
||||||
|
offset: parseInt(f.flashOffset || '0x0', 16),
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.json(manifest);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/flash/:token/consume — 플래시 완료 기록
|
||||||
|
// esp-web-tools 또는 플래시 페이지에서 호출
|
||||||
|
router.post('/:token/consume', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ft = await findToken(req.params.token);
|
||||||
|
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
|
||||||
|
if (ft.isUsed) return res.status(410).json({ error: '이미 사용된 토큰입니다' });
|
||||||
|
if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰입니다' });
|
||||||
|
|
||||||
|
const { mac = 'unknown', chipFamily, success = true, errorMessage } = req.body;
|
||||||
|
const project = ft.order.product.project;
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.flashToken.update({
|
||||||
|
where: { id: ft.id },
|
||||||
|
data: { isUsed: true, usedAt: new Date(), macAddress: mac, chipFamily: chipFamily || project.chipFamily },
|
||||||
|
}),
|
||||||
|
prisma.flashLog.create({
|
||||||
|
data: {
|
||||||
|
flashTokenId: ft.id,
|
||||||
|
macAddress: mac,
|
||||||
|
chipFamily: chipFamily || project.chipFamily,
|
||||||
|
firmwareName: project.title,
|
||||||
|
firmwareId: project.id,
|
||||||
|
success: Boolean(success),
|
||||||
|
errorMessage: errorMessage || null,
|
||||||
|
clientIp: req.ip || 'unknown',
|
||||||
|
userAgent: req.headers['user-agent'] || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await writeAuditLog({
|
||||||
|
userId: ft.order.buyerId,
|
||||||
|
action: success ? 'FLASH_SUCCESS' : 'FLASH_FAIL',
|
||||||
|
targetType: 'FlashToken', targetId: ft.id,
|
||||||
|
req, metadata: { mac, chipFamily, firmwareId: project.id }, responseStatus: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: success ? '플래시 완료 기록됨' : '실패 기록됨' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
195
platform/backend/src/routes/orders.js
Normal file
195
platform/backend/src/routes/orders.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const prisma = require('../config/db');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
const { writeAuditLog } = require('../middleware/audit');
|
||||||
|
|
||||||
|
// POST /api/orders — 주문 생성 (결제 전)
|
||||||
|
router.post('/', requireAuth, async (req, res) => {
|
||||||
|
const { productId } = req.body;
|
||||||
|
if (!productId) return res.status(400).json({ error: 'productId가 필요합니다' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const product = await prisma.product.findUnique({
|
||||||
|
where: { id: productId },
|
||||||
|
include: { project: { select: { commissionRate: true, userId: true } } },
|
||||||
|
});
|
||||||
|
if (!product) return res.status(404).json({ error: '상품을 찾을 수 없습니다' });
|
||||||
|
if (!product.isOnSale) return res.status(400).json({ error: '판매 중지된 상품입니다' });
|
||||||
|
if (product.project.userId === req.user.id) return res.status(400).json({ error: '자신의 상품은 구매할 수 없습니다' });
|
||||||
|
|
||||||
|
// 이미 유효한 주문이 있는지 확인 (중복 구매 방지)
|
||||||
|
const existing = await prisma.order.findFirst({
|
||||||
|
where: { buyerId: req.user.id, productId, status: { in: ['pending', 'paid'] } },
|
||||||
|
include: { flashToken: true },
|
||||||
|
});
|
||||||
|
if (existing?.status === 'paid') {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: '이미 구매한 상품입니다',
|
||||||
|
flashToken: existing.flashToken?.token,
|
||||||
|
orderId: existing.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existing?.status === 'pending') {
|
||||||
|
return res.json({ orderId: existing.id, amount: existing.amount, status: 'pending' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const commissionAmount = Math.round(product.price * product.project.commissionRate);
|
||||||
|
const sellerAmount = product.price - commissionAmount;
|
||||||
|
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
buyerId: req.user.id,
|
||||||
|
productId,
|
||||||
|
amount: product.price,
|
||||||
|
commissionAmount,
|
||||||
|
sellerAmount,
|
||||||
|
paymentGateway: 'toss',
|
||||||
|
tossOrderId: `mock_${uuidv4()}`,
|
||||||
|
buyerInfo: { email: req.user.email, nickname: req.user.nickname },
|
||||||
|
deviceInfo: { ip: req.ip, userAgent: req.headers['user-agent'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeAuditLog({ userId: req.user.id, action: 'ORDER_CREATE', targetType: 'Order', targetId: order.id, req, responseStatus: 201 });
|
||||||
|
res.status(201).json({ orderId: order.id, amount: order.amount, status: 'pending' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/orders/:id/mock-pay — 모의 결제 (사업자 등록 전 테스트용)
|
||||||
|
// 실제 결제 없이 즉시 paid 처리하고 FlashToken 발급
|
||||||
|
router.post('/:id/mock-pay', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: { flashToken: true },
|
||||||
|
});
|
||||||
|
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
|
||||||
|
if (order.buyerId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
|
if (order.status === 'paid') {
|
||||||
|
return res.json({ flashToken: order.flashToken?.token, orderId: order.id, alreadyPaid: true });
|
||||||
|
}
|
||||||
|
if (order.status !== 'pending') {
|
||||||
|
return res.status(400).json({ error: `${order.status} 상태의 주문은 결제할 수 없습니다` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30일
|
||||||
|
|
||||||
|
const [updatedOrder, flashToken] = await prisma.$transaction([
|
||||||
|
prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: 'paid', paidAt: new Date(), paymentKey: `mock_${uuidv4()}` },
|
||||||
|
}),
|
||||||
|
prisma.flashToken.create({
|
||||||
|
data: { orderId: order.id, expiresAt },
|
||||||
|
}),
|
||||||
|
prisma.product.update({
|
||||||
|
where: { id: order.productId },
|
||||||
|
data: { totalSales: { increment: 1 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await writeAuditLog({
|
||||||
|
userId: req.user.id, action: 'MOCK_PAYMENT',
|
||||||
|
targetType: 'Order', targetId: order.id,
|
||||||
|
req, metadata: { amount: order.amount, mode: 'mock' }, responseStatus: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ flashToken: flashToken.token, orderId: order.id, expiresAt: flashToken.expiresAt });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/orders/me — 내 주문 목록
|
||||||
|
router.get('/me', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { buyerId: req.user.id },
|
||||||
|
orderBy: { orderedAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
select: {
|
||||||
|
id: true, price: true,
|
||||||
|
project: { select: { title: true, chipFamily: true, files: { where: { fileType: 'image' }, take: 1 } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
flashToken: { select: { token: true, isUsed: true, expiresAt: true } },
|
||||||
|
review: { select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json(orders);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/orders/:id
|
||||||
|
router.get('/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: {
|
||||||
|
product: { include: { project: true } },
|
||||||
|
flashToken: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
|
||||||
|
if (order.buyerId !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
|
}
|
||||||
|
res.json(order);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/orders/:id/refund — 환불 (모의 / 플래시 미완료 시만)
|
||||||
|
router.post('/:id/refund', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: { flashToken: true },
|
||||||
|
});
|
||||||
|
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
|
||||||
|
if (order.buyerId !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
|
}
|
||||||
|
if (order.status !== 'paid') return res.status(400).json({ error: '결제 완료 상태가 아닙니다' });
|
||||||
|
if (order.flashToken?.isUsed && req.user.role !== 'admin') {
|
||||||
|
return res.status(400).json({ error: '플래시가 완료된 주문은 환불할 수 없습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: 'refunded', refundedAt: new Date(), refundReason: req.body.reason || '사용자 요청' },
|
||||||
|
}),
|
||||||
|
// FlashToken 무효화
|
||||||
|
...(order.flashToken ? [prisma.flashToken.update({
|
||||||
|
where: { id: order.flashToken.id },
|
||||||
|
data: { expiresAt: new Date() },
|
||||||
|
})] : []),
|
||||||
|
prisma.product.update({
|
||||||
|
where: { id: order.productId },
|
||||||
|
data: { totalSales: { decrement: 1 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await writeAuditLog({
|
||||||
|
userId: req.user.id, action: 'REFUND',
|
||||||
|
targetType: 'Order', targetId: order.id,
|
||||||
|
req, responseStatus: 200,
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -108,7 +108,7 @@ router.post('/', requireAuth, [
|
|||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
|
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
|
||||||
|
|
||||||
const { title, description, difficultyLevel, requiredParts } = req.body;
|
const { title, description, difficultyLevel, chipFamily, requiredParts } = req.body;
|
||||||
try {
|
try {
|
||||||
const project = await prisma.project.create({
|
const project = await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -116,6 +116,7 @@ router.post('/', requireAuth, [
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
difficultyLevel: difficultyLevel ? parseInt(difficultyLevel) : 3,
|
difficultyLevel: difficultyLevel ? parseInt(difficultyLevel) : 3,
|
||||||
|
chipFamily: chipFamily || 'ESP32-S3',
|
||||||
requiredParts: requiredParts || [],
|
requiredParts: requiredParts || [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -138,13 +139,14 @@ router.put('/:id', requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: '승인 대기/완료 상태에서는 수정할 수 없습니다' });
|
return res.status(400).json({ error: '승인 대기/완료 상태에서는 수정할 수 없습니다' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, difficultyLevel, requiredParts } = req.body;
|
const { title, description, difficultyLevel, chipFamily, requiredParts } = req.body;
|
||||||
const updated = await prisma.project.update({
|
const updated = await prisma.project.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: {
|
data: {
|
||||||
...(title && { title }),
|
...(title && { title }),
|
||||||
...(description && { description }),
|
...(description && { description }),
|
||||||
...(difficultyLevel && { difficultyLevel: parseInt(difficultyLevel) }),
|
...(difficultyLevel && { difficultyLevel: parseInt(difficultyLevel) }),
|
||||||
|
...(chipFamily && { chipFamily }),
|
||||||
...(requiredParts !== undefined && { requiredParts }),
|
...(requiredParts !== undefined && { requiredParts }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -205,7 +207,8 @@ router.post('/:id/files', requireAuth, upload.array('files', 10), async (req, re
|
|||||||
if (project.userId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
|
if (project.userId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
if (!req.files?.length) return res.status(400).json({ error: '파일이 없습니다' });
|
if (!req.files?.length) return res.status(400).json({ error: '파일이 없습니다' });
|
||||||
|
|
||||||
const fileType = req.body.fileType || 'image'; // image|video|stl|wiring|firmware
|
const fileType = req.body.fileType || 'image'; // image|video|stl|wiring|firmware
|
||||||
|
const flashOffset = req.body.flashOffset || '0x0';
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const file of req.files) {
|
for (const file of req.files) {
|
||||||
@@ -225,6 +228,7 @@ router.post('/:id/files', requireAuth, upload.array('files', 10), async (req, re
|
|||||||
fileSize: stored.fileSize,
|
fileSize: stored.fileSize,
|
||||||
mimeType: stored.mimeType,
|
mimeType: stored.mimeType,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
|
flashOffset: fileType === 'firmware' ? flashOffset : '0x0',
|
||||||
displayOrder: results.length,
|
displayOrder: results.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ESP32 DIY 플랫폼</title>
|
<title>ESP32 DIY 플랫폼</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AuthProvider, useAuth } from './hooks/useAuth';
|
|||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
|
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
|
import Flash from './pages/Flash';
|
||||||
import Projects from './pages/Projects';
|
import Projects from './pages/Projects';
|
||||||
import ProjectDetail from './pages/ProjectDetail';
|
import ProjectDetail from './pages/ProjectDetail';
|
||||||
import Shop from './pages/Shop';
|
import Shop from './pages/Shop';
|
||||||
@@ -51,6 +52,7 @@ function AppRoutes() {
|
|||||||
<Route path="/dashboard/projects/:id" element={<RequireAuth><ProjectEdit /></RequireAuth>} />
|
<Route path="/dashboard/projects/:id" element={<RequireAuth><ProjectEdit /></RequireAuth>} />
|
||||||
<Route path="/dashboard/orders" element={<RequireAuth><MyOrders /></RequireAuth>} />
|
<Route path="/dashboard/orders" element={<RequireAuth><MyOrders /></RequireAuth>} />
|
||||||
<Route path="/dashboard/sales" element={<RequireAuth><MySales /></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" element={<RequireAdmin><AdminIndex /></RequireAdmin>} />
|
||||||
<Route path="/admin/projects" element={<RequireAdmin><AdminProjects /></RequireAdmin>} />
|
<Route path="/admin/projects" element={<RequireAdmin><AdminProjects /></RequireAdmin>} />
|
||||||
|
|||||||
@@ -1,10 +1,103 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../../api/client';
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
pending: { label: '결제 대기', color: 'var(--warn)' },
|
||||||
|
paid: { label: '결제 완료', color: 'var(--success)' },
|
||||||
|
refunded: { label: '환불됨', color: 'var(--text2)' },
|
||||||
|
cancelled:{ label: '취소됨', color: 'var(--text2)' },
|
||||||
|
};
|
||||||
|
|
||||||
export default function MyOrders() {
|
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 (
|
return (
|
||||||
<div className="container page">
|
<div className="container page">
|
||||||
<h2 style={{ marginBottom: 24 }}>구매 내역</h2>
|
<h2 style={{ marginBottom: 24 }}>구매 내역</h2>
|
||||||
<div className="card text-center" style={{ padding: 48 }}>
|
|
||||||
<p className="text-muted">결제 기능은 2단계에서 구현됩니다.</p>
|
{orders.length === 0 ? (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import api from '../../api/client';
|
|||||||
export default function ProjectNew() {
|
export default function ProjectNew() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', description: '', difficultyLevel: 3, requiredParts: [],
|
title: '', description: '', difficultyLevel: 3, chipFamily: 'ESP32-S3', requiredParts: [],
|
||||||
});
|
});
|
||||||
const [partRow, setPartRow] = useState({ name: '', quantity: '', link: '' });
|
const [partRow, setPartRow] = useState({ name: '', quantity: '', link: '' });
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [fileType, setFileType] = useState('image');
|
const [fileType, setFileType] = useState('image');
|
||||||
|
const [flashOffset, setOffset] = useState('0x0');
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [step, setStep] = useState(1); // 1:기본정보, 2:파일, 3:완료
|
const [step, setStep] = useState(1); // 1:기본정보, 2:파일, 3:완료
|
||||||
@@ -35,6 +36,7 @@ export default function ProjectNew() {
|
|||||||
title: form.title,
|
title: form.title,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
difficultyLevel: form.difficultyLevel,
|
difficultyLevel: form.difficultyLevel,
|
||||||
|
chipFamily: form.chipFamily,
|
||||||
requiredParts: form.requiredParts,
|
requiredParts: form.requiredParts,
|
||||||
});
|
});
|
||||||
setProjectId(data.id);
|
setProjectId(data.id);
|
||||||
@@ -53,6 +55,7 @@ export default function ProjectNew() {
|
|||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('fileType', fileType);
|
fd.append('fileType', fileType);
|
||||||
|
if (fileType === 'firmware') fd.append('flashOffset', flashOffset);
|
||||||
files.forEach(f => fd.append('files', f));
|
files.forEach(f => fd.append('files', f));
|
||||||
await api.post(`/projects/${projectId}/files`, fd, {
|
await api.post(`/projects/${projectId}/files`, fd, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
@@ -114,11 +117,24 @@ export default function ProjectNew() {
|
|||||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||||
placeholder="프로젝트 목적, 기능, 특징을 설명해주세요" rows={6} />
|
placeholder="프로젝트 목적, 기능, 특징을 설명해주세요" rows={6} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<label>난이도</label>
|
<div className="form-group">
|
||||||
<select value={form.difficultyLevel} onChange={e => setForm(f => ({ ...f, difficultyLevel: parseInt(e.target.value) }))}>
|
<label>난이도</label>
|
||||||
{[1,2,3,4,5].map(d => <option key={d} value={d}>{d} — {['입문','초급','중급','고급','전문가'][d-1]}</option>)}
|
<select value={form.difficultyLevel} onChange={e => setForm(f => ({ ...f, difficultyLevel: parseInt(e.target.value) }))}>
|
||||||
</select>
|
{[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>
|
||||||
|
|
||||||
{/* 필요 부품 */}
|
{/* 필요 부품 */}
|
||||||
@@ -150,15 +166,29 @@ export default function ProjectNew() {
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 style={{ marginBottom: 20 }}>파일 업로드</h3>
|
<h3 style={{ marginBottom: 20 }}>파일 업로드</h3>
|
||||||
<div className="form-group">
|
<div style={{ display: 'grid', gridTemplateColumns: fileType === 'firmware' ? '1fr 1fr' : '1fr', gap: 12 }}>
|
||||||
<label>파일 종류</label>
|
<div className="form-group">
|
||||||
<select value={fileType} onChange={e => setFileType(e.target.value)}>
|
<label>파일 종류</label>
|
||||||
<option value="image">이미지 (jpg, png, webp)</option>
|
<select value={fileType} onChange={e => setFileType(e.target.value)}>
|
||||||
<option value="video">영상 (mp4, mov)</option>
|
<option value="image">이미지 (jpg, png, webp)</option>
|
||||||
<option value="wiring">배선도 (jpg, png, pdf)</option>
|
<option value="video">영상 (mp4, mov)</option>
|
||||||
<option value="stl">3D 케이스 STL</option>
|
<option value="wiring">배선도 (jpg, png, pdf)</option>
|
||||||
<option value="firmware">펌웨어 (.bin)</option>
|
<option value="stl">3D 케이스 STL</option>
|
||||||
</select>
|
<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>
|
||||||
<div className={`dropzone${dragging ? ' over' : ''}`}
|
<div className={`dropzone${dragging ? ' over' : ''}`}
|
||||||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||||
|
|||||||
184
platform/frontend/src/pages/Flash.jsx
Normal file
184
platform/frontend/src/pages/Flash.jsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
const CHIP_LABELS = {
|
||||||
|
'ESP32-S3': 'ESP32-S3', 'ESP32-S2': 'ESP32-S2', 'ESP32-C3': 'ESP32-C3',
|
||||||
|
'ESP32-C6': 'ESP32-C6', 'ESP32-H2': 'ESP32-H2', 'ESP32': 'ESP32',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Flash() {
|
||||||
|
const { token } = useParams();
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [flashDone, setFlashDone] = useState(false);
|
||||||
|
const installRef = useRef(null);
|
||||||
|
|
||||||
|
const manifestUrl = `${window.location.origin}/api/flash/${token}/manifest`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get(`/flash/${token}`)
|
||||||
|
.then(r => setInfo(r.data))
|
||||||
|
.catch(() => setInfo(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// esp-web-tools 이벤트 — 플래시 완료/실패 시 서버에 기록
|
||||||
|
useEffect(() => {
|
||||||
|
const btn = installRef.current;
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
function onSuccess(e) {
|
||||||
|
const mac = e.detail?.device?.macAddress || 'unknown';
|
||||||
|
api.post(`/flash/${token}/consume`, {
|
||||||
|
mac, chipFamily: info?.chipFamily, success: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
setFlashDone(true);
|
||||||
|
}
|
||||||
|
function onFail(e) {
|
||||||
|
api.post(`/flash/${token}/consume`, {
|
||||||
|
mac: 'unknown', chipFamily: info?.chipFamily,
|
||||||
|
success: false, errorMessage: e.detail?.message,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('state-changed', (e) => {
|
||||||
|
if (e.detail?.state === 'finished') onSuccess(e);
|
||||||
|
if (e.detail?.state === 'error') onFail(e);
|
||||||
|
});
|
||||||
|
}, [info, token]);
|
||||||
|
|
||||||
|
if (loading) return <div className="spinner" />;
|
||||||
|
|
||||||
|
// 유효하지 않은 토큰
|
||||||
|
if (!info) {
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 500 }}>
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>❌</div>
|
||||||
|
<h2 style={{ marginBottom: 8 }}>유효하지 않은 토큰</h2>
|
||||||
|
<p className="text-muted">토큰을 찾을 수 없습니다.</p>
|
||||||
|
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||||
|
구매 내역으로
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 사용된 토큰
|
||||||
|
if (info.isUsed) {
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 500 }}>
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
||||||
|
<h2 style={{ marginBottom: 8 }}>이미 플래시됨</h2>
|
||||||
|
<p className="text-muted" style={{ marginBottom: 8 }}>
|
||||||
|
이 토큰은 {info.usedAt ? new Date(info.usedAt).toLocaleString('ko-KR') : ''}에 사용되었습니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted" style={{ fontSize: 13 }}>1회용 토큰은 재사용할 수 없습니다.</p>
|
||||||
|
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||||
|
구매 내역으로
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 만료된 토큰
|
||||||
|
if (info.expired) {
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 500 }}>
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>⏰</div>
|
||||||
|
<h2 style={{ marginBottom: 8 }}>만료된 토큰</h2>
|
||||||
|
<p className="text-muted">토큰 유효기간이 지났습니다.</p>
|
||||||
|
<p className="text-muted" style={{ fontSize: 13 }}>고객센터에 문의해주세요.</p>
|
||||||
|
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||||
|
구매 내역으로
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 펌웨어 없음
|
||||||
|
if (!info.hasFirmware) {
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 500 }}>
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||||
|
<h2 style={{ marginBottom: 8 }}>펌웨어 파일 없음</h2>
|
||||||
|
<p className="text-muted">판매자가 아직 펌웨어를 업로드하지 않았습니다.</p>
|
||||||
|
<p className="text-muted" style={{ fontSize: 13 }}>판매자에게 문의하거나 환불을 요청하세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 600 }}>
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: 36 }}>⚡</div>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ marginBottom: 2 }}>{info.productName}</h2>
|
||||||
|
<span className="text-muted" style={{ fontSize: 13 }}>
|
||||||
|
{CHIP_LABELS[info.chipFamily] || info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{flashDone ? (
|
||||||
|
<div className="alert alert-success" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🎉</div>
|
||||||
|
<strong>플래시 완료!</strong>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13 }}>펌웨어가 성공적으로 ESP32에 기록되었습니다.</p>
|
||||||
|
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-info" style={{ marginBottom: 20 }}>
|
||||||
|
<strong>플래시 전 확인사항</strong>
|
||||||
|
<ul style={{ marginTop: 8, paddingLeft: 16, fontSize: 13, lineHeight: 2 }}>
|
||||||
|
<li>Chrome 또는 Edge 브라우저를 사용하고 있나요?</li>
|
||||||
|
<li>ESP32를 USB 케이블로 PC에 연결했나요?</li>
|
||||||
|
<li>이 토큰은 <strong>1회만</strong> 사용 가능합니다</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: '24px 0' }}>
|
||||||
|
<esp-web-install-button
|
||||||
|
ref={installRef}
|
||||||
|
manifest={manifestUrl}
|
||||||
|
style={{ '--esp-tools-button-color': '#6366f1', '--esp-tools-button-text-color': '#fff' }}
|
||||||
|
>
|
||||||
|
<button slot="activate" className="btn btn-primary"
|
||||||
|
style={{ fontSize: 16, padding: '12px 32px' }}>
|
||||||
|
⚡ ESP32 플래시 시작
|
||||||
|
</button>
|
||||||
|
<span slot="unsupported" style={{ color: 'var(--danger)', fontSize: 14 }}>
|
||||||
|
Chrome 또는 Edge 브라우저가 필요합니다
|
||||||
|
</span>
|
||||||
|
</esp-web-install-button>
|
||||||
|
<p className="text-muted" style={{ fontSize: 12, textAlign: 'center' }}>
|
||||||
|
버튼 클릭 후 팝업에서 ESP32 포트를 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
<details style={{ fontSize: 13, color: 'var(--text2)' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', marginBottom: 8 }}>토큰 정보 (고급)</summary>
|
||||||
|
<code style={{ wordBreak: 'break-all', display: 'block', background: 'var(--bg3)', padding: 8, borderRadius: 4 }}>
|
||||||
|
{token}
|
||||||
|
</code>
|
||||||
|
<p style={{ marginTop: 8 }}>매니페스트 URL: <code style={{ fontSize: 11 }}>{manifestUrl}</code></p>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ export default function ProductDetail() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [product, setProduct] = useState(null);
|
const [product, setProduct] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [buying, setBuying] = useState(false);
|
||||||
|
const [buyError, setBuyError] = useState('');
|
||||||
const [imgIdx, setImgIdx] = useState(0);
|
const [imgIdx, setImgIdx] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,6 +24,46 @@ export default function ProductDetail() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [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 (loading) return <div className="spinner" />;
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
|
|
||||||
@@ -31,7 +73,7 @@ export default function ProductDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container page">
|
<div className="container page">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 32 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 32, alignItems: 'start' }}>
|
||||||
{/* 왼쪽 */}
|
{/* 왼쪽 */}
|
||||||
<div>
|
<div>
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
@@ -52,7 +94,7 @@ export default function ProductDetail() {
|
|||||||
|
|
||||||
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{product.project.title}</h1>
|
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{product.project.title}</h1>
|
||||||
<div className="text-muted" style={{ fontSize: 13, marginBottom: 8 }}>
|
<div className="text-muted" style={{ fontSize: 13, marginBottom: 8 }}>
|
||||||
by {product.project.user.nickname}
|
by {product.project.user.nickname} · 칩: {product.project.chipFamily}
|
||||||
</div>
|
</div>
|
||||||
{avgRating && (
|
{avgRating && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
@@ -98,8 +140,8 @@ export default function ProductDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽 — 구매 패널 */}
|
{/* 오른쪽 — 구매 패널 */}
|
||||||
<div>
|
<div style={{ position: 'sticky', top: 80 }}>
|
||||||
<div className="card" style={{ position: 'sticky', top: 80 }}>
|
<div className="card">
|
||||||
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--accent2)', marginBottom: 4 }}>
|
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--accent2)', marginBottom: 4 }}>
|
||||||
₩{product.price.toLocaleString()}
|
₩{product.price.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
@@ -109,22 +151,34 @@ export default function ProductDetail() {
|
|||||||
|
|
||||||
{product.isOnSale ? (
|
{product.isOnSale ? (
|
||||||
<>
|
<>
|
||||||
{user ? (
|
{buyError && <div className="alert alert-error" style={{ fontSize: 13 }}>{buyError}</div>}
|
||||||
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', fontSize: 16 }}
|
|
||||||
onClick={() => alert('결제 기능은 2단계에서 구현됩니다')}>
|
<button
|
||||||
구매하기
|
className="btn btn-primary"
|
||||||
</button>
|
style={{ width: '100%', justifyContent: 'center', fontSize: 16, padding: '12px' }}
|
||||||
) : (
|
onClick={handleBuy}
|
||||||
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }}
|
disabled={buying}
|
||||||
onClick={() => navigate('/auth/login', { state: { from: `/shop/${id}` } })}>
|
>
|
||||||
로그인 후 구매
|
{buying ? '처리 중...' : user ? '지금 구매하기' : '로그인 후 구매'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<div className="divider" />
|
<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 }}>
|
<ul style={{ color: 'var(--text2)', fontSize: 13, paddingLeft: 16 }}>
|
||||||
<li>구매 즉시 플래시 토큰 발급</li>
|
<li>구매 즉시 1회용 플래시 토큰 발급</li>
|
||||||
<li>USB 연결 후 브라우저에서 플래시</li>
|
<li>USB 연결 후 브라우저에서 직접 플래시</li>
|
||||||
<li>플래시 완료 후 환불 불가</li>
|
<li>플래시 완료 후 환불 불가</li>
|
||||||
|
<li>토큰 유효기간: 30일</li>
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user