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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ Load the lightweight `ecosystem.js` bundle, which only contains these two elemen
| Attribute | Description |
| -------------- | ------------------------------------------------------------------------------------- |
| `manufacturer` | DeviceDB manufacturer slug. Defaults to the manufacturer of a manufacturer embed key. |
| `provider` | Ecosystem provider slug, e.g., `oracode`. Scopes both models and capability badges. |
| `device-type` | DeviceDB device type slug, e.g., `smartlock` or `thermostat`. |
| `capability` | Only devices supporting this capability, e.g., `can_program_online_access_codes`. |
| `hide-search` | Hide the search field. |
Expand Down Expand Up @@ -232,6 +233,41 @@ The data is also available with the `useCompatiblePartners`, `useEcosystemDevice

[Seam Ecosystem]: https://ecosystem.seam.co/

### Hosted supported-device embed

The native React page at
`https://react.seam.co/examples/supported-devices` is designed for documentation
hosts such as Mintlify that cannot import an npm component. It composes
`EcosystemProvider` and `SupportedDevices`, reads the public production Ecosystem
catalog without an embed key, and never uses Storybook fixtures or Strapi.

Query parameters are `manufacturer`, `provider` (Ecosystem slugs, not Seam Connect
provider keys), `device_type`, `capability`, and `theme` (`light`, `dark`, `auto`).
For example, use `?manufacturer=dormakaba&provider=oracode` to avoid listing other
Dormakaba systems. Provider scoping applies to both models and capability badges.
Unavailable, planned, and stale capabilities are not advertised as supported;
beta capabilities are labelled, and conditional requirements can be expanded.

Embed the page in an iframe with `referrerpolicy="origin"`. Approved frame
ancestors are Seam's website/docs, Mintlify previews, and localhost development.
Other routes retain their existing frame restrictions. The host should always
provide a device catalog link outside the iframe, so it remains available if
scripts or the iframe fail to load.

The iframe posts `{ type: 'seam-supported-devices:resize', height }` to the exact
origin from `document.referrer` whenever its content height changes. The host
must check both `event.origin === 'https://react.seam.co'` and
`event.source === iframe.contentWindow`, then validate a finite positive height.
From that same parent window and origin, the page accepts
`{ type: 'seam-supported-devices:theme', theme: 'light' | 'dark' }` and
`{ type: 'seam-supported-devices:measure' }`. This lets docs follow their theme
toggle and request a fresh measurement after loading. Listeners and observers
are removed on unmount.

Deploy this React change before switching the docs snippet to the new URL.
The page uses the component built in the same deployment and hashed assets;
it does not load an unpinned npm package or CDN script at runtime.

### React Hooks

All components interact with the Seam API via a set of React Hooks.
Expand Down
15 changes: 15 additions & 0 deletions examples/supported-devices/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, follow" />
<title>Supported devices — Seam</title>
</head>
<body>
<main id="root" aria-label="Supported devices">
Loading supported devices…
</main>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
67 changes: 67 additions & 0 deletions examples/supported-devices/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react'

import { SupportedDevices } from 'lib/seam/components/SupportedDevices/SupportedDevices.js'
import { EcosystemProvider } from 'lib/seam/ecosystem/EcosystemProvider.js'

import { getEmbedConfig, getParentOrigin } from './config.js'

const config = getEmbedConfig(globalThis.location.search)
const parentOrigin = getParentOrigin(globalThis.document.referrer)

export function App(): JSX.Element {
const [theme, setTheme] = useState(config.theme)

useEffect(() => {
if (parentOrigin == null || globalThis.parent === globalThis.window) return
let previousHeight = 0
const measure = (): void => {
const height = Math.ceil(
globalThis.document.body.getBoundingClientRect().height
)
if (height <= 0 || height === previousHeight) return
previousHeight = height
globalThis.parent.postMessage(
{ type: 'seam-supported-devices:resize', height },
parentOrigin
)
}
const onMessage = (event: MessageEvent): void => {
if (event.source !== globalThis.parent || event.origin !== parentOrigin)
return
const data = event.data as { type?: string; theme?: string } | null
if (
data?.type === 'seam-supported-devices:theme' &&
(data.theme === 'dark' || data.theme === 'light')
)
setTheme(data.theme)
if (data?.type === 'seam-supported-devices:measure') {
previousHeight = 0
measure()
}
}
const observer = new ResizeObserver(measure)
observer.observe(globalThis.document.body)
globalThis.addEventListener('message', onMessage)
measure()
return () => {
observer.disconnect()
globalThis.removeEventListener('message', onMessage)
}
}, [])

const catalogUrl =
config.manufacturer == null
? 'https://www.seam.co/supported-devices-and-systems'
: `https://www.seam.co/manufacturers/${encodeURIComponent(config.manufacturer)}`

return (
<EcosystemProvider disableCssInjection disableFontInjection>
<SupportedDevices {...config} theme={theme} />
<p className='seam-device-embed-footer'>
<a href={catalogUrl} target='_blank' rel='noopener noreferrer'>
View the device catalog on seam.co
</a>
</p>
</EcosystemProvider>
)
}
27 changes: 27 additions & 0 deletions examples/supported-devices/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from 'vitest'

