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..a742fa4 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[]; @@ -20,11 +19,29 @@ export const escrowPaymentsApi = { create: (data: CreateEscrowPaymentDto) => http.post(ENDPOINTS.escrowPayments.root, data), + 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)), - 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/components/ConfirmModal.jsx b/src/components/ConfirmModal.jsx new file mode 100644 index 0000000..3edc4bd --- /dev/null +++ b/src/components/ConfirmModal.jsx @@ -0,0 +1,153 @@ +import { useEffect } from "react"; + +export default function ConfirmModal({ + open, + onClose, + onConfirm, + isPending, + title, + subtitle, + cancelLabel, + confirmLabel, + pendingLabel, +}) { + 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, + }} + > +
+ +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+
+ +
+ + +
+
+
+ ); +} 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/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 }} diff --git a/src/components/payment/EscrowProgressBar.jsx b/src/components/payment/EscrowProgressBar.jsx new file mode 100644 index 0000000..ae41df6 --- /dev/null +++ b/src/components/payment/EscrowProgressBar.jsx @@ -0,0 +1,145 @@ +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, + 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 isLastStep = i === total - 1; + const nextIsActive = stepNum < currentStep; + + return ( +
+
+ + {stepNum} + + + {labels[i] ?? `Step ${stepNum}`} + +
+ + {!isLastStep && ( +
+
+ +
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/hooks/payments/useEscrowPayments.js b/src/hooks/payments/useEscrowPayments.js new file mode 100644 index 0000000..fe42243 --- /dev/null +++ b/src/hooks/payments/useEscrowPayments.js @@ -0,0 +1,83 @@ +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], + 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: (_, action) => { + queryClient.invalidateQueries({ + queryKey: ["escrow-payment", paymentId], + }); + queryClient.invalidateQueries({ queryKey: ["escrow-payments"] }); + toast.success( + action === "REJECT" + ? "결제 요청을 거절했습니다." + : "결제 요청을 수락했습니다.", + ); + }, + onError: (error) => { + toast.error(extractErrorMessage(error, "결제 승인에 실패했습니다.")); + }, + }); +} + +export function useApproveEscrowEvent(paymentId) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ escrowId, type }) => + escrowPaymentsApi.approveEvent(paymentId, escrowId, type), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["escrow-payment", paymentId], + }); + }, + onError: (error) => { + toast.error(extractErrorMessage(error, "이벤트 승인에 실패했습니다.")); + }, + }); +} + +export function usePayEscrow(paymentId) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => escrowPaymentsApi.pay(paymentId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["escrow-payment", paymentId], + }); + }, + onError: (error) => { + toast.error(extractErrorMessage(error, "결제 개시에 실패했습니다.")); + }, + }); +} 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, + }); +} diff --git a/src/lib/i18n/dict.js b/src/lib/i18n/dict.js index 7574591..9d2f561 100644 --- a/src/lib/i18n/dict.js +++ b/src/lib/i18n/dict.js @@ -106,6 +106,16 @@ 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", + "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", @@ -235,6 +245,36 @@ const keys = [ "payments_total_sent", "payments_transactions_subheading", "payments_transactions_title", + "payments_hero_title", + "payments_hero_subtitle", + "payments_tab_ongoing", + "payments_tab_done", + "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_status_finished", + "payments_status_cancelled", + "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", + "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", @@ -438,6 +478,37 @@ 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_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\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", + payments_detail_request_date: "Request Date", + payments_detail_total_amount: "Total Amount", + 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", @@ -621,6 +692,18 @@ 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", + 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", @@ -783,6 +866,37 @@ const ko = { payments_pending_tx: "대기 거래", payments_completed_tx: "완료 거래", payments_recent_transactions: "최근 거래", + payments_hero_title: "하이브리드 결제 시스템", + payments_hero_subtitle: + "빠르고 안정적이며 국경 없는 결제 — XRP로 샘플 거래를 즉시 처리하고, RLUSD로 무역 대금을 안전하게 정산하세요.", + payments_status_awaiting: "응답 대기 중", + payments_tab_ongoing: "진행중", + payments_tab_done: "종료", + payments_add_create: "결제 추가", + payments_col_request_date: "요청일", + payments_col_company: "업체", + 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: "업체", + payments_detail_request_date: "요청일", + payments_detail_total_amount: "총 금액", + 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: "결제 생성", @@ -807,6 +921,18 @@ 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: "처리 중...", + 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/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() { )} -
+ ); 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")}

