diff --git a/.changeset/pos-dev-console-open-in-pos.md b/.changeset/pos-dev-console-open-in-pos.md
new file mode 100644
index 00000000000..79eda609f83
--- /dev/null
+++ b/.changeset/pos-dev-console-open-in-pos.md
@@ -0,0 +1,6 @@
+---
+'@shopify/ui-extensions-dev-console-app': patch
+---
+
+Add an "Open in Shopify POS" call-to-action in the mobile QR code modal for POS extensions.
+
diff --git a/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.module.scss b/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.module.scss
index f0f3e41bc53..d81ed0fae73 100644
--- a/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.module.scss
+++ b/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.module.scss
@@ -28,6 +28,7 @@
.UrlCta {
display: flex;
+ flex-wrap: wrap;
gap: 0.8rem;
align-items: center;
}
diff --git a/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.test.tsx b/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.test.tsx
index d5f925a0af7..d29386d5735 100644
--- a/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.test.tsx
+++ b/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.test.tsx
@@ -6,12 +6,17 @@ import {mockApp, mockExtension} from '@shopify/ui-extensions-server-kit/testing'
import {render, withProviders} from '@shopify/ui-extensions-test-utils'
import {mockI18n} from 'tests/mock-i18n'
import {DefaultProviders} from 'tests/DefaultProviders'
+import {ExternalIcon} from '@shopify/polaris-icons'
import {Modal} from '@/components/Modal'
+import {IconButton} from '@/components/IconButton'
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
vi.mock('@/components/Modal', () => ({Modal: (props: any) => props.children}))
+let isMobile = false
+vi.mock('@/utilities/device', () => ({isMobileDevice: () => isMobile}))
+
mockI18n(en)
describe('QRCodeModal', () => {
@@ -24,6 +29,10 @@ describe('QRCodeModal', () => {
},
}
+ beforeEach(() => {
+ isMobile = false
+ })
+
test('Renders closed if code is undefined', async () => {
const app = mockApp()
const store = 'example.com'
@@ -65,6 +74,64 @@ describe('QRCodeModal', () => {
})
})
+ test('renders Open in Shopify POS CTA for pos on mobile', async () => {
+ isMobile = true
+
+ const app = mockApp()
+ const store = 'example.com'
+ const extension = mockExtension()
+ const container = render(
+ ,
+ withProviders(DefaultProviders),
+ {
+ state: {app, store, extensions: [extension]},
+ },
+ )
+
+ expect(container).toContainReactComponent(IconButton, {
+ source: ExternalIcon,
+ accessibilityLabel: en.qrcode.openPos,
+ })
+ })
+
+ test('does not render Open in Shopify POS CTA for pos on desktop', async () => {
+ isMobile = false
+
+ const app = mockApp()
+ const store = 'example.com'
+ const extension = mockExtension()
+ const container = render(
+ ,
+ withProviders(DefaultProviders),
+ {
+ state: {app, store, extensions: [extension]},
+ },
+ )
+
+ expect(container).not.toContainReactComponent(IconButton, {
+ source: ExternalIcon,
+ })
+ })
+
+ test('does not render Open in Shopify POS CTA for non-pos types on mobile', async () => {
+ isMobile = true
+
+ const app = mockApp()
+ const store = 'example.com'
+ const extension = mockExtension()
+ const container = render(
+ ,
+ withProviders(DefaultProviders),
+ {
+ state: {app, store, extensions: [extension]},
+ },
+ )
+
+ expect(container).not.toContainReactComponent(IconButton, {
+ source: ExternalIcon,
+ })
+ })
+
test('renders QRCode for app home', async () => {
const app = mockApp()
const store = 'example.com'
diff --git a/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.tsx b/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.tsx
index 007e062e9b2..af1ef3f3718 100644
--- a/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.tsx
+++ b/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/QRCodeModal.tsx
@@ -7,9 +7,10 @@ import copyToClipboard from 'copy-to-clipboard'
import QRCode from 'qrcode.react'
import {toast} from 'react-toastify'
import {Surface} from '@shopify/ui-extensions-server-kit'
-import {ClipboardIcon} from '@shopify/polaris-icons'
+import {ClipboardIcon, ExternalIcon} from '@shopify/polaris-icons'
import {Modal, ModalProps} from '@/components/Modal'
import {IconButton} from '@/components/IconButton'
+import {isMobileDevice} from '@/utilities/device'
interface Code {
url: string
@@ -42,6 +43,8 @@ function QRCodeContent({url, type}: Code) {
const {store, app} = useApp()
+ const shouldShowOpenInPOSCTA = type === 'point_of_sale' && isMobileDevice()
+
const qrCodeURL = useMemo(() => {
// The Websocket hasn't loaded data yet.
// Shouldn't happen since you can't open modal without data,
@@ -64,12 +67,17 @@ function QRCodeContent({url, type}: Code) {
return `https://${store}/admin/extensions-dev/mobile?url=${url}`
}, [url, app, app?.mobileUrl])
- const onButtonClick = useCallback(() => {
+ const onCopyClick = useCallback(() => {
if (qrCodeURL && copyToClipboard(qrCodeURL)) {
toast(i18n.translate('qrcode.copied'), {toastId: `copy-qrcode-${qrCodeURL}`})
}
}, [qrCodeURL])
+ const onOpenInPOSClick = useCallback(() => {
+ if (!qrCodeURL) return
+ window.location.assign(qrCodeURL)
+ }, [qrCodeURL])
+
if (!qrCodeURL) {
return null
}
@@ -84,14 +92,25 @@ function QRCodeContent({url, type}: Code) {
{i18n.translate('right.one')}
- {i18n.translate('right.two')}{' '}
+ {i18n.translate('right.two')}
+ {shouldShowOpenInPOSCTA && (
+
+ {i18n.translate('right.three')}
+
+
+ )}
)
diff --git a/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/translations/en.json b/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/translations/en.json
index 113d32e5e8c..67cf46a224a 100644
--- a/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/translations/en.json
+++ b/packages/ui-extensions-dev-console/src/sections/Extensions/components/QRCodeModal/translations/en.json
@@ -2,11 +2,13 @@
"title": "View your work on mobile",
"right": {
"one": "Scan with your phone camera to see your work",
- "two": "Or copy the URL to share:"
+ "two": "Or copy the URL to share:",
+ "three": "Or open it directly in Shopify POS:"
},
"qrcode": {
"copy": "Copy link",
"copied": "Link copied",
+ "openPos": "Open in Shopify POS",
"content": "Scan to test {title} on mobile"
}
}
diff --git a/packages/ui-extensions-dev-console/src/utilities/device.ts b/packages/ui-extensions-dev-console/src/utilities/device.ts
new file mode 100644
index 00000000000..c2d8e32687d
--- /dev/null
+++ b/packages/ui-extensions-dev-console/src/utilities/device.ts
@@ -0,0 +1,20 @@
+type NavigatorWithUserAgentData = Navigator & {
+ userAgentData?: {
+ mobile?: boolean
+ }
+}
+
+export function isMobileDevice(): boolean {
+ if (typeof navigator === 'undefined') return false
+
+ const navigatorWithUserAgentData = navigator as NavigatorWithUserAgentData
+ if (navigatorWithUserAgentData.userAgentData?.mobile) return true
+
+ const userAgent = navigator.userAgent ?? ''
+ if (/(Android|iPhone|iPad|iPod)/i.test(userAgent)) return true
+
+ // iPadOS 13+ uses a desktop-like UA but exposes touch points.
+ if (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) return true
+
+ return false
+}