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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ Embed the lightweight modal checkout script tag in your HTML and attach it to an
</script>
```

**Self-hosting the widget?** Point the script tag at your own deployment and
the widget infers the host from its own `<script src>` automatically - no
extra config needed. If you load `widget.js` some other way (bundled,
inlined, injected without a matching `<script src="...widget.js">` tag),
`Quay.open()` **cannot detect the host and will not guess** — pass it explicitly:

```js
Quay.open({ linkId: "lnk_123", host: "https://checkout.your-domain.com" });
```

A widget that can't determine its host throws a clear error rather than
silently pointing at someone else's deployment.

### 2. Create a Link via API

Both write endpoints require authentication. Mint an API key from the dashboard
Expand Down
23 changes: 15 additions & 8 deletions apps/web/app/components/CheckoutClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,14 @@ function terminalCopy(status: string): { heading: string; detail: string } {

// ── Component ───────────────────────────────────────────────────────────────

export default function CheckoutClient({ initial }: { initial: LinkWithRequest }) {
export default function CheckoutClient({
initial,
embed = false,
}: {
initial: LinkWithRequest;
/** Rendered inside the widget's 440x680 iframe (issue 5.10) - drives tighter spacing/QR sizing via `.checkout--embed` (globals.css) and a smaller QR that CSS alone can't produce (the `size` prop is a real pixel value, not stylable). */
embed?: boolean;
}) {
const { request } = initial;
const [link, setLink] = useState(initial.link);
const [submittedTxHash, setSubmittedTxHash] = useState<string | null>(null);
Expand Down Expand Up @@ -142,7 +149,7 @@ export default function CheckoutClient({ initial }: { initial: LinkWithRequest }

if (isSettled) {
return (
<div className="checkout">
<div className={embed ? "checkout checkout--embed" : "checkout"}>
<div className="settled-check" aria-hidden>
</div>
Expand All @@ -162,7 +169,7 @@ export default function CheckoutClient({ initial }: { initial: LinkWithRequest }

if (submittedTxHash) {
return (
<div className="checkout">
<div className={embed ? "checkout checkout--embed" : "checkout"}>
<div className="status-icon" aria-hidden>
</div>
Expand All @@ -186,7 +193,7 @@ export default function CheckoutClient({ initial }: { initial: LinkWithRequest }

if (link.status === "underpaid") {
return (
<div className="checkout">
<div className={embed ? "checkout checkout--embed" : "checkout"}>
<div className="error-icon" aria-hidden>
</div>
Expand All @@ -212,7 +219,7 @@ export default function CheckoutClient({ initial }: { initial: LinkWithRequest }
if (link.status === "expired" || link.status === "cancelled") {
const copy = terminalCopy(link.status);
return (
<div className="checkout">
<div className={embed ? "checkout checkout--embed" : "checkout"}>
<div className="error-icon" aria-hidden>
{link.status === "expired" ? "⏰" : "✕"}
</div>
Expand All @@ -232,7 +239,7 @@ export default function CheckoutClient({ initial }: { initial: LinkWithRequest }

if (connectionLost) {
return (
<div className="checkout">
<div className={embed ? "checkout checkout--embed" : "checkout"}>
<div className="error-icon" aria-hidden>
</div>
Expand All @@ -251,7 +258,7 @@ export default function CheckoutClient({ initial }: { initial: LinkWithRequest }
// ── RENDER: Active — waiting for payment ─────────────────────────────────

return (
<div className="checkout">
<div className={embed ? "checkout checkout--embed" : "checkout"}>
<div className="merchant">Pay merchant</div>
<p className="title">{link.title}</p>

Expand All @@ -261,7 +268,7 @@ export default function CheckoutClient({ initial }: { initial: LinkWithRequest }
</div>

<div className="qr-wrap">
<QRCodeSVG value={request.uri} size={180} fgColor="#0b0f14" bgColor="#ffffff" level="M" />
<QRCodeSVG value={request.uri} size={embed ? 140 : 180} fgColor="#0b0f14" bgColor="#ffffff" level="M" />
</div>
<p className="muted" style={{ fontSize: 13 }}>
Scan with a Stellar wallet, or
Expand Down
44 changes: 44 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,50 @@ a { color: inherit; }
.dash-link-title { color: var(--accent); text-decoration: none; font-weight: 500; }
.dash-link-title:hover { text-decoration: underline; }

/* ---------- embedded checkout (issue 5.10) ---------- */
/* The widget's modal is a 440x680 (max) iframe - `.shell`'s page-level
max-width/centering/32-80px padding was designed for a full browser tab
and just crams into that. `.shell--embed` strips all of it; the widget's
own outer <div> (packages/widget/src/modal.ts) already provides the
rounded corners/border/shadow around the iframe, so the page inside it
only needs to fill the frame, not look like a card floating in a card. */
.shell--embed {
max-width: none;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
}

.panel--embed {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
border: none;
border-radius: 0;
padding: 20px 16px;
}

/* Tighter than the default (and the existing <=560px breakpoint) - 440px is
narrower than that breakpoint already assumes, and every state here needs
to fit a 680px-tall frame with no page chrome to spare. Sized/checked
against the "active - waiting for payment" render, the tallest state
(title + amount + QR + memo note + status rail). */
.checkout--embed .title { font-size: 14px; margin-bottom: 16px; }
.checkout--embed .amount-hero { font-size: 34px; }
.checkout--embed .qr-wrap { margin: 16px 0 6px; padding: 12px; }
.checkout--embed .memo-note { margin: 14px 0; padding: 10px 12px; }
.checkout--embed .status-rail { margin-top: 14px; }
.checkout--embed .settled-check,
.checkout--embed .status-icon,
.checkout--embed .error-icon {
width: 40px;
height: 40px;
font-size: 20px;
margin-bottom: 8px;
}

@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
.tl-dot.dot--pending { animation: none; }
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export default async function PayPage({
</header>
)}
<div className={isEmbed ? "panel panel--embed" : "panel"}>
<CheckoutClient initial={data} />
<CheckoutClient initial={data} embed={isEmbed} />
</div>
</main>
);
Expand Down
52 changes: 41 additions & 11 deletions packages/widget/src/modal.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,44 @@
import type { QuayEventData, QuayEventHandler, QuayEventType, QuayOpenOptions } from "./types";

const DEFAULT_HOST = "https://quay-web.vercel.app";

const listeners = new Map<QuayEventType, Set<QuayEventHandler>>();

let activeOverlay: HTMLElement | null = null;
let activeIframe: HTMLIFrameElement | null = null;
let activeLinkId: string | null = null;
let activeHost: string = DEFAULT_HOST;
// Always overwritten before use in openModal() - resolveHost() throws rather
// than letting a call proceed with no host resolved (issue 5.10).
let activeHost = "";
let focusTrapHandler: ((e: KeyboardEvent) => void) | null = null;
let postMessageHandler: ((e: MessageEvent) => void) | null = null;
let lastFocusedElement: HTMLElement | null = null;

/**
* Resolves which Quay deployment the checkout iframe should point at.
*
* Previously this fell back to `https://quay-web.vercel.app` (the
* maintainer's own deployment) when it couldn't be detected any other way -
* silently pointing a self-hoster's integration at someone else's backend
* is worse than failing loudly, since the failure mode there is a checkout
* that mysteriously doesn't match the merchant's own links, not an obvious
* error. There is deliberately no fallback host anymore: either the
* integrator passes `host` explicitly, or it's inferred from the actual
* `<script src="...widget.js">` tag that loaded this code - both are
* genuinely correct answers. Anything else throws.
*/
function resolveHost(explicitHost: string | undefined): string {
if (explicitHost) return explicitHost;

const scriptTag = document.querySelector<HTMLScriptElement>("script[src*='widget.js']");
if (scriptTag) return new URL(scriptTag.src, window.location.href).origin;

throw new Error(
"Quay widget: could not determine which Quay deployment to use. Pass `host` explicitly " +
'(e.g. Quay.open({ linkId, host: "https://your-quay-deployment.example.com" })), or make ' +
"sure the widget script tag's own src is reachable (script[src*='widget.js']) so it can be " +
"inferred automatically.",
);
}

