diff --git a/package-lock.json b/package-lock.json
index 99a0855..5a50d3c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"lucide-react": "^0.562.0",
"next": "16.1.4",
"next-pwa": "^5.6.0",
+ "qrcode.react": "^4.2.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
@@ -10243,6 +10244,15 @@
"node": ">=6"
}
},
+ "node_modules/qrcode.react": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
+ "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
diff --git a/package.json b/package.json
index 40ad44e..aacf189 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"lucide-react": "^0.562.0",
"next": "16.1.4",
"next-mdx-remote": "^5.0.0",
+ "qrcode.react": "^4.2.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
diff --git a/src/__tests__/stellar-payment-instructions.test.tsx b/src/__tests__/stellar-payment-instructions.test.tsx
new file mode 100644
index 0000000..547ed29
--- /dev/null
+++ b/src/__tests__/stellar-payment-instructions.test.tsx
@@ -0,0 +1,119 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { StellarPaymentInstructions } from "@/features/orders/components/StellarPaymentInstructions";
+import { toast } from "react-toastify";
+
+const push = vi.fn();
+const writeText = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+}));
+
+vi.mock("react-toastify", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe("StellarPaymentInstructions", () => {
+ beforeEach(() => {
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ writeText.mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ status: "PENDING" }),
+ }) as unknown as typeof fetch;
+ });
+
+ it("renders Stellar payment details and copy controls", async () => {
+ render(
+
+ );
+
+ expect(screen.getByText("Complete your ticket payment")).toBeInTheDocument();
+ expect(screen.getByText("Required — your payment will fail without the memo")).toBeInTheDocument();
+ expect(screen.getByText("25.5 XLM")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /copy destination address/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /copy memo/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /copy amount/i })).toBeInTheDocument();
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith("/api/orders/order-1", expect.any(Object));
+ });
+ });
+
+ it("redirects to tickets when polling returns PAID", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ status: "PAID" }),
+ }) as unknown as typeof fetch;
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(toast.success).toHaveBeenCalledWith("\uD83C\uDF89 Tickets issued! Check your email.");
+ expect(push).toHaveBeenCalledWith("/tickets");
+ });
+ });
+
+ it("retries an expired payment window", async () => {
+ const user = userEvent.setup();
+ global.fetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ status: "PENDING" }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ destinationAddress: "GNEWDESTINATION",
+ memo: "NEW-MEMO",
+ amountXLM: "30",
+ }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ status: "PENDING" }),
+ }) as unknown as typeof fetch;
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: /retry payment/i }));
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith("/api/orders/order-expired/retry-payment", {
+ method: "POST",
+ });
+ expect(screen.getByText("NEW-MEMO")).toBeInTheDocument();
+ expect(toast.success).toHaveBeenCalledWith("Payment window refreshed.");
+ });
+ });
+});
diff --git a/src/features/orders/components/StellarPaymentInstructions.tsx b/src/features/orders/components/StellarPaymentInstructions.tsx
new file mode 100644
index 0000000..784d76b
--- /dev/null
+++ b/src/features/orders/components/StellarPaymentInstructions.tsx
@@ -0,0 +1,281 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+import { QRCodeSVG } from "qrcode.react";
+import { toast } from "react-toastify";
+import { Check, Clock, Copy, RefreshCw, TicketCheck } from "lucide-react";
+import { useOrderStatus } from "@/hooks/useOrderStatus";
+
+type StellarPaymentInstructionsProps = {
+ orderId: string;
+ destinationAddress: string;
+ memo: string;
+ amountXLM: string | number;
+ expiresAt?: string | Date;
+ expiresInMinutes?: number;
+ className?: string;
+};
+
+type RetryPaymentResponse = {
+ destinationAddress?: string;
+ memo?: string;
+ amountXLM?: string | number;
+ expiresAt?: string;
+};
+
+const DEFAULT_EXPIRY_MINUTES = 15;
+const TICKETS_ISSUED_TOAST = "\uD83C\uDF89 Tickets issued! Check your email.";
+
+function normalizeAmount(amount: string | number) {
+ return typeof amount === "number" ? amount.toFixed(7).replace(/\.?0+$/, "") : amount;
+}
+
+function truncateAddress(address: string) {
+ if (address.length <= 18) return address;
+ return `${address.slice(0, 8)}...${address.slice(-8)}`;
+}
+
+function getInitialExpiry(expiresAt?: string | Date, expiresInMinutes = DEFAULT_EXPIRY_MINUTES) {
+ if (expiresAt) return new Date(expiresAt).getTime();
+ return Date.now() + expiresInMinutes * 60_000;
+}
+
+function formatRemaining(milliseconds: number) {
+ const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
+}
+
+export function StellarPaymentInstructions({
+ orderId,
+ destinationAddress,
+ memo,
+ amountXLM,
+ expiresAt,
+ expiresInMinutes = DEFAULT_EXPIRY_MINUTES,
+ className = "",
+}: StellarPaymentInstructionsProps) {
+ const router = useRouter();
+ const [payment, setPayment] = useState({
+ destinationAddress,
+ memo,
+ amountXLM,
+ expiresAt: getInitialExpiry(expiresAt, expiresInMinutes),
+ });
+ const [now, setNow] = useState(() => Date.now());
+ const [copiedField, setCopiedField] = useState<"destination" | "memo" | "amount" | null>(null);
+ const [isRetrying, setIsRetrying] = useState(false);
+ const { data, error, isLoading, refetch } = useOrderStatus(orderId, {
+ enabled: Boolean(orderId),
+ intervalMs: 5000,
+ });
+
+ const amount = normalizeAmount(payment.amountXLM);
+ const remainingMs = payment.expiresAt - now;
+ const isExpired = remainingMs <= 0;
+
+ const stellarPayUri = useMemo(() => {
+ const params = new URLSearchParams({
+ destination: payment.destinationAddress,
+ amount: String(amount),
+ memo: payment.memo,
+ });
+ return `web+stellar:pay?${params.toString()}`;
+ }, [amount, payment.destinationAddress, payment.memo]);
+
+ useEffect(() => {
+ // This syncs the local payment window when the backend returns a fresh order payload.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setPayment({
+ destinationAddress,
+ memo,
+ amountXLM,
+ expiresAt: getInitialExpiry(expiresAt, expiresInMinutes),
+ });
+ }, [amountXLM, destinationAddress, expiresAt, expiresInMinutes, memo]);
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ useEffect(() => {
+ if (data?.status === "PAID") {
+ toast.success(TICKETS_ISSUED_TOAST);
+ router.push("/tickets");
+ }
+ }, [data?.status, router]);
+
+ async function copyValue(field: "destination" | "memo" | "amount", value: string) {
+ await navigator.clipboard.writeText(value);
+ setCopiedField(field);
+ window.setTimeout(() => setCopiedField(null), 1800);
+ }
+
+ async function retryPayment() {
+ setIsRetrying(true);
+ try {
+ const response = await fetch(`/api/orders/${encodeURIComponent(orderId)}/retry-payment`, {
+ method: "POST",
+ });
+
+ if (!response.ok) {
+ throw new Error(`Retry payment request failed with ${response.status}`);
+ }
+
+ const retryData = (await response.json()) as RetryPaymentResponse;
+ setPayment((current) => ({
+ destinationAddress: retryData.destinationAddress ?? current.destinationAddress,
+ memo: retryData.memo ?? current.memo,
+ amountXLM: retryData.amountXLM ?? current.amountXLM,
+ expiresAt: getInitialExpiry(retryData.expiresAt, expiresInMinutes),
+ }));
+ setNow(Date.now());
+ toast.success("Payment window refreshed.");
+ await refetch();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Could not retry payment.");
+ } finally {
+ setIsRetrying(false);
+ }
+ }
+
+ return (
+
+
+
+
+ Stellar payment
+
+
+ Complete your ticket payment
+
+
+ Send the exact XLM amount to the destination below and include the memo. The order
+ will auto-confirm once the payment is detected.
+
+
+
+
+
+ {isExpired ? (
+ Payment window expired
+ ) : (
+
+ Expires in {formatRemaining(remainingMs)}
+
+ )}
+
+
+
+
+
+
+
+
+
+
copyValue("destination", payment.destinationAddress)}
+ />
+ copyValue("memo", payment.memo)}
+ />
+ copyValue("amount", String(amount))}
+ />
+
+ {isExpired ? (
+
+
This payment window expired.
+
+ Retry payment to request a fresh memo and expiry window for this order.
+
+
+
+ ) : (
+
+
+ {isLoading ? "Checking payment status..." : "Waiting for payment confirmation."}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
+
+type PaymentRowProps = {
+ label: string;
+ value: string;
+ displayValue: string;
+ copied: boolean;
+ isCritical?: boolean;
+ onCopy: () => void;
+};
+
+function PaymentRow({ label, value, displayValue, copied, isCritical = false, onCopy }: PaymentRowProps) {
+ return (
+
+
+
+
+ {label}
+
+
+ {displayValue}
+
+ {isCritical && (
+
+ Required — your payment will fail without the memo
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/hooks/useOrderStatus.ts b/src/hooks/useOrderStatus.ts
new file mode 100644
index 0000000..556be9b
--- /dev/null
+++ b/src/hooks/useOrderStatus.ts
@@ -0,0 +1,91 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export type OrderStatus = "PENDING" | "PAID" | "EXPIRED" | "FAILED" | "CANCELLED";
+
+export type OrderStatusResponse = {
+ id?: string;
+ status?: OrderStatus | string;
+ destinationAddress?: string;
+ memo?: string;
+ amountXLM?: string | number;
+ expiresAt?: string;
+ [key: string]: unknown;
+};
+
+type UseOrderStatusOptions = {
+ enabled?: boolean;
+ intervalMs?: number;
+};
+
+type UseOrderStatusResult = {
+ data: OrderStatusResponse | null;
+ error: string | null;
+ isLoading: boolean;
+ refetch: () => Promise;
+};
+
+export function useOrderStatus(
+ orderId: string | null | undefined,
+ { enabled = true, intervalMs = 5000 }: UseOrderStatusOptions = {}
+): UseOrderStatusResult {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const abortRef = useRef(null);
+
+ const refetch = useCallback(async () => {
+ if (!orderId) return null;
+
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/orders/${encodeURIComponent(orderId)}`, {
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Order status request failed with ${response.status}`);
+ }
+
+ const nextData = (await response.json()) as OrderStatusResponse;
+ setData(nextData);
+ return nextData;
+ } catch (err) {
+ if (err instanceof DOMException && err.name === "AbortError") {
+ return null;
+ }
+
+ const message = err instanceof Error ? err.message : "Could not refresh order status.";
+ setError(message);
+ return null;
+ } finally {
+ if (abortRef.current === controller) {
+ setIsLoading(false);
+ }
+ }
+ }, [orderId]);
+
+ useEffect(() => {
+ if (!enabled || !orderId) return;
+
+ // Kick off an immediate poll before the interval so confirmation can redirect quickly.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ void refetch();
+ const timer = window.setInterval(() => {
+ void refetch();
+ }, intervalMs);
+
+ return () => {
+ window.clearInterval(timer);
+ abortRef.current?.abort();
+ };
+ }, [enabled, intervalMs, orderId, refetch]);
+
+ return { data, error, isLoading, refetch };
+}