Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9679020
feat: login page
ziaiz Jul 11, 2026
762dd5b
style: 폰트 수정
ziaiz Jul 14, 2026
7c694b0
style: 경고 아이콘 추가
ziaiz Jul 14, 2026
62f691b
feat: 회원가입 페이지
ziaiz Jul 14, 2026
cda0f83
feat: 비밀번호 변경 페이지
ziaiz Jul 14, 2026
77218f8
style: 폰트 변경
ziaiz Jul 14, 2026
6f42d6c
feat: findPW, signup 추가
ziaiz Jul 14, 2026
d086ff2
style: pretendard 수정
ziaiz Jul 14, 2026
03e7033
style: 아이콘 폴더 정리
ziaiz Jul 14, 2026
2eaedeb
style: 아이콘 폴더 정리
ziaiz Jul 14, 2026
5a07a88
style: 뒤로가기 공통 컴포넌트로 수정
ziaiz Jul 14, 2026
ce8ab79
style: 뒤로가기 아이콘 변경
ziaiz Jul 15, 2026
b5fa574
feat: 알림 페이지 생성
ziaiz Jul 15, 2026
4c5f1f4
fix: 커밋 오류 수정
ziaiz Jul 15, 2026
50fb522
Merge remote-tracking branch 'origin/develop' into feature/#16-alert
ziaiz Jul 15, 2026
eb94daf
style: 스타일 수정
ziaiz Jul 15, 2026
a41e96b
style: 에러 시 스타일 수정
ziaiz Jul 15, 2026
97f065a
style: 에러 시 스타일 수정
ziaiz Jul 15, 2026
81faa52
style: 배경색 수정
ziaiz Jul 16, 2026
850dfc6
fix: 오류 수정
ziaiz Jul 16, 2026
07064ad
feat: 교환 채팅방 구현
ziaiz Jul 19, 2026
5444748
style: 삼선 햄버거 버튼 수정
ziaiz Jul 19, 2026
eb1fa67
feat: 거래 파기 텍스트 추가
ziaiz Jul 19, 2026
a60f30b
style: 로그인 페이지 수정
ziaiz Jul 20, 2026
8f3c154
fix: 로그인 파일 요류, style: 헤더, 알림 페이지 수정
ziaiz Jul 20, 2026
ce87c47
style: 헤더 수정
ziaiz Jul 20, 2026
416b67e
style: 파일 정리
ziaiz Jul 27, 2026
247b41c
feat: 로그인, 알람 api 연결
ziaiz Aug 1, 2026
21600ce
Merge branch 'develop' of https://github.com/SongWalks/Frontend into …
ziaiz Aug 1, 2026
9fa114d
style: 파일정리
ziaiz Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
703 changes: 703 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-query": "^5.101.4",
"axios": "^1.18.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.0",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"devDependencies": {
Expand All @@ -26,6 +28,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.2",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
Expand Down
53 changes: 53 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// public/sw.js
self.addEventListener('install', () => {
self.skipWaiting();
});

self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});

// 서버(FCM/Web Push)가 보낸 푸시를 받아서 브라우저 알림으로 표시
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = { title: '알림', body: event.data ? event.data.text() : '' };
}

const title = payload.title || '알림';
const options = {
body: payload.body || '',
icon: '/icons/icon-192.png', // 👈 실제 아이콘 경로로 교체
badge: '/icons/badge-72.png', // 👈 실제 배지 아이콘으로 교체
data: {
// 💡 요청대로 딥링크는 항상 /alert로 고정
url: '/alert',
notificationId: payload.notificationId ?? null,
},
};

event.waitUntil(self.registration.showNotification(title, options));
});

// 알림 클릭 시 /alert로 이동 (이미 열린 탭 있으면 그쪽 포커스)
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = event.notification.data?.url || '/alert';

event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if (client.url.includes('/alert') && 'focus' in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
}),
);
});
83 changes: 83 additions & 0 deletions src/api/alert/push.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// src/api/push.js
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';
const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY ?? '';
// 👆 VAPID 공개키는 백엔드가 발급해줘야 해요. .env에 VITE_VAPID_PUBLIC_KEY=... 로 넣어두세요.

const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
};

export const registerServiceWorker = async () => {
if (!('serviceWorker' in navigator)) {
console.warn('이 브라우저는 Service Worker를 지원하지 않습니다.');
return null;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js');
return registration;
} catch (err) {
console.error('Service Worker 등록 실패:', err);
return null;
}
};

