# P2P Widget UI 구현 지침서 (B-6)

> **작성일**: 2026-06-10  
> **대상 모듈**: `widget-ui/` (Vue 3 + vanilla JS)  
> **백엔드 API**: `open-api` — `P2pWidgetController` + `P2pDepositLinkWidgetController`  

---

## 1. 개요

P2P Buyer Widget은 TORQ 위젯(`torq.vue`)과 동일한 구조를 따르되, **1:3 분할 매칭**(최대 3개 은행 계좌 동시 표시) + **개별 이체 완료 신고** 패턴을 구현한다.

### 진입 경로 2가지

| 경로 | 설명 | 토큰 |
|------|------|------|
| **A. deposit.vue → torq.vue → p2p.vue** | 금액 입력 후 route API 판정 → P2P이면 router.push('/p2p') | torq.vue에서 이미 발급된 Bearer 토큰 |
| **B. /p2p/:linkCode** | 파트너가 생성한 입금 링크로 직접 진입 | `GET /widgets/p2p/links/{linkCode}`에서 accessToken 발급 |

### 6단계 스텝 흐름

```
p2p-matching → p2p-deposit → p2p-verifying → p2p-success
                                           ↘ p2p-dispute → p2p-dispute-reviewing
                ↘ p2p-failed (만료/취소)
```

---

## 2. 파일 변경 목록

| 파일 | 변경 유형 | 내용 |
|------|-----------|------|
| `src/api/widgetApis.js` | **추가** | P2P API 함수 8개 |
| `src/views/p2p.vue` | **신규** | P2P Buyer Widget 전체 |
| `src/router/index.js` | **수정** | `/p2p`, `/p2p/:linkCode` 라우트 추가 |
| `src/views/torq.vue` | **수정** | `startTorqTrade()` 직전 P2P 라우팅 분기 삽입 |
| `src/locales/ko.js` | **수정** | `p2p: { ... }` 섹션 추가 |
| `src/locales/en.js` | **수정** | `p2p: { ... }` 섹션 추가 |

---

## 3. API 레이어 (widgetApis.js)

### 3.1 P2P Widget 인증 API (6개)

모든 API는 `Rest.axios()`로 호출 — Bearer 토큰 자동 부착.

```js
// ── P2P 라우팅 판정 ──
export async function p2pRouteApi(krwAmount, networkId) {
  return await Rest.axios('post', '/widgets/api/p2p/route', { krwAmount, networkId })
}

// ── P2P 매칭 생성 ──
export async function p2pCreateMatchApi(krwAmount, networkId) {
  return await Rest.axios('post', '/widgets/api/p2p/match', { krwAmount, networkId })
}

// ── P2P 매칭 상태 폴링 ──
export async function p2pGetMatchStatusApi(orderCode) {
  return await Rest.axios('get', `/widgets/api/p2p/match/${orderCode}`)
}

// ── P2P 이체 완료 신고 ──
export async function p2pTransferDoneApi(matchCode) {
  return await Rest.axios('post', `/widgets/api/p2p/match/${matchCode}/transfer-done`, null)
}

// ── P2P 매칭 취소 ──
export async function p2pCancelMatchApi(matchCode, reason) {
  return await Rest.axios('post', `/widgets/api/p2p/match/${matchCode}/cancel`, { reason })
}

// ── P2P 분쟁 제기 ──
export async function p2pDisputeApi(matchCode, reason, evidenceUrl) {
  return await Rest.axios('post', `/widgets/api/p2p/match/${matchCode}/dispute`, { reason, evidenceUrl })
}
```

### 3.2 P2P Link API (2개, Public — 토큰 없이 호출)

```js
// ── P2P 입금 링크 정보 조회 (Public) ──
export async function getP2pLinkInfoApi(linkCode) {
  const baseUrl = import.meta.env.VITE_API
  return await axios.get(`${baseUrl}/widgets/p2p/links/${linkCode}`)
}

// ── P2P 입금 링크 사용 (인증 필요) ──
export async function useP2pLinkApi(linkCode, krwAmount) {
  return await Rest.axios('post', `/widgets/p2p/links/${linkCode}/use`, 
    krwAmount ? { krwAmount } : null)
}
```

---

## 4. 라우터 (router/index.js)

```js
{
  path: "/p2p",
  component: () => import("../views/p2p.vue"),
},
{
  path: "/p2p/:linkCode",
  component: () => import("../views/p2p.vue"),
},
```

---

## 5. torq.vue 라우팅 분기