function emit(type: QuayEventType, data: QuayEventData): void {
const handlers = listeners.get(type);
if (handlers) {
Expand Down Expand Up @@ -61,10 +88,6 @@ export function closeModal(): void {
}

export function openModal(linkIdOrOpts: string | QuayOpenOptions, opts?: Partial<QuayOpenOptions>): void {
closeModal();

lastFocusedElement = document.activeElement as HTMLElement | null;

let linkId: string;
let options: QuayOpenOptions;

Expand All @@ -76,14 +99,21 @@ export function openModal(linkIdOrOpts: string | QuayOpenOptions, opts?: Partial
options = linkIdOrOpts;
}

// Resolved (and allowed to throw) before any state changes - a failed
// open() call because the host can't be determined should be a true
// no-op, not one that closes whatever modal was already open and leaves
// nothing in its place.
const resolvedHost = resolveHost(options.host);

closeModal();

lastFocusedElement = document.activeElement as HTMLElement | null;

if (options.onPaid) addEventListener("quay:paid", options.onPaid);
if (options.onClosed) addEventListener("quay:closed", options.onClosed);
if (options.onError) addEventListener("quay:error", options.onError);

const scriptTag = document.querySelector<HTMLScriptElement>("script[src*='widget.js']");
const hostFromScript = scriptTag ? new URL(scriptTag.src, window.location.href).origin : null;

activeHost = options.host || hostFromScript || (typeof window !== "undefined" ? window.location.origin : DEFAULT_HOST);
activeHost = resolvedHost;
activeLinkId = linkId;

// Create Modal Overlay
Expand Down
71 changes: 65 additions & 6 deletions packages/widget/test/widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ describe("Quay Widget SDK", () => {
expect(typeof Quay.on).toBe("function");
});

const TEST_HOST = "https://test.example.com";

it("opens modal and appends overlay element to body", () => {
Quay.open("lnk_test_123");
Quay.open({ linkId: "lnk_test_123", host: TEST_HOST });

const modal = document.getElementById("quay-checkout-modal");
expect(modal).not.toBeNull();
Expand All @@ -29,19 +31,23 @@ describe("Quay Widget SDK", () => {
const iframe = document.getElementById("quay-checkout-iframe") as HTMLIFrameElement;
expect(iframe).not.toBeNull();
expect(iframe.src).toContain("/pay/lnk_test_123?embed=true");
expect(iframe.src).toContain(TEST_HOST);
});

it("closes modal on Quay.close() call", () => {
Quay.open("lnk_test_123");
Quay.open({ linkId: "lnk_test_123", host: TEST_HOST });
expect(document.getElementById("quay-checkout-modal")).not.toBeNull();

Quay.close();
expect(document.getElementById("quay-checkout-modal")).toBeNull();
});

it("binds click listener to [data-quay-link] button", () => {
document.body.innerHTML = `<button id="pay-btn" data-quay-link="lnk_btn_456" data-quay-label="Pay $10">Pay</button>`;

it("binds click listener to [data-quay-link] button, inferring the host from the widget's own script tag", () => {
document.body.innerHTML = `
<script src="${TEST_HOST}/widget.js"></script>
<button id="pay-btn" data-quay-link="lnk_btn_456" data-quay-label="Pay $10">Pay</button>
`;

Quay.init();

const btn = document.getElementById("pay-btn")!;
Expand All @@ -53,13 +59,14 @@ describe("Quay Widget SDK", () => {
expect(modal).not.toBeNull();
const iframe = document.getElementById("quay-checkout-iframe") as HTMLIFrameElement;
expect(iframe.src).toContain("/pay/lnk_btn_456?embed=true");
expect(iframe.src).toContain(TEST_HOST);
});

it("subscribes to events with Quay.on()", () => {
const onPaid = vi.fn();
const unsubscribe = Quay.on("quay:paid", onPaid);

Quay.open("lnk_test_123");
Quay.open({ linkId: "lnk_test_123", host: TEST_HOST });

const event = new MessageEvent("message", {
data: { type: "quay:paid", linkId: "lnk_test_123", link: { id: "lnk_test_123" } },
Expand All @@ -75,4 +82,56 @@ describe("Quay Widget SDK", () => {

unsubscribe();
});

// ---------------------------------------------------------------------------
// Host resolution (issue 5.10) - no silent fallback to someone else's deployment.
// ---------------------------------------------------------------------------

describe("host resolution", () => {
it("throws a clear error when no host can be determined - no explicit host and no widget.js script tag", () => {
expect(() => Quay.open("lnk_test_123")).toThrow(/could not determine which Quay deployment/i);
// A failed open() must be a true no-op - nothing left in the DOM.
expect(document.getElementById("quay-checkout-modal")).toBeNull();
});

it("does not fall back to https://quay-web.vercel.app or any other hardcoded host", () => {
try {
Quay.open("lnk_test_123");
} catch {
/* expected */
}
expect(document.getElementById("quay-checkout-iframe")).toBeNull();
});

it("uses the explicit host option when given, even with no script tag present", () => {
Quay.open({ linkId: "lnk_test_123", host: TEST_HOST });
const iframe = document.getElementById("quay-checkout-iframe") as HTMLIFrameElement;
expect(iframe.src.startsWith(TEST_HOST)).toBe(true);
});

it("infers the host from the widget's own script tag when no explicit host is given", () => {
document.body.innerHTML = `<script src="${TEST_HOST}/widget.js"></script>`;
Quay.open("lnk_test_123");
const iframe = document.getElementById("quay-checkout-iframe") as HTMLIFrameElement;
expect(iframe.src.startsWith(TEST_HOST)).toBe(true);
});

it("an explicit host option takes precedence over a detected script tag", () => {
document.body.innerHTML = `<script src="https://wrong-host.example.com/widget.js"></script>`;
Quay.open({ linkId: "lnk_test_123", host: TEST_HOST });
const iframe = document.getElementById("quay-checkout-iframe") as HTMLIFrameElement;
expect(iframe.src.startsWith(TEST_HOST)).toBe(true);
});

it("a failed open() does not disturb a modal that was already open", () => {
Quay.open({ linkId: "lnk_already_open", host: TEST_HOST });
expect(document.getElementById("quay-checkout-modal")).not.toBeNull();

expect(() => Quay.open("lnk_no_host")).toThrow();

// The original modal, for the original link, is untouched.
const iframe = document.getElementById("quay-checkout-iframe") as HTMLIFrameElement;
expect(iframe.src).toContain("lnk_already_open");
});
});
});