diff --git a/README.md b/README.md
index c4761e844..4dcc4acab 100644
--- a/README.md
+++ b/README.md
@@ -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. |
@@ -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.
diff --git a/examples/supported-devices/index.html b/examples/supported-devices/index.html
new file mode 100644
index 000000000..c3e62f7ec
--- /dev/null
+++ b/examples/supported-devices/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ Supported devices — Seam
+
+
+
+ Loading supported devices…
+
+
+
+
diff --git a/examples/supported-devices/src/App.tsx b/examples/supported-devices/src/App.tsx
new file mode 100644
index 000000000..a6f8f4c0c
--- /dev/null
+++ b/examples/supported-devices/src/App.tsx
@@ -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 (
+
+
+
+
+ View the device catalog on seam.co
+
+
+
+ )
+}
diff --git a/examples/supported-devices/src/config.test.ts b/examples/supported-devices/src/config.test.ts
new file mode 100644
index 000000000..2e078d4ab
--- /dev/null
+++ b/examples/supported-devices/src/config.test.ts
@@ -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()
+})
diff --git a/examples/supported-devices/src/config.ts b/examples/supported-devices/src/config.ts
new file mode 100644
index 000000000..412fd0182
--- /dev/null
+++ b/examples/supported-devices/src/config.ts
@@ -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
+}
diff --git a/examples/supported-devices/src/main.tsx b/examples/supported-devices/src/main.tsx
new file mode 100644
index 000000000..ab1da13df
--- /dev/null
+++ b/examples/supported-devices/src/main.tsx
@@ -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()
diff --git a/examples/supported-devices/src/style.css b/examples/supported-devices/src/style.css
new file mode 100644
index 000000000..8ea9b6d02
--- /dev/null
+++ b/examples/supported-devices/src/style.css
@@ -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;
+}
diff --git a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
index fd4d55750..0f2d1bae7 100644
--- a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
+++ b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
@@ -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,
@@ -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' && (
{` (${t.conditional})`}
@@ -97,6 +112,22 @@ export function DeviceModelRow({
))}
+ {deviceModel.capabilities_stale ? (
+ {t.refreshing}
+ ) : capabilities.length === 0 &&
+ deviceModel.device_type?.is_accessory !== true ? (
+ {t.unconfirmed}
+ ) : null}
+ {requirements.length > 0 && (
+
+ {t.requirements}
+
+ {requirements.map((requirement) => (
+ - {requirement}
+ ))}
+
+
+ )}
)
}
@@ -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.',
}
diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts b/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts
index e62783d2b..b2b46f12c 100644
--- a/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts
+++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts
@@ -6,6 +6,7 @@ export const name = 'seam-supported-devices'
export const props: EcosystemElementProps = {
manufacturer: 'string',
+ provider: 'string',
deviceType: 'string',
capability: 'string',
hideSearch: 'boolean',
diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx
index 37fc59bc6..f11f348bd 100644
--- a/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx
+++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx
@@ -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 },
}
diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
index 0f8e4ccd1..ee2449785 100644
--- a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
+++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
@@ -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,
@@ -15,6 +15,39 @@ import { SupportedDevices } from './SupportedDevices.js'
setupFakeEcosystem()
+test('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(, 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 })
@@ -58,6 +91,30 @@ test('SupportedDevices shows one manufacturer', async (ctx
screen.getByText('Engage Gateway')
})
+test('SupportedDevices scopes the query and badges to a provider', async (ctx) => {
+ renderWithEcosystem(
+ ,
+ 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('conditional requirements are readable without hovering', async (ctx) => {
+ renderWithEcosystem(, 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('SupportedDevices shows device type and capability badges', async (ctx) => {
renderWithEcosystem(, ctx)
const name = await screen.findByText('Encode Plus Smart WiFi Deadbolt')
diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
index a1253e5bd..ff0771f9e 100644
--- a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
+++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
@@ -10,6 +10,7 @@ import {
getManufacturerName,
getManufacturerOptions,
groupDeviceModelsByManufacturer,
+ scopeDeviceModelsToProvider,
} from 'lib/seam/components/SupportedDevices/device-models.js'
import { FilterSelect } from 'lib/seam/components/SupportedDevices/FilterSelect.js'
import { ManufacturerSection } from 'lib/seam/components/SupportedDevices/ManufacturerSection.js'
@@ -35,6 +36,8 @@ export interface SupportedDevicesProps {
* Defaults to the manufacturer of a manufacturer embed key.
*/
manufacturer?: string | undefined
+ /** Ecosystem provider slug, e.g., `oracode`. Scopes models and capability badges. */
+ provider?: string | undefined
/** DeviceDB device type slug, e.g., `smartlock` or `thermostat`. */
deviceType?: string | undefined
/** Only devices supporting this capability, e.g., `can_program_online_access_codes`. */
@@ -53,6 +56,7 @@ export interface SupportedDevicesProps {
*/
export function SupportedDevices({
manufacturer,
+ provider,
deviceType,
capability,
hideSearch = false,
@@ -63,6 +67,7 @@ export function SupportedDevices({
}: SupportedDevicesProps): JSX.Element {
const { embedKey } = useEcosystemContext()
const explicitManufacturer = normalizeSlug(manufacturer)
+ const fixedProvider = normalizeSlug(provider)
const fixedDeviceType = normalizeSlug(deviceType)
const fixedCapability = normalizeSlug(capability)
@@ -84,6 +89,7 @@ export function SupportedDevices({
refetch: refetchDeviceModels,
} = useEcosystemDeviceModelSummaries({
manufacturer: scopeManufacturer ?? '',
+ provider: fixedProvider ?? '',
deviceType: fixedDeviceType ?? '',
capability:
fixedCapability != null && fixedCapability in capabilityLabels
@@ -93,8 +99,12 @@ export function SupportedDevices({
})
const availableDeviceModels = useMemo(
- () => applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions),
- [deviceModels, embedResolution]
+ () =>
+ scopeDeviceModelsToProvider(
+ applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions),
+ fixedProvider
+ ),
+ [deviceModels, embedResolution, fixedProvider]
)
const manufacturerSlugs = useMemo(
diff --git a/src/lib/seam/components/SupportedDevices/device-models.test.ts b/src/lib/seam/components/SupportedDevices/device-models.test.ts
index bea8ed0d2..2cb6b0349 100644
--- a/src/lib/seam/components/SupportedDevices/device-models.test.ts
+++ b/src/lib/seam/components/SupportedDevices/device-models.test.ts
@@ -13,6 +13,7 @@ import {
getCapabilityOptions,
getSupportedCapabilities,
groupDeviceModelsByManufacturer,
+ scopeDeviceModelsToProvider,
} from './device-models.js'
const ref = (slug: string): { slug: string; display_name: string } => ({
@@ -173,11 +174,15 @@ test('getSupportedCapabilities: takes the best support across providers', () =>
name: 'can_remotely_unlock',
supported: 'yes',
details: 'can_remotely_unlock via cloud',
+ integrationSupportLevel: 'stable',
+ requirements: [],
},
{
name: 'can_provision_mobile_keys',
supported: 'yes',
details: 'can_provision_mobile_keys via cloud',
+ integrationSupportLevel: 'stable',
+ requirements: [],
},
])
expect(getSupportedCapabilities(at(models, 1))).toEqual([
@@ -185,6 +190,8 @@ test('getSupportedCapabilities: takes the best support across providers', () =>
name: 'can_remotely_unlock',
supported: 'conditionally',
details: 'can_remotely_unlock via hub',
+ integrationSupportLevel: 'stable',
+ requirements: [],
},
])
expect(getSupportedCapabilities(at(models, 2)).map((c) => c.name)).toEqual([
@@ -192,6 +199,83 @@ test('getSupportedCapabilities: takes the best support across providers', () =>
])
})
+test('provider scope removes unrelated models, providers and capability badges', () => {
+ const result = scopeDeviceModelsToProvider(models, 'hub')
+ expect(result.map((m) => m.slug)).toEqual(['a', 'b'])
+ expect(result[0]?.providers.map((p) => p.slug)).toEqual(['hub'])
+ expect(
+ result[0]?.capabilities_by_provider.map((p) => p.provider.slug)
+ ).toEqual(['hub'])
+ expect(getSupportedCapabilities(at(result, 0)).map((c) => c.name)).toEqual([
+ 'can_remotely_unlock',
+ ])
+ expect(scopeDeviceModelsToProvider(models, 'unknown')).toEqual([])
+ expect(scopeDeviceModelsToProvider(models, null)).toBe(models)
+})
+
+test('conditional requirements include dependency reports without following cycles', () => {
+ const model = deviceModel({
+ slug: 'lock',
+ capabilities: {
+ cloud: {
+ can_program_online_access_codes: 'conditionally',
+ device_connectivity: 'conditionally',
+ },
+ },
+ })
+ const caps = model.capabilities_by_provider[0]?.capabilities
+ if (caps == null) throw new Error('Missing capabilities')
+ const codes = caps.find((c) => c.name === 'can_program_online_access_codes')
+ const connectivity = caps.find((c) => c.name === 'device_connectivity')
+ if (codes == null || connectivity == null)
+ throw new Error('Missing capability')
+ codes.report = {
+ details: 'Codes need a keypad.',
+ requirements: { keypad: { message: 'An external keypad is required.' } },
+ dependencies: ['device_connectivity'],
+ }
+ connectivity.report = {
+ details: 'A Wi-Fi bridge is required.',
+ dependencies: ['can_program_online_access_codes'],
+ }
+ expect(
+ getSupportedCapabilities(model).find((c) => c.name === codes.name)
+ ?.requirements
+ ).toEqual([
+ 'An external keypad is required.',
+ 'A Wi-Fi bridge is required.',
+ 'Codes need a keypad.',
+ ])
+})
+
+test('unavailable or stale capabilities never become supported badges or filters', () => {
+ const model = deviceModel({
+ slug: 'lock',
+ capabilities: { cloud: { can_remotely_unlock: 'yes' } },
+ })
+ const capability = model.capabilities_by_provider[0]?.capabilities[0]
+ if (capability == null) throw new Error('Missing capability')
+ for (const level of [
+ 'planned',
+ 'in-progress',
+ 'unavailable',
+ 'inquire',
+ null,
+ ] as const) {
+ capability.integration_support_level = level
+ expect(getSupportedCapabilities(model)).toEqual([])
+ expect(
+ filterDeviceModels([model], { capability: 'can_remotely_unlock' })
+ ).toEqual([])
+ }
+ capability.integration_support_level = 'beta'
+ expect(getSupportedCapabilities(model)[0]?.integrationSupportLevel).toBe(
+ 'beta'
+ )
+ model.capabilities_stale = true
+ expect(getSupportedCapabilities(model)).toEqual([])
+})
+
test('filterDeviceModels: filters by manufacturer, device type, capability and search', () => {
const slugs = (filters: Parameters[1]): string[] =>
filterDeviceModels(models, filters).map((m) => m.slug)
diff --git a/src/lib/seam/components/SupportedDevices/device-models.ts b/src/lib/seam/components/SupportedDevices/device-models.ts
index d253ae6cb..575040c0d 100644
--- a/src/lib/seam/components/SupportedDevices/device-models.ts
+++ b/src/lib/seam/components/SupportedDevices/device-models.ts
@@ -3,6 +3,7 @@ import type {
CapabilitySummary,
DeviceModelSummary,
EmbedExclusions,
+ IntegrationSupportLevel,
Manufacturer,
ManufacturerRef,
} from 'lib/seam/ecosystem/types.js'
@@ -18,6 +19,8 @@ export interface SupportedCapability {
name: CapabilityName
supported: 'yes' | 'conditionally'
details: string | null
+ integrationSupportLevel: IntegrationSupportLevel | null
+ requirements: string[]
}
export interface ManufacturerGroup {
@@ -121,21 +124,51 @@ export const applyEmbedExclusions = (
return result
}
-/** The best support for each capability across the model's providers. */
+/** The API filters models by provider, but still returns every provider's capabilities. */
+export const scopeDeviceModelsToProvider = (
+ deviceModels: DeviceModelSummary[],
+ provider: string | null | undefined
+): DeviceModelSummary[] => {
+ if (provider == null) return deviceModels
+ return deviceModels
+ .filter((model) => model.providers.some((p) => p.slug === provider))
+ .map((model) => ({
+ ...model,
+ providers: model.providers.filter((p) => p.slug === provider),
+ capabilities_by_provider: model.capabilities_by_provider.filter(
+ (entry) => entry.provider.slug === provider
+ ),
+ }))
+}
+
+/** The best currently available support for each capability across the model's providers. */
export const getSupportedCapabilities = (
deviceModel: DeviceModelSummary
): SupportedCapability[] => {
+ if (deviceModel.capabilities_stale) return []
const best = new Map()
for (const { capabilities } of deviceModel.capabilities_by_provider) {
for (const capability of capabilities) {
const supported = toSupported(capability)
if (supported == null) continue
const current = best.get(capability.name)
- if (current?.supported === 'yes') continue
+ if (current != null) {
+ const currentRank = supportRank(
+ current.integrationSupportLevel,
+ current.supported
+ )
+ const nextRank = supportRank(
+ capability.integration_support_level,
+ supported
+ )
+ if (currentRank >= nextRank) continue
+ }
best.set(capability.name, {
name: capability.name,
supported,
details: capability.report?.details ?? null,
+ integrationSupportLevel: capability.integration_support_level,
+ requirements: getCapabilityRequirements(capability, capabilities),
})
}
}
@@ -144,6 +177,36 @@ export const getSupportedCapabilities = (
)
}
+const supportRank = (
+ level: IntegrationSupportLevel | null,
+ supported: SupportedCapability['supported']
+): number =>
+ (level === 'stable' ? 4 : level === 'beta' ? 2 : 0) +
+ (supported === 'yes' ? 1 : 0)
+
+const getCapabilityRequirements = (
+ capability: CapabilitySummary,
+ capabilities: CapabilitySummary[],
+ visited = new Set()
+): string[] => {
+ if (visited.has(capability.name)) return []
+ visited.add(capability.name)
+ const requirements = Object.values(capability.report?.requirements ?? {}).map(
+ (requirement) => requirement.message
+ )
+ for (const name of capability.report?.dependencies ?? []) {
+ const dependency = capabilities.find((c) => c.name === name)
+ if (dependency == null) continue
+ if (dependency.supported !== 'yes' && dependency.report?.details != null) {
+ requirements.push(dependency.report.details)
+ }
+ requirements.push(
+ ...getCapabilityRequirements(dependency, capabilities, visited)
+ )
+ }
+ return [...new Set(requirements)]
+}
+
export const supportsCapability = (
deviceModel: DeviceModelSummary,
capability: string
@@ -305,6 +368,13 @@ export const getManufacturerLogoUrl = (
const toSupported = (
capability: CapabilitySummary
): SupportedCapability['supported'] | null => {
+ const level = capability.integration_support_level
+ if (
+ level !== 'stable' &&
+ level !== 'beta' &&
+ capability.name !== 'device_connectivity'
+ )
+ return null
if (capability.supported === 'yes') return 'yes'
if (capability.supported === 'conditionally') return 'conditionally'
return null
diff --git a/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts
index 577b6cfb5..7c89c6592 100644
--- a/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts
+++ b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts
@@ -1,9 +1,6 @@
import { useQuery } from '@tanstack/react-query'
-import {
- type EcosystemApiError,
- fetchEcosystem,
-} from 'lib/seam/ecosystem/client.js'
+import { EcosystemApiError, fetchEcosystem } from 'lib/seam/ecosystem/client.js'
import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js'
import type {
DeviceModelSummariesListResponse,
@@ -77,10 +74,21 @@ export function useEcosystemDeviceModelSummaries({
deviceModels.push(...res.device_models)
const { has_next_page: hasNextPage, next_page_cursor: nextPageCursor } =
res.pagination
- if (!hasNextPage || nextPageCursor == null) break
+ if (!hasNextPage) return deviceModels
+ if (nextPageCursor == null || nextPageCursor === pageCursor) {
+ throw new EcosystemApiError({
+ status: 502,
+ type: 'pagination_incomplete',
+ message: 'The device catalog returned an invalid page cursor.',
+ })
+ }
pageCursor = nextPageCursor
}
- return deviceModels
+ throw new EcosystemApiError({
+ status: 502,
+ type: 'pagination_incomplete',
+ message: 'The device catalog exceeded the supported number of pages.',
+ })
},
retry: retryEcosystemQuery,
})
diff --git a/src/styles/_supported-devices.scss b/src/styles/_supported-devices.scss
index aefa2b854..6f83df194 100644
--- a/src/styles/_supported-devices.scss
+++ b/src/styles/_supported-devices.scss
@@ -139,6 +139,22 @@
border-top: 1px solid var(--seam-ecosystem-line-1);
}
+ .seam-supported-devices-status,
+ .seam-supported-devices-requirements {
+ grid-column: 2 / -1;
+ color: var(--seam-ecosystem-fg-2);
+ font-size: 13px;
+ }
+
+ .seam-supported-devices-requirements summary {
+ cursor: pointer;
+ }
+
+ .seam-supported-devices-requirements ul {
+ margin: 8px 0 0;
+ padding-left: 20px;
+ }
+
@container (width >= 720px) {
.seam-supported-devices-row {
grid-template-columns: 48px minmax(0, 1fr) minmax(0, 45%);
diff --git a/test/fixtures/ecosystem.tsx b/test/fixtures/ecosystem.tsx
index 651370552..644292478 100644
--- a/test/fixtures/ecosystem.tsx
+++ b/test/fixtures/ecosystem.tsx
@@ -21,6 +21,7 @@ export interface EcosystemTestContext {
ecosystemEndpoint: string
/** The method and path of every request to the fake, e.g., `GET v1/compatibility`. */
ecosystemRequests: string[]
+ ecosystemRequestUrls: URL[]
}
const fakeEcosystemOrigin = 'http://fake-ecosystem.test'
@@ -35,6 +36,7 @@ export const setupFakeEcosystem = (): void => {
const requests: string[] = []
ctx.ecosystemEndpoint = `${fakeEcosystemOrigin}/v1/`
ctx.ecosystemRequests = requests
+ ctx.ecosystemRequestUrls = []
const { fetch } = globalThis
// UPSTREAM: Under happy-dom, the global fetch is Node's,
@@ -46,6 +48,7 @@ export const setupFakeEcosystem = (): void => {
const method = init.method ?? 'GET'
const path = url.pathname.replace(/^\/+/, '')
requests.push(`${method} ${path}`)
+ ctx.ecosystemRequestUrls.push(url)
const headers: Record = {}
new Headers(init.headers).forEach((value, key) => {
@@ -83,6 +86,7 @@ export const renderWithEcosystem = (
{
ecosystemEndpoint,
ecosystemRequests: _ecosystemRequests,
+ ecosystemRequestUrls: _ecosystemRequestUrls,
embedKey,
...options
}: Parameters[1] & EcosystemTestContext & { embedKey?: string }
diff --git a/tsconfig.json b/tsconfig.json
index 4ce63ae42..2aca95fb3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -33,7 +33,7 @@
"lib/*": ["./src/lib/*"]
}
},
- "files": ["src/index.ts", "src/ecosystem.d.ts"],
+ "files": ["src/index.ts", "src/elements.d.ts", "src/ecosystem.d.ts"],
"include": [
"src/**/*",
"test/**/*",
diff --git a/vercel.json b/vercel.json
index ec1b37458..49575e38b 100644
--- a/vercel.json
+++ b/vercel.json
@@ -44,10 +44,6 @@
"key": "X-Content-Type-Options",
"value": "nosniff"
},
- {
- "key": "X-Frame-Options",
- "value": "SAMEORIGIN"
- },
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
@@ -154,6 +150,28 @@
"value": "noindex, follow"
}
]
+ },
+ {
+ "source": "/((?!examples/supported-devices(?:/|$)).*)",
+ "headers": [
+ {
+ "key": "X-Frame-Options",
+ "value": "SAMEORIGIN"
+ }
+ ]
+ },
+ {
+ "source": "/examples/supported-devices(/.*)?",
+ "headers": [
+ {
+ "key": "Content-Security-Policy",
+ "value": "default-src 'self'; script-src 'self'; connect-src 'self' https://ecosystem.seam.co; img-src 'self' data: https://seam-ecosystem-media.fly.storage.tigris.dev https://strapi-media-bucket.fly.storage.tigris.dev; style-src 'self'; frame-ancestors 'self' https://www.seam.co https://seam.co https://docs.seam.co https://*.mintlify.app http://localhost:* http://127.0.0.1:*"
+ },
+ {
+ "key": "X-Robots-Tag",
+ "value": "noindex, follow"
+ }
+ ]
}
]
}