From c6803edc85b4e78e5e48de2ad2097f4674a58ae9 Mon Sep 17 00:00:00 2001
From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com>
Date: Mon, 27 Jul 2026 18:19:30 -0700
Subject: [PATCH] feat(react): add compact qr handoff option
---
.../react/src/__tests__/QRState.test.tsx | 22 ++++++-
.../react/src/components/States/QRState.tsx | 60 ++++++++++++++++---
js/packages/react/src/lang/translations/en.ts | 3 +
js/packages/react/src/lang/translations/es.ts | 3 +
js/packages/react/src/lang/translations/th.ts | 3 +
js/packages/react/src/lang/types.ts | 3 +
js/packages/react/src/styles/widgetStyles.ts | 47 +++++++++++++++
7 files changed, 132 insertions(+), 9 deletions(-)
diff --git a/js/packages/react/src/__tests__/QRState.test.tsx b/js/packages/react/src/__tests__/QRState.test.tsx
index f1abe1a8..830f2f49 100644
--- a/js/packages/react/src/__tests__/QRState.test.tsx
+++ b/js/packages/react/src/__tests__/QRState.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QRState } from "../components/States/QRState";
@@ -36,4 +36,24 @@ describe("QRState simulator callout", () => {
screen.queryByRole("link", { name: /use the simulator/i }),
).toBeNull();
});
+
+ it("offers both app handoff and a QR fallback on smaller screens", () => {
+ render();
+
+ expect(
+ screen
+ .getByRole("link", { name: /open world app/i })
+ .getAttribute("href"),
+ ).toBe(qrData);
+ expect(screen.getAllByTestId("qr-code")).toHaveLength(1);
+
+ fireEvent.click(screen.getByRole("button", { name: /display qr code/i }));
+
+ expect(
+ screen
+ .getByRole("button", { name: /hide qr code/i })
+ .getAttribute("aria-expanded"),
+ ).toBe("true");
+ expect(screen.getAllByTestId("qr-code")).toHaveLength(2);
+ });
});
diff --git a/js/packages/react/src/components/States/QRState.tsx b/js/packages/react/src/components/States/QRState.tsx
index 95d58d53..f0b0df10 100644
--- a/js/packages/react/src/components/States/QRState.tsx
+++ b/js/packages/react/src/components/States/QRState.tsx
@@ -1,6 +1,5 @@
import { useCallback, useState, type ReactElement } from "react";
import { __ } from "../../lang";
-import { useMedia } from "../../hooks/useMedia";
import { QRCode } from "../../widget/QRCode";
import { QRPlaceholderIcon } from "../Icons/QRPlaceholderIcon";
import { WorldcoinIcon } from "../Icons/WorldIcon";
@@ -14,8 +13,8 @@ export function QRState({
qrData,
showSimulatorCallout,
}: QRStateProps): ReactElement {
- const media = useMedia();
const [copiedLink, setCopiedLink] = useState(false);
+ const [showMobileQR, setShowMobileQR] = useState(false);
const copyLink = useCallback(() => {
if (!qrData) return;
@@ -26,12 +25,57 @@ export function QRState({
return (
<>
- {/* Mobile: deep-link button */}
+ {/* Mobile and tablet: deep link with a QR fallback */}
-
-
- {__("Open World App")}
-
+
+
+
+ {__("Open World App")}
+
+
+
+ {__("or")}
+
+
+
+ {showMobileQR && (
+
+
+ {__("QR Code copied")}
+
+
+
+ {qrData ? (
+
{
+ if (event.key === "Enter") copyLink();
+ }}
+ role="button"
+ tabIndex={0}
+ style={{ cursor: "pointer" }}
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ )}
+
{/* Desktop: QR code */}
@@ -58,7 +102,7 @@ export function QRState({
tabIndex={0}
style={{ cursor: "pointer" }}
>
-
+
) : (
diff --git a/js/packages/react/src/lang/translations/en.ts b/js/packages/react/src/lang/translations/en.ts
index db330dbf..9bae6cc4 100644
--- a/js/packages/react/src/lang/translations/en.ts
+++ b/js/packages/react/src/lang/translations/en.ts
@@ -30,6 +30,9 @@ export const en: TranslationStrings = {
"Try Again": "Try Again",
Close: "Close",
"Open World App": "Open World App",
+ "Display QR Code": "Display QR Code",
+ "Hide QR Code": "Hide QR Code",
+ or: "or",
"QR Code copied": "QR Code copied",
"Connect your World ID": "Connect your World ID",
"Use phone camera to scan the QR code":
diff --git a/js/packages/react/src/lang/translations/es.ts b/js/packages/react/src/lang/translations/es.ts
index 772fd2e6..11facfbc 100644
--- a/js/packages/react/src/lang/translations/es.ts
+++ b/js/packages/react/src/lang/translations/es.ts
@@ -31,6 +31,9 @@ export const es: TranslationStrings = {
"Try Again": "Intentar de nuevo",
Close: "Cerrar",
"Open World App": "Abrir World App",
+ "Display QR Code": "Mostrar c\u00f3digo QR",
+ "Hide QR Code": "Ocultar c\u00f3digo QR",
+ or: "o",
"QR Code copied": "C\u00f3digo QR copiado",
"Connect your World ID": "Conecta tu World ID",
"Use phone camera to scan the QR code":
diff --git a/js/packages/react/src/lang/translations/th.ts b/js/packages/react/src/lang/translations/th.ts
index cbf376e7..f7c2c45e 100644
--- a/js/packages/react/src/lang/translations/th.ts
+++ b/js/packages/react/src/lang/translations/th.ts
@@ -40,6 +40,9 @@ export const th: TranslationStrings = {
"\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07",
Close: "\u0e1b\u0e34\u0e14",
"Open World App": "\u0e40\u0e1b\u0e34\u0e14 World App",
+ "Display QR Code": "\u0e41\u0e2a\u0e14\u0e07 QR Code",
+ "Hide QR Code": "\u0e0b\u0e48\u0e2d\u0e19 QR Code",
+ or: "\u0e2b\u0e23\u0e37\u0e2d",
"QR Code copied":
"\u0e04\u0e31\u0e14\u0e25\u0e2d\u0e01 QR Code \u0e41\u0e25\u0e49\u0e27",
"Connect your World ID":
diff --git a/js/packages/react/src/lang/types.ts b/js/packages/react/src/lang/types.ts
index 78bc6bc6..ef2a3c81 100644
--- a/js/packages/react/src/lang/types.ts
+++ b/js/packages/react/src/lang/types.ts
@@ -26,6 +26,9 @@ export interface TranslationStrings {
"Try Again": string;
Close: string;
"Open World App": string;
+ "Display QR Code": string;
+ "Hide QR Code": string;
+ or: string;
"QR Code copied": string;
"Connect your World ID": string;
"Use phone camera to scan the QR code": string;
diff --git a/js/packages/react/src/styles/widgetStyles.ts b/js/packages/react/src/styles/widgetStyles.ts
index 2d395c2d..f4da3cad 100644
--- a/js/packages/react/src/styles/widgetStyles.ts
+++ b/js/packages/react/src/styles/widgetStyles.ts
@@ -344,6 +344,53 @@ export const WIDGET_STYLES = `
min-width: 0;
}
+.idkit-mobile-handoff {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+}
+.idkit-mobile-handoff .idkit-deeplink-btn {
+ width: 100%;
+}
+.idkit-handoff-divider {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 16px;
+ color: var(--idkit-text-muted);
+ font-size: 12px;
+ font-weight: 500;
+}
+.idkit-handoff-divider > span:first-child,
+.idkit-handoff-divider > span:last-child {
+ height: 1px;
+ flex: 1;
+ background: var(--idkit-border-light);
+}
+.idkit-qr-toggle-btn {
+ box-sizing: border-box;
+ width: 100%;
+ border: 1px solid var(--idkit-border);
+ border-radius: 16px;
+ background: transparent;
+ color: var(--idkit-text);
+ padding: 16px 32px;
+ font-family: var(--idkit-font);
+ font-size: 16px;
+ font-weight: 500;
+ cursor: pointer;
+}
+.idkit-mobile-qr {
+ position: relative;
+}
+.idkit-mobile-qr .idkit-copy-toast {
+ top: 100%;
+ bottom: auto;
+ margin: 8px 0 0;
+}
+
/* Loading spinner */
.idkit-spinner {
animation: idkit-spin 1s linear infinite;