### 삽입 위치: `startTorqTrade()` 함수 시작부 (line ~1086)

기존 `startTorqTrade()`는 TORQ quote 확인 화면에서 "거래 시작" 클릭 시 호출된다.
여기에 P2P route 판정을 먼저 수행하고, P2P이면 p2p.vue로 이동한다.

```js
const startTorqTrade = async () => {
  if (!torqQuote.value) return
  store.setIsLoading(true)
  torqError.value = null
  try {
    // ── P2P 라우팅 판정 (신규 삽입) ──
    const krw = Number(torqQuote.value.krwAmount)
    try {
      const routeRes = await p2pRouteApi(krw, params.value.networkId || 3)
      if (routeRes?.data?.route === 'P2P') {
        store.setIsLoading(false)
        // P2P 위젯으로 이동 — 현재 params 전달
        router.push({
          path: '/p2p',
          query: {
            ...params.value,
            krwAmount: String(krw),
            networkId: String(params.value.networkId || 3),
            from: 'torq',
          }
        })
        return
      }
    } catch (e) {
      // P2P 라우팅 실패 → TORQ fallback (기존 흐름 유지)
      console.warn('P2P 라우팅 판정 실패, TORQ fallback:', e?.message)
    }
    // ── 기존 TORQ 거래 생성 로직 유지 ──
    const payload = { ... }
    // ... (기존 코드 그대로)
```

**import 추가**: `import { p2pRouteApi } from '@/api/widgetApis'`

---

## 6. p2p.vue 컴포넌트 구조

### 6.1 전체 아키텍처

torq.vue와 동일한 패턴: `currentStep` ref로 화면 전환, `goToStep()` 함수로 이동.

```
<template>
  <div class="widget_container window">
    <template v-if="currentStep === 'p2p-matching'"> ... </template>
    <template v-else-if="currentStep === 'p2p-deposit'"> ... </template>
    <template v-else-if="currentStep === 'p2p-verifying'"> ... </template>
    <template v-else-if="currentStep === 'p2p-success'"> ... </template>
    <template v-else-if="currentStep === 'p2p-dispute'"> ... </template>
    <template v-else-if="currentStep === 'p2p-dispute-reviewing'"> ... </template>
    <template v-else-if="currentStep === 'p2p-failed'"> ... </template>
    <div class="powered-by">Powered by Cryptoments</div>
  </div>
</template>
```

### 6.2 스텝 상세 명세

#### Step 1: `p2p-matching` — 매칭 진행 중

- **화면**: Lottie 로딩 애니메이션 + "매칭 중..." 텍스트
- **로직**: 
  - `onMounted()`에서 자동 호출
  - 경로 A: `p2pCreateMatchApi(krwAmount, networkId)` 호출
  - 경로 B (linkCode): `useP2pLinkApi(linkCode, krwAmount)` 호출
  - 응답 성공 → `p2pOrder` ref에 저장 → `goToStep('p2p-deposit')`
  - 응답 실패 → 에러 코드별 분기 (LP 없음 → `p2p-failed`)
- **소요 시간**: 1~3초 (즉시 매칭)

#### Step 2: `p2p-deposit` — 이체 안내 (핵심 화면)

- **화면 구조**:
  ```
  ┌─────────────────────────────┐
  │ 💳 입금 정보가 발급되었어요    │
  │ 아래 계좌로 이체를 완료해주세요 │
  ├─────────────────────────────┤
  │ 📋 매칭 #1 (75,000원)       │
  │   은행: 국민은행              │
  │   계좌: 123-456-789 [복사]   │
  │   예금주: 홍길동              │
  │   [이체 완료]                │
  ├─────────────────────────────┤
  │ 📋 매칭 #2 (63,000원)       │
  │   은행: 우리은행              │
  │   계좌: 987-654-321 [복사]   │
  │   예금주: 김철수              │
  │   [이체 완료]                │
  ├─────────────────────────────┤
  │ ⏰ 잔여 시간: 18:45          │
  │ ⚠️ 본인 명의 계좌에서만 송금   │
  ├─────────────────────────────┤
  │ [전체 취소]                  │
  └─────────────────────────────┘
  ```

- **데이터 바인딩** (`P2pWidgetMatchResponse`):
  - `p2pOrder.orderCode` — 주문 코드
  - `p2pOrder.krwAmount` — 총 KRW 금액
  - `p2pOrder.remainingAmount` — 미매칭 잔여 금액
  - `p2pOrder.matches[]` — 매칭 목록 (1~3개)
  - `p2pOrder.remainingSeconds` → 카운트다운 타이머
  - 각 match: `matchCode`, `krwAmount`, `bankName`, `accountNumber`, `accountHolder`, `status`

