diff --git a/comebackhere-frontend/src/App.css b/comebackhere-frontend/src/App.css index 8b0d546..6f58f7a 100644 --- a/comebackhere-frontend/src/App.css +++ b/comebackhere-frontend/src/App.css @@ -1607,3 +1607,15 @@ h3 { justify-content: center; } } + +/* Keep the copy icon visible on narrow screens: long identifiers wrap + instead of pushing the control out of the viewport. */ +.copyable-text { + max-width: 100%; +} + +.copyable-text__value { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-all; +} diff --git a/comebackhere-frontend/src/components/CopyableText.tsx b/comebackhere-frontend/src/components/CopyableText.tsx index 1bc8fed..9e15bbb 100644 --- a/comebackhere-frontend/src/components/CopyableText.tsx +++ b/comebackhere-frontend/src/components/CopyableText.tsx @@ -38,7 +38,7 @@ export function CopyableText({ text, label, className }: CopyableTextProps) { aria-label={`${label ?? "Copy"}: ${text}`} style={{ cursor: "pointer", position: "relative", display: "inline-flex", alignItems: "center", gap: "4px" }} > - {text} + {text} controller.abort() }, [merchantAddress, status, page, reloadKey]) + // Refetch when a payment completes elsewhere in the app. + useEffect(() => { + const onPaid = () => setReloadKey((k) => k + 1) + window.addEventListener(PAYMENT_COMPLETED_EVENT, onPaid) + return () => window.removeEventListener(PAYMENT_COMPLETED_EVENT, onPaid) + }, []) + const handleFilter = useCallback((next: InvoiceStatus | null) => { setStatus(next) setPage(1) diff --git a/comebackhere-frontend/src/components/InvoiceTimeline.tsx b/comebackhere-frontend/src/components/InvoiceTimeline.tsx index f85e4d1..6c294e8 100644 --- a/comebackhere-frontend/src/components/InvoiceTimeline.tsx +++ b/comebackhere-frontend/src/components/InvoiceTimeline.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react" import { useT } from "../i18n" +import { getExplorerTxBaseUrl } from "../utils/explorerUrl" // --------------------------------------------------------------------------- // Types matching the GET /invoices/:id/events endpoint shape @@ -90,7 +91,7 @@ async function fetchEvents( // Component // --------------------------------------------------------------------------- -const DEFAULT_EXPLORER = "https://stellar.expert/explorer/testnet/tx" +const DEFAULT_EXPLORER = getExplorerTxBaseUrl() export function InvoiceTimeline({ invoiceId, diff --git a/comebackhere-frontend/src/components/PaymentReceipt.tsx b/comebackhere-frontend/src/components/PaymentReceipt.tsx index 3575aa5..94d4391 100644 --- a/comebackhere-frontend/src/components/PaymentReceipt.tsx +++ b/comebackhere-frontend/src/components/PaymentReceipt.tsx @@ -1,18 +1,20 @@ -import { useCallback } from "react" +import { useCallback, useEffect } from "react" import type { Invoice } from "../types" import { useT } from "../i18n" import { CopyableText } from "./CopyableText" +import { emitPaymentCompleted } from "../utils/invoiceEvents" +import { getExplorerTxBaseUrl } from "../utils/explorerUrl" interface PaymentReceiptProps { invoice: Invoice transactionHash: string /** Unix timestamp (seconds) when the payment was confirmed. */ paidAt: number - /** Base URL for the block explorer — defaults to Stellar Expert testnet. */ + /** Base URL for the block explorer — defaults to the explorer for the configured network. */ explorerBaseUrl?: string } -const DEFAULT_EXPLORER = "https://stellar.expert/explorer/testnet/tx" +const DEFAULT_EXPLORER = getExplorerTxBaseUrl() export function PaymentReceipt({ invoice, @@ -22,6 +24,11 @@ export function PaymentReceipt({ }: PaymentReceiptProps) { const t = useT() + // The receipt only renders after a confirmed payment; tell lists to refetch. + useEffect(() => { + emitPaymentCompleted(String(invoice.id)) + }, [invoice.id, transactionHash]) + const explorerUrl = `${explorerBaseUrl}/${transactionHash}` const handlePrint = useCallback(() => { diff --git a/comebackhere-frontend/src/tests/CopyableText.test.tsx b/comebackhere-frontend/src/tests/CopyableText.test.tsx index 06a1959..18efc6c 100644 --- a/comebackhere-frontend/src/tests/CopyableText.test.tsx +++ b/comebackhere-frontend/src/tests/CopyableText.test.tsx @@ -35,3 +35,10 @@ describe("CopyableText", () => { expect(writeText).toHaveBeenCalledWith("clipboard-value") }) }) + +describe("CopyableText layout", () => { + it("marks the value span so long identifiers can wrap beside the copy icon", () => { + render() + expect(screen.getByText("G".repeat(56))).toHaveClass("copyable-text__value") + }) +}) diff --git a/comebackhere-frontend/src/tests/explorerUrl.test.ts b/comebackhere-frontend/src/tests/explorerUrl.test.ts new file mode 100644 index 0000000..630ee6b --- /dev/null +++ b/comebackhere-frontend/src/tests/explorerUrl.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest" +import { getExplorerTxBaseUrl } from "../utils/explorerUrl" + +describe("getExplorerTxBaseUrl", () => { + it("uses the public network segment for mainnet", () => { + expect(getExplorerTxBaseUrl("Public Global Stellar Network ; September 2015")).toBe( + "https://stellar.expert/explorer/public/tx", + ) + }) + + it("uses the testnet segment for testnet and unknown networks", () => { + expect(getExplorerTxBaseUrl("Test SDF Network ; September 2015")).toBe( + "https://stellar.expert/explorer/testnet/tx", + ) + expect(getExplorerTxBaseUrl("something else")).toBe("https://stellar.expert/explorer/testnet/tx") + }) +}) diff --git a/comebackhere-frontend/src/tests/invoiceEvents.test.ts b/comebackhere-frontend/src/tests/invoiceEvents.test.ts new file mode 100644 index 0000000..e304fb5 --- /dev/null +++ b/comebackhere-frontend/src/tests/invoiceEvents.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect, vi } from "vitest" +import { emitPaymentCompleted, PAYMENT_COMPLETED_EVENT } from "../utils/invoiceEvents" + +describe("emitPaymentCompleted", () => { + it("dispatches a window event with the invoice id", () => { + const handler = vi.fn() + window.addEventListener(PAYMENT_COMPLETED_EVENT, handler) + emitPaymentCompleted("42") + window.removeEventListener(PAYMENT_COMPLETED_EVENT, handler) + expect(handler).toHaveBeenCalledTimes(1) + expect((handler.mock.calls[0][0] as CustomEvent).detail).toEqual({ invoiceId: "42" }) + }) +}) diff --git a/comebackhere-frontend/src/tests/useHashTab.test.ts b/comebackhere-frontend/src/tests/useHashTab.test.ts index 84a5198..c89d2a0 100644 --- a/comebackhere-frontend/src/tests/useHashTab.test.ts +++ b/comebackhere-frontend/src/tests/useHashTab.test.ts @@ -94,3 +94,11 @@ describe("useHashTab", () => { expect(result.current[0]).toBe("payment") }) }) + +describe("parseTabHash after authentication expires", () => { + it("falls back to the default tab for stale authenticated-only routes", () => { + expect(parseTabHash("#/session-expired")).toBe(DEFAULT_TAB) + expect(parseTabHash("#login")).toBe(DEFAULT_TAB) + expect(parseTabHash("#%E0%A4%A")).toBe(DEFAULT_TAB) + }) +}) diff --git a/comebackhere-frontend/src/utils/explorerUrl.ts b/comebackhere-frontend/src/utils/explorerUrl.ts new file mode 100644 index 0000000..4964f7e --- /dev/null +++ b/comebackhere-frontend/src/utils/explorerUrl.ts @@ -0,0 +1,16 @@ +/** + * Block-explorer URL helpers. The explorer network segment is derived from the + * configured network passphrase so links never point at the wrong network. + */ + +const EXPLORER_NETWORK_SEGMENTS: Record = { + "Public Global Stellar Network ; September 2015": "public", + "Test SDF Network ; September 2015": "testnet", +} + +/** Stellar Expert base URL for transactions on the given network passphrase. */ +export function getExplorerTxBaseUrl(passphrase?: string): string { + const value = passphrase ?? import.meta.env.VITE_NETWORK_PASSPHRASE ?? "" + const segment = EXPLORER_NETWORK_SEGMENTS[value] ?? "testnet" + return `https://stellar.expert/explorer/${segment}/tx` +} diff --git a/comebackhere-frontend/src/utils/invoiceEvents.ts b/comebackhere-frontend/src/utils/invoiceEvents.ts new file mode 100644 index 0000000..f207733 --- /dev/null +++ b/comebackhere-frontend/src/utils/invoiceEvents.ts @@ -0,0 +1,7 @@ +/** Window event fired once a payment has been confirmed, so lists can refetch. */ +export const PAYMENT_COMPLETED_EVENT = "comebackhere:payment-completed" + +export function emitPaymentCompleted(invoiceId?: string): void { + if (typeof window === "undefined") return + window.dispatchEvent(new CustomEvent(PAYMENT_COMPLETED_EVENT, { detail: { invoiceId } })) +} diff --git a/docs/drips-notes/916-invalid-route-after-auth-expiry.md b/docs/drips-notes/916-invalid-route-after-auth-expiry.md new file mode 100644 index 0000000..eaf6e30 --- /dev/null +++ b/docs/drips-notes/916-invalid-route-after-auth-expiry.md @@ -0,0 +1,9 @@ +# #916: Redirect after authentication expires + +Note to maintainers: the frontend has no separate session/login route. When the +wallet session ends (disconnect, locked or expired Freighter authorization), +`handleDisconnect` in `comebackhere-frontend/src/App.tsx` calls `setTab("payment")`, +and `useHashTab` (`parseTabHash`) maps any empty, malformed or unknown hash to +`DEFAULT_TAB` ("payment"). The app therefore always lands on a valid route, so +no behavior change was needed. A regression test in +`src/tests/useHashTab.test.ts` pins the fallback for stale/unknown routes.