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
16 changes: 16 additions & 0 deletions .storybook/fake-ecosystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -1377,6 +1377,22 @@ const routes = {
}
},

'v1/providers/list': ({ searchParams, headers }) => {
optionalEmbedKey({ headers, searchParams })
const slugs = listParam(searchParams, 'slugs')
const list = Object.keys(providerProfiles)
.filter((slug) => slugs.length === 0 || slugs.includes(slug))
.map((slug) => ({
...toProviderRef(slug),
has_requirements: false,
requirements_md: null,
logos: [],
integrations: [{ integration_support_level: 'stable' }],
}))
const { items, pagination } = paginate(list, searchParams)
return { providers: items, pagination }
},

'v1/manufacturers/list': ({ searchParams, headers, assetBaseUrl }) => {
optionalEmbedKey({ headers, searchParams })
const slugs = listParam(searchParams, 'slugs')
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,69 @@ The data is also available with the `useCompatiblePartners`, `useEcosystemDevice

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

### Pre-render supported devices in Next.js

Use the native React components from `@seamapi/react/ecosystem-react.js` and the
server loader from `@seamapi/react/server.js` (5.4.0 or newer). The React entry
declares the client boundary for App Router; Next.js can still pre-render its
HTML when data is supplied by the server.

```tsx
// app/devices/page.tsx — Server Component
import {
EcosystemProvider,
SupportedDevices,
} from '@seamapi/react/ecosystem-react.js'
import { prefetchSupportedDevices } from '@seamapi/react/server.js'

export default async function Page() {
const embedKey = process.env.NEXT_PUBLIC_ECOSYSTEM_EMBED_KEY
const filters = {
manufacturer: 'august',
provider: 'august-access',
deviceType: 'smartlock',
}
const dehydratedState = await prefetchSupportedDevices({
...filters,
embedKey,
origin: 'https://your-site.example',
})

return (
<EcosystemProvider
embedKey={embedKey}
dehydratedState={dehydratedState}
disableCssInjection
disableFontInjection
>
<SupportedDevices {...filters} />
</EcosystemProvider>
)
}
```

Import `@seamapi/react/index.css` in `app/layout.tsx` so the server-rendered
content is styled before JavaScript loads. The loader resolves the key's
manufacturer scope and exclusions, paginates the catalog, and fetches provider,
manufacturer, and linked accessory details. It uses an isolated cache per call,
removes excluded records before serialization, and throws on failed or
incomplete reads. Configure the embed key's allowed origins to include the page
origin; the loader forwards that origin to Ecosystem for validation. Keep the
loader and component's key, endpoint, and filters identical.

The resulting HTML contains device names, model numbers, capabilities,
requirements, and accessory links. JavaScript hydrates the existing UI for
search, filtering, and galleries. Device disclosures and links work without
JavaScript; the unscoped catalog's per-manufacturer "Show all" button still
requires it. Server-prefetched queries remain fresh for one minute after their
fetch time before becoming eligible for background refresh.

For caching, pass a `fetch` implementation that applies your Next.js
revalidation settings. See the [working Next.js example](./examples/nextjs-supported-devices)
for hourly revalidation, configuration, and verification. This renders the
component inline in a Next.js page; it does not change the Mintlify iframe or
put iframe content into its parent page's HTML.

### Hosted supported-device embed

The native React page at
Expand Down
16 changes: 16 additions & 0 deletions examples/nextjs-supported-devices/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"root": true,
"extends": [
"standard",
"standard-jsx",
"standard-react",
"plugin:react/jsx-runtime",
"prettier"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": { "jsx": true }
},
"rules": { "react/prop-types": "off" }
}
57 changes: 57 additions & 0 deletions examples/nextjs-supported-devices/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Supported devices in Next.js

This App Router example renders the catalog into the page HTML. React then hydrates the same component for search, capability filters, and galleries. It uses the native React entry point rather than an iframe or custom element.

Requires Node.js 20.9 or newer and `@seamapi/react` 5.4.0 or newer.

## Run

Install this example's dependencies from this directory:

```sh
npm install
```

Create `.env.local` with an Ecosystem embed key whose allowed origins include the URL where you run the page:

```dotenv
NEXT_PUBLIC_ECOSYSTEM_EMBED_KEY=eco_live_your_embed_key
ECOSYSTEM_PAGE_ORIGIN=http://localhost:3000
```

An embed key is public browser configuration, not a Seam secret API key. The loader sends the configured page origin to Ecosystem for allowlist validation. Use a deployment-specific origin and a key that allows it for production and previews.

Run the development server:

```sh
npm run dev
```

Open `http://localhost:3000`. To inspect the production rendering and caching behavior:

```sh
npm run build
npm start
```

The page scopes both the server loader and component to August Access smart locks. Change `filters` in `app/page.jsx` to inspect another manufacturer or provider. Keep the loader and rendered component's filters, embed key, and endpoint identical.

## Rendering and freshness

`prefetchSupportedDevices` resolves embed-key scope and exclusions, fetches every device page, and loads the provider, manufacturer, and linked accessory records needed for the initial view. Each call uses a separate query cache and throws on failed reads or incomplete pagination. Excluded devices and providers are removed before serializing the state.

`EcosystemProvider` accepts that serializable state through `dehydratedState`. Next.js pre-renders the existing component with it; no separate static catalog needs to be maintained. Native device disclosures and links work without JavaScript. Search, filters, and photo interactions activate after hydration. For an unscoped catalog, the existing five-model preview per manufacturer still applies; showing the remaining models requires JavaScript.

`app/layout.jsx` imports the package CSS before rendering. CSS injection is disabled on the provider because the stylesheet is already present. This example uses the application's font rather than injecting a font after load.

The page and server fetches revalidate hourly. New renders use the refreshed Ecosystem responses; already hydrated queries become eligible for background refresh after one minute. Adjust the Next.js fetch/revalidation settings for your application. Cache Components applications should use their corresponding cache APIs instead of this example's route-segment setting. When caching complete loader results, include the endpoint, key, page origin, and filters in the cache identity, and bound the lifetime to the acceptable delay for key revocations and exclusion changes.

For local acceptance testing, `NEXT_PUBLIC_ECOSYSTEM_ENDPOINT` can point at an absolute fake Ecosystem URL accessible to both the Next.js server and browser. Keep test fixtures and real embed keys separate.

## Verify

1. Inspect the HTTP response or View Source: device names, model numbers, capability tables, requirement text, and accessory links should be HTML elements, not only serialized JSON.
2. Disable JavaScript, reload, and expand a device. Hardware and provider details should remain readable.
3. Enable JavaScript and reload. Search and multi-capability filtering should work without hydration errors or an initial empty/loading replacement.

This example does not change the hosted iframe embed. Rendering the iframe application on a server would not inline its device content into the parent documentation page.
17 changes: 17 additions & 0 deletions examples/nextjs-supported-devices/app/layout.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import '@seamapi/react/index.css'

export const metadata = {
title: 'Supported devices',
description:
'Explore supported devices, capabilities, and integration requirements.',
}

export default function Layout({ children }) {
return (
<html lang='en'>
<body style={{ margin: 0, fontFamily: 'Arial, sans-serif' }}>
{children}
</body>
</html>
)
}
53 changes: 53 additions & 0 deletions examples/nextjs-supported-devices/app/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
EcosystemProvider,
SupportedDevices,
} from '@seamapi/react/ecosystem-react.js'
import { prefetchSupportedDevices } from '@seamapi/react/server.js'