- **매칭별 이체 완료 버튼**:
  - match.status === 'CREATED' → [이체 완료] 버튼 활성
  - match.status === 'BANK_PENDING' → "확인 중..." 표시
  - match.status === 'BANK_CONFIRMED' → ✅ 체크 아이콘
  - match.status === 'SETTLED' → ✅ "정산 완료"

- **이체 완료 클릭 시**: `p2pTransferDoneApi(matchCode)` → match.status 갱신 → 폴링 시작
- **전체 취소**: 모든 CREATED 상태 매칭에 `p2pCancelMatchApi()` 호출
- **타이머 만료**: `goToStep('p2p-failed')` with reason 'EXPIRED'
- **모든 매칭 BANK_PENDING 이상**: 자동으로 `goToStep('p2p-verifying')` + 폴링 시작

- **복사 기능**: 계좌번호 클릭 → `navigator.clipboard.writeText()` + toast

#### Step 3: `p2p-verifying` — 입금 확인 중 (폴링)

- **화면**: Lottie 애니메이션 + "입금 확인 중..." + 거래 요약 정보
- **데이터 표시**:
  - 입금 금액: `p2pOrder.krwAmount` KRW
  - 수령 USDT: matches의 `usdtAmount` 합산
  - 매칭별 상태 카드 (BANK_PENDING → BANK_CONFIRMED → SETTLING → SETTLED)
- **폴링**: 
  - `p2pGetMatchStatusApi(orderCode)` — 3초 간격
  - 모든 match가 SETTLED → `goToStep('p2p-success')`
  - order.status === 'COMPLETED' → `goToStep('p2p-success')`
  - match.status === 'DISPUTED' → `goToStep('p2p-dispute')`
  - order.status === 'EXPIRED'/'CANCELLED' → `goToStep('p2p-failed')`
  - 연속 3회 실패 → `goToStep('p2p-failed')`

#### Step 4: `p2p-success` — 완료

- **화면**: 성공 애니메이션 + "충전이 완료되었어요" + USDT 수량 표시
- **데이터**: matches의 usdtAmount 합산
- **postMessage**: `TORQ_TOPUP_COMPLETED` (기존 TORQ 완료 이벤트와 동일)
  - `{ type: 'P2P', orderCode, usdtAmount, matchCount }`
- **버튼**: [닫기] — `closeWidget()`

#### Step 5: `p2p-dispute` — 분쟁 제기

- **진입 조건**: match.status === 'BANK_PENDING' 상태에서 타이머 만료, 또는 사용자가 직접 "입금했는데 확인이 안 돼요" 클릭
- **화면**: torq.vue의 `torq-dispute` 스텝과 동일 구조
  - 사유 입력 (textarea)
  - 증빙 사진 업로드 (`uploadEvidenceImageApi` 재사용)
  - [증빙 제출] 버튼
- **API**: `p2pDisputeApi(matchCode, reason, evidenceUrl)`
- **제출 후**: `goToStep('p2p-dispute-reviewing')` + 폴링 재시작

#### Step 6: `p2p-dispute-reviewing` — 분쟁 심사 중

- **화면**: "증빙 검토 중입니다" + 제출 내용 요약
- **폴링**: 3초 간격으로 `p2pGetMatchStatusApi(orderCode)`
  - match.status 변경 감지:
    - SETTLED → `goToStep('p2p-success')`
    - CANCELLED/FAILED → `goToStep('p2p-failed')`

#### Step 7: `p2p-failed` — 실패/만료/취소

- **화면**: torq.vue의 `torq-failed` 패턴과 동일
- **분기별 메시지**:
  - EXPIRED: "거래 시간이 만료되었습니다"
  - CANCELLED: "거래가 취소되었습니다"
  - FAILED: "거래에 실패했습니다"
  - DISPUTE_REJECTED: "분쟁 검토 결과, 입금이 확인되지 않았습니다"
- **버튼**: [다시 시도] + [닫기]

---

## 7. 상태 관리 (Script Setup)

### 7.1 reactive 상태