- -
-
- ); -} 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 (
- - - diff --git a/src/pages/payment/PaymentDetail.jsx b/src/pages/payment/PaymentDetail.jsx new file mode 100644 index 0000000..f1239b9 --- /dev/null +++ b/src/pages/payment/PaymentDetail.jsx @@ -0,0 +1,577 @@ +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"; +import ConfirmModal from "@/components/ConfirmModal"; +import { + useEscrowPayment, + useApproveEscrowPayment, + useApproveEscrowEvent, + usePayEscrow, +} 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 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"; + return ( + + {isDone ? t("payments_detail_done") : t("payments_status_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, + escrowLocked, +}) { + 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) + .toISOString() + .slice(0, 10) + : null; + + return ( +
+ + {approval.eventType} + +
+
+ setConfirmOpen(true)} + /> + setConfirmOpen(true)} + /> +
+ {bothDone && ( + + {doneDate + ? `${doneDate} ${t("payments_detail_done")}` + : t("payments_detail_done")} + + )} +
+ + 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")} + /> +
+ ); +} + +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 ( +
+
+ + {t("payment_escrow_info_label")} {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 { 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); + const animatedAmount = useCountUp(data?.totalAmountXrp ?? null); + + if (isLoading) return ; + + if (isError) { + return ( +
+ {t("payments_detail_load_error")} + +
+ ); + } + + if (!data) return null; + + const isBuyer = role === "buyer"; + + const counterpartyName = data.partnerName ?? "-"; + + 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 + ? `${Math.round(animatedAmount).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 = + 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 ( +
+
+
+ + {t("payments_detail_title")} + +
+ + {counterpartyName} + + + {t("payments_detail_request_date")} {requestDate} + +
+
+ +
+ + {t("payments_detail_total_amount")} + + + {totalAmount} + +
+ +
+
+ + {t("payments_detail_buyer_wallet")} + +
+ + {buyerWalletAddress ?? "-"} + +
+
+ +
+ + {t("payments_detail_seller_wallet")} + +
+ + {sellerWalletAddress ?? "-"} + +
+
+
+ + {showPayButton && ( + + )} +
+ + setPayModalOpen(false)} + onConfirm={() => + payEscrow(undefined, { + 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")} + /> + +
+ {sortedEscrows.map((escrow, i) => { + const prevEscrowsDone = + i === 0 || + sortedEscrows.slice(0, i).every((e) => e.status === "RELEASED"); + return ( + + ); + })} +
+ + {showApprovalButtons && ( +
+ + +
+ )} +
+ ); +} 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, diff --git a/src/pages/payment/PaymentsPage.jsx b/src/pages/payment/PaymentsPage.jsx new file mode 100644 index 0000000..d0dece8 --- /dev/null +++ b/src/pages/payment/PaymentsPage.jsx @@ -0,0 +1,402 @@ +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"; +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 = { + PENDING_APPROVAL: "awaiting", + APPROVED: "in_progress", +}; + +const ROW_EASE = [0.16, 1, 0.3, 1]; + +const PROGRESS_BAR_STATUSES = ["PROCESSING", "ACTIVE"]; +const ACTIVE_ITEM_STATUSES = [ + "SUBMITTING", + "ESCROWED", + "RELEASING", + "RELEASED", +]; + +function AmountCell({ amount, currency }) { + if (amount == null) + return -; + return ( + + {amount.toLocaleString()} + + {" "} + {currency ?? "XRP"} + + + ); +} + +function PaymentRow({ item, isSelected, onClick, index = 0 }) { + const { t } = useI18n(); + const counterparty = item.partnerName ?? "-"; + const date = item.createdAt + ? new Date(item.createdAt) + .toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .replace(/\. /g, " / ") + .replace(/\.$/, "") + : "-"; + 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 ( + + {date} + {counterparty ?? "-"} + + + + + {progressContent} + + + ); +} + +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 [tab, setTab] = useState("ongoing"); + const [page, setPage] = useState(1); + const [selectedId, setSelectedId] = useState(null); + const limit = 10; + + const { data, isLoading, isError, refetch } = useEscrowPaymentList( + tab, + page, + limit, + ); + + const items = Array.isArray(data) ? data : (data?.data ?? data?.items ?? []); + const total = Array.isArray(data) ? data.length : (data?.total ?? 0); + 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, index) => ( + + setSelectedId( + item._id === selectedId ? null : item._id, + ) + } + index={index} + /> + )) + )} + +
+ {t("payments_col_request_date")} + + {t("payments_col_company")} + + {t("payments_col_amount")} + {t("payments_col_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";