Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/components/pay/confirm-payment-button.tsx
Original file line number Diff line number Diff line change
@@ -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> | 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<Status>("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 (
<Button
type="button"
size="lg"
onClick={handleClick}
disabled={disabled || isBusy || isDone}
aria-busy={isBusy}
aria-live="polite"
className="w-full"
>
{isBusy ? (
<>
<Loader2 className="animate-spin" aria-hidden />
<span>Verifying on-chain…</span>
</>
) : isDone ? (
<>
<CheckCircle2 aria-hidden />
<span>Payment confirmed</span>
</>
) : (
<>
<Send aria-hidden />
<span>I have sent the payment</span>
</>
)}
</Button>
);
}
148 changes: 148 additions & 0 deletions src/components/pay/copy-address-amount.tsx
Original file line number Diff line number Diff line change
@@ -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<CopyField | null>(null);

async function copyToClipboard(value: string): Promise<boolean> {
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 (
<div className="flex flex-col gap-4">
<CopyField
id="manual-transfer-address"
label="Address"
value={address}
copied={copied === "address"}
onCopy={() => handleCopy("address", address, "Address")}
/>
<CopyField
id="manual-transfer-amount"
label={`Amount (${amountAssetCode})`}
value={amount}
copied={copied === "amount"}
onCopy={() => handleCopy("amount", amount, "Amount")}
/>
</div>
);
}

interface CopyFieldProps {
id: string;
label: string;
value: string;
copied: boolean;
onCopy: () => void;
}

function CopyField({ id, label, value, copied, onCopy }: CopyFieldProps) {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={id} className="text-xs font-medium text-muted-foreground">
{label}
</label>
<div className="flex items-stretch overflow-hidden rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
<input
id={id}
type="text"
readOnly
value={value}
aria-label={label}
className="flex-1 truncate bg-transparent px-3 py-2 font-mono text-sm text-foreground outline-none"
onFocus={(event) => event.currentTarget.select()}
/>
<button
type="button"
onClick={onCopy}
aria-label={`Copy ${label.toLowerCase()}`}
aria-pressed={copied}
className={cn(
"flex items-center gap-1.5 border-l px-3 text-xs font-medium transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
copied && "bg-emerald-50 text-emerald-900",
)}
>
{copied ? (
<Check className="size-4" aria-hidden />
) : (
<Copy className="size-4" aria-hidden />
)}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
</div>
</div>
);
}
68 changes: 68 additions & 0 deletions src/components/pay/manual-transfer-view.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="flex flex-col gap-6 rounded-lg border bg-card p-6 shadow-sm">
<header className="space-y-2">
<div className="flex items-center gap-2 text-primary">
<ClipboardCopy className="size-5" aria-hidden />
<h2 className="text-lg font-semibold text-foreground">
Pay by manual transfer
</h2>
</div>
<p className="text-sm text-muted-foreground">
Copy the address and exact amount into your Stellar wallet, send the
payment, then tap{" "}
<span className="font-medium text-foreground">
I have sent the payment
</span>{" "}
so Shade can verify it on-chain.
</p>
</header>

<CopyAddressAmount
address={address}
amount={amount}
amountAssetCode={amountAssetCode}
/>

<ConfirmPaymentButton onConfirm={handleConfirm} />
</section>
);
}
Loading