export const revalidate = 3600

export default async function Page() {
const embedKey = process.env.NEXT_PUBLIC_ECOSYSTEM_EMBED_KEY
const ecosystemEndpoint = process.env.NEXT_PUBLIC_ECOSYSTEM_ENDPOINT
const origin = process.env.ECOSYSTEM_PAGE_ORIGIN
if (!embedKey || !origin) {
throw new Error(
'Set NEXT_PUBLIC_ECOSYSTEM_EMBED_KEY and ECOSYSTEM_PAGE_ORIGIN. See the example README.'
)
}
const filters = {
manufacturer: 'august',
provider: 'august-access',
deviceType: 'smartlock',
}
const dehydratedState = await prefetchSupportedDevices({
...filters,
embedKey,
ecosystemEndpoint,
origin,
fetch: async (input, init) =>
await fetch(input, {
...init,
next: { revalidate: 3600 },
}),
})

return (
<main style={{ maxWidth: 1000, margin: '40px auto', padding: '0 16px' }}>
<h1>August supported devices</h1>
<p>
Expand a device to review model information, providers, and capability
requirements.
</p>
<EcosystemProvider
embedKey={embedKey}
ecosystemEndpoint={ecosystemEndpoint}
dehydratedState={dehydratedState}
disableCssInjection
disableFontInjection
>
<SupportedDevices {...filters} hideTitle />
</EcosystemProvider>
</main>
)
}
19 changes: 19 additions & 0 deletions examples/nextjs-supported-devices/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "seam-supported-devices-nextjs-example",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"engines": {
"node": ">=20.9.0"
},
"dependencies": {
"@seamapi/react": "^5.4.0",
"next": "^16.4.3",
"react": "^19.3.1",
"react-dom": "^19.3.1"
}
}
2 changes: 1 addition & 1 deletion examples/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default defineConfig(async ({ command, mode }) => {
input: Object.fromEntries(
glob
.sync([`./${base}/**/*.html`], {
ignore: ['**/dist/**'],
ignore: ['**/dist/**', '**/.next/**', '**/node_modules/**'],
absolute: true,
})
.map((file) => [
Expand Down
18 changes: 16 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@
},
"./index.css": {
"import": "./dist/index.css"
},
"./ecosystem-react.js": {
"types": "./ecosystem-react.d.ts",
"import": "./ecosystem-react.js"
},
"./server.js": {
"types": "./server.d.ts",
"import": "./server.js"
}
},
"module": "index.js",
Expand All @@ -45,6 +53,12 @@
"email": "devops@getseam.com"
},
"files": [
"server.js",
"server.js.map",
"server.d.ts",
"ecosystem-react.js",
"ecosystem-react.js.map",
"ecosystem-react.d.ts",
"index.js",
"index.js.map",
"index.d.ts",
Expand All @@ -65,8 +79,8 @@
"start": "concurrently --raw --kill-others npm:examples:storybook npm:storybook",
"dev": "npm run start",
"build": "npm run build:entrypoints",
"prebuild": "del 'index.*' 'hooks.*' lib dist",
"postbuild": "concurrently --raw --group 'node ./index.js' 'node ./hooks.js'",
"prebuild": "del 'index.*' 'hooks.*' 'ecosystem-react.*' 'server.*' lib dist",
"postbuild": "concurrently --raw --group 'node ./index.js' 'node ./hooks.js' 'node ./ecosystem-react.js' 'node ./server.js'",
"build:entrypoints": "npm run build:js",
"prebuild:entrypoints": "npm run build:css",
"postbuild:entrypoints": "npm run build:elements && npm run build:ecosystem",
Expand Down
17 changes: 17 additions & 0 deletions src/ecosystem-react.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client'

// Native React entry point, including the Next.js client boundary, without
// registering custom elements or importing the Seam API client.
export {
CompatibleApps,
type CompatibleAppsProps,
} from './lib/seam/components/CompatibleApps/CompatibleApps.js'
export {
SupportedDevices,
type SupportedDevicesProps,
} from './lib/seam/components/SupportedDevices/SupportedDevices.js'
export {
type EcosystemOptions,
EcosystemProvider,
type EcosystemProviderProps,
} from './lib/seam/ecosystem/EcosystemProvider.js'
5 changes: 4 additions & 1 deletion src/lib/ecosystem-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export interface EcosystemElementDefinition {
props?: Record<string, PropType>
}

type ProviderProps = Omit<EcosystemProviderProps, 'children'>
type ProviderProps = Omit<
EcosystemProviderProps,
'children' | 'dehydratedState'
>

const providerProps: Record<keyof ProviderProps, PropType> = {
embedKey: 'string',
Expand Down
13 changes: 2 additions & 11 deletions src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
import { AccessoryCatalog } from './AccessoryCatalog.js'
import { CapabilityFilter } from './CapabilityFilter.js'
import { CapabilityLegend } from './DeviceRow.js'
import { presentDevice } from './presentation.js'
import { getAccessorySlugs, presentDevice } from './presentation.js'

export interface SupportedDevicesProps {
/**
Expand Down Expand Up @@ -160,16 +160,7 @@ export function SupportedDevices({
[presentedDevices]
)
const accessorySlugs = useMemo(
() =>
presentedDevices.flatMap((device) =>
device.providers
.filter((provider) => provider.integrationStatus !== 'unsupported')
.flatMap((provider) =>
provider.requirements.flatMap(
(requirement) => requirement.deviceSlugs ?? []
)
)
),
() => getAccessorySlugs(presentedDevices),
[presentedDevices]
)

Expand Down
11 changes: 11 additions & 0 deletions src/lib/seam/components/SupportedDevices/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ export interface PresentedDevice {
providers: PresentedProvider[]
}

export const getAccessorySlugs = (devices: PresentedDevice[]): string[] =>
devices.flatMap((device) =>
device.providers
.filter((provider) => provider.integrationStatus !== 'unsupported')
.flatMap((provider) =>
provider.requirements.flatMap(
(requirement) => requirement.deviceSlugs ?? []
)
)
)

interface PresentedProvider extends ProviderRef {
logos?: DeviceAsset[]
primary: boolean
Expand Down
Loading
Loading