From eac1f0d31ea6316eda70061d3890cbe3d3e0c52d Mon Sep 17 00:00:00 2001 From: seongmin Date: Mon, 11 May 2026 22:07:07 +0900 Subject: [PATCH 01/15] =?UTF-8?q?[#20]Feat:=20=EC=97=90=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=9C=20=EA=B2=B0=EC=A0=9C=20API/hooks=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - escrowPayments: getList, approveEvent 추가 및 approve action 파라미터 지원 - endpoints에 approveEvent 경로 추가 - useBuyer, useCompany, useEscrowPaymentList/useEscrowPayment 등 단건/목록 조회 hooks 추가 --- src/apis/endpoints.ts | 2 + src/apis/modules/escrowPayments.ts | 10 ++++- src/hooks/payments/useEscrowPayments.js | 55 +++++++++++++++++++++++++ src/hooks/useBuyers.js | 8 ++++ src/hooks/useCompanies.js | 8 ++++ 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/hooks/payments/useEscrowPayments.js diff --git a/src/apis/endpoints.ts b/src/apis/endpoints.ts index c51c1bb..52f49f2 100644 --- a/src/apis/endpoints.ts +++ b/src/apis/endpoints.ts @@ -43,5 +43,7 @@ export const ENDPOINTS = { pay: (id: string) => `/escrow-payments/${id}/pay`, userByWallet: (address: string) => `/escrow-payments/users/wallet/${address}`, + approveEvent: (id: string, escrowId: string, type: string) => + `/escrow-payments/${id}/escrows/${escrowId}/events/${encodeURIComponent(type)}/approve`, }, }; diff --git a/src/apis/modules/escrowPayments.ts b/src/apis/modules/escrowPayments.ts index 9ed6e0a..b8b8782 100644 --- a/src/apis/modules/escrowPayments.ts +++ b/src/apis/modules/escrowPayments.ts @@ -20,11 +20,17 @@ export const escrowPaymentsApi = { create: (data: CreateEscrowPaymentDto) => http.post(ENDPOINTS.escrowPayments.root, data), + getList: (params: { group?: string; page?: number; limit?: number } = {}) => + http.get(ENDPOINTS.escrowPayments.root, { params }), + getById: (id: string) => http.get(ENDPOINTS.escrowPayments.byId(id)), - approve: (id: string) => - http.post(ENDPOINTS.escrowPayments.approve(id)), + approve: (id: string, action?: "ACCEPT" | "REJECT") => + http.post(ENDPOINTS.escrowPayments.approve(id), action ? { action } : undefined), + + approveEvent: (id: string, escrowId: string, type: string) => + http.post(ENDPOINTS.escrowPayments.approveEvent(id, escrowId, type)), pay: (id: string) => http.post(ENDPOINTS.escrowPayments.pay(id)), diff --git a/src/hooks/payments/useEscrowPayments.js b/src/hooks/payments/useEscrowPayments.js new file mode 100644 index 0000000..bb106eb --- /dev/null +++ b/src/hooks/payments/useEscrowPayments.js @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { escrowPaymentsApi } from "@/apis"; + +export function useEscrowPaymentList(group, page = 1, limit = 10) { + return useQuery({ + queryKey: ["escrow-payments", group, page, limit], + queryFn: () => escrowPaymentsApi.getList({ group, page, limit }), + }); +} + +export function useEscrowPayment(id) { + return useQuery({ + queryKey: ["escrow-payment", id], + queryFn: () => escrowPaymentsApi.getById(id), + enabled: !!id, + }); +} + +export function useApproveEscrowPayment(paymentId) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (action) => escrowPaymentsApi.approve(paymentId, action), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["escrow-payment", paymentId], + }); + queryClient.invalidateQueries({ queryKey: ["escrow-payments"] }); + }, + }); +} + +export function useApproveEscrowEvent(paymentId) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ escrowId, type }) => + escrowPaymentsApi.approveEvent(paymentId, escrowId, type), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["escrow-payment", paymentId], + }); + }, + }); +} + +export function usePayEscrow(paymentId) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => escrowPaymentsApi.pay(paymentId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["escrow-payment", paymentId], + }); + }, + }); +} diff --git a/src/hooks/useBuyers.js b/src/hooks/useBuyers.js index d182add..97183d8 100644 --- a/src/hooks/useBuyers.js +++ b/src/hooks/useBuyers.js @@ -7,3 +7,11 @@ export function useBuyers(params) { queryFn: () => buyersApi.list(params), }); } + +export function useBuyer(id) { + return useQuery({ + queryKey: ["buyer", id], + queryFn: () => buyersApi.getById(id), + enabled: !!id, + }); +} diff --git a/src/hooks/useCompanies.js b/src/hooks/useCompanies.js index fa53c4a..6b8299d 100644 --- a/src/hooks/useCompanies.js +++ b/src/hooks/useCompanies.js @@ -8,3 +8,11 @@ export function useCompanies(filters, page, limit) { placeholderData: (prev) => prev, }); } + +export function useCompany(id) { + return useQuery({ + queryKey: ["company", id], + queryFn: () => companiesApi.getById(id), + enabled: !!id, + }); +} From 25dc0705787328fc296ef563313c607e65c4087a Mon Sep 17 00:00:00 2001 From: seongmin Date: Mon, 11 May 2026 22:11:40 +0900 Subject: [PATCH 02/15] =?UTF-8?q?[#20]Feat:=20PaymentDetail=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 에스크로 결제 단건 상세, 항목별 승인 UI, 결제 개시 동작 - 양측 지갑 주소는 에스크로 결제 응답 필드와 내 프로필에서 직접 추출 --- src/pages/payment/PaymentDetail.jsx | 480 ++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 src/pages/payment/PaymentDetail.jsx diff --git a/src/pages/payment/PaymentDetail.jsx b/src/pages/payment/PaymentDetail.jsx new file mode 100644 index 0000000..54bc11f --- /dev/null +++ b/src/pages/payment/PaymentDetail.jsx @@ -0,0 +1,480 @@ +import { useAuthStore } from "@/stores/authStore"; +import LoadingSpinner from "@/components/LoadingSpinner.jsx"; +import { + useEscrowPayment, + useApproveEscrowPayment, + useApproveEscrowEvent, + usePayEscrow, +} from "@/hooks/payments/useEscrowPayments"; +import { useMyProfile } from "@/hooks/myBusiness/useMyProfile"; + +function EscrowStatusBadge({ status }) { + const isDone = status === "RELEASED"; + return ( + + {isDone ? "Done" : "In Progress"} + + ); +} + +function ConfirmButton({ label, myApproved, bothDone, disabled, onClick }) { + let bg, color; + if (bothDone) { + bg = "#0056ee"; + color = "#fafafa"; + } else if (myApproved) { + bg = "#bed4fa"; + color = "#0056ee"; + } else { + bg = "#dadada"; + color = "#fafafa"; + } + + const clickable = !disabled && !myApproved && !bothDone; + + return ( + + ); +} + +function EventRow({ + approval, + index, + isBuyer, + paymentId, + escrowId, + prevDone, + isPendingAny, + escrowStatus, +}) { + const { mutate: approveEvent, isPending: isPendingApprove } = + useApproveEscrowEvent(paymentId); + const { mutate: payEscrow, isPending: isPendingPay } = + usePayEscrow(paymentId); + const isPending = isPendingApprove || isPendingPay; + + const isPendingEscrow = escrowStatus === "PENDING_ESCROW"; + const bothDone = approval.buyerApproved && approval.sellerApproved; + const locked = !prevDone && index > 0; + + const doneDate = + bothDone && (approval.completedAt ?? approval.updatedAt ?? approval.doneAt) + ? new Date(approval.completedAt ?? approval.updatedAt ?? approval.doneAt) + .toISOString() + .slice(0, 10) + : null; + + return ( +
+ + {approval.eventType} + +
+
+ + isPendingEscrow + ? payEscrow() + : approveEvent({ escrowId, type: approval.eventType }) + } + /> + approveEvent({ escrowId, type: approval.eventType })} + /> +
+ {bothDone && ( + + {doneDate ? `${doneDate} Done` : "Done"} + + )} +
+
+ ); +} + +function EscrowItem({ escrow, index, isBuyer, paymentId }) { + const { mutate: _approveEvent, isPending: isPendingAny } = + useApproveEscrowEvent(paymentId); + + const approvals = escrow.approvals ?? []; + + return ( +
+
+ + Escrow Info {index + 1} + + +
+ +
+
+ + {escrow.label} + + + {escrow.amountXrp != null ? escrow.amountXrp.toLocaleString() : "0"}{" "} + XRP + +
+ +
+ {approvals.map((approval, i) => { + const prevDone = + i === 0 || + (approvals[i - 1].buyerApproved && + approvals[i - 1].sellerApproved); + return ( + + ); + })} +
+
+
+ ); +} + +export default function PaymentDetail({ paymentId }) { + const { companyId } = useAuthStore(); + const { data, isLoading, isError, refetch } = useEscrowPayment(paymentId); + const { mutate: approvePayment, isPending: isApproving } = + useApproveEscrowPayment(paymentId); + + const { data: myProfile } = useMyProfile(); + + if (isLoading) return ; + + if (isError) { + return ( +
+ 데이터를 불러오지 못했습니다. + +
+ ); + } + + if (!data) return null; + + const isBuyer = data.buyerId === companyId; + + const counterpartyName = isBuyer + ? (data.seller?.name ?? data.sellerCompanyName ?? "-") + : (data.buyer?.name ?? data.buyerCompanyName ?? "-"); + + const requestDate = data.createdAt + ? new Date(data.createdAt) + .toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .replace(/\. /g, " / ") + .replace(/\.$/, "") + : "-"; + + const totalAmount = + data.totalAmountXrp != null + ? `${data.totalAmountXrp.toLocaleString()} ${data.currency ?? "XRP"}` + : "-"; + + const myApprovedAtPaymentLevel = isBuyer + ? data.buyerApproved + : data.sellerApproved; + const showApprovalButtons = !myApprovedAtPaymentLevel; + const sortedEscrows = [...(data.escrows ?? [])].sort( + (a, b) => a.order - b.order, + ); + + const myWalletAddress = myProfile?.wallet?.address ?? null; + const buyerWalletAddress = isBuyer + ? myWalletAddress + : (data.buyerWalletAddress ?? data.buyer?.wallet?.address ?? null); + const sellerWalletAddress = isBuyer + ? (data.sellerWalletAddress ?? data.seller?.wallet?.address ?? null) + : myWalletAddress; + + return ( +
+
+
+ + Transaction Details + +
+ + {counterpartyName} + + + Request Date {requestDate} + +
+
+ +
+ + Total Amount + + + {totalAmount} + +
+ +
+
+ + Buyer XRP Wallet Address + +
+ + {buyerWalletAddress ?? "-"} + +
+
+ +
+ + Seller XRP Wallet Address + +
+ + {sellerWalletAddress ?? "-"} + +
+
+
+
+ +
+ {sortedEscrows.map((escrow, i) => ( + + ))} +
+ + {showApprovalButtons && ( +
+ + +
+ )} +
+ ); +} From a009d85bd002c4c99132cecfb43e88ea2ec1b6d8 Mon Sep 17 00:00:00 2001 From: seongmin Date: Mon, 11 May 2026 22:11:58 +0900 Subject: [PATCH 03/15] =?UTF-8?q?[#20]Refactor:=20PaymentsPage=EB=A5=BC=20?= =?UTF-8?q?payment=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20PageHero=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaymentsPage 위치를 src/pages/payment/ 하위로 정리 - 인라인 히어로 마크업을 공용 PageHero 컴포넌트로 교체 - 헤더 XRP Payment 네비게이션을 /payments/create → /payments로 변경 --- src/components/Header.jsx | 2 +- src/pages/PaymentsPage.jsx | 269 ---------------------- src/pages/payment/PaymentsPage.jsx | 343 +++++++++++++++++++++++++++++ src/router.jsx | 2 +- 4 files changed, 345 insertions(+), 271 deletions(-) delete mode 100644 src/pages/PaymentsPage.jsx create mode 100644 src/pages/payment/PaymentsPage.jsx diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 3e50f2d..59f8669 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -6,7 +6,7 @@ import logoTypo from "@/assets/logo_typo.png"; const navItems = [ { to: "/matches", key: "nav_partner_matching" }, - { to: "/payments/create", key: "nav_xrp_payment" }, + { to: "/payments", key: "nav_xrp_payment" }, { to: "/my-business", key: "nav_my_business" }, { to: "/schedule", key: "nav_schedule" }, ]; diff --git a/src/pages/PaymentsPage.jsx b/src/pages/PaymentsPage.jsx deleted file mode 100644 index 3ad253e..0000000 --- a/src/pages/PaymentsPage.jsx +++ /dev/null @@ -1,269 +0,0 @@ -import { useMemo } from "react"; -import Button from "@/components/Button.jsx"; -import { useI18n } from "@/lib/i18n/I18nProvider.jsx"; -import { track } from "@/lib/analytics.js"; -import { useAnalyticsDashboard } from "@/hooks/useAnalytics"; -import { usePaymentSummary, useRecentPayments } from "@/hooks/usePayments"; - -const paymentStatSeed = [ - { - id: "total", - labelKey: "payments_total", - value: 45, - change: "+12% last month", - icon: "🧾", - }, - { - id: "contracts", - labelKey: "payments_active_contracts", - value: 12, - change: "+3 new this week", - icon: "📄", - }, - { - id: "pending", - labelKey: "payments_pending", - value: 8, - change: "2 due this week", - icon: "⏳", - }, - { - id: "completed", - labelKey: "payments_completed", - value: 37, - change: "$2.4M total value", - icon: "✅", - }, -]; - -const transactionStatSeed = [ - { - id: "totalSent", - labelKey: "payments_total_sent", - value: "$50,000.00", - change: "85,420.30 XRP", - tone: "warning", - }, - { - id: "totalReceived", - labelKey: "payments_total_received", - value: "$120,000.00", - change: "205,200.70 XRP", - tone: "success", - }, - { - id: "pendingTransactions", - labelKey: "payments_pending_tx", - value: "1", - change: "Awaiting confirmation", - tone: "warning", - }, - { - id: "completedTransactions", - labelKey: "payments_completed_tx", - value: "2", - change: "Successful transactions", - tone: "success", - }, -]; - -const transactionsSeed = [ - { - id: 1, - company: "BeautyTech Korea", - description: "K-Beauty product order - Premium skincare line", - date: "Jan 15, 2024, 07:30 PM", - amount: "-$50,000.00", - xrpl: "85,420.30 XRP", - status: "completed", - }, - { - id: 2, - company: "RoboTech Solutions", - description: "Industrial automation system sale", - date: "Jan 15, 2024, 12:45 AM", - amount: "+$120,000.00", - xrpl: "205,200.70 XRP", - status: "completed", - }, -]; - -export default function PaymentsPage() { - const { t } = useI18n(); - - const { data: dashboardData } = useAnalyticsDashboard(); - const { data: summaryData } = usePaymentSummary(); - const { data: recentPaymentsData } = useRecentPayments(); - - const paymentStats = useMemo(() => { - if (!dashboardData) return paymentStatSeed; - return paymentStatSeed.map((card) => { - if (card.id === "total" && dashboardData.totalPartners !== undefined) - return { ...card, value: dashboardData.totalPartners }; - if (card.id === "pending" && dashboardData.pendingPayments !== undefined) - return { ...card, value: dashboardData.pendingPayments }; - if (card.id === "completed" && dashboardData.completedDeals !== undefined) - return { ...card, value: dashboardData.completedDeals }; - return card; - }); - }, [dashboardData]); - - const transactionStats = useMemo(() => { - if (!summaryData) return transactionStatSeed; - return transactionStatSeed.map((card) => { - if (card.id === "totalSent" && summaryData.totalAmount !== undefined) - return { - ...card, - value: `$${Number(summaryData.totalAmount).toLocaleString()}`, - }; - if ( - card.id === "pendingTransactions" && - summaryData.pending !== undefined - ) - return { ...card, value: String(summaryData.pending) }; - if (card.id === "completedTransactions" && summaryData.paid !== undefined) - return { ...card, value: String(summaryData.paid) }; - return card; - }); - }, [summaryData]); - - const transactions = useMemo(() => { - if (!Array.isArray(recentPaymentsData)) return transactionsSeed; - return recentPaymentsData.map((item) => ({ - id: item._id, - company: item.companyId?.name || "Unknown", - description: item.memo || "", - date: item.createdAt ? new Date(item.createdAt).toLocaleString() : "", - amount: `${item.amount >= 0 ? "+" : "-"}$${Math.abs(item.amount || 0).toLocaleString()}`, - xrpl: `${(item.amount || 0).toLocaleString()} ${item.currency || ""}`, - status: item.status?.toLowerCase() || "pending", - })); - }, [recentPaymentsData]); - - return ( -
-
-
-

{t("payments_title_heading")}

-

{t("payments_subheading")}

-
- -
- -
- {paymentStats.map((card) => ( -
- -
-

{t(card.labelKey)}

- {card.value} - {card.change} -
-
- ))} -
- -
- - - -
- -
-
-

{t("payments_transactions_title")}

-

{t("payments_transactions_subheading")}

-
- -
- -
- - - -
- -
- {transactionStats.map((card) => ( -
-
-

{t(card.labelKey)}

- {card.value} - {card.change} -
-
- ))} -
- -
-

{t("payments_recent_transactions")}

-
    - {transactions.map((tx) => ( -
  • -
    - {tx.company} -
    {tx.description}
    -
    {tx.date}
    -
    -
    - {tx.amount} - {tx.xrpl} - {tx.status} -
    -
  • - ))} -
-
-
- ); -} diff --git a/src/pages/payment/PaymentsPage.jsx b/src/pages/payment/PaymentsPage.jsx new file mode 100644 index 0000000..7883736 --- /dev/null +++ b/src/pages/payment/PaymentsPage.jsx @@ -0,0 +1,343 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Plus } from "lucide-react"; +import { useI18n } from "@/lib/i18n/I18nProvider.jsx"; +import { useAuthStore } from "@/stores/authStore"; +import PageHero from "@/components/PageHero"; +import SegmentedControl from "@/components/SegmentedControl.jsx"; +import LoadingSpinner from "@/components/LoadingSpinner.jsx"; +import { useEscrowPaymentList } from "@/hooks/payments/useEscrowPayments"; +import PaymentDetail from "@/pages/payment/PaymentDetail.jsx"; + +const STATUS_TEXT = { + DRAFT: "awaiting", + PENDING_APPROVAL: "awaiting", + APPROVED: "in_progress", + PROCESSING: "in_progress", + ACTIVE: "in_progress", + COMPLETED: "done", + CANCELLED: "awaiting", +}; + +function AmountCell({ amount, currency }) { + if (amount == null) + return -; + return ( + + {amount.toLocaleString()} + + {" "} + {currency ?? "XRP"} + + + ); +} + +function PaymentRow({ item, isSelected, onClick, tab }) { + const counterparty = + tab === "received" + ? (item.buyer?.name ?? item.buyerCompanyName ?? "-") + : (item.seller?.name ?? item.sellerCompanyName ?? "-"); + const date = item.createdAt + ? new Date(item.createdAt) + .toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .replace(/\. /g, " / ") + .replace(/\.$/, "") + : "-"; + const statusKey = STATUS_TEXT[item.status] ?? "awaiting"; + + return ( + + {date} + {counterparty ?? "-"} + + + + + {statusKey === "awaiting" + ? "Awaiting Response" + : statusKey === "done" + ? "Done" + : "In Progress"} + + + ); +} + +const thStyle = { + padding: "12px 8px", + fontSize: 14, + fontWeight: 500, + color: "#434055", + textAlign: "center", + whiteSpace: "nowrap", +}; + +const tdStyle = { + padding: "20px 12px", + fontSize: 12, + color: "#080616", + borderBottom: "1px dashed #dadada", +}; + +export default function PaymentsPage() { + const { t } = useI18n(); + const navigate = useNavigate(); + const { companyId } = useAuthStore(); + const [tab, setTab] = useState("received"); + const [page, setPage] = useState(1); + const [selectedId, setSelectedId] = useState(null); + const limit = 10; + + const { data, isLoading, isError, refetch } = useEscrowPaymentList( + "ongoing", + page, + limit, + ); + + const rawItems = Array.isArray(data) + ? data + : (data?.data ?? data?.items ?? []); + const total = Array.isArray(data) ? data.length : (data?.total ?? 0); + const items = rawItems.filter((item) => + tab === "received" + ? item.sellerId === companyId + : item.buyerId === companyId, + ); + const totalPages = Math.max(1, Math.ceil(total / limit)); + + function handleTabChange(val) { + setTab(val); + setPage(1); + setSelectedId(null); + } + + return ( +
+ + +
+
+
+ + +
+ +
+ {isLoading ? ( + + ) : isError ? ( +
+ 데이터를 불러오지 못했습니다. + +
+ ) : ( + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item) => ( + + setSelectedId( + item._id === selectedId ? null : item._id, + ) + } + tab={tab} + /> + )) + )} + +
REQUEST DATECOMPANY + TOTAL AMOUNT +
/ CURRENCY +
PROGRESS STATUS
+ {t("payments_empty")} +
+ )} +
+ + {totalPages > 1 && ( +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + + ))} + +
+ )} +
+ +
+ {selectedId ? ( + + ) : ( +
+ 목록에서 결제 건을 선택하세요. +
+ )} +
+
+
+ ); +} + +function paginationBtnStyle(disabled, isActive = false) { + return { + width: 32, + height: 32, + display: "flex", + alignItems: "center", + justifyContent: "center", + border: "1px solid #e8ecf0", + borderRadius: 6, + background: isActive ? "#0056ee" : "#fff", + color: isActive ? "#fff" : disabled ? "#c0c0c0" : "#080616", + fontSize: 13, + cursor: disabled ? "default" : "pointer", + fontWeight: isActive ? 600 : 400, + }; +} diff --git a/src/router.jsx b/src/router.jsx index f3aa671..0aa7d6e 100644 --- a/src/router.jsx +++ b/src/router.jsx @@ -10,7 +10,7 @@ import CompanyList from "@/pages/CompanyList.jsx"; import BuyerForm from "@/pages/BuyerForm.jsx"; import CompanyInputForm from "@/pages/CompanyInputForm.jsx"; import Matches from "@/pages/Matches.jsx"; -import PaymentsPage from "@/pages/PaymentsPage.jsx"; +import PaymentsPage from "@/pages/payment/PaymentsPage.jsx"; import SchedulePage from "@/pages/schedule/SchedulePage.jsx"; import PaymentCheckout from "@/pages/PaymentCheckout.jsx"; import PaymentStatus from "@/pages/PaymentStatus.jsx"; From 216fb5392295166ff58a31a56acdbb953125e00b Mon Sep 17 00:00:00 2001 From: seongmin Date: Mon, 11 May 2026 22:12:14 +0900 Subject: [PATCH 04/15] =?UTF-8?q?[#20]Fix:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=9E=88=EC=96=B4=EB=A1=9C=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=ED=8B=80=20=ED=95=9C=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit payments_hero_title 한글 locale이 영문과 동일하게 노출되던 문제 수정 --- src/lib/i18n/dict.js | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/lib/i18n/dict.js b/src/lib/i18n/dict.js index 7574591..e61d1d4 100644 --- a/src/lib/i18n/dict.js +++ b/src/lib/i18n/dict.js @@ -235,6 +235,29 @@ const keys = [ "payments_total_sent", "payments_transactions_subheading", "payments_transactions_title", + "payments_hero_title", + "payments_hero_subtitle", + "payments_tab_received", + "payments_tab_sent", + "payments_add_create", + "payments_status_awaiting", + "payments_col_request_date", + "payments_col_company", + "payments_col_amount", + "payments_col_status", + "payments_status_in_progress", + "payments_status_done", + "payments_empty", + "payments_detail_title", + "payments_detail_company", + "payments_detail_request_date", + "payments_detail_total_amount", + "payments_detail_buyer_wallet", + "payments_detail_seller_wallet", + "payments_confirm_buyer", + "payments_confirm_seller", + "payments_action_accept", + "payments_action_reject", "primary_contact", "qr_label", "quick_lookup_empty", @@ -438,6 +461,30 @@ Object.assign(en, { payments_pending_tx: "Pending transactions", payments_completed_tx: "Completed transactions", payments_recent_transactions: "Recent transactions", + payments_hero_title: "Hybrid Payment System", + payments_hero_subtitle: + "Fast, stable, and borderless — settle sample transactions instantly with XRP and secure trade payments reliably with RLUSD.", + payments_status_awaiting: "Awaiting Response", + payments_tab_received: "Received", + payments_tab_sent: "Sent", + payments_add_create: "Add Create Payment", + payments_col_request_date: "REQUEST DATE", + payments_col_company: "COMPANY", + payments_col_amount: "TOTAL AMOUNT · CURRENCY", + payments_col_status: "PROGRESS STATUS", + payments_status_in_progress: "In Progress", + payments_status_done: "Done", + payments_empty: "No payments found.", + payments_detail_title: "Transaction Details", + payments_detail_company: "Company", + payments_detail_request_date: "Request Date", + payments_detail_total_amount: "Total Amount", + payments_detail_buyer_wallet: "Buyer Wallet", + payments_detail_seller_wallet: "Seller Wallet", + payments_confirm_buyer: "Buyer Confirm", + payments_confirm_seller: "Seller Confirm", + payments_action_accept: "Accept", + payments_action_reject: "Reject", payment_checkout_title: "Payment instructions", payment_status_title: "Payment status", about_title_heading: "About K-Statra", @@ -783,6 +830,30 @@ const ko = { payments_pending_tx: "대기 거래", payments_completed_tx: "완료 거래", payments_recent_transactions: "최근 거래", + payments_hero_title: "하이브리드 결제 시스템", + payments_hero_subtitle: + "빠르고 안정적이며 국경 없는 결제 — XRP로 샘플 거래를 즉시 처리하고, RLUSD로 무역 대금을 안전하게 정산하세요.", + payments_status_awaiting: "응답 대기 중", + payments_tab_received: "받은 결제", + payments_tab_sent: "보낸 결제", + payments_add_create: "결제 추가", + payments_col_request_date: "요청일", + payments_col_company: "업체", + payments_col_amount: "총 금액 · 통화", + payments_col_status: "진행 상태", + payments_status_in_progress: "진행 중", + payments_status_done: "완료", + payments_empty: "결제 내역이 없습니다.", + payments_detail_title: "거래 상세", + payments_detail_company: "업체", + payments_detail_request_date: "요청일", + payments_detail_total_amount: "총 금액", + payments_detail_buyer_wallet: "구매자 지갑", + payments_detail_seller_wallet: "판매자 지갑", + payments_confirm_buyer: "구매자 확인", + payments_confirm_seller: "판매자 확인", + payments_action_accept: "수락", + payments_action_reject: "거절", payment_checkout_title: "결제 안내", payment_status_title: "결제 상태", create_payment_title: "결제 생성", From 7bbd6de5590db22914a06b5ec2c511bedbb1e24a Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:04:40 +0900 Subject: [PATCH 05/15] =?UTF-8?q?[#20]Refactor:=20MyInfo=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=86=8C=EA=B0=9C/=EC=A0=9C=ED=92=88/=EC=9B=B9=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/myBusiness/MyInfo.jsx | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/src/pages/myBusiness/MyInfo.jsx b/src/pages/myBusiness/MyInfo.jsx index 6d92405..0782cbc 100644 --- a/src/pages/myBusiness/MyInfo.jsx +++ b/src/pages/myBusiness/MyInfo.jsx @@ -1,12 +1,4 @@ -import { - Phone, - Mail, - Tag, - FileText, - Link, - Building2, - Wallet, -} from "lucide-react"; +import { Phone, Mail, Tag, Building2, Wallet } from "lucide-react"; import { useI18n } from "@/lib/i18n/I18nProvider"; import LabeledInput from "@/components/LabeledInput"; import AuthDropdown from "@/components/AuthDropdown"; @@ -24,10 +16,6 @@ export default function MyInfo() { const email = data?.representativeEmail || data?.email || ""; const companyType = data?.type || "buyer"; const keywords = (data?.needs || data?.exportItems || []).join(", "); - const companyIntro = - data?.sellerIntroduction || data?.buyerIntroduction || data?.intro_en || ""; - const productIntro = data?.productIntroduction || data?.product_intro || ""; - const websiteUrl = data?.websiteUrl || data?.website || ""; return (
- - - From 96ca1729ee298d01d0418346482814613dee66b0 Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:05:46 +0900 Subject: [PATCH 06/15] =?UTF-8?q?[#20]Fix:=20SegmentedControl=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=ED=83=AD=20=EC=99=B8=EA=B3=BD=EC=84=A0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SegmentedControl.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SegmentedControl.jsx b/src/components/SegmentedControl.jsx index 982b7e5..9e626c7 100644 --- a/src/components/SegmentedControl.jsx +++ b/src/components/SegmentedControl.jsx @@ -38,7 +38,7 @@ export default function SegmentedControl({ tabs, value, onChange }) { inset: 0, borderRadius: 8, background: "#fafafa", - border: "1px solid #dadada", + border: "none", zIndex: -1, }} transition={{ type: "spring", stiffness: 400, damping: 30 }} From 28691a6b716d6d5eafb6e3fc27f805b34463f1d5 Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:06:13 +0900 Subject: [PATCH 07/15] =?UTF-8?q?[#20]Refactor:=20=EC=97=90=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=9C=20=EA=B2=B0=EC=A0=9C=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?DTO=EB=A5=BC=20counterpartyWalletAddress=EB=A1=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buyerId/sellerWalletAddress 두 필드로 나뉘어 있던 구조를 백엔드 API 스펙(CreateEscrowPaymentDto)에 맞춰 counterpartyWalletAddress 하나로 통합. 세션 기반 인증으로 buyerId는 서버에서 식별. --- src/apis/modules/escrowPayments.ts | 3 +-- src/pages/payment/PaymentRequestForm.jsx | 7 +------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/apis/modules/escrowPayments.ts b/src/apis/modules/escrowPayments.ts index b8b8782..9f9ec31 100644 --- a/src/apis/modules/escrowPayments.ts +++ b/src/apis/modules/escrowPayments.ts @@ -9,8 +9,7 @@ export interface EscrowItemDto { } export interface CreateEscrowPaymentDto { - buyerId: string; - sellerWalletAddress: string; + counterpartyWalletAddress: string; memo?: string; currency?: "XRP" | "RLUSD"; escrows: EscrowItemDto[]; diff --git a/src/pages/payment/PaymentRequestForm.jsx b/src/pages/payment/PaymentRequestForm.jsx index 50e2138..1475ab8 100644 --- a/src/pages/payment/PaymentRequestForm.jsx +++ b/src/pages/payment/PaymentRequestForm.jsx @@ -6,7 +6,6 @@ import { useI18n } from "@/lib/i18n/I18nProvider"; import SquareButton from "@/components/SquareButton"; import EscrowInfoCard from "@/components/payment/EscrowInfoCard"; import WalletAddressCard from "@/components/payment/WalletAddressCard"; -import { useAuthStore } from "@/stores/authStore"; import { useCreateEscrowPayment } from "@/hooks/payments/useCreateEscrowPayment"; export default function PaymentRequestForm({ @@ -18,7 +17,6 @@ export default function PaymentRequestForm({ }) { const { t } = useI18n(); const [confirmed, setConfirmed] = useState(false); - const { companyId } = useAuthStore(); const { mutateAsync, isPending } = useCreateEscrowPayment(); const handleChange = (index, updated) => { @@ -45,8 +43,6 @@ export default function PaymentRequestForm({ }; const handleSubmit = async () => { - const buyerId = companyId; - const escrows = milestones.map((m, index) => ({ label: m.label, amountXrp: parseFloat(m.xrplAmount) || 0, @@ -57,8 +53,7 @@ export default function PaymentRequestForm({ const firstMilestone = milestones[0]; try { await mutateAsync({ - buyerId, - sellerWalletAddress, + counterpartyWalletAddress: sellerWalletAddress, memo: firstMilestone?.memo?.trim() || undefined, currency: firstMilestone?.currency || "XRP", escrows, From d5726f54ea7709c7d8860335e8440620750437d0 Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:07:09 +0900 Subject: [PATCH 08/15] =?UTF-8?q?[#20]Feat:=20PaymentDetail=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=20-=20=EC=97=90=EC=8A=A4=ED=81=AC=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EB=AA=A8=EB=8B=AC=20/=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=9C=EC=8B=9C=20/=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=95=8C=EB=A6=BC=20/=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 에스크로 결제 개시 확인 모달 추가 (EscrowPaymentModal) - buyer가 APPROVED 상태에서 결제를 개시할 수 있는 버튼/모달 흐름 구현 - 이전 에스크로가 모두 RELEASED여야 다음 항목 이벤트 승인 가능하도록 잠금 처리 - 승인/이벤트승인/결제개시 hook에 toast 알림 및 잔액 부족 메세지 처리 추가 - 하드코딩된 영문 문자열을 i18n으로 분리 (모달/디테일 키 추가) - 식별 기준을 companyId → role 기반 isBuyer로 단순화 --- src/components/payment/EscrowPaymentModal.jsx | 150 ++++++++++++++++++ src/hooks/payments/useEscrowPayments.js | 30 +++- src/lib/i18n/dict.js | 48 +++++- src/pages/payment/PaymentDetail.jsx | 150 ++++++++++++------ 4 files changed, 321 insertions(+), 57 deletions(-) create mode 100644 src/components/payment/EscrowPaymentModal.jsx diff --git a/src/components/payment/EscrowPaymentModal.jsx b/src/components/payment/EscrowPaymentModal.jsx new file mode 100644 index 0000000..0b887f2 --- /dev/null +++ b/src/components/payment/EscrowPaymentModal.jsx @@ -0,0 +1,150 @@ +import { useEffect } from "react"; +import { useI18n } from "@/lib/i18n/I18nProvider"; + +export default function EscrowPaymentModal({ + open, + onClose, + onConfirm, + isPending, +}) { + const { t } = useI18n(); + useEffect(() => { + function onKey(e) { + if (e.key === "Escape" && !isPending) onClose?.(); + } + if (open) document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose, isPending]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + style={{ + width: 411, + background: "#fafafa", + borderRadius: 32, + padding: 20, + display: "flex", + flexDirection: "column", + gap: 60, + }} + > +
+ +
+ + {t("escrow_payment_modal_title")} + + + {t("escrow_payment_modal_subtitle")} + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/hooks/payments/useEscrowPayments.js b/src/hooks/payments/useEscrowPayments.js index bb106eb..fe42243 100644 --- a/src/hooks/payments/useEscrowPayments.js +++ b/src/hooks/payments/useEscrowPayments.js @@ -1,6 +1,20 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import { escrowPaymentsApi } from "@/apis"; +function extractErrorMessage(error, fallback) { + const msg = error?.response?.data?.message?.message; + let text; + if (Array.isArray(msg)) text = msg.join(", "); + else if (typeof msg === "string") text = msg; + else return fallback; + + if (text.startsWith("Insufficient XRP balance")) { + return "잔액이 부족합니다."; + } + return text; +} + export function useEscrowPaymentList(group, page = 1, limit = 10) { return useQuery({ queryKey: ["escrow-payments", group, page, limit], @@ -20,11 +34,19 @@ export function useApproveEscrowPayment(paymentId) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (action) => escrowPaymentsApi.approve(paymentId, action), - onSuccess: () => { + onSuccess: (_, action) => { queryClient.invalidateQueries({ queryKey: ["escrow-payment", paymentId], }); queryClient.invalidateQueries({ queryKey: ["escrow-payments"] }); + toast.success( + action === "REJECT" + ? "결제 요청을 거절했습니다." + : "결제 요청을 수락했습니다.", + ); + }, + onError: (error) => { + toast.error(extractErrorMessage(error, "결제 승인에 실패했습니다.")); }, }); } @@ -39,6 +61,9 @@ export function useApproveEscrowEvent(paymentId) { queryKey: ["escrow-payment", paymentId], }); }, + onError: (error) => { + toast.error(extractErrorMessage(error, "이벤트 승인에 실패했습니다.")); + }, }); } @@ -51,5 +76,8 @@ export function usePayEscrow(paymentId) { queryKey: ["escrow-payment", paymentId], }); }, + onError: (error) => { + toast.error(extractErrorMessage(error, "결제 개시에 실패했습니다.")); + }, }); } diff --git a/src/lib/i18n/dict.js b/src/lib/i18n/dict.js index e61d1d4..bd099ce 100644 --- a/src/lib/i18n/dict.js +++ b/src/lib/i18n/dict.js @@ -106,6 +106,11 @@ const keys = [ "detail_recommendation_placeholder", "detail_website", "detail_not_provided", + "escrow_payment_modal_title", + "escrow_payment_modal_subtitle", + "escrow_payment_modal_cancel", + "escrow_payment_modal_confirm", + "escrow_payment_modal_processing", "expired", "export_csv", "feedback_button", @@ -258,6 +263,11 @@ const keys = [ "payments_confirm_seller", "payments_action_accept", "payments_action_reject", + "payments_detail_done", + "payments_detail_load_error", + "payments_detail_retry", + "payments_detail_escrow_payment", + "payments_detail_escrow_payment_completed", "primary_contact", "qr_label", "quick_lookup_empty", @@ -479,12 +489,17 @@ Object.assign(en, { payments_detail_company: "Company", payments_detail_request_date: "Request Date", payments_detail_total_amount: "Total Amount", - payments_detail_buyer_wallet: "Buyer Wallet", - payments_detail_seller_wallet: "Seller Wallet", - payments_confirm_buyer: "Buyer Confirm", - payments_confirm_seller: "Seller Confirm", + payments_detail_buyer_wallet: "Buyer XRP Wallet Address", + payments_detail_seller_wallet: "Seller XRP Wallet Address", + payments_confirm_buyer: "Buyer Confirmed", + payments_confirm_seller: "Seller Confirmed", payments_action_accept: "Accept", payments_action_reject: "Reject", + payments_detail_done: "Done", + payments_detail_load_error: "Failed to load data.", + payments_detail_retry: "Retry", + payments_detail_escrow_payment: "Escrow Payment", + payments_detail_escrow_payment_completed: "Escrow Payment Completed", payment_checkout_title: "Payment instructions", payment_status_title: "Payment status", about_title_heading: "About K-Statra", @@ -668,6 +683,12 @@ Object.assign(en, { payment_currency_label: "Currency", payment_memo_label: "Memo", payment_memo_placeholder: "Payment memo (optional)", + escrow_payment_modal_title: "Do you have XRP in your wallet?", + escrow_payment_modal_subtitle: + "Check your XRP balance before payment.\nPayment will not proceed if your balance is insufficient.", + escrow_payment_modal_cancel: "No, Check Wallet First", + escrow_payment_modal_confirm: "Yes, Continue Payment", + escrow_payment_modal_processing: "Processing...", payment_escrow_info_label: "Escrow Info", payment_escrow_label: "Label", payment_xrp_info_title: "XRP Info", @@ -848,12 +869,17 @@ const ko = { payments_detail_company: "업체", payments_detail_request_date: "요청일", payments_detail_total_amount: "총 금액", - payments_detail_buyer_wallet: "구매자 지갑", - payments_detail_seller_wallet: "판매자 지갑", - payments_confirm_buyer: "구매자 확인", - payments_confirm_seller: "판매자 확인", + payments_detail_buyer_wallet: "구매자 XRP 지갑 주소", + payments_detail_seller_wallet: "판매자 XRP 지갑 주소", + payments_confirm_buyer: "구매자 확정", + payments_confirm_seller: "판매자 확정", payments_action_accept: "수락", payments_action_reject: "거절", + payments_detail_done: "완료", + payments_detail_load_error: "데이터를 불러오지 못했습니다.", + payments_detail_retry: "다시 시도", + payments_detail_escrow_payment: "에스크로 결제", + payments_detail_escrow_payment_completed: "에스크로 결제 완료", payment_checkout_title: "결제 안내", payment_status_title: "결제 상태", create_payment_title: "결제 생성", @@ -878,6 +904,12 @@ const ko = { payment_currency_label: "통화", payment_memo_label: "결제 메모", payment_memo_placeholder: "결제 메모를 입력하세요 (선택사항)", + escrow_payment_modal_title: "지갑에 XRP가 있나요?", + escrow_payment_modal_subtitle: + "결제 전 XRP 잔액을 확인해 주세요.\n잔액이 부족하면 결제가 진행되지 않습니다.", + escrow_payment_modal_cancel: "아니요, 지갑부터 확인할게요", + escrow_payment_modal_confirm: "네, 결제 진행할게요", + escrow_payment_modal_processing: "처리 중...", payment_escrow_info_label: "에스크로 정보", payment_escrow_label: "라벨", payment_xrp_info_title: "XRP 정보", diff --git a/src/pages/payment/PaymentDetail.jsx b/src/pages/payment/PaymentDetail.jsx index 54bc11f..c6f0fae 100644 --- a/src/pages/payment/PaymentDetail.jsx +++ b/src/pages/payment/PaymentDetail.jsx @@ -1,5 +1,8 @@ +import { useState } from "react"; +import { useI18n } from "@/lib/i18n/I18nProvider"; import { useAuthStore } from "@/stores/authStore"; import LoadingSpinner from "@/components/LoadingSpinner.jsx"; +import EscrowPaymentModal from "@/components/payment/EscrowPaymentModal"; import { useEscrowPayment, useApproveEscrowPayment, @@ -8,7 +11,11 @@ import { } from "@/hooks/payments/useEscrowPayments"; import { useMyProfile } from "@/hooks/myBusiness/useMyProfile"; +const PAID_STATUSES = ["PROCESSING", "ACTIVE", "COMPLETED"]; +const PRE_ESCROWED_ITEM_STATUSES = ["PENDING_ESCROW", "SUBMITTING"]; + function EscrowStatusBadge({ status }) { + const { t } = useI18n(); const isDone = status === "RELEASED"; return ( - {isDone ? "Done" : "In Progress"} + {isDone ? t("payments_detail_done") : t("payments_status_in_progress")} ); } @@ -73,16 +80,15 @@ function EventRow({ prevDone, isPendingAny, escrowStatus, + escrowLocked, }) { + const { t } = useI18n(); const { mutate: approveEvent, isPending: isPendingApprove } = useApproveEscrowEvent(paymentId); - const { mutate: payEscrow, isPending: isPendingPay } = - usePayEscrow(paymentId); - const isPending = isPendingApprove || isPendingPay; - const isPendingEscrow = escrowStatus === "PENDING_ESCROW"; + const notYetEscrowed = PRE_ESCROWED_ITEM_STATUSES.includes(escrowStatus); const bothDone = approval.buyerApproved && approval.sellerApproved; - const locked = !prevDone && index > 0; + const locked = escrowLocked || (!prevDone && index > 0) || notYetEscrowed; const doneDate = bothDone && (approval.completedAt ?? approval.updatedAt ?? approval.doneAt) @@ -113,27 +119,25 @@ function EventRow({ >
- isPendingEscrow - ? payEscrow() - : approveEvent({ escrowId, type: approval.eventType }) - } + disabled={!isBuyer || locked || isPendingApprove || isPendingAny} + onClick={() => approveEvent({ escrowId, type: approval.eventType })} /> approveEvent({ escrowId, type: approval.eventType })} />
{bothDone && ( - {doneDate ? `${doneDate} Done` : "Done"} + {doneDate + ? `${doneDate} ${t("payments_detail_done")}` + : t("payments_detail_done")} )} @@ -141,17 +145,19 @@ function EventRow({ ); } -function EscrowItem({ escrow, index, isBuyer, paymentId }) { +function EscrowItem({ escrow, index, isBuyer, paymentId, prevEscrowsDone }) { + const { t } = useI18n(); const { mutate: _approveEvent, isPending: isPendingAny } = useApproveEscrowEvent(paymentId); const approvals = escrow.approvals ?? []; + const escrowLocked = !prevEscrowsDone; return (
- Escrow Info {index + 1} + {t("payment_escrow_info_label")} {index + 1}
@@ -208,6 +214,7 @@ function EscrowItem({ escrow, index, isBuyer, paymentId }) { prevDone={prevDone} isPendingAny={isPendingAny} escrowStatus={escrow.status} + escrowLocked={escrowLocked} /> ); })} @@ -218,12 +225,16 @@ function EscrowItem({ escrow, index, isBuyer, paymentId }) { } export default function PaymentDetail({ paymentId }) { - const { companyId } = useAuthStore(); + const { t } = useI18n(); + const { role } = useAuthStore(); const { data, isLoading, isError, refetch } = useEscrowPayment(paymentId); const { mutate: approvePayment, isPending: isApproving } = useApproveEscrowPayment(paymentId); + const { mutate: payEscrow, isPending: isPayPending } = + usePayEscrow(paymentId); const { data: myProfile } = useMyProfile(); + const [payModalOpen, setPayModalOpen] = useState(false); if (isLoading) return ; @@ -240,7 +251,7 @@ export default function PaymentDetail({ paymentId }) { fontSize: 14, }} > - 데이터를 불러오지 못했습니다. + {t("payments_detail_load_error")}
); @@ -262,11 +273,9 @@ export default function PaymentDetail({ paymentId }) { if (!data) return null; - const isBuyer = data.buyerId === companyId; + const isBuyer = role === "buyer"; - const counterpartyName = isBuyer - ? (data.seller?.name ?? data.sellerCompanyName ?? "-") - : (data.buyer?.name ?? data.buyerCompanyName ?? "-"); + const counterpartyName = data.partnerName ?? "-"; const requestDate = data.createdAt ? new Date(data.createdAt) @@ -292,13 +301,15 @@ export default function PaymentDetail({ paymentId }) { (a, b) => a.order - b.order, ); - const myWalletAddress = myProfile?.wallet?.address ?? null; - const buyerWalletAddress = isBuyer - ? myWalletAddress - : (data.buyerWalletAddress ?? data.buyer?.wallet?.address ?? null); - const sellerWalletAddress = isBuyer - ? (data.sellerWalletAddress ?? data.seller?.wallet?.address ?? null) - : myWalletAddress; + const myWalletAddress = + data.myWalletAddress ?? myProfile?.wallet?.address ?? null; + const partnerWalletAddress = data.partnerWalletAddress ?? null; + const buyerWalletAddress = isBuyer ? myWalletAddress : partnerWalletAddress; + const sellerWalletAddress = isBuyer ? partnerWalletAddress : myWalletAddress; + + const isPaid = PAID_STATUSES.includes(data.status); + const canInitiatePayment = isBuyer && data.status === "APPROVED"; + const showPayButton = isBuyer && (canInitiatePayment || isPaid); return (
- Transaction Details + {t("payments_detail_title")}
{counterpartyName} - Request Date {requestDate} + {t("payments_detail_request_date")} {requestDate}
@@ -336,7 +347,7 @@ export default function PaymentDetail({ paymentId }) { }} > - Total Amount + {t("payments_detail_total_amount")} {totalAmount} @@ -353,7 +364,7 @@ export default function PaymentDetail({ paymentId }) { whiteSpace: "nowrap", }} > - Buyer XRP Wallet Address + {t("payments_detail_buyer_wallet")}
- Seller XRP Wallet Address + {t("payments_detail_seller_wallet")}
+ + {showPayButton && ( + + )}
+ setPayModalOpen(false)} + onConfirm={() => + payEscrow(undefined, { + onSuccess: () => setPayModalOpen(false), + }) + } + /> +
- {sortedEscrows.map((escrow, i) => ( - - ))} + {sortedEscrows.map((escrow, i) => { + const prevEscrowsDone = + i === 0 || + sortedEscrows.slice(0, i).every((e) => e.status === "RELEASED"); + return ( + + ); + })}
{showApprovalButtons && ( @@ -454,7 +508,7 @@ export default function PaymentDetail({ paymentId }) { cursor: "pointer", }} > - Reject + {t("payments_action_reject")} )} From 8b768320261471821422bb3fdb1854f55b10b36b Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:08:05 +0900 Subject: [PATCH 09/15] =?UTF-8?q?[#20]Feat:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A7=84=ED=96=89=EB=A5=A0=20=EB=B0=94=20?= =?UTF-8?q?+=20=EC=A7=84=ED=96=89=EC=A4=91/=EC=A2=85=EB=A3=8C(ongoing/done?= =?UTF-8?q?)=20=ED=83=AD=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 결제 항목 진행 상태를 시각화하는 EscrowProgressBar 컴포넌트 추가 - PaymentsPage에서 PROCESSING/ACTIVE 상태는 progress bar로 렌더링, COMPLETED/CANCELLED는 별도 라벨로 표시 - 보낸/받은 탭은 응답에 sender 식별 필드가 없어 프론트 단독 분류가 불가능하므로, 백엔드 group 파라미터 스펙(ongoing/done)에 맞춰 탭의 의미를 진행 상태 그룹으로 재정의 - escrowPaymentsApi.getList 파라미터를 group/status enum으로 타이트닝 - 관련 i18n 키 추가 및 col_amount 줄바꿈 표시 적용 --- src/apis/modules/escrowPayments.ts | 16 ++- src/components/payment/EscrowProgressBar.jsx | 99 +++++++++++++++++ src/lib/i18n/dict.js | 22 ++-- src/pages/payment/PaymentsPage.jsx | 107 ++++++++++++------- 4 files changed, 197 insertions(+), 47 deletions(-) create mode 100644 src/components/payment/EscrowProgressBar.jsx diff --git a/src/apis/modules/escrowPayments.ts b/src/apis/modules/escrowPayments.ts index 9f9ec31..a742fa4 100644 --- a/src/apis/modules/escrowPayments.ts +++ b/src/apis/modules/escrowPayments.ts @@ -19,8 +19,20 @@ export const escrowPaymentsApi = { create: (data: CreateEscrowPaymentDto) => http.post(ENDPOINTS.escrowPayments.root, data), - getList: (params: { group?: string; page?: number; limit?: number } = {}) => - http.get(ENDPOINTS.escrowPayments.root, { params }), + getList: ( + params: { + group?: "ongoing" | "done"; + status?: + | "PENDING_APPROVAL" + | "APPROVED" + | "PROCESSING" + | "ACTIVE" + | "COMPLETED" + | "CANCELLED"; + page?: number; + limit?: number; + } = {}, + ) => http.get(ENDPOINTS.escrowPayments.root, { params }), getById: (id: string) => http.get(ENDPOINTS.escrowPayments.byId(id)), diff --git a/src/components/payment/EscrowProgressBar.jsx b/src/components/payment/EscrowProgressBar.jsx new file mode 100644 index 0000000..6487b57 --- /dev/null +++ b/src/components/payment/EscrowProgressBar.jsx @@ -0,0 +1,99 @@ +const MAX_STEPS = 3; +const ACTIVE_COLOR = "#0056ee"; +const PENDING_COLOR = "#dadada"; +const ACTIVE_LABEL_COLOR = "#080616"; + +export default function EscrowProgressBar({ + steps, + currentStep = 0, + labels = [], +}) { + const total = Math.min(Math.max(steps ?? 0, 1), MAX_STEPS); + + return ( +
+ {Array.from({ length: total }).map((_, i) => { + const stepNum = i + 1; + const isActive = stepNum <= currentStep; + const circleColor = isActive ? ACTIVE_COLOR : PENDING_COLOR; + const isLastStep = i === total - 1; + const nextIsActive = stepNum < currentStep; + const lineColor = nextIsActive ? ACTIVE_COLOR : PENDING_COLOR; + + return ( +
+
+
+ {stepNum} +
+ + {labels[i] ?? `Step ${stepNum}`} + +
+ + {!isLastStep && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/lib/i18n/dict.js b/src/lib/i18n/dict.js index bd099ce..d544269 100644 --- a/src/lib/i18n/dict.js +++ b/src/lib/i18n/dict.js @@ -242,8 +242,8 @@ const keys = [ "payments_transactions_title", "payments_hero_title", "payments_hero_subtitle", - "payments_tab_received", - "payments_tab_sent", + "payments_tab_ongoing", + "payments_tab_done", "payments_add_create", "payments_status_awaiting", "payments_col_request_date", @@ -252,6 +252,8 @@ const keys = [ "payments_col_status", "payments_status_in_progress", "payments_status_done", + "payments_status_finished", + "payments_status_cancelled", "payments_empty", "payments_detail_title", "payments_detail_company", @@ -475,15 +477,17 @@ Object.assign(en, { payments_hero_subtitle: "Fast, stable, and borderless — settle sample transactions instantly with XRP and secure trade payments reliably with RLUSD.", payments_status_awaiting: "Awaiting Response", - payments_tab_received: "Received", - payments_tab_sent: "Sent", + payments_tab_ongoing: "Ongoing", + payments_tab_done: "Done", payments_add_create: "Add Create Payment", payments_col_request_date: "REQUEST DATE", payments_col_company: "COMPANY", - payments_col_amount: "TOTAL AMOUNT · CURRENCY", + payments_col_amount: "TOTAL AMOUNT\n/ CURRENCY", payments_col_status: "PROGRESS STATUS", payments_status_in_progress: "In Progress", payments_status_done: "Done", + payments_status_finished: "Finished", + payments_status_cancelled: "Cancelled", payments_empty: "No payments found.", payments_detail_title: "Transaction Details", payments_detail_company: "Company", @@ -855,15 +859,17 @@ const ko = { payments_hero_subtitle: "빠르고 안정적이며 국경 없는 결제 — XRP로 샘플 거래를 즉시 처리하고, RLUSD로 무역 대금을 안전하게 정산하세요.", payments_status_awaiting: "응답 대기 중", - payments_tab_received: "받은 결제", - payments_tab_sent: "보낸 결제", + payments_tab_ongoing: "진행중", + payments_tab_done: "종료", payments_add_create: "결제 추가", payments_col_request_date: "요청일", payments_col_company: "업체", - payments_col_amount: "총 금액 · 통화", + payments_col_amount: "총 금액\n/ 통화", payments_col_status: "진행 상태", payments_status_in_progress: "진행 중", payments_status_done: "완료", + payments_status_finished: "완료됨", + payments_status_cancelled: "취소됨", payments_empty: "결제 내역이 없습니다.", payments_detail_title: "거래 상세", payments_detail_company: "업체", diff --git a/src/pages/payment/PaymentsPage.jsx b/src/pages/payment/PaymentsPage.jsx index 7883736..4b241f3 100644 --- a/src/pages/payment/PaymentsPage.jsx +++ b/src/pages/payment/PaymentsPage.jsx @@ -2,23 +2,26 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Plus } from "lucide-react"; import { useI18n } from "@/lib/i18n/I18nProvider.jsx"; -import { useAuthStore } from "@/stores/authStore"; import PageHero from "@/components/PageHero"; import SegmentedControl from "@/components/SegmentedControl.jsx"; import LoadingSpinner from "@/components/LoadingSpinner.jsx"; import { useEscrowPaymentList } from "@/hooks/payments/useEscrowPayments"; import PaymentDetail from "@/pages/payment/PaymentDetail.jsx"; +import EscrowProgressBar from "@/components/payment/EscrowProgressBar"; const STATUS_TEXT = { - DRAFT: "awaiting", PENDING_APPROVAL: "awaiting", APPROVED: "in_progress", - PROCESSING: "in_progress", - ACTIVE: "in_progress", - COMPLETED: "done", - CANCELLED: "awaiting", }; +const PROGRESS_BAR_STATUSES = ["PROCESSING", "ACTIVE"]; +const ACTIVE_ITEM_STATUSES = [ + "SUBMITTING", + "ESCROWED", + "RELEASING", + "RELEASED", +]; + function AmountCell({ amount, currency }) { if (amount == null) return -; @@ -33,11 +36,9 @@ function AmountCell({ amount, currency }) { ); } -function PaymentRow({ item, isSelected, onClick, tab }) { - const counterparty = - tab === "received" - ? (item.buyer?.name ?? item.buyerCompanyName ?? "-") - : (item.seller?.name ?? item.sellerCompanyName ?? "-"); +function PaymentRow({ item, isSelected, onClick }) { + const { t } = useI18n(); + const counterparty = item.partnerName ?? "-"; const date = item.createdAt ? new Date(item.createdAt) .toLocaleDateString("ko-KR", { @@ -48,7 +49,47 @@ function PaymentRow({ item, isSelected, onClick, tab }) { .replace(/\. /g, " / ") .replace(/\.$/, "") : "-"; - const statusKey = STATUS_TEXT[item.status] ?? "awaiting"; + const escrows = Array.isArray(item.escrows) ? item.escrows : []; + + let progressContent; + if (item.status === "COMPLETED") { + progressContent = t("payments_status_finished"); + } else if (item.status === "CANCELLED") { + progressContent = t("payments_status_cancelled"); + } else if ( + PROGRESS_BAR_STATUSES.includes(item.status) && + escrows.length > 0 + ) { + const sortedEscrows = [...escrows].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ); + const steps = Math.min(sortedEscrows.length, 3); + let activeCount = 0; + for (const e of sortedEscrows.slice(0, steps)) { + if (e.status === "RELEASED") { + activeCount++; + } else if (ACTIVE_ITEM_STATUSES.includes(e.status)) { + activeCount++; + break; + } else { + break; + } + } + const labels = sortedEscrows.slice(0, steps).map((e) => e.label ?? ""); + progressContent = ( + + ); + } else { + const statusKey = STATUS_TEXT[item.status] ?? "awaiting"; + progressContent = + statusKey === "in_progress" + ? t("payments_status_in_progress") + : t("payments_status_awaiting"); + } return ( - {statusKey === "awaiting" - ? "Awaiting Response" - : statusKey === "done" - ? "Done" - : "In Progress"} + {progressContent} ); @@ -93,27 +130,19 @@ const tdStyle = { export default function PaymentsPage() { const { t } = useI18n(); const navigate = useNavigate(); - const { companyId } = useAuthStore(); - const [tab, setTab] = useState("received"); + const [tab, setTab] = useState("ongoing"); const [page, setPage] = useState(1); const [selectedId, setSelectedId] = useState(null); const limit = 10; const { data, isLoading, isError, refetch } = useEscrowPaymentList( - "ongoing", + tab, page, limit, ); - const rawItems = Array.isArray(data) - ? data - : (data?.data ?? data?.items ?? []); + const items = Array.isArray(data) ? data : (data?.data ?? data?.items ?? []); const total = Array.isArray(data) ? data.length : (data?.total ?? 0); - const items = rawItems.filter((item) => - tab === "received" - ? item.sellerId === companyId - : item.buyerId === companyId, - ); const totalPages = Math.max(1, Math.ceil(total / limit)); function handleTabChange(val) { @@ -150,8 +179,8 @@ export default function PaymentsPage() { > - REQUEST DATE - COMPANY - - TOTAL AMOUNT -
/ CURRENCY + + {t("payments_col_request_date")} + + + {t("payments_col_company")} + + + {t("payments_col_amount")} - PROGRESS STATUS + {t("payments_col_status")} @@ -249,7 +283,6 @@ export default function PaymentsPage() { item._id === selectedId ? null : item._id, ) } - tab={tab} /> )) )} From 0c3e32cd96ec01cbdc7b3b145ddcd0900585905e Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:26:22 +0900 Subject: [PATCH 10/15] =?UTF-8?q?[#20]Refactor:=20EscrowPaymentModal=20?= =?UTF-8?q?=E2=86=92=20=EB=B2=94=EC=9A=A9=20ConfirmModal=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 결제 개시 전용이던 모달을 ConfirmModal로 리네임/일반화. 제목·부제· 버튼 라벨을 props로 받아 다양한 확정 흐름에서 재사용할 수 있도록 변경. 파일 위치도 components/payment에서 components 루트로 이동. 취소 버튼 라벨도 짧게 정리. --- ...scrowPaymentModal.jsx => ConfirmModal.jsx} | 41 ++++++++++--------- src/lib/i18n/dict.js | 4 +- src/pages/payment/PaymentDetail.jsx | 9 +++- 3 files changed, 31 insertions(+), 23 deletions(-) rename src/components/{payment/EscrowPaymentModal.jsx => ConfirmModal.jsx} (83%) diff --git a/src/components/payment/EscrowPaymentModal.jsx b/src/components/ConfirmModal.jsx similarity index 83% rename from src/components/payment/EscrowPaymentModal.jsx rename to src/components/ConfirmModal.jsx index 0b887f2..3edc4bd 100644 --- a/src/components/payment/EscrowPaymentModal.jsx +++ b/src/components/ConfirmModal.jsx @@ -1,13 +1,16 @@ import { useEffect } from "react"; -import { useI18n } from "@/lib/i18n/I18nProvider"; -export default function EscrowPaymentModal({ +export default function ConfirmModal({ open, onClose, onConfirm, isPending, + title, + subtitle, + cancelLabel, + confirmLabel, + pendingLabel, }) { - const { t } = useI18n(); useEffect(() => { function onKey(e) { if (e.key === "Escape" && !isPending) onClose?.(); @@ -86,19 +89,21 @@ export default function EscrowPaymentModal({ lineHeight: "30px", }} > - {t("escrow_payment_modal_title")} - - - {t("escrow_payment_modal_subtitle")} + {title} + {subtitle && ( + + {subtitle} + + )}
@@ -120,7 +125,7 @@ export default function EscrowPaymentModal({ opacity: isPending ? 0.6 : 1, }} > - {t("escrow_payment_modal_cancel")} + {cancelLabel} diff --git a/src/lib/i18n/dict.js b/src/lib/i18n/dict.js index d544269..008a5e9 100644 --- a/src/lib/i18n/dict.js +++ b/src/lib/i18n/dict.js @@ -690,7 +690,7 @@ Object.assign(en, { escrow_payment_modal_title: "Do you have XRP in your wallet?", escrow_payment_modal_subtitle: "Check your XRP balance before payment.\nPayment will not proceed if your balance is insufficient.", - escrow_payment_modal_cancel: "No, Check Wallet First", + escrow_payment_modal_cancel: "No", escrow_payment_modal_confirm: "Yes, Continue Payment", escrow_payment_modal_processing: "Processing...", payment_escrow_info_label: "Escrow Info", @@ -913,7 +913,7 @@ const ko = { escrow_payment_modal_title: "지갑에 XRP가 있나요?", escrow_payment_modal_subtitle: "결제 전 XRP 잔액을 확인해 주세요.\n잔액이 부족하면 결제가 진행되지 않습니다.", - escrow_payment_modal_cancel: "아니요, 지갑부터 확인할게요", + escrow_payment_modal_cancel: "아니요", escrow_payment_modal_confirm: "네, 결제 진행할게요", escrow_payment_modal_processing: "처리 중...", payment_escrow_info_label: "에스크로 정보", diff --git a/src/pages/payment/PaymentDetail.jsx b/src/pages/payment/PaymentDetail.jsx index c6f0fae..e35a46d 100644 --- a/src/pages/payment/PaymentDetail.jsx +++ b/src/pages/payment/PaymentDetail.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useI18n } from "@/lib/i18n/I18nProvider"; import { useAuthStore } from "@/stores/authStore"; import LoadingSpinner from "@/components/LoadingSpinner.jsx"; -import EscrowPaymentModal from "@/components/payment/EscrowPaymentModal"; +import ConfirmModal from "@/components/ConfirmModal"; import { useEscrowPayment, useApproveEscrowPayment, @@ -455,7 +455,7 @@ export default function PaymentDetail({ paymentId }) { )} - setPayModalOpen(false)} @@ -464,6 +464,11 @@ export default function PaymentDetail({ paymentId }) { onSuccess: () => setPayModalOpen(false), }) } + title={t("escrow_payment_modal_title")} + subtitle={t("escrow_payment_modal_subtitle")} + cancelLabel={t("escrow_payment_modal_cancel")} + confirmLabel={t("escrow_payment_modal_confirm")} + pendingLabel={t("escrow_payment_modal_processing")} />
Date: Tue, 12 May 2026 15:27:21 +0900 Subject: [PATCH 11/15] =?UTF-8?q?[#20]Feat:=20=EC=97=90=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EC=8B=9C=20ConfirmModal=EB=A1=9C=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=EB=8D=94=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구매자/판매자 확정 버튼 클릭 시 바로 approveEvent를 호출하지 않고 ConfirmModal로 한 번 더 확인받도록 EventRow에 모달 흐름 추가. event_confirm_modal_* i18n 키 추가. --- src/lib/i18n/dict.js | 17 +++++++++++++++++ src/pages/payment/PaymentDetail.jsx | 24 ++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/lib/i18n/dict.js b/src/lib/i18n/dict.js index 008a5e9..9d2f561 100644 --- a/src/lib/i18n/dict.js +++ b/src/lib/i18n/dict.js @@ -111,6 +111,11 @@ const keys = [ "escrow_payment_modal_cancel", "escrow_payment_modal_confirm", "escrow_payment_modal_processing", + "event_confirm_modal_title", + "event_confirm_modal_subtitle", + "event_confirm_modal_cancel", + "event_confirm_modal_confirm", + "event_confirm_modal_processing", "expired", "export_csv", "feedback_button", @@ -693,6 +698,12 @@ Object.assign(en, { escrow_payment_modal_cancel: "No", escrow_payment_modal_confirm: "Yes, Continue Payment", escrow_payment_modal_processing: "Processing...", + event_confirm_modal_title: "Confirm this event?", + event_confirm_modal_subtitle: + "Once confirmed, this cannot be undone.\nPlease review the progress before proceeding.", + event_confirm_modal_cancel: "No", + event_confirm_modal_confirm: "Yes, Confirm", + event_confirm_modal_processing: "Processing...", payment_escrow_info_label: "Escrow Info", payment_escrow_label: "Label", payment_xrp_info_title: "XRP Info", @@ -916,6 +927,12 @@ const ko = { escrow_payment_modal_cancel: "아니요", escrow_payment_modal_confirm: "네, 결제 진행할게요", escrow_payment_modal_processing: "처리 중...", + event_confirm_modal_title: "이벤트를 확정할까요?", + event_confirm_modal_subtitle: + "한 번 확정하면 되돌릴 수 없어요.\n진행 상황을 다시 확인해 주세요.", + event_confirm_modal_cancel: "아니요", + event_confirm_modal_confirm: "네, 확정할게요", + event_confirm_modal_processing: "처리 중...", payment_escrow_info_label: "에스크로 정보", payment_escrow_label: "라벨", payment_xrp_info_title: "XRP 정보", diff --git a/src/pages/payment/PaymentDetail.jsx b/src/pages/payment/PaymentDetail.jsx index e35a46d..8971e28 100644 --- a/src/pages/payment/PaymentDetail.jsx +++ b/src/pages/payment/PaymentDetail.jsx @@ -85,11 +85,19 @@ function EventRow({ const { t } = useI18n(); const { mutate: approveEvent, isPending: isPendingApprove } = useApproveEscrowEvent(paymentId); + const [confirmOpen, setConfirmOpen] = useState(false); const notYetEscrowed = PRE_ESCROWED_ITEM_STATUSES.includes(escrowStatus); const bothDone = approval.buyerApproved && approval.sellerApproved; const locked = escrowLocked || (!prevDone && index > 0) || notYetEscrowed; + function handleConfirmEvent() { + approveEvent( + { escrowId, type: approval.eventType }, + { onSuccess: () => setConfirmOpen(false) }, + ); + } + const doneDate = bothDone && (approval.completedAt ?? approval.updatedAt ?? approval.doneAt) ? new Date(approval.completedAt ?? approval.updatedAt ?? approval.doneAt) @@ -123,14 +131,14 @@ function EventRow({ myApproved={approval.buyerApproved} bothDone={bothDone} disabled={!isBuyer || locked || isPendingApprove || isPendingAny} - onClick={() => approveEvent({ escrowId, type: approval.eventType })} + onClick={() => setConfirmOpen(true)} /> approveEvent({ escrowId, type: approval.eventType })} + onClick={() => setConfirmOpen(true)} />
{bothDone && ( @@ -141,6 +149,18 @@ function EventRow({ )} + + setConfirmOpen(false)} + onConfirm={handleConfirmEvent} + title={t("event_confirm_modal_title")} + subtitle={t("event_confirm_modal_subtitle")} + cancelLabel={t("event_confirm_modal_cancel")} + confirmLabel={t("event_confirm_modal_confirm")} + pendingLabel={t("event_confirm_modal_processing")} + /> ); } From 546a879a874e699fc6fc76863cf51d9ed9276a4c Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:32:56 +0900 Subject: [PATCH 12/15] =?UTF-8?q?[#20]Feat:=20=EB=9E=9C=EB=94=A9=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20hero=EC=97=90=20=EB=A7=88?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit framer-motion으로 tagline·hero 라인·description·CTA를 순차 fade+slide-up 하고, 강조 박스는 clipPath 좌→우 wipe로 박스와 텍스트가 함께 reveal되도록 처리. --- src/pages/LandingPage.jsx | 47 +++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index c7f8a3f..1945b11 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -1,9 +1,20 @@ import { useNavigate } from "react-router-dom"; +import { motion } from "framer-motion"; import SquareButton from "@/components/SquareButton.jsx"; import worldMap from "@/assets/world-map.png"; import { useI18n } from "@/lib/i18n/I18nProvider"; import { useAuthStore } from "@/stores/authStore"; +const EASE = [0.16, 1, 0.3, 1]; + +function fadeUp(delay) { + return { + initial: { opacity: 0, y: 24 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: 0.7, delay, ease: EASE }, + }; +} + export default function LandingPage() { const navigate = useNavigate(); const { t } = useI18n(); @@ -63,7 +74,8 @@ export default function LandingPage() { alignItems: "center", }} > -

{t("landing_tagline")} -

+
-

{t("landing_hero_line1")} -

+ -
+ {t("landing_hero_prefix") && (

)} -

{t("landing_hero_highlight")}

-
+
{t("landing_hero_suffix") && (

)} -

+
-

{t("landing_description")} -

+ -
+ {isLoggedIn ? ( navigate("/matches")}> {t("nav_partner_matching")} @@ -190,7 +213,7 @@ export default function LandingPage() { )} -
+ ); From 024ce8311b808d3fa6e30dc8e88de286651f1c24 Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:43:01 +0900 Subject: [PATCH 13/15] =?UTF-8?q?[#20]Feat:=20EscrowProgressBar=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=EB=B3=84=20fill=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 단계 원이 scale 0.5→1 + 배경색 전환으로 등장, 점선은 clipPath 좌→우 wipe로 채워지면서 단계 간 stagger로 순차 reveal. --- src/components/payment/EscrowProgressBar.jsx | 66 +++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/src/components/payment/EscrowProgressBar.jsx b/src/components/payment/EscrowProgressBar.jsx index 6487b57..ae41df6 100644 --- a/src/components/payment/EscrowProgressBar.jsx +++ b/src/components/payment/EscrowProgressBar.jsx @@ -1,7 +1,11 @@ +import { motion } from "framer-motion"; + const MAX_STEPS = 3; const ACTIVE_COLOR = "#0056ee"; const PENDING_COLOR = "#dadada"; const ACTIVE_LABEL_COLOR = "#080616"; +const EASE = [0.16, 1, 0.3, 1]; +const STEP_DELAY = 0.18; export default function EscrowProgressBar({ steps, @@ -22,10 +26,8 @@ export default function EscrowProgressBar({ {Array.from({ length: total }).map((_, i) => { const stepNum = i + 1; const isActive = stepNum <= currentStep; - const circleColor = isActive ? ACTIVE_COLOR : PENDING_COLOR; const isLastStep = i === total - 1; const nextIsActive = stepNum < currentStep; - const lineColor = nextIsActive ? ACTIVE_COLOR : PENDING_COLOR; return (
-
{stepNum} -
- + {labels[i] ?? `Step ${stepNum}`} - +
{!isLastStep && ( @@ -87,9 +107,35 @@ export default function EscrowProgressBar({ flex: 1, height: 1, marginTop: 9.5, - borderTop: `1px dashed ${lineColor}`, + position: "relative", }} - /> + > +
+ +
)} ); From c50c8833ac670f2314d42391b8654776e85f8ff4 Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:43:45 +0900 Subject: [PATCH 14/15] =?UTF-8?q?[#20]Feat:=20PaymentDetail=20=EC=B4=9D=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20count-up=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit framer-motion animate()를 이용한 useCountUp 훅 추가. 0에서 totalAmountXrp 까지 ease-out으로 카운트업. paymentId가 바뀌면(다른 row 선택) 새 값에 맞춰 다시 한 번 재생. --- src/pages/payment/PaymentDetail.jsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/pages/payment/PaymentDetail.jsx b/src/pages/payment/PaymentDetail.jsx index 8971e28..f1239b9 100644 --- a/src/pages/payment/PaymentDetail.jsx +++ b/src/pages/payment/PaymentDetail.jsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { animate } from "framer-motion"; import { useI18n } from "@/lib/i18n/I18nProvider"; import { useAuthStore } from "@/stores/authStore"; import LoadingSpinner from "@/components/LoadingSpinner.jsx"; @@ -14,6 +15,22 @@ import { useMyProfile } from "@/hooks/myBusiness/useMyProfile"; const PAID_STATUSES = ["PROCESSING", "ACTIVE", "COMPLETED"]; const PRE_ESCROWED_ITEM_STATUSES = ["PENDING_ESCROW", "SUBMITTING"]; +function useCountUp(target, duration = 1.2) { + const [value, setValue] = useState(0); + + useEffect(() => { + if (target == null) return undefined; + const controls = animate(0, target, { + duration, + ease: [0.16, 1, 0.3, 1], + onUpdate: setValue, + }); + return () => controls.stop(); + }, [target, duration]); + + return value; +} + function EscrowStatusBadge({ status }) { const { t } = useI18n(); const isDone = status === "RELEASED"; @@ -255,6 +272,7 @@ export default function PaymentDetail({ paymentId }) { const { data: myProfile } = useMyProfile(); const [payModalOpen, setPayModalOpen] = useState(false); + const animatedAmount = useCountUp(data?.totalAmountXrp ?? null); if (isLoading) return ; @@ -310,7 +328,7 @@ export default function PaymentDetail({ paymentId }) { const totalAmount = data.totalAmountXrp != null - ? `${data.totalAmountXrp.toLocaleString()} ${data.currency ?? "XRP"}` + ? `${Math.round(animatedAmount).toLocaleString()} ${data.currency ?? "XRP"}` : "-"; const myApprovedAtPaymentLevel = isBuyer From 5434ce46edb51eeca7c86ff3d7f2de93b23e1f88 Mon Sep 17 00:00:00 2001 From: seongmin Date: Tue, 12 May 2026 15:44:03 +0900 Subject: [PATCH 15/15] =?UTF-8?q?[#20]Feat:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20row=20stagger=20+=20=EC=9A=B0=EC=B8=A1=20d?= =?UTF-8?q?etail=20=ED=8C=A8=EB=84=90=20slide-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaymentRow를 motion.tr로 변환, index 기반 0.04s stagger로 순차 마운트 - 우측 PaymentDetail 패널을 AnimatePresence mode="wait"로 감싸고 selectedId 변경 시 opacity + x 슬라이드로 cross-fade 전환 --- src/pages/payment/PaymentsPage.jsx | 66 +++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/src/pages/payment/PaymentsPage.jsx b/src/pages/payment/PaymentsPage.jsx index 4b241f3..d0dece8 100644 --- a/src/pages/payment/PaymentsPage.jsx +++ b/src/pages/payment/PaymentsPage.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { motion, AnimatePresence } from "framer-motion"; import { Plus } from "lucide-react"; import { useI18n } from "@/lib/i18n/I18nProvider.jsx"; import PageHero from "@/components/PageHero"; @@ -14,6 +15,8 @@ const STATUS_TEXT = { APPROVED: "in_progress", }; +const ROW_EASE = [0.16, 1, 0.3, 1]; + const PROGRESS_BAR_STATUSES = ["PROCESSING", "ACTIVE"]; const ACTIVE_ITEM_STATUSES = [ "SUBMITTING", @@ -36,7 +39,7 @@ function AmountCell({ amount, currency }) { ); } -function PaymentRow({ item, isSelected, onClick }) { +function PaymentRow({ item, isSelected, onClick, index = 0 }) { const { t } = useI18n(); const counterparty = item.partnerName ?? "-"; const date = item.createdAt @@ -92,8 +95,15 @@ function PaymentRow({ item, isSelected, onClick }) { } return ( - {progressContent} - + ); } @@ -273,7 +283,7 @@ export default function PaymentsPage() { ) : ( - items.map((item) => ( + items.map((item, index) => ( )) )} @@ -336,22 +347,37 @@ export default function PaymentsPage() { overflowY: "auto", }} > - {selectedId ? ( - - ) : ( -
- 목록에서 결제 건을 선택하세요. -
- )} + + {selectedId ? ( + + + + ) : ( + + 목록에서 결제 건을 선택하세요. + + )} +