```js
// ── 핵심 상태 ──
const currentStep = ref(null)
const p2pOrder = ref(null)           // P2pWidgetMatchResponse
const p2pError = ref(null)           // 에러 메시지
const linkResp = ref(null)           // P2P 링크 응답 (linkCode 진입 시)

// ── URL 파라미터 ──
const params = ref({})               // parseUrlParams() 결과
// 기대 파라미터: krwAmount, networkId, from, linkCode

// ── 타이머 ──
const expiryCountdown = ref(null)    // { minutes, seconds }
let expiryTimer = null

// ── 분쟁 ──
const disputeReason = ref('')
const disputeEvidenceUrl = ref('')
const disputeEvidenceFile = ref(null)
const disputeEvidencePreview = ref(null)
const disputeEvidenceUploading = ref(false)
const disputeMatchCode = ref(null)   // 분쟁 대상 매칭 코드

// ── 폴링 ──
let p2pPollTimer = null
let p2pPollFailures = 0
const P2P_POLL_MAX_FAILURES = 3
```

### 7.2 핵심 함수

```js
// ── 진입 로직 (onMounted) ──
onMounted(async () => {
  params.value = parseUrlParams()
  const linkCode = route.params.linkCode
  
  if (linkCode) {
    // 경로 B: 링크 진입
    await initFromLink(linkCode)
  } else {
    // 경로 A: torq.vue에서 라우팅
    await initFromRoute()
  }
})

// ── 경로 A: 라우팅 진입 ──
async function initFromRoute() {
  const krw = Number(params.value.krwAmount)
  const networkId = Number(params.value.networkId) || 3
  if (!krw || krw <= 0) {
    p2pError.value = t('p2p.invalidAmount')
    goToStep('p2p-failed')
    return
  }
  goToStep('p2p-matching')
  try {
    const res = await p2pCreateMatchApi(krw, networkId)
    if (!res?.data) throw new Error('empty response')
    p2pOrder.value = res.data
    handleMatchResult()
  } catch (e) {
    handleMatchError(e)
  }
}

// ── 경로 B: 링크 진입 ──
async function initFromLink(linkCode) {
  store.setIsLoading(true)
  try {
    const infoRes = await getP2pLinkInfoApi(linkCode)
    if (!infoRes?.data) throw new Error('link not found')
    linkResp.value = infoRes.data
    
    if (!infoRes.data.isUsable) {
      // 만료/사용됨/취소 → 에러 표시
      p2pError.value = getLinkErrorMsg(infoRes.data.status)
      goToStep('p2p-failed')
      return
    }
    
    // 세션 토큰 저장
    if (infoRes.data.accessToken) {
      Session.setAccessToken(infoRes.data.accessToken)
    }
    
    // 매칭 시작
    goToStep('p2p-matching')
    const krw = infoRes.data.amount  // 링크에 금액이 설정되어 있으면 사용
    const useRes = await useP2pLinkApi(linkCode, krw)
    if (!useRes?.data) throw new Error('match failed')
    p2pOrder.value = useRes.data
    handleMatchResult()
  } catch (e) {
    handleMatchError(e)
  } finally {
    store.setIsLoading(false)
  }
}

// ── 매칭 결과 처리 ──
function handleMatchResult() {
  const order = p2pOrder.value
  if (!order.matches || order.matches.length === 0) {
    // 매칭 대기 → 폴링 시작 (곧 매칭 결과 도착 예상)
    startP2pPolling()
    return
  }
  // 매칭 완료 → 이체 안내 화면
  goToStep('p2p-deposit')
  if (order.remainingSeconds) {
    startExpiryCountdown(order.remainingSeconds)
  }
}

// ── 폴링 ──
function startP2pPolling() {
  stopP2pPolling()
  p2pPollFailures = 0
  p2pPollTimer = setInterval(async () => {
    if (!p2pOrder.value) return
    try {
      const res = await p2pGetMatchStatusApi(p2pOrder.value.orderCode)
      if (!res?.data) { /* 실패 카운트 ++ */ return }
      p2pPollFailures = 0
      p2pOrder.value = res.data
      
      const status = res.data.status
      if (status === 'COMPLETED') {
        stopP2pPolling()
        goToStep('p2p-success')
        sendMessageToParent('P2P_TOPUP_COMPLETED', { ... })
      } else if (status === 'EXPIRED' || status === 'CANCELLED') {
        stopP2pPolling()
        goToStep('p2p-failed')
      } else if (status === 'MATCHED' && res.data.matches?.length > 0) {
        // 매칭 결과 도착
        if (currentStep.value === 'p2p-matching') {
          goToStep('p2p-deposit')
          if (res.data.remainingSeconds) startExpiryCountdown(res.data.remainingSeconds)
        }
        // 개별 매칭 상태 확인
        checkMatchStatuses(res.data.matches)
      }
    } catch (e) { /* 에러 처리 */ }
  }, 3000)
}

// ── 개별 매칭 상태 체크 ──
function checkMatchStatuses(matches) {
  const hasDisputed = matches.some(m => m.status === 'DISPUTED')
  const allSettled = matches.every(m => m.status === 'SETTLED' || m.status === 'BANK_CONFIRMED')
  
  if (hasDisputed && currentStep.value !== 'p2p-dispute' && currentStep.value !== 'p2p-dispute-reviewing') {
    const disputed = matches.find(m => m.status === 'DISPUTED')
    disputeMatchCode.value = disputed.matchCode
    goToStep('p2p-dispute')
  }
  
  if (allSettled && currentStep.value === 'p2p-verifying') {
    // → p2p-success는 order status COMPLETED에서 처리
  }
}
```

