diff --git a/src/components/pay/confirm-payment-button.tsx b/src/components/pay/confirm-payment-button.tsx new file mode 100644 index 0000000..78ebc2a --- /dev/null +++ b/src/components/pay/confirm-payment-button.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { CheckCircle2, Loader2, Send } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +interface ConfirmPaymentButtonProps { + /** + * Called after the user confirms they've sent the payment. The parent + * usually starts polling the blockchain for the matching incoming + * transaction here. Returning a promise keeps the button in its + * "verifying" state until verification settles. + */ + onConfirm?: () => Promise | void; + disabled?: boolean; +} + +type Status = "idle" | "verifying" | "verified"; + +/** + * Primary "I have sent the payment" button shown at the bottom of the + * manual transfer view. On click it enters a loading state, invokes the + * parent's onConfirm callback (which is typically a chain-polling + * routine), and stays in the verifying state until that callback + * resolves. After a successful verification it switches to a verified + * state so the user gets unambiguous feedback. + * + * See ShadeProtocol/shade-frontend#56. + */ +export function ConfirmPaymentButton({ + onConfirm, + disabled, +}: ConfirmPaymentButtonProps) { + const [status, setStatus] = useState("idle"); + + async function handleClick() { + if (status !== "idle") return; + setStatus("verifying"); + try { + await onConfirm?.(); + setStatus("verified"); + } catch { + // Bubble the error back to idle so the user can retry. The parent + // is responsible for surfacing a toast/inline error with detail. + setStatus("idle"); + } + } + + const isBusy = status === "verifying"; + const isDone = status === "verified"; + + return ( + + ); +} diff --git a/src/components/pay/copy-address-amount.tsx b/src/components/pay/copy-address-amount.tsx new file mode 100644 index 0000000..d4ce3db --- /dev/null +++ b/src/components/pay/copy-address-amount.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { notify } from "@/lib/toast"; + +interface CopyAddressAmountProps { + address: string; + amount: string; + amountAssetCode?: string; +} + +type CopyField = "address" | "amount"; + +/** + * Read-only display of the destination address and amount for a manual + * transfer, each with a copy-to-clipboard icon. The user copies these + * into their external Stellar wallet to complete the payment. + * + * The copy implementation prefers the async Clipboard API and falls back + * to an offscreen textarea + execCommand("copy") so it still works in + * non-secure (HTTP) contexts, embedded webviews, and older browsers. + * + * See ShadeProtocol/shade-frontend#55. + */ +export function CopyAddressAmount({ + address, + amount, + amountAssetCode = "XLM", +}: CopyAddressAmountProps) { + const [copied, setCopied] = useState(null); + + async function copyToClipboard(value: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + // Fall through to the execCommand fallback below — common in + // non-secure contexts where navigator.clipboard rejects. + } + } + + if (typeof document === "undefined") return false; + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch { + ok = false; + } + document.body.removeChild(textarea); + return ok; + } + + async function handleCopy(field: CopyField, value: string, label: string) { + const ok = await copyToClipboard(value); + if (ok) { + setCopied(field); + notify.success(`${label} copied`); + window.setTimeout(() => { + setCopied((current) => (current === field ? null : current)); + }, 1500); + } else { + notify.error( + `Could not copy ${label.toLowerCase()}`, + "Long-press the field to copy it manually.", + ); + } + } + + return ( +
+ handleCopy("address", address, "Address")} + /> + handleCopy("amount", amount, "Amount")} + /> +
+ ); +} + +interface CopyFieldProps { + id: string; + label: string; + value: string; + copied: boolean; + onCopy: () => void; +} + +function CopyField({ id, label, value, copied, onCopy }: CopyFieldProps) { + return ( +
+ +
+ event.currentTarget.select()} + /> + +
+
+ ); +} diff --git a/src/components/pay/manual-transfer-view.tsx b/src/components/pay/manual-transfer-view.tsx new file mode 100644 index 0000000..f7c475f --- /dev/null +++ b/src/components/pay/manual-transfer-view.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { ClipboardCopy } from "lucide-react"; + +import { CopyAddressAmount } from "./copy-address-amount"; +import { ConfirmPaymentButton } from "./confirm-payment-button"; + +interface ManualTransferViewProps { + /** Destination Stellar address the user must send to. */ + address?: string; + /** Amount the user must send, formatted as a string. */ + amount?: string; + /** Asset code the amount is denominated in (defaults to XLM). */ + amountAssetCode?: string; +} + +/** + * Manual-transfer payment flow: + * 1. The user copies the address and amount into their wallet (#55). + * 2. The user sends the payment from their wallet. + * 3. The user taps "I have sent the payment" (#56), which kicks off + * blockchain polling for the matching transaction. + * + * Real address/amount + the polling routine ship in follow-up work; for + * now we render with placeholder demo values so the flow is testable in + * isolation. + */ +export function ManualTransferView({ + address = "GABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567", + amount = "12.5000000", + amountAssetCode = "XLM", +}: ManualTransferViewProps) { + async function handleConfirm() { + // Placeholder: the real implementation polls Horizon / Soroban RPC + // for the matching incoming transaction. We wait a short tick so the + // button visibly enters its verifying state in the demo. + await new Promise((resolve) => setTimeout(resolve, 900)); + } + + return ( +
+
+
+ +

+ Pay by manual transfer +

+
+

+ Copy the address and exact amount into your Stellar wallet, send the + payment, then tap{" "} + + I have sent the payment + {" "} + so Shade can verify it on-chain. +

+
+ + + + +
+ ); +}