import { getEmbedConfig, getParentOrigin } from './config.js'

test('embed config scopes a provider without inventing a manufacturer', () => {
expect(
getEmbedConfig('?provider=first-alert&device_type=thermostat&theme=dark')
).toEqual({
manufacturer: undefined,
provider: 'first-alert',
deviceType: 'thermostat',
capability: undefined,
theme: 'dark',
hideTitle: true,
})
expect(getEmbedConfig('?manufacturer=august&theme=invalid').theme).toBe(
'auto'
)
})

test('parent messages use the exact HTTP origin of the referrer', () => {
expect(getParentOrigin('https://www.seam.co/docs/august')).toBe(
'https://www.seam.co'
)
expect(getParentOrigin('')).toBeNull()
expect(getParentOrigin('javascript:alert(1)')).toBeNull()
})
34 changes: 34 additions & 0 deletions examples/supported-devices/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { SupportedDevicesProps } from 'lib/seam/components/SupportedDevices/SupportedDevices.js'

export const getEmbedConfig = (search: string): SupportedDevicesProps => {
const params = new URLSearchParams(search)
const theme = params.get('theme')
return {
manufacturer: optionalParam(params, 'manufacturer'),
provider: optionalParam(params, 'provider'),
deviceType: optionalParam(params, 'device_type'),
capability: optionalParam(params, 'capability'),
theme: theme === 'dark' || theme === 'light' ? theme : 'auto',
hideTitle: true,
}
}

/** An exact origin, never a wildcard; referrerPolicy="origin" on the host iframe. */
export const getParentOrigin = (referrer: string): string | null => {
try {
const url = new URL(referrer)
return url.protocol === 'https:' || url.protocol === 'http:'
? url.origin
: null
} catch {
return null
}
}

const optionalParam = (
params: URLSearchParams,
name: string
): string | undefined => {
const value = params.get(name)?.trim()
return value == null || value.length === 0 ? undefined : value
}
10 changes: 10 additions & 0 deletions examples/supported-devices/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import '@seamapi/react/index.css'
import './style.css'

import { createRoot } from 'react-dom/client'

import { App } from './App.js'

const root = globalThis.document.getElementById('root')
if (root == null) throw new Error('Root element not found')
createRoot(root).render(<App />)
21 changes: 21 additions & 0 deletions examples/supported-devices/src/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
html,
body {
margin: 0;
padding: 0;
background: transparent;
}

body {
display: flow-root;
font-family: system-ui, sans-serif;
}

.seam-device-embed-footer {
margin: 0;
padding: 12px 0;
font-size: 13px;
}