### 7.3 타이머

torq.vue의 `startExpiryCountdown()` / `stopExpiryCountdown()` 패턴 그대로 복사.

```js
function startExpiryCountdown(seconds) {
  stopExpiryCountdown()
  let remaining = seconds
  const tick = () => {
    if (remaining <= 0) {
      stopExpiryCountdown()
      // 타이머 만료 시 현재 스텝에 따라 분기
      if (currentStep.value === 'p2p-deposit') {
        // BANK_PENDING인 매칭이 있으면 분쟁 안내, 아니면 실패
        const hasPending = p2pOrder.value?.matches?.some(m => m.status === 'BANK_PENDING')
        if (hasPending) {
          goToStep('p2p-dispute')
        } else {
          goToStep('p2p-failed')
        }
      }
      return
    }
    expiryCountdown.value = {
      minutes: Math.floor(remaining / 60),
      seconds: remaining % 60,
    }
    remaining--
  }
  tick()
  expiryTimer = setInterval(tick, 1000)
}
```

---

## 8. postMessage 통신

Widget → 호스트 페이지 메시지 (torq.vue 패턴 동일):

```js
function sendMessageToParent(type, data) {
  try {
    window.parent.postMessage({ source: 'cryptoments-widget', type, data }, '*')
  } catch (_) {}
}
```

이벤트 타입:
- `P2P_TOPUP_COMPLETED` — P2P 충전 완료 (`{ orderCode, usdtAmount, matchCount }`)
- `P2P_TOPUP_CANCELLED` — P2P 취소
- `P2P_TOPUP_FAILED` — P2P 실패

---

## 9. i18n 키 목록

### ko.js — `p2p: { ... }` 섹션

