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
18 changes: 17 additions & 1 deletion examples/supported-devices/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ 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'
import {
getEmbedConfig,
getEmbeddedViewport,
getParentOrigin,
} from './config.js'

const config = getEmbedConfig(globalThis.location.search)
const parentOrigin = getParentOrigin(globalThis.document.referrer)
Expand Down Expand Up @@ -34,6 +38,18 @@ export function App(): JSX.Element {
const onMessage = (event: MessageEvent): void => {
if (event.source !== globalThis.parent || event.origin !== parentOrigin)
return
const viewport = getEmbeddedViewport(event.data)
if (viewport != null) {
const root = globalThis.document.documentElement
root.style.setProperty(
'--seam-supported-device-viewport-top',
`${viewport.top}px`
)
root.style.setProperty(
'--seam-supported-device-viewport-height',
`${viewport.height}px`
)
}
const data = event.data as { type?: string; theme?: string } | null
if (
data?.type === 'seam-supported-devices:theme' &&
Expand Down
24 changes: 23 additions & 1 deletion examples/supported-devices/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { expect, test } from 'vitest'

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

test('embed config scopes a provider without inventing a manufacturer', () => {
expect(
Expand All @@ -25,3 +29,21 @@ test('parent messages use the exact HTTP origin of the referrer', () => {
expect(getParentOrigin('')).toBeNull()
expect(getParentOrigin('javascript:alert(1)')).toBeNull()
})

test('embedded viewport dimensions accept finite bounds and reject malformed messages', () => {
const type = 'seam-supported-devices:viewport'
expect(getEmbeddedViewport({ type, top: 120, height: 640 })).toEqual({
top: 120,
height: 640,
})
for (const data of [
null,
{ type, top: -1, height: 640 },
{ type, top: 0, height: 0 },
{ type, top: 0, height: Infinity },
{ type, top: NaN, height: 640 },
{ type, top: '0', height: 640 },
{ type: 'other', top: 0, height: 640 },
])
expect(getEmbeddedViewport(data)).toBeNull()
})
18 changes: 18 additions & 0 deletions examples/supported-devices/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,21 @@ const optionalParam = (
const value = params.get(name)?.trim()
return value == null || value.length === 0 ? undefined : value
}

export const getEmbeddedViewport = (
data: unknown
): { top: number; height: number } | null => {
if (data == null || typeof data !== 'object') return null
const message = data as { type?: unknown; top?: unknown; height?: unknown }
if (
message.type !== 'seam-supported-devices:viewport' ||
typeof message.top !== 'number' ||
!Number.isFinite(message.top) ||
message.top < 0 ||
typeof message.height !== 'number' ||
!Number.isFinite(message.height) ||
message.height <= 0
)
return null
return { top: message.top, height: message.height }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { screen } from '@testing-library/react'
import { expect, test } from 'vitest'

import {
type EcosystemTestContext,
renderWithEcosystem,
setupFakeEcosystem,
} from 'fixtures/ecosystem.js'

import { AccessoryCatalog, AccessoryLinks } from './AccessoryCatalog.js'

setupFakeEcosystem()

test<EcosystemTestContext>('accessory lookups are batched by slug across manufacturers and use canonical device routes', async (ctx) => {
renderWithEcosystem(
<AccessoryCatalog
slugs={['engage-gateway', 'august-connect-wifi-bridge', 'engage-gateway']}
>
<AccessoryLinks
message='A compatible bridge is required.'
slugs={['engage-gateway']}
/>
<AccessoryLinks
message='A compatible bridge is required.'
slugs={['august-connect-wifi-bridge']}
/>
</AccessoryCatalog>,
ctx
)
const gateway = await screen.findByRole('link', { name: /Engage Gateway/ })
const bridge = await screen.findByRole('link', {
name: /Connect Wi-Fi Bridge/,
})
expect(gateway.getAttribute('href')).toBe(
'https://www.seam.co/gateways/schlage_engage-gateway'
)
expect(bridge.getAttribute('href')).toBe(
'https://www.seam.co/bridges/august_august-connect-wifi-bridge'
)
expect(ctx.ecosystemRequestUrls).toHaveLength(1)
const request = ctx.ecosystemRequestUrls[0]
expect(request?.searchParams.get('slugs')).toBe(
'august-connect-wifi-bridge,engage-gateway'
)
expect(request?.searchParams.has('manufacturer')).toBe(false)
expect(request?.searchParams.has('provider')).toBe(false)
})

test<EcosystemTestContext>('missing accessory records stay explicit while known accessories remain linked', async (ctx) => {
renderWithEcosystem(
<AccessoryCatalog slugs={['engage-gateway', 'missing']}>
<AccessoryLinks
message='A compatible bridge is required.'
slugs={['engage-gateway', 'missing']}
/>
</AccessoryCatalog>,
ctx
)
await screen.findByRole('link', { name: /Engage Gateway/ })
await screen.findByText('Some accessory details couldn’t be loaded.')
expect(screen.queryByRole('link', { name: /missing/ })).toBeNull()
})

test<EcosystemTestContext>('requirements without accessory relations do not fetch an unfiltered catalog', (ctx) => {
renderWithEcosystem(
<AccessoryCatalog slugs={[]}>
<AccessoryLinks message='An external keypad is required.' />
</AccessoryCatalog>,
ctx
)
expect(ctx.ecosystemRequests).toEqual([])
expect(screen.queryByText('Compatible keypads')).toBeNull()
})
138 changes: 138 additions & 0 deletions src/lib/seam/components/SupportedDevices/AccessoryCatalog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { createContext, type PropsWithChildren, useContext } from 'react'

import {
getDeviceModelImageUrl,
getDeviceModelName,
} from 'lib/seam/components/SupportedDevices/device-models.js'
import type { DeviceModelSummary } from 'lib/seam/ecosystem/types.js'
import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js'

import { getRequirementKinds } from './presentation.js'

const AccessoryContext = createContext<{
models: DeviceModelSummary[]
isLoading: boolean
isError: boolean
} | null>(null)

/** One batched lookup per catalog, shared by every expanded provider. */
export function AccessoryCatalog({
slugs,
children,
}: PropsWithChildren<{ slugs: string[] }>): JSX.Element {
const { deviceModels, isLoading, isError } = useEcosystemDeviceModelSummaries(
{
slugs,
enabled: slugs.length > 0,
}
)
return (
<AccessoryContext.Provider
value={{ models: deviceModels ?? [], isLoading, isError }}
>
{children}
</AccessoryContext.Provider>
)
}

export function AccessoryLinks({
message,
slugs = [],
}: {
message: string
slugs?: string[] | undefined
}): JSX.Element | null {
const catalog = useContext(AccessoryContext)
if (slugs.length === 0 || catalog == null) return null
const kinds = getRequirementKinds(message)
const label = kinds.includes('bridge')
? 'Compatible bridges'
: kinds.includes('keypad')
? 'Compatible keypads'
: 'Compatible accessories'
const models = [...new Set(slugs)].flatMap((slug) => {
const model = catalog.models.find((entry) => entry.slug === slug)
return model == null ? [] : [model]
})
return (
<div className='seam-supported-device-accessories'>
<p className='seam-supported-device-accessories-label'>{label}</p>
{catalog.isLoading ? (
<p className='seam-supported-device-accessories-state' role='status'>
Loading accessories…
</p>
) : (
<>
{models.length > 0 && (
<ul
className='seam-supported-device-accessory-list'
aria-label={label}
>
{models.map((model) => {
const image = getDeviceModelImageUrl(model)
const name = getDeviceModelName(model)
const manufacturer = model.manufacturer
const href =
manufacturer == null || model.device_type == null
? null
: `https://www.seam.co/${encodeURIComponent(model.device_type.slug)}s/${encodeURIComponent(manufacturer.slug)}_${encodeURIComponent(model.slug)}`
const content = (
<>
{image != null && (
<img
src={image}
alt=''
loading='lazy'
width='28'
height='28'
/>
)}
<div>
{name}
{manufacturer != null && (
<span>
{manufacturer.display_name ?? manufacturer.slug}
</span>
)}
</div>
{href != null && (
<span
className='seam-supported-device-accessory-arrow'
aria-hidden
>
↗
</span>
)}
</>
)
return (
<li key={model.device_model_id}>
{href == null ? (
<div className='seam-supported-device-accessory-link'>
{content}
</div>
) : (
<a
className='seam-supported-device-accessory-link'
href={href}
target='_blank'
rel='noopener noreferrer'
>
{content}
</a>
)}
</li>
)
})}
</ul>
)}
{(catalog.isError || models.length < new Set(slugs).size) && (
<p className='seam-supported-device-accessories-state'>
Some accessory details couldn’t be loaded.
</p>
)}
</>
)}
</div>
)
}
61 changes: 61 additions & 0 deletions src/lib/seam/components/SupportedDevices/BrandMark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useState } from 'react'

import type { DeviceAsset } from 'lib/seam/ecosystem/types.js'

const logoKinds = [
'square_logo',
'horizontal_logo',
'square_logo_mono',
'horizontal_logo_mono',
] as const

export function BrandMark({
name,
logos,
className = '',
}: {
name: string
logos?: DeviceAsset[]
className?: string
}): JSX.Element {
const logo = logoKinds
.map((kind) => logos?.find((asset) => asset.kind === kind))
.find((asset) => asset != null)
const src = logo?.formats.small?.url ?? logo?.url
const initial = name.slice(0, 1)
return (
<span
className={`seam-supported-device-brand-mark ${className}`}
aria-hidden
>
{src == null ? (
<span>{initial}</span>
) : (
<LogoImage key={src} src={src} initial={initial} />
)}
</span>
)
}

function LogoImage({
src,
initial,
}: {
src: string
initial: string
}): JSX.Element {
const [failed, setFailed] = useState(false)
return failed ? (
<span>{initial}</span>
) : (
<img
src={src}
alt=''
width='24'
height='24'
onError={() => {
setFailed(true)
}}
/>
)
}
Loading
Loading