From 73c6b994513de89d8949a287dbf12426b213cbe6 Mon Sep 17 00:00:00 2001 From: taherd <183945978+taherdhanera@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:07:20 +0530 Subject: [PATCH 1/3] feat: add Stellar payment instructions --- package.json | 1 + .../stellar-payment-instructions.test.tsx | 119 ++++++++ .../components/StellarPaymentInstructions.tsx | 280 ++++++++++++++++++ src/hooks/useOrderStatus.ts | 91 ++++++ 4 files changed, 491 insertions(+) create mode 100644 src/__tests__/stellar-payment-instructions.test.tsx create mode 100644 src/features/orders/components/StellarPaymentInstructions.tsx create mode 100644 src/hooks/useOrderStatus.ts 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..35df054 --- /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("🎉 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..67db444 --- /dev/null +++ b/src/features/orders/components/StellarPaymentInstructions.tsx @@ -0,0 +1,280 @@ +"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; + +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! Check your email."); + 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. +

+
+ +
+
+
+ +
+
+ +
+ +
+ 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. +

+ +
+ ) : ( +
+
+ )} + + {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 }; +} From 8946d8464768f695621297f6bcce310957902728 Mon Sep 17 00:00:00 2001 From: taherd <183945978+taherdhanera@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:56:45 +0530 Subject: [PATCH 2/3] fix: tighten stellar payment copy --- src/__tests__/stellar-payment-instructions.test.tsx | 4 ++-- .../orders/components/StellarPaymentInstructions.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/__tests__/stellar-payment-instructions.test.tsx b/src/__tests__/stellar-payment-instructions.test.tsx index 35df054..547ed29 100644 --- a/src/__tests__/stellar-payment-instructions.test.tsx +++ b/src/__tests__/stellar-payment-instructions.test.tsx @@ -44,7 +44,7 @@ describe("StellarPaymentInstructions", () => { ); expect(screen.getByText("Complete your ticket payment")).toBeInTheDocument(); - expect(screen.getByText("Required - your payment will fail without the memo")).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(); @@ -70,7 +70,7 @@ describe("StellarPaymentInstructions", () => { ); await waitFor(() => { - expect(toast.success).toHaveBeenCalledWith("🎉 Tickets issued! Check your email."); + expect(toast.success).toHaveBeenCalledWith("\uD83C\uDF89 Tickets issued! Check your email."); expect(push).toHaveBeenCalledWith("/tickets"); }); }); diff --git a/src/features/orders/components/StellarPaymentInstructions.tsx b/src/features/orders/components/StellarPaymentInstructions.tsx index 67db444..784d76b 100644 --- a/src/features/orders/components/StellarPaymentInstructions.tsx +++ b/src/features/orders/components/StellarPaymentInstructions.tsx @@ -25,6 +25,7 @@ type RetryPaymentResponse = { }; 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; @@ -102,7 +103,7 @@ export function StellarPaymentInstructions({ useEffect(() => { if (data?.status === "PAID") { - toast.success("🎉 Tickets issued! Check your email."); + toast.success(TICKETS_ISSUED_TOAST); router.push("/tickets"); } }, [data?.status, router]); @@ -261,7 +262,7 @@ function PaymentRow({ label, value, displayValue, copied, isCritical = false, on

{isCritical && (

- Required - your payment will fail without the memo + Required — your payment will fail without the memo

)} From b0d8e9ad7e1b459ab5fdde6a9d819df54594c9cd Mon Sep 17 00:00:00 2001 From: taherd <183945978+taherdhanera@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:16:49 +0530 Subject: [PATCH 3/3] fix: preserve QR dependency lock metadata --- package-lock.json | 10 ++++++++++ 1 file changed, 10 insertions(+) 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",