```js
p2p: {
  // step 1: matching
  matchingTitle: '매칭 중이에요',
  matchingDesc: '최적의 거래 상대를 찾고 있어요.\n잠시만 기다려주세요.',

  // step 2: deposit (은행 계좌 표시)
  depositTitle: '입금 정보가 발급되었어요',
  depositDesc: '아래 안내 드린 계좌로\n계좌 이체를 완료해주세요.',
  matchLabel: '매칭',
  bankLabel: '은행',
  accountNumber: '계좌번호',
  accountHolder: '예금주',
  transferAmount: '이체 금액',
  totalAmount: '총 결제 금액',
  remainingTime: '잔여 시간',
  transferDone: '이체 완료',
  verifying: '확인 중...',
  confirmed: '확인 완료',
  settled: '정산 완료',
  cancelAll: '전체 취소',
  ownAccountOnly: '본인 명의 계좌에서만 송금해주세요',
  ownAccountDesc: '다른 명의 송금은 자동으로 분쟁 처리됩니다.\n금액이 정확히 일치해야 합니다.',
  transferGuide: '이체 후 반드시 "이체 완료" 버튼을 눌러주세요',
  copySuccess: '복사되었습니다',

  // step 3: verifying
  verifyingTitle: '입금 확인 중',
  verifyingDesc: '은행 이체를 확인하고 있어요.\n최대 10분 걸릴 수 있습니다.',
  orderCode: '주문 코드',
  depositAmount: '입금 금액',
  expectedUsdt: '수령 USDT',

  // step 4: success
  successTitle: '충전이 완료되었어요',
  successDescTpl: '{usdt} USDT가 정상적으로\n충전되었습니다.',

  // step 5: dispute
  disputeTitle: '입금 확인 요청',
  disputeDesc: '시간 내 입금이 자동 확인되지 않았어요.\n송금하셨다면 증빙을 제출해주세요.',
  reason: '사유',
  reasonPh: '예: 본인 명의 ○○은행 계좌에서 이체했습니다.',
  evidencePhoto: '송금 영수증 사진',
  pickPhoto: '사진 선택 (최대 5MB)',
  submitEvidence: '증빙 제출',

  // step 5b: dispute reviewing
  reviewingTitle: '증빙 검토 중입니다',
  reviewingDesc: '증빙이 제출되었습니다.\n관리자가 확인 중이며, 결과가 나오면 자동으로 안내됩니다.',

  // step 6: failed
  failedExpired: '거래 시간이 만료되었습니다',
  failedCancelled: '거래가 취소되었습니다',
  failedGeneric: '거래에 실패했습니다',
  failedDesc: '다시 시도하시거나 창을 닫아주세요.',
  retryBtn: '다시 시도',

  // errors
  invalidAmount: '유효하지 않은 금액입니다.',
  matchFail: '매칭에 실패했습니다.',
  noLpAvailable: '현재 거래 가능한 상대방이 없습니다.\n잠시 후 다시 시도해주세요.',
  transferDoneFail: '이체 완료 처리에 실패했습니다.',
  cancelFail: '취소 처리에 실패했습니다.',
  disputeSubmitFail: '분쟁 제기에 실패했습니다.',
  pollFail: '거래 정보를 불러올 수 없습니다.',
  fileSizeLimit: '파일 크기는 5MB 이하여야 합니다.',
  uploadFail: '이미지 업로드 실패.',
  evidenceRequired: '사유 또는 영수증 사진 중 하나는 필수입니다.',

  // link errors
  linkExpired: '만료된 링크입니다.',
  linkUsed: '이미 사용된 링크입니다.',
  linkCancelled: '취소된 링크입니다.',
  linkUnavailable: '사용할 수 없는 링크입니다.',
},
```

### en.js — `p2p: { ... }` 동일 구조, 영문 번역

---

## 10. 스타일링

**기존 CSS 클래스 100% 재사용** — 신규 CSS 작성 불필요.

| torq.vue 클래스 | p2p.vue 용도 |
|----------------|-------------|
| `widget_container window` | 최상위 래퍼 |
| `widget_scroll` | 스크롤 영역 |
| `widget_layout center page` | 중앙 정렬 레이아웃 |
| `widget_nav` | 하단 버튼 영역 |
| `amount_info` | 금액/계좌 정보 카드 |
| `info_row` + `.label` + `.value` | 키-값 행 |
| `status_card blue/error` | 경고/안내 카드 |
| `btn_common btn btn_primary` | Primary 버튼 |
| `btn_common btn btn_outline` | Secondary 버튼 |

### 매칭 카드 추가 스타일 (인라인 or scoped)

1:3 분할 매칭 시 각 매칭 카드 구분이 필요하므로, `amount_info` 카드를 `v-for`로 반복하되 `margin-bottom: 12px`로 간격 부여.

---

## 11. 구현 순서

1. `widgetApis.js` — P2P API 함수 8개 추가
2. `p2p.vue` — 전체 컴포넌트 작성
3. `router/index.js` — `/p2p`, `/p2p/:linkCode` 라우트 추가
4. `torq.vue` — `startTorqTrade()` 내 P2P 라우팅 분기 삽입
5. `locales/ko.js` — `p2p: { ... }` 추가
6. `locales/en.js` — `p2p: { ... }` 추가
7. 빌드 검증

---

## 12. 핵심 주의사항

1. **WidgetSessionData 사용**: P2P Widget API는 `/widgets/api/*` 경로 → Bearer 토큰은 반드시 WidgetSessionData로 생성된 것이어야 함
2. **링크 진입 시 토큰**: `getP2pLinkInfoApi`에서 받은 `accessToken`을 `Session.setAccessToken()`으로 저장해야 후속 API 호출 가능
3. **1:3 분할 매칭**: matches 배열 길이 1~3. 각 매칭별 독립적으로 이체 완료 신고
4. **폴링 정리**: `onUnmounted()`에서 반드시 모든 타이머/인터벌 정리
5. **에러 코드 분기**: 매칭 생성 시 409 → LP 없음 / 이미 진행 중인 주문 등 서버 에러 코드 확인
6. **Vue3Lottie**: `animationLink` 속성으로 `/json/inProgress.json` 등 기존 애니메이션 재사용
7. **클립보드 복사**: `navigator.clipboard.writeText()` + toast 알림