export const subscribeToPush = async () => {
if (!('Notification' in window)) {
return { success: false, reason: 'unsupported' };
}

const permission = await Notification.requestPermission();
if (permission !== 'granted') {
return { success: false, reason: 'denied' };
}

const registration =
(await navigator.serviceWorker.getRegistration()) ||
(await registerServiceWorker());
if (!registration) return { success: false, reason: 'no-sw' };

const existing = await registration.pushManager.getSubscription();
const subscription =
existing ||
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
}));

const subJson = subscription.toJSON();

// 서버에 구독 정보 등록 (API 문서의 "펌푸시 구독 등록" 엔드포인트)
const res = await fetch(`${API_BASE}/api/notifications/push-subscription`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subJson.endpoint,
p256dh: subJson.keys?.p256dh,
auth: subJson.keys?.auth,
}),
});

return { success: res.ok, subscription };
};

export const unsubscribeFromPush = async () => {
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return { success: true };

const endpoint = subscription.endpoint;
await subscription.unsubscribe();

const res = await fetch(`${API_BASE}/api/notifications/push-subscription`, {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
});

return { success: res.ok };
};
115 changes: 115 additions & 0 deletions src/api/auth/authApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { apiFetch, ApiError } from './client';
import { getTokens } from '../../store/tokenStorage';

export { ApiError };

/**
* 이메일 인증코드 발송
* POST /api/auth/email/code
* body: { email }
*/
export function sendEmailCode(email) {
return apiFetch('/api/auth/email/code', { method: 'POST', body: { email } });
}

/**
* 이메일 인증코드 검증
* POST /api/auth/email/verify
* body: { email, code }
*/
export function verifyEmailCode(email, code) {
return apiFetch('/api/auth/email/verify', {
method: 'POST',
body: { email, code },
});
}

/**
* 이메일(아이디) 중복 확인
* GET /api/auth/email/exists?email=...
* ⚠ 쿼리 파라미터 이름 미확인 상태 (email로 가정). 실제 응답 확인 후 조정 필요.
*/
export function checkEmailExists(email) {
return apiFetch(`/api/auth/email/exists?email=${encodeURIComponent(email)}`);
}

/**
* 회원가입
* POST /api/auth/signup
* body: { email, password, passwordConfirm }
*/
export function signupRequest({ email, password, passwordConfirm }) {
return apiFetch('/api/auth/signup', {
method: 'POST',
body: { email, password, passwordConfirm },
});
}

/**
* 로그인
* POST /api/auth/login
* body: { email, password }
* response.data: { accessToken, refreshToken }
*/
export function loginRequest({ email, password }) {
return apiFetch('/api/auth/login', {
method: 'POST',
body: { email, password },
});
}

/**
* 토큰 재발급
* POST /api/auth/token/refresh
* body: { refreshToken }
* response.data: { accessToken, refreshToken }
*/
export function refreshTokenRequest(refreshToken) {
return apiFetch('/api/auth/token/refresh', {
method: 'POST',
body: { refreshToken },
});
}

/**
* 로그아웃
* POST /api/auth/logout
* Authorization: Bearer {accessToken}
*/
export function logoutRequest() {
const tokens = getTokens();
return apiFetch('/api/auth/logout', {
method: 'POST',
token: tokens?.accessToken,
});
}

/**
* 비밀번호 재설정용 인증코드 발송 (findpw 전용, 미가입 이메일이면 404)
* POST /api/auth/password/email/code
* body: { email }
*/
export function sendPasswordResetCode(email) {
return apiFetch('/api/auth/password/email/code', {
method: 'POST',
body: { email },
});
}

/**
* 비밀번호 재설정 (인증 완료 후 새 비밀번호로 교체)
* POST /api/auth/password/reset
* body: { email, newPassword, newPasswordConfirm }
*
* ※ 인증코드 확인 자체는 기존 verifyEmailCode(/api/auth/email/verify)를 재사용
*/
export function resetPasswordRequest({
email,
newPassword,
newPasswordConfirm,
}) {
return apiFetch('/api/auth/password/reset', {
method: 'POST',
body: { email, newPassword, newPasswordConfirm },
});
}
37 changes: 37 additions & 0 deletions src/api/auth/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';

export class ApiError extends Error {
constructor(status, message, data) {
super(message);
this.status = status;
this.data = data;
}
}

export async function apiFetch(path, { method = 'GET', body, token } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;

const res = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});

let payload = null;
try {
payload = await res.json();
} catch {
// body 없는 응답 대비
}