.seam-device-embed-footer a {
color: #6675d9;
}
36 changes: 35 additions & 1 deletion src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ export function DeviceModelRow({
const capabilities = getSupportedCapabilities(deviceModel).filter(
(c) => c.name !== 'device_connectivity'
)
const requirements = [
...new Set(
capabilities.flatMap((capability) => [
...capability.requirements,
...(capability.supported === 'conditionally' &&
capability.details != null
? [capability.details]
: []),
])
),
]

const meta = [
modelIds.length > 0 ? modelIds.slice(0, 2).join(', ') : null,
Expand Down Expand Up @@ -84,11 +95,15 @@ export function DeviceModelRow({
})}
title={
capability.supported === 'conditionally'
? (capability.details ?? t.conditional)
? [
...capability.requirements,
capability.details ?? t.conditional,
].join('\n')
: undefined
}
>
{getCapabilityLabel(capability.name)}
{capability.integrationSupportLevel === 'beta' && ' (beta)'}
{capability.supported === 'conditionally' && (
<span className='seam-ecosystem-visually-hidden'>
{` (${t.conditional})`}
Expand All @@ -97,6 +112,22 @@ export function DeviceModelRow({
</li>
))}
</ul>
{deviceModel.capabilities_stale ? (
<span className='seam-supported-devices-status'>{t.refreshing}</span>
) : capabilities.length === 0 &&
deviceModel.device_type?.is_accessory !== true ? (
<span className='seam-supported-devices-status'>{t.unconfirmed}</span>
) : null}
{requirements.length > 0 && (
<details className='seam-supported-devices-requirements'>
<summary>{t.requirements}</summary>
<ul>
{requirements.map((requirement) => (
<li key={requirement}>{requirement}</li>
))}
</ul>
</details>
)}
</li>
)
}
Expand All @@ -105,4 +136,7 @@ const t = {
via: (providers: string) => `via ${providers}`,
badges: 'Device type and capabilities',
conditional: 'conditionally supported',
requirements: 'Requirements and compatibility',
refreshing: 'Compatibility is being refreshed.',
unconfirmed: 'Compatibility not confirmed for this integration.',
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const name = 'seam-supported-devices'

export const props: EcosystemElementProps<SupportedDevicesProps> = {
manufacturer: 'string',
provider: 'string',
deviceType: 'string',
capability: 'string',
hideSearch: 'boolean',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export const SingleManufacturer: Story = {
args: { manufacturer: 'schlage' },
}

export const SingleProvider: Story = {
args: { manufacturer: 'schlage', provider: 'schlage-engage' },
}

export const ManufacturerEmbedKey: Story = {
args: { embedKey: storybookEmbedKeys.manufacturer },
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { expect, test } from 'vitest'
import { expect, test, vi } from 'vitest'

import {
type EcosystemTestContext,
Expand All @@ -15,6 +15,39 @@ import { SupportedDevices } from './SupportedDevices.js'

setupFakeEcosystem()

test<EcosystemTestContext>('SupportedDevices follows every catalog page', async (ctx) => {
const fetch = globalThis.fetch
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input, init) => {
const response = await fetch(input, init)
const url = new URL(String(input))
if (
!url.pathname.endsWith('device_models/summaries/list') ||
url.searchParams.has('page_cursor')
)
return response
const data = await response.json()
data.device_models = data.device_models.slice(0, 2)
data.pagination = {
has_next_page: true,
next_page_cursor: data.device_models[1].slug,
}
return new Response(JSON.stringify(data), { status: 200 })
})
try {
renderWithEcosystem(<SupportedDevices manufacturer='schlage' />, ctx)
await screen.findByText('7 devices')
expect(
ctx.ecosystemRequestUrls.filter((url) =>
url.pathname.endsWith('device_models/summaries/list')
)
).toHaveLength(2)
} finally {
fetchSpy.mockRestore()
}
})

const getSectionTitles = (): string[] =>
screen
.getAllByRole('heading', { level: 3 })
Expand Down Expand Up @@ -58,6 +91,30 @@ test<EcosystemTestContext>('SupportedDevices shows one manufacturer', async (ctx
screen.getByText('Engage Gateway')
})

test<EcosystemTestContext>('SupportedDevices scopes the query and badges to a provider', async (ctx) => {
renderWithEcosystem(
<SupportedDevices manufacturer='schlage' provider='schlage-engage' />,
ctx
)
await screen.findByText('Control Smart Lock')
expect(screen.queryByText('Encode Plus Smart WiFi Deadbolt')).toBeNull()
expect(screen.queryByText(/via Schlage Home/)).toBeNull()
screen.getByText('Engage Gateway')
const request = ctx.ecosystemRequestUrls.find((url) =>
url.pathname.endsWith('device_models/summaries/list')
)
expect(request?.searchParams.get('provider')).toBe('schlage-engage')
})

test<EcosystemTestContext>('conditional requirements are readable without hovering', async (ctx) => {
renderWithEcosystem(<SupportedDevices manufacturer='august' />, ctx)
const name = await screen.findByText('Smart Lock Pro')
const row = name.closest('li')
if (row == null) throw new Error('Missing row')
within(row).getByText('Requirements and compatibility')
within(row).getByText(/Requires an August keypad/)
})

test<EcosystemTestContext>('SupportedDevices shows device type and capability badges', async (ctx) => {
renderWithEcosystem(<SupportedDevices manufacturer='schlage' />, ctx)
const name = await screen.findByText('Encode Plus Smart WiFi Deadbolt')
Expand Down
Loading
Loading