Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/pos-dev-console-open-in-pos.md
Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

.UrlCta {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
align-items: center;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -24,6 +29,10 @@ describe('QRCodeModal', () => {
},
}

beforeEach(() => {
isMobile = false
})

test('Renders <Modal/> closed if code is undefined', async () => {
const app = mockApp()
const store = 'example.com'
Expand Down Expand Up @@ -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(
<QRCodeModal {...defaultProps} code={{...defaultProps.code, type: 'point_of_sale'}} />,
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(
<QRCodeModal {...defaultProps} code={{...defaultProps.code, type: 'point_of_sale'}} />,
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(
<QRCodeModal {...defaultProps} code={{...defaultProps.code, type: 'checkout'}} />,
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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand All @@ -84,14 +92,25 @@ function QRCodeContent({url, type}: Code) {
<span className={styles.RightColumn}>
{i18n.translate('right.one')}
<span className={styles.UrlCta}>
{i18n.translate('right.two')}{' '}
{i18n.translate('right.two')}
<IconButton
type="button"
source={ClipboardIcon}
accessibilityLabel={i18n.translate('qrcode.copy')}
onClick={onButtonClick}
onClick={onCopyClick}
/>
</span>
{shouldShowOpenInPOSCTA && (
<span className={styles.UrlCta}>
{i18n.translate('right.three')}
<IconButton
type="button"
source={ExternalIcon}
accessibilityLabel={i18n.translate('qrcode.openPos')}
onClick={onOpenInPOSClick}
/>
</span>
)}
</span>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
20 changes: 20 additions & 0 deletions packages/ui-extensions-dev-console/src/utilities/device.ts
Original file line number Diff line number Diff line change
@@ -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
}