if (!res.ok || payload?.success === false) {
throw new ApiError(
res.status,
payload?.message ?? '요청에 실패했습니다.',
payload,
);
}

return payload; // { success, data, message }
}
75 changes: 75 additions & 0 deletions src/api/chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# API 연동 가이드

## 지금 (mock 모드)
`src/api/config.ts` 의 `USE_MOCK = true` 상태입니다.
모든 데이터는 `localStorage`에 저장되고, `BroadcastChannel`로 브라우저 탭 간에
동기화됩니다 (탭을 2개 열면 한쪽은 나, 한쪽은 상대방처럼 테스트 가능).

## 백엔드 연결할 때
`src/api/config.ts` 파일을 열어서 아래 한 줄만 바꾸면 됩니다.

```ts
export const USE_MOCK = false;
```

그리고 `.env`에 실제 서버 주소를 설정하세요.

```
VITE_API_BASE_URL=https://your-api-server.com
```

이게 전부입니다. `exchangeApi.ts` / `chatApi.ts` 안의 모든 함수가 이미
명세서에 맞는 실제 fetch 호출 코드를 가지고 있어서, 페이지 컴포넌트
(ChatRoomPage, ScheduleDecisionPage, TerminateDealPage)는 손댈 필요가 없습니다.

## 필요한 패키지
실시간 채팅(WebSocket STOMP)을 위해 아래 패키지가 필요합니다.

```
npm i @stomp/stompjs
```

(mock 모드에서는 이 패키지가 로드는 되지만 실제로 연결하지 않습니다.
USE_MOCK=true인 동안은 설치 안 해도 당장 에러는 안 나지만, import 자체는
있으므로 빌드 전에 설치해두는 걸 권장합니다.)

## 파일별 역할

| 파일 | 설명 |
|---|---|
| `config.ts` | USE_MOCK 스위치, API_BASE, WS_URL, 로그인 유저 id 등 공통 설정 |
| `http.ts` | 실제 API 호출용 공통 fetch 래퍼 (에러 처리 포함) |
| `mockDb.ts` | mock 전용 - localStorage + BroadcastChannel 기반 가짜 DB |
| `exchangeApi.ts` | API 1, 2, 5, 7, 8 (QR생성/결과선택/시간확정/파기/캡처업로드) |
| `chatApi.ts` | API 3, 9, 10, 11 (방조회/메시지목록/전송/실시간소켓) |

## 백엔드와 반드시 확인해야 할 것 (명세서 대비 gap)

1. **API 11(채팅방 조회) 응답에 아래 필드가 명세에 없지만 화면에 필요합니다.**
- `scheduledAt` (교환 확정 시각)
- `myVerified` / `counterpartVerified` (5분전 인증 완료 여부)
- `myDisputeVerified` (사후 인증 완료 여부)
- `qrToken` (QR 표시용)
→ 이 필드들을 room 응답에 포함해달라고 요청하거나, 별도 엔드포인트로
받아오도록 협의가 필요합니다. 지금은 `chatApi.ts`의 `RoomInfo` 타입에
optional로 선언해뒀고, 없으면 그냥 undefined로 처리됩니다.

2. **상태 실시간 반영 방식이 명세에 없습니다.**
상대방이 인증을 완료했다든지, 카운트다운이 시작됐다든지 하는 변화를
실시간으로 받을 방법이 명세서(웹소켓은 채팅 메시지만 다룸)에 없어서
`chatApi.ts`의 `subscribeRoomStatus()`가 3초 간격 폴링으로 대체
구현되어 있습니다. 웹소켓 상태 이벤트가 추가되면 이 폴링을 제거하고
이벤트 구독으로 바꾸는 게 좋습니다.

3. **원상복구 API가 명세서 1~11번에 없습니다.**
`exchangeApi.ts`의 `restoreDeal()` 안에 TODO로 표시해뒀습니다.
엔드포인트가 정해지면 그 부분만 채우면 됩니다.

4. **거래 파기 사유 enum 중 `MONEY_DEMAND`가 화면(TerminateDealPage) UI에
대응하는 선택지가 없습니다.** 필요하면 디자인팀과 항목 추가를 논의해야
합니다.

5. **API 8(캡처 업로드-QR검증) 완료 후 바로 COUNTDOWN으로 넘어가는지,
아니면 화면에 있는 "상대방 강의 확인 → 카운트다운 시작" 중간 단계
(READY)를 거치는지 명세서와 디자인이 서로 다릅니다.** 지금은 디자인
기준(중간 단계 있음)으로 구현했습니다.
Loading
Loading