From a6fa51ed9d8c370c0f9feb47454b2256ab4bdcc6 Mon Sep 17 00:00:00 2001 From: Sy Bohy Date: Fri, 16 Oct 2026 16:48:59 -0700 Subject: [PATCH] feat: Add Ecosystem components (CompatibleApps, SupportedDevices) Add Seam Ecosystem (PartnerDB and DeviceDB) components for marketing and product pages: - CompatibleApps / : the live partner apps that support a device type, manufacturer, provider or capability, from GET /v1/compatibility (declared-first), with per-partner notes. - SupportedDevices / : device models grouped by manufacturer with device type and capability badges, search and filters, from GET /v1/device_models/list. Defaults to the manufacturer of a manufacturer embed key and hides a partner key's declared exclusions (GET /v1/embed/resolve). Add EcosystemProvider, an embedKey and ecosystemEndpoint prop on SeamProvider, react-query hooks that send X-Embed-Key, and Ecosystem response types copied from seamapi/ecosystem public-types. Add a lightweight dist/ecosystem.js bundle (@seamapi/react/ecosystem.js) with only the Ecosystem elements and no Seam API client; elements.js also defines them. CI checks its size. Storybook and tests use a fake Ecosystem API (.storybook/fake-ecosystem.js, served at /api/ecosystem), never a real embed key or production data. Fixes DEVDB-443 Fixes DEVDB-444 Fixes DEVDB-445 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/_build.yml | 2 + .storybook/fake-ecosystem.d.ts | 37 + .storybook/fake-ecosystem.js | 1575 +++++++++++++++++ .storybook/middleware.js | 7 + CLAUDE.md | 15 +- README.md | 109 ++ api/fake-ecosystem.ts | 45 + package.json | 12 +- size.ts | 52 + src/ecosystem.d.ts | 2 + src/ecosystem.ts | 12 + src/elements.ts | 4 + src/lib/ecosystem-element.tsx | 154 ++ src/lib/element.tsx | 6 + src/lib/seam/SeamProvider.tsx | 20 +- .../CompatibleApps/CompatibleApps.element.ts | 19 + .../CompatibleApps/CompatibleApps.stories.tsx | 105 ++ .../CompatibleApps/CompatibleApps.test.tsx | 171 ++ .../CompatibleApps/CompatibleApps.tsx | 344 ++++ .../SupportedDevices/DeviceModelRow.tsx | 105 ++ .../SupportedDevices/FilterSelect.tsx | 43 + .../SupportedDevices/ManufacturerSection.tsx | 114 ++ .../SupportedDevices.element.ts | 17 + .../SupportedDevices.stories.tsx | 102 ++ .../SupportedDevices.test.tsx | 156 ++ .../SupportedDevices/SupportedDevices.tsx | 382 ++++ .../SupportedDevices/device-models.test.ts | 228 +++ .../SupportedDevices/device-models.ts | 326 ++++ src/lib/seam/components/ecosystem-elements.ts | 2 + src/lib/seam/components/index.ts | 2 + src/lib/seam/ecosystem/EcosystemProvider.tsx | 133 ++ src/lib/seam/ecosystem/client.ts | 116 ++ src/lib/seam/ecosystem/index.ts | 19 + src/lib/seam/ecosystem/types.ts | 334 ++++ .../seam/ecosystem/use-compatible-partners.ts | 80 + .../ecosystem/use-ecosystem-device-models.ts | 96 + .../ecosystem/use-ecosystem-manufacturers.ts | 86 + .../ecosystem/use-ecosystem-query-result.ts | 19 + .../use-embed-key-resolution.test.ts | 71 + .../ecosystem/use-embed-key-resolution.ts | 80 + src/lib/seam/index.ts | 1 + src/lib/seam/seam-components-class-name.ts | 1 + src/lib/ui/ecosystem/SearchField.tsx | 32 + src/lib/ui/ecosystem/StateMessage.tsx | 29 + src/lib/ui/ecosystem/errors.ts | 30 + src/lib/ui/ecosystem/storybook.tsx | 40 + src/lib/ui/ecosystem/theme.ts | 14 + src/styles/_compatible-apps.scss | 102 ++ src/styles/_ecosystem.scss | 295 +++ src/styles/_main.scss | 6 + src/styles/_supported-devices.scss | 153 ++ test/ecosystem.test.ts | 72 + test/fixtures/ecosystem.tsx | 74 + tsconfig.build.json | 3 + tsconfig.json | 4 +- vercel.json | 4 + vite.config.ts | 46 +- 57 files changed, 6098 insertions(+), 10 deletions(-) create mode 100644 .storybook/fake-ecosystem.d.ts create mode 100644 .storybook/fake-ecosystem.js create mode 100644 api/fake-ecosystem.ts create mode 100644 size.ts create mode 100644 src/ecosystem.d.ts create mode 100644 src/ecosystem.ts create mode 100644 src/lib/ecosystem-element.tsx create mode 100644 src/lib/seam/components/CompatibleApps/CompatibleApps.element.ts create mode 100644 src/lib/seam/components/CompatibleApps/CompatibleApps.stories.tsx create mode 100644 src/lib/seam/components/CompatibleApps/CompatibleApps.test.tsx create mode 100644 src/lib/seam/components/CompatibleApps/CompatibleApps.tsx create mode 100644 src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx create mode 100644 src/lib/seam/components/SupportedDevices/FilterSelect.tsx create mode 100644 src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx create mode 100644 src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts create mode 100644 src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx create mode 100644 src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx create mode 100644 src/lib/seam/components/SupportedDevices/SupportedDevices.tsx create mode 100644 src/lib/seam/components/SupportedDevices/device-models.test.ts create mode 100644 src/lib/seam/components/SupportedDevices/device-models.ts create mode 100644 src/lib/seam/components/ecosystem-elements.ts create mode 100644 src/lib/seam/ecosystem/EcosystemProvider.tsx create mode 100644 src/lib/seam/ecosystem/client.ts create mode 100644 src/lib/seam/ecosystem/index.ts create mode 100644 src/lib/seam/ecosystem/types.ts create mode 100644 src/lib/seam/ecosystem/use-compatible-partners.ts create mode 100644 src/lib/seam/ecosystem/use-ecosystem-device-models.ts create mode 100644 src/lib/seam/ecosystem/use-ecosystem-manufacturers.ts create mode 100644 src/lib/seam/ecosystem/use-ecosystem-query-result.ts create mode 100644 src/lib/seam/ecosystem/use-embed-key-resolution.test.ts create mode 100644 src/lib/seam/ecosystem/use-embed-key-resolution.ts create mode 100644 src/lib/seam/seam-components-class-name.ts create mode 100644 src/lib/ui/ecosystem/SearchField.tsx create mode 100644 src/lib/ui/ecosystem/StateMessage.tsx create mode 100644 src/lib/ui/ecosystem/errors.ts create mode 100644 src/lib/ui/ecosystem/storybook.tsx create mode 100644 src/lib/ui/ecosystem/theme.ts create mode 100644 src/styles/_compatible-apps.scss create mode 100644 src/styles/_ecosystem.scss create mode 100644 src/styles/_supported-devices.scss create mode 100644 test/ecosystem.test.ts create mode 100644 test/fixtures/ecosystem.tsx diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 6d9a09926..92a145054 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -28,6 +28,8 @@ jobs: node_version: ${{ inputs.node_version }} - name: Build run: npm run build + - name: Check bundle size + run: npm run size - name: Package run: npm pack - name: Upload artifact diff --git a/.storybook/fake-ecosystem.d.ts b/.storybook/fake-ecosystem.d.ts new file mode 100644 index 000000000..067e459fc --- /dev/null +++ b/.storybook/fake-ecosystem.d.ts @@ -0,0 +1,37 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +export declare const fakeEmbedKey: string +export declare const fakePartnerEmbedKey: string +export declare const fakeManufacturerEmbedKey: string +export declare const fakeWrongOriginEmbedKey: string +export declare const fakeInvalidEmbedKey: string + +export interface FakeEcosystemRequest { + method: string + path: string + searchParams: URLSearchParams + headers: Record + assetBaseUrl: string +} + +export interface FakeEcosystemResponse { + status: number + headers: Record + body: string | null +} + +export declare function handleFakeEcosystemRequest( + request: FakeEcosystemRequest +): FakeEcosystemResponse + +export declare function createFakeEcosystemMiddleware(options: { + assetBaseUrl: string +}): (req: IncomingMessage, res: ServerResponse) => void + +export interface FakeEcosystemServer { + serverUrl: string + endpoint: string + close: () => Promise +} + +export declare function startFakeEcosystemServer(): Promise diff --git a/.storybook/fake-ecosystem.js b/.storybook/fake-ecosystem.js new file mode 100644 index 000000000..52c4e31f0 --- /dev/null +++ b/.storybook/fake-ecosystem.js @@ -0,0 +1,1575 @@ +import { createHash } from 'node:crypto' +import { createServer } from 'node:http' + +// A fake of the public Seam Ecosystem API (PartnerDB and DeviceDB) for Storybook and tests. +// It mirrors the routes, parameters, errors and declared-first support resolution of +// seamapi/ecosystem, with fictional partners and a small set of device models. +// Unlike the real API, /v1/compatibility always requires an embed key here, +// as if every caller were cross-origin. + +export const fakeEmbedKey = 'eco_live_storybook' +export const fakePartnerEmbedKey = 'eco_live_storybook_partner' +export const fakeManufacturerEmbedKey = 'eco_live_storybook_schlage' +export const fakeWrongOriginEmbedKey = 'eco_live_storybook_wrong_origin' +export const fakeInvalidEmbedKey = 'eco_live_invalid' + +const timestamp = '2026-10-01T00:00:00.000Z' + +const uuid = (name) => { + const hex = createHash('sha1').update(`fake-ecosystem:${name}`).digest('hex') + return [ + hex.slice(0, 8), + hex.slice(8, 12), + `5${hex.slice(13, 16)}`, + `a${hex.slice(17, 20)}`, + hex.slice(20, 32), + ].join('-') +} + +// Taxonomy + +const industries = { + str: ['short-term-rental', 'Short-Term Rental', 'STR'], + multifamily: ['multifamily', 'Multifamily', null], + hospitality: ['hospitality', 'Hospitality', null], + coworking: ['coworking', 'Coworking', null], +} + +const solutions = { + pms: ['property-management-system', 'Property Management System', 'PMS'], + guest: ['guest-experience', 'Guest Experience', null], + operations: ['operations', 'Operations', null], + access: ['access-control', 'Access Control', null], + energy: ['energy-management', 'Energy Management', null], + noise: ['noise-monitoring', 'Noise Monitoring', null], + operator: ['operator', 'Operator', null, 'service'], +} + +const toIndustry = (key) => { + const [slug, displayName, shortName] = industries[key] + return { + industry_id: uuid(`industry:${slug}`), + slug, + display_name: displayName, + short_name: shortName, + } +} + +const toSolution = (key) => { + const [slug, displayName, shortName, type = 'software'] = solutions[key] + return { + solution_id: uuid(`solution:${slug}`), + slug, + display_name: displayName, + short_name: shortName, + type, + } +} + +// Partners (PartnerDB): [category, manufacturer, provider, capability, declared_status, note] + +const partners = [ + { + slug: 'staypilot', + name: 'StayPilot', + color: '#2563eb', + featured: true, + industries: ['str'], + solutions: ['pms'], + support: [ + ['smartlock', '', '', '', 'enabled'], + ['thermostat', '', '', '', 'enabled'], + ['sensor', '', '', '', 'enabled'], + [ + 'smartlock', + '', + 'salto-ks', + 'can_provision_mobile_keys', + 'disabled', + 'Mobile keys for Salto KS are on the StayPilot roadmap.', + ], + ], + }, + { + slug: 'hostly', + name: 'Hostly', + color: '#db2777', + industries: ['str'], + solutions: ['pms'], + support: [ + ['smartlock', '', '', '', 'enabled'], + ['thermostat', '', '', '', 'enabled'], + ['smartlock', 'august', '', '', 'disabled'], + ['smartlock', '', 'smart-things-cloud', '', 'not_via_seam'], + [ + 'smartlock', + '', + 'salto-ks', + 'can_provision_mobile_keys', + 'disabled', + 'Hostly sends access codes instead of mobile keys.', + ], + ], + }, + { + slug: 'keyturn', + name: 'Keyturn', + color: '#0d9488', + industries: ['str'], + solutions: ['operator'], + support: [ + ['smartlock', '', '', '', 'enabled'], + [ + 'smartlock', + 'schlage', + '', + '', + 'enabled', + 'Keyturn programs Schlage Encode codes for every reservation.', + ], + ], + }, + { + slug: 'nestwell', + name: 'Nestwell', + color: '#7c3aed', + industries: ['str'], + solutions: ['guest'], + support: [ + ['thermostat', '', '', '', 'enabled'], + ['sensor', '', '', '', 'enabled'], + ], + }, + { + slug: 'thermostay', + name: 'ThermoStay', + color: '#ea580c', + featured: true, + industries: ['str'], + solutions: ['energy'], + support: [ + ['thermostat', '', '', '', 'enabled'], + [ + 'thermostat', + '', + '', + 'can_program_thermostat_weekly_programs', + 'enabled', + 'Pre-heats and pre-cools between stays.', + ], + ], + }, + { + slug: 'quietly', + name: 'Quietly', + color: '#475569', + hasLogo: false, + industries: ['str'], + solutions: ['noise'], + support: [['sensor', '', '', '', 'enabled']], + }, + { + slug: 'gatehouse', + name: 'Gatehouse', + color: '#16a34a', + industries: ['multifamily'], + solutions: ['access'], + support: [ + ['smartlock', '', '', '', 'enabled'], + ['intercom', '', '', '', 'enabled'], + [ + 'smartlock', + '', + 'salto-ks', + 'can_provision_mobile_keys', + 'enabled', + 'Residents unlock Salto KS doors from the Gatehouse app.', + ], + ], + }, + { + slug: 'tenantry', + name: 'Tenantry', + color: '#9333ea', + industries: ['multifamily'], + solutions: ['pms'], + support: [ + ['smartlock', '', '', '', 'enabled'], + ['thermostat', '', '', '', 'enabled'], + [ + 'smartlock', + '', + 'salto-ks', + 'can_provision_mobile_keys', + 'not_via_seam', + 'Tenantry uses its own Salto KS integration.', + ], + ], + }, + { + slug: 'roomsuite', + name: 'Roomsuite', + color: '#b45309', + industries: ['hospitality'], + solutions: ['pms'], + support: [ + ['smartlock', '', '', '', 'enabled'], + [ + 'smartlock', + '', + 'salto-ks', + 'can_provision_mobile_keys', + 'enabled', + 'Guests receive Salto KS mobile keys at check-in.', + ], + ], + }, + { + slug: 'deskdock', + name: 'DeskDock', + color: '#0284c7', + industries: ['coworking'], + solutions: ['access'], + support: [ + ['smartlock', '', '', '', 'enabled'], + ['smartlock', '', 'salto-ks', 'can_provision_mobile_keys', 'enabled'], + ], + }, + { + slug: 'hearth-and-key', + name: 'Hearth & Key', + color: '#be123c', + industries: ['str'], + solutions: ['operator'], + support: [ + ['smartlock', 'schlage', '', '', 'enabled'], + ['smartlock', 'yale', '', '', 'enabled'], + ], + }, + { + slug: 'parcelpoint', + name: 'Parcelpoint', + color: '#4d7c0f', + industries: ['multifamily'], + solutions: ['operations'], + support: [ + ['smartlock', '', '', '', 'enabled'], + ['intercom', '', '', '', 'enabled'], + ], + }, + { + slug: 'cleanturn', + name: 'CleanTurn', + color: '#0891b2', + industries: ['str'], + solutions: ['operations'], + support: [ + ['smartlock', '', '', '', 'enabled'], + [ + 'smartlock', + 'yale', + '', + '', + 'not_via_seam', + 'CleanTurn connects to Yale directly.', + ], + ], + }, + { + slug: 'brightside-suites', + name: 'Brightside Suites', + color: '#ca8a04', + website: null, + industries: ['hospitality'], + solutions: ['guest'], + support: [ + ['smartlock', '', '', '', 'enabled'], + ['thermostat', '', '', '', 'enabled'], + ], + }, + { + slug: 'lockbox-labs', + name: 'Lockbox Labs', + color: '#64748b', + live: false, + industries: ['str'], + solutions: ['pms'], + support: [['smartlock', '', '', '', 'enabled']], + }, +] + +const levelOf = ([, manufacturer, provider, capability]) => { + if (capability !== '') return 'capability' + if (provider !== '') return 'provider' + if (manufacturer !== '') return 'manufacturer' + return 'device_category' +} + +const toSupportRow = (partner, row) => { + const [category, manufacturer, provider, capability, declared, note = null] = + row + return { + partner_support_id: uuid(`support:${partner.slug}:${row.join(':')}`), + partner_id: uuid(`partner:${partner.slug}`), + level: levelOf(row), + device_category: category, + manufacturer_slug: manufacturer, + provider_slug: provider, + capability, + observed_status: declared === 'enabled' ? 'active' : 'none', + observed_metric: {}, + observed_at: timestamp, + declared_status: declared, + declared_by_actor_type: 'seam', + declared_by_actor_id: null, + declared_note: note, + declared_at: timestamp, + } +} + +const toPartner = (partner, assetBaseUrl) => ({ + partner_id: uuid(`partner:${partner.slug}`), + slug: partner.slug, + display_name: partner.name, + website: + partner.website === undefined + ? `https://${partner.slug}.example.com` + : partner.website, + description_md: `${partner.name} is a fictional Seam partner used in the Seam Components Storybook.`, + is_live: partner.live ?? true, + is_featured: partner.featured ?? false, + legacy_industry_featured: false, + industries: partner.industries.map(toIndustry), + solutions: partner.solutions.map(toSolution), + assets: + partner.hasLogo === false + ? [] + : [ + { + partner_asset_id: uuid(`partner-asset:${partner.slug}`), + kind: 'square_logo', + url: `${assetBaseUrl}/assets/partner/${partner.slug}.svg`, + mime: 'image/svg+xml', + width: 64, + height: 64, + sort_order: 0, + }, + { + partner_asset_id: uuid(`partner-asset-mono:${partner.slug}`), + kind: 'square_logo_mono', + url: `${assetBaseUrl}/assets/partner-mono/${partner.slug}.svg`, + mime: 'image/svg+xml', + width: 64, + height: 64, + sort_order: 1, + }, + ], + created_at: timestamp, + updated_at: timestamp, +}) + +// Declared-first resolution, ported from seamapi/ecosystem lib/support/support-matrix.ts. + +const scopesToWalk = ({ + device_category: category, + manufacturer_slug: m, + provider_slug: p, + capability: c, +}) => { + const candidates = [ + [m, p, c], + ['', p, c], + [m, '', c], + ['', '', c], + [m, p, ''], + ['', p, ''], + [m, '', ''], + ['', '', ''], + ] + const seen = new Set() + const walk = [] + for (const [manufacturer, provider, capability] of candidates) { + const key = `${manufacturer}|${provider}|${capability}` + if (seen.has(key)) continue + seen.add(key) + walk.push({ + device_category: category, + manufacturer_slug: manufacturer, + provider_slug: provider, + capability, + }) + } + return walk +} + +const sameScope = (a, b) => + a.device_category === b.device_category && + a.manufacturer_slug === b.manufacturer_slug && + a.provider_slug === b.provider_slug && + a.capability === b.capability + +const resolveSupport = (rows, query) => { + const walk = scopesToWalk(query) + for (const scope of walk) { + const row = rows.find((r) => sameScope(r, scope)) + if (row?.declared_status != null) { + return { + supported: row.declared_status === 'enabled', + resolved_level: row.level, + declared_status: row.declared_status, + observed_status: row.observed_status, + note: row.declared_note, + } + } + } + const broadest = rows.find((r) => sameScope(r, walk.at(-1))) + return { + supported: false, + resolved_level: null, + declared_status: null, + observed_status: broadest?.observed_status ?? null, + note: null, + } +} + +const sortedUnique = (values) => + [...new Set(values)].sort((a, b) => a.localeCompare(b)) + +const excluding = new Set(['disabled', 'not_via_seam']) + +const exclusionsFromSupportRows = (rows, capability) => { + const capabilityExclusions = [] + if (capability !== '') { + for (const category of sortedUnique(rows.map((r) => r.device_category))) { + const inCategory = rows.filter((r) => r.device_category === category) + const manufacturers = [ + '', + ...sortedUnique( + inCategory.map((r) => r.manufacturer_slug).filter((m) => m !== '') + ), + ] + const providers = [ + '', + ...sortedUnique( + inCategory.map((r) => r.provider_slug).filter((p) => p !== '') + ), + ] + for (const manufacturer of manufacturers) { + for (const provider of providers) { + const resolution = resolveSupport(inCategory, { + device_category: category, + manufacturer_slug: manufacturer, + provider_slug: provider, + capability, + }) + if ( + resolution.resolved_level === 'capability' && + !resolution.supported + ) { + capabilityExclusions.push({ + device_category: category, + manufacturer_slug: manufacturer, + provider_slug: provider, + }) + } + } + } + } + } + const declaredExcluding = rows.filter((r) => excluding.has(r.declared_status)) + const at = (level, key) => + sortedUnique( + declaredExcluding.filter((r) => r.level === level).map((r) => r[key]) + ) + return { + excluded_manufacturers: at('manufacturer', 'manufacturer_slug'), + excluded_device_types: at('device_category', 'device_category'), + unsupported_providers: at('provider', 'provider_slug'), + capability, + capability_exclusions: capabilityExclusions, + } +} + +// Devices (DeviceDB) + +const deviceTypes = { + smartlock: ['Smartlock', false, 0], + thermostat: ['Thermostat', false, 1], + sensor: ['Sensor', false, 2], + intercom: ['Intercom', false, 3], + bridge: ['Bridge', true, 10], + gateway: ['Gateway', true, 11], +} + +const toDeviceTypeRef = (slug) => { + const [displayName, isAccessory] = deviceTypes[slug] + return { + device_type_id: uuid(`device-type:${slug}`), + slug, + display_name: displayName, + is_accessory: isAccessory, + } +} + +const providers = { + schlage: 'Schlage Home', + 'schlage-engage': 'Schlage Engage', + 'smart-things-cloud': 'SmartThings Cloud', + 'yale-access': 'Yale Access (US)', + 'yale-home': 'Yale Home', + 'august-access': 'August Access', + 'salto-ks': 'Salto KS', + 'igloohome-api': 'igloohome API', + ecobee: 'ecobee', + 'honeywell-resideo': 'Honeywell Resideo', + 'google-nest': 'Google Nest', + minut: 'Minut', + 'noise-aware-api': 'NoiseAware API', + '2n': '2N', +} + +const toProviderRef = (slug) => ({ + provider_id: uuid(`provider:${slug}`), + slug, + display_name: providers[slug], +}) + +const capabilitiesByDeviceType = { + smartlock: [ + 'device_connectivity', + 'can_remotely_unlock', + 'can_remotely_lock', + 'can_program_online_access_codes', + 'can_program_offline_access_codes', + 'can_provision_mobile_keys', + 'can_provision_wallet_keys', + 'can_encode_plastic_cards', + ], + thermostat: [ + 'device_connectivity', + 'can_hvac_heat', + 'can_hvac_cool', + 'can_hvac_auto', + 'can_hvac_fan', + 'can_program_thermostat_weekly_programs', + ], + sensor: ['device_connectivity', 'can_monitor'], + intercom: [ + 'device_connectivity', + 'can_remotely_unlock', + 'can_program_online_access_codes', + ], + bridge: ['device_connectivity'], + gateway: ['device_connectivity'], +} + +// Provider capability support: capability → 'yes' or [conditionally, details]. Others are 'no'. +const providerProfiles = { + schlage: { + device_connectivity: 'yes', + can_remotely_unlock: 'yes', + can_remotely_lock: 'yes', + can_program_online_access_codes: 'yes', + }, + 'schlage-engage': { + device_connectivity: [ + 'conditionally', + 'Requires a Schlage Engage gateway.', + ], + can_remotely_unlock: 'yes', + can_remotely_lock: 'yes', + can_program_online_access_codes: [ + 'conditionally', + 'Requires a Schlage Engage gateway.', + ], + can_provision_mobile_keys: 'yes', + can_encode_plastic_cards: 'yes', + }, + 'smart-things-cloud': { + device_connectivity: ['conditionally', 'Requires a SmartThings hub.'], + can_remotely_unlock: ['conditionally', 'Requires a SmartThings hub.'], + can_remotely_lock: ['conditionally', 'Requires a SmartThings hub.'], + can_program_online_access_codes: [ + 'conditionally', + 'Requires a SmartThings hub.', + ], + }, + 'yale-access': { + device_connectivity: 'yes', + can_remotely_unlock: 'yes', + can_remotely_lock: 'yes', + can_program_online_access_codes: 'yes', + can_program_offline_access_codes: 'yes', + }, + 'yale-home': { + device_connectivity: 'yes', + can_remotely_unlock: 'yes', + can_remotely_lock: 'yes', + can_program_online_access_codes: 'yes', + }, + 'august-access': { + device_connectivity: [ + 'conditionally', + 'Requires an August Connect bridge.', + ], + can_remotely_unlock: 'yes', + can_remotely_lock: 'yes', + can_program_online_access_codes: [ + 'conditionally', + 'Requires an August keypad.', + ], + }, + 'salto-ks': { + device_connectivity: ['conditionally', 'Requires a Salto KS IQ gateway.'], + can_remotely_unlock: 'yes', + can_program_online_access_codes: [ + 'conditionally', + 'Requires a keypad reader.', + ], + can_provision_mobile_keys: 'yes', + can_provision_wallet_keys: [ + 'conditionally', + 'Requires wallet key support on the lock.', + ], + can_encode_plastic_cards: 'yes', + }, + 'igloohome-api': { + device_connectivity: ['conditionally', 'Requires an igloohome bridge.'], + can_remotely_unlock: ['conditionally', 'Requires an igloohome bridge.'], + can_program_offline_access_codes: 'yes', + }, + ecobee: { + device_connectivity: 'yes', + can_hvac_heat: 'yes', + can_hvac_cool: 'yes', + can_hvac_auto: 'yes', + can_hvac_fan: 'yes', + can_program_thermostat_weekly_programs: 'yes', + }, + 'honeywell-resideo': { + device_connectivity: 'yes', + can_hvac_heat: 'yes', + can_hvac_cool: 'yes', + can_hvac_auto: 'yes', + can_hvac_fan: 'yes', + }, + 'google-nest': { + device_connectivity: 'yes', + can_hvac_heat: 'yes', + can_hvac_cool: 'yes', + can_hvac_auto: 'yes', + can_hvac_fan: ['conditionally', 'Requires a connected fan.'], + }, + minut: { device_connectivity: 'yes', can_monitor: 'yes' }, + 'noise-aware-api': { device_connectivity: 'yes', can_monitor: 'yes' }, + '2n': { + device_connectivity: 'yes', + can_remotely_unlock: 'yes', + can_program_online_access_codes: 'yes', + }, +} + +const manufacturers = [ + { + slug: 'schlage', + name: 'Schlage', + color: '#c8102e', + providers: ['schlage', 'schlage-engage', 'smart-things-cloud'], + }, + { + slug: 'yale', + name: 'Yale', + color: '#f5c400', + text: '#111111', + providers: ['yale-access', 'yale-home', 'smart-things-cloud'], + }, + { + slug: 'august', + name: 'August', + color: '#4a4a4a', + providers: ['august-access'], + }, + { slug: 'salto', name: 'Salto', color: '#0057b8', providers: ['salto-ks'] }, + { + slug: 'igloohome', + name: 'igloohome', + color: '#00a3e0', + providers: ['igloohome-api'], + }, + { slug: 'ecobee', name: 'ecobee', color: '#2f9e44', providers: ['ecobee'] }, + { + slug: 'honeywell', + name: 'Honeywell', + color: '#e4002b', + providers: ['honeywell-resideo'], + }, + { + slug: 'google-nest', + name: 'Google Nest', + color: '#5f6368', + providers: ['google-nest'], + }, + { slug: 'minut', name: 'Minut', color: '#1f2937', providers: ['minut'] }, + { + slug: 'noiseaware', + name: 'NoiseAware', + color: '#6d28d9', + providers: ['noise-aware-api'], + }, + { slug: '2n', name: '2N', color: '#00205b', providers: ['2n'] }, +] + +// [slug, name, manufacturer, device type, providers, model ids, options] +const deviceModels = [ + [ + 'encode-plus-smart-wifi-deadbolt', + 'Encode Plus Smart WiFi Deadbolt', + 'schlage', + 'smartlock', + ['schlage'], + ['BE499WB'], + ], + [ + 'encode-smart-wifi-deadbolt', + 'Encode Smart WiFi Deadbolt', + 'schlage', + 'smartlock', + ['schlage'], + ['BE489WB'], + ], + [ + 'encode-smart-wifi-lever', + 'Encode Smart WiFi Lever', + 'schlage', + 'smartlock', + ['schlage'], + ['FE789WB'], + ], + [ + 'control-smart-lock', + 'Control Smart Lock', + 'schlage', + 'smartlock', + ['schlage-engage'], + ['FE410F'], + ], + [ + 'le-wireless-mortise-lock', + 'LE Wireless Mortise Lock', + 'schlage', + 'smartlock', + ['schlage-engage'], + ['LEMB'], + ], + [ + 'connect-smart-deadbolt', + 'Connect Smart Deadbolt', + 'schlage', + 'smartlock', + ['smart-things-cloud'], + ['BE469ZP'], + ], + [ + 'engage-gateway', + 'Engage Gateway', + 'schlage', + 'gateway', + ['schlage-engage'], + ['GWE'], + ], + [ + 'schlage-internal-test-lock', + 'Internal Test Lock', + 'schlage', + 'smartlock', + ['schlage'], + [], + { forbidden: true }, + ], + [ + 'assure-lock-2', + 'Assure Lock 2', + 'yale', + 'smartlock', + ['yale-access'], + ['YRD420'], + ], + [ + 'assure-lock-2-touch', + 'Assure Lock 2 Touch', + 'yale', + 'smartlock', + ['yale-access'], + ['YRD450'], + ], + [ + 'approach-lock', + 'Approach Lock', + 'yale', + 'smartlock', + ['yale-access', 'yale-home'], + ['YRD250'], + ], + [ + 'linus-smart-lock-l2', + 'Linus Smart Lock L2', + 'yale', + 'smartlock', + ['yale-home'], + ['YDM4115A'], + ], + [ + 'assure-lock-sl-z-wave', + 'Assure Lock SL (Z-Wave)', + 'yale', + 'smartlock', + ['smart-things-cloud'], + ['YRD256'], + ], + [ + 'yale-connect-wifi-bridge', + 'Connect Wi-Fi Bridge', + 'yale', + 'bridge', + ['yale-access'], + ['AYR-CB'], + ], + [ + 'wifi-smart-lock', + 'Wi-Fi Smart Lock', + 'august', + 'smartlock', + ['august-access'], + ['ASL-05'], + ], + [ + 'smart-lock-pro', + 'Smart Lock Pro', + 'august', + 'smartlock', + ['august-access'], + ['ASL-03'], + ], + [ + 'august-connect-wifi-bridge', + 'Connect Wi-Fi Bridge', + 'august', + 'bridge', + ['august-access'], + ['AC-R1'], + ], + ['xs4-one', 'XS4 One', 'salto', 'smartlock', ['salto-ks'], ['XS4ONE']], + ['neo-cylinder', 'Neo Cylinder', 'salto', 'smartlock', ['salto-ks'], ['NEO']], + ['xs4-mini', 'XS4 Mini', 'salto', 'smartlock', ['salto-ks'], ['XS4MINI']], + ['ks-iq-gateway', 'KS IQ Gateway', 'salto', 'gateway', ['salto-ks'], ['IQ2']], + [ + 'smart-deadbolt-2s', + 'Smart Deadbolt 2S', + 'igloohome', + 'smartlock', + ['igloohome-api'], + ['IGM3'], + ], + [ + 'keybox-3', + 'Keybox 3', + 'igloohome', + 'smartlock', + ['igloohome-api'], + ['IGK3'], + ], + [ + 'smart-thermostat-premium', + 'Smart Thermostat Premium', + 'ecobee', + 'thermostat', + ['ecobee'], + ['EB-STATE6P-01'], + ], + [ + 'smart-thermostat-enhanced', + 'Smart Thermostat Enhanced', + 'ecobee', + 'thermostat', + ['ecobee'], + ['EB-STATE6L-01'], + ], + [ + 't6-pro-smart-thermostat', + 'T6 Pro Smart Thermostat', + 'honeywell', + 'thermostat', + ['honeywell-resideo'], + ['TH6220WF2006'], + ], + [ + 't9-smart-thermostat', + 'T9 Smart Thermostat', + 'honeywell', + 'thermostat', + ['honeywell-resideo'], + ['RCHT9610WF'], + ], + [ + 'nest-learning-thermostat', + 'Nest Learning Thermostat', + 'google-nest', + 'thermostat', + ['google-nest'], + ['GA02081'], + ], + [ + 'nest-thermostat', + 'Nest Thermostat', + 'google-nest', + 'thermostat', + ['google-nest'], + ['GA01334'], + ], + [ + 'minut-smart-home-sensor', + 'Minut Smart Home Sensor', + 'minut', + 'sensor', + ['minut'], + ['MT-03'], + ], + [ + 'indoor-noise-sensor', + 'Indoor Noise Sensor', + 'noiseaware', + 'sensor', + ['noise-aware-api'], + ['NA-2'], + ], + ['ip-style', 'IP Style', '2n', 'intercom', ['2n'], ['9157101']], + ['ip-verso', 'IP Verso', '2n', 'intercom', ['2n'], ['9155101']], +] + +const toAsset = ({ kind, name, url, sortOrder = 0 }) => ({ + asset_id: uuid(`asset:${kind}:${name}`), + kind, + object_key: `fake/${kind}/${name}.svg`, + url, + mime: 'image/svg+xml', + width: 64, + height: 64, + bytes: null, + sha256: null, + sort_order: sortOrder, + formats: {}, + created_at: timestamp, + updated_at: timestamp, +}) + +const toManufacturerRef = (slug) => { + const manufacturer = manufacturers.find((m) => m.slug === slug) + return { + manufacturer_id: uuid(`manufacturer:${slug}`), + slug, + display_name: manufacturer?.name ?? null, + } +} + +const toCapability = (slug, providerSlug, name) => { + const value = providerProfiles[providerSlug]?.[name] ?? 'no' + const [supported, details] = Array.isArray(value) ? value : [value, null] + const report = + supported === 'no' + ? { details: 'This system does not provide this capability.' } + : { details: details ?? 'Supported through this system.' } + return { + capability_id: uuid(`capability:${slug}:${providerSlug}:${name}`), + name, + supported, + integration_support_level: supported === 'no' ? 'unavailable' : 'stable', + report, + report_raw: report, + locale: 'en', + computed_at: timestamp, + } +} + +const toDeviceModel = (row, assetBaseUrl) => { + const [ + slug, + name, + manufacturer, + deviceType, + providerSlugs, + modelIds, + options = {}, + ] = row + return { + device_model_id: uuid(`device-model:${slug}`), + slug, + display_name: name, + description_md: null, + product_url: `https://${manufacturer}.example.com/products/${slug}`, + is_forbidden_to_3dparty: options.forbidden ?? false, + no_longer_sold: false, + shop_listings: null, + last_commerce_search_at: null, + manufacturer: toManufacturerRef(manufacturer), + device_type: toDeviceTypeRef(deviceType), + device_type_properties: null, + variants: modelIds.map((modelId, index) => ({ + device_model_variant_id: uuid(`variant:${slug}:${modelId}`), + color_name: null, + style_name: null, + primary_color_hex: null, + manufacturer_model_id: modelId, + asin: null, + gtins: null, + marketplace_listings: null, + sort_order: index, + images: [], + created_at: timestamp, + updated_at: timestamp, + })), + featured_image: toAsset({ + kind: 'featured', + name: slug, + url: `${assetBaseUrl}/assets/device/${deviceType}/${manufacturer}.svg`, + }), + providers: providerSlugs.map(toProviderRef), + capabilities_by_provider: providerSlugs.map((providerSlug) => ({ + provider: toProviderRef(providerSlug), + capabilities: capabilitiesByDeviceType[deviceType].map((capability) => + toCapability(slug, providerSlug, capability) + ), + })), + capabilities_stale: false, + created_at: timestamp, + updated_at: timestamp, + } +} + +const toManufacturer = (manufacturer, assetBaseUrl) => { + const types = [ + ...new Set( + deviceModels.filter((m) => m[2] === manufacturer.slug).map((m) => m[3]) + ), + ] + return { + manufacturer_id: uuid(`manufacturer:${manufacturer.slug}`), + slug: manufacturer.slug, + display_name: manufacturer.name, + website: `https://${manufacturer.slug}.example.com`, + headquarters: null, + country_of_origin: null, + parent_org: null, + inc_date: null, + customer_support_phone: null, + customer_support_email: null, + customer_support_url: null, + description_md: null, + seam_api_guide: null, + commerce_footprint: null, + last_commerce_search_at: null, + logos: [ + toAsset({ + kind: 'square_logo', + name: manufacturer.slug, + url: `${assetBaseUrl}/assets/manufacturer/${manufacturer.slug}.svg`, + }), + toAsset({ + kind: 'square_logo_mono', + name: manufacturer.slug, + url: `${assetBaseUrl}/assets/manufacturer-mono/${manufacturer.slug}.svg`, + sortOrder: 1, + }), + ], + default_device_images: types.map((type) => ({ + device_type: toDeviceTypeRef(type), + asset: toAsset({ + kind: 'default_device_image', + name: `${manufacturer.slug}-${type}`, + url: `${assetBaseUrl}/assets/device/${type}/${manufacturer.slug}.svg`, + }), + })), + providers: manufacturer.providers.map(toProviderRef), + device_model_count: deviceModels.filter((m) => m[2] === manufacturer.slug) + .length, + created_at: timestamp, + updated_at: timestamp, + } +} + +// Embed keys + +const embedKeys = { + [fakeEmbedKey]: { + display_name: 'Storybook', + owner_kind: 'seam', + partner: null, + manufacturer_slug: null, + }, + [fakePartnerEmbedKey]: { + display_name: 'Hostly website', + owner_kind: 'partner', + partner: 'hostly', + manufacturer_slug: null, + }, + [fakeManufacturerEmbedKey]: { + display_name: 'Schlage website', + owner_kind: 'manufacturer', + partner: null, + manufacturer_slug: 'schlage', + }, + [fakeWrongOriginEmbedKey]: { + display_name: 'Wrong origin', + owner_kind: 'seam', + partner: null, + manufacturer_slug: null, + allowed_origins: ['https://example.com'], + }, +} + +class FakeApiError extends Error { + constructor(status, type, message, data) { + super(message) + this.status = status + this.type = type + this.data = data + } +} + +const requireEmbedKey = ({ headers, searchParams }) => { + const key = searchParams.get('embed_key') ?? headers['x-embed-key'] ?? '' + if (key.length === 0) { + throw new FakeApiError( + 401, + 'embed_key_required', + 'Send the embed key as X-Embed-Key or embed_key' + ) + } + const embedKey = embedKeys[key] + if (embedKey == null) { + throw new FakeApiError( + 401, + 'invalid_embed_key', + 'Unknown or revoked embed key' + ) + } + if (key === fakeWrongOriginEmbedKey) { + throw new FakeApiError( + 403, + 'origin_not_allowed', + `Origin ${headers.origin ?? 'https://react.seam.co'} is not allowed for this key` + ) + } + return { key, ...embedKey } +} + +const capabilityNames = new Set(Object.values(capabilitiesByDeviceType).flat()) + +const paginate = (items, searchParams) => { + const limit = Math.min( + Math.max(Number(searchParams.get('limit') ?? 100) || 100, 1), + 500 + ) + const cursor = searchParams.get('page_cursor') + const sorted = [...items].sort((a, b) => a.slug.localeCompare(b.slug)) + const start = cursor == null ? 0 : sorted.findIndex((i) => i.slug > cursor) + const page = start === -1 ? [] : sorted.slice(start, start + limit + 1) + const hasNextPage = page.length > limit + const items_ = page.slice(0, limit) + return { + items: items_, + pagination: { + has_next_page: hasNextPage, + next_page_cursor: hasNextPage ? (items_.at(-1)?.slug ?? null) : null, + }, + } +} + +const listParam = (searchParams, key) => + searchParams + .getAll(key) + .flatMap((v) => v.split(',')) + .map((v) => v.trim()) + .filter((v) => v.length > 0) + +const routes = { + 'v1/compatibility': ({ searchParams, headers, assetBaseUrl }) => { + requireEmbedKey({ headers, searchParams }) + const category = searchParams.get('device_category') ?? '' + if (!/^[a-z0-9_]+$/.test(category)) { + throw new FakeApiError( + 400, + 'invalid_params', + 'Invalid request parameters' + ) + } + const query = { + device_category: category, + manufacturer_slug: searchParams.get('manufacturer_slug') ?? '', + provider_slug: searchParams.get('provider_slug') ?? '', + capability: searchParams.get('capability') ?? '', + } + const industry = searchParams.get('industry') + const includeUnsupported = + searchParams.get('include_unsupported') === 'true' + const resolved = partners + .filter((p) => p.live !== false) + .filter( + (p) => + industry == null || + p.industries.some((i) => industries[i][0] === industry) + ) + .map((p) => ({ + ...toPartner(p, assetBaseUrl), + support: resolveSupport( + p.support + .map((row) => toSupportRow(p, row)) + .filter((row) => row.device_category === category), + query + ), + })) + return { + partners: includeUnsupported + ? resolved + : resolved.filter((p) => p.support.supported), + } + }, + + 'v1/device_models/list': ({ searchParams, assetBaseUrl }) => { + const capability = searchParams.get('capability') + if (capability != null && !capabilityNames.has(capability)) { + throw new FakeApiError( + 400, + 'invalid_params', + 'Invalid request parameters' + ) + } + const slugs = listParam(searchParams, 'slugs') + const manufacturer = searchParams.get('manufacturer') + const deviceType = searchParams.get('device_type') + const provider = searchParams.get('provider') + const supported = searchParams.get('supported') + const includeForbidden = searchParams.get('include_forbidden') === 'true' + const isAccessory = searchParams.get('is_accessory') + const models = deviceModels + .map((row) => toDeviceModel(row, assetBaseUrl)) + .filter((m) => includeForbidden || m.is_forbidden_to_3dparty !== true) + .filter((m) => slugs.length === 0 || slugs.includes(m.slug)) + .filter( + (m) => manufacturer == null || m.manufacturer?.slug === manufacturer + ) + .filter((m) => deviceType == null || m.device_type?.slug === deviceType) + .filter( + (m) => provider == null || m.providers.some((p) => p.slug === provider) + ) + .filter( + (m) => + isAccessory == null || + String(m.device_type?.is_accessory ?? false) === isAccessory + ) + .filter( + (m) => + capability == null || + m.capabilities_by_provider.some(({ capabilities }) => + capabilities.some( + (c) => + c.name === capability && + (supported == null || c.supported === supported) + ) + ) + ) + const { items, pagination } = paginate(models, searchParams) + return { device_models: items, pagination } + }, + + 'v1/manufacturers/list': ({ searchParams, assetBaseUrl }) => { + const slugs = listParam(searchParams, 'slugs') + const provider = searchParams.get('provider') + const list = manufacturers + .filter((m) => slugs.length === 0 || slugs.includes(m.slug)) + .filter((m) => provider == null || m.providers.includes(provider)) + .map((m) => toManufacturer(m, assetBaseUrl)) + const { items, pagination } = paginate(list, searchParams) + return { manufacturers: items, pagination } + }, + + 'v1/manufacturers/get': ({ searchParams, assetBaseUrl }) => { + const slug = searchParams.get('slug') + const manufacturerId = searchParams.get('manufacturer_id') + if (slug == null && manufacturerId == null) { + throw new FakeApiError( + 400, + 'missing_identifier', + 'Provide slug or manufacturer_id' + ) + } + const found = manufacturers.find( + (m) => + m.slug === slug || uuid(`manufacturer:${m.slug}`) === manufacturerId + ) + if (found == null) { + throw new FakeApiError( + 404, + 'manufacturer_not_found', + 'Manufacturer not found' + ) + } + return { manufacturer: toManufacturer(found, assetBaseUrl) } + }, + + 'v1/embed/resolve': ({ searchParams, headers }) => { + const embedKey = requireEmbedKey({ headers, searchParams }) + const capability = searchParams.get('capability') ?? '' + const partner = partners.find((p) => p.slug === embedKey.partner) + const exclusions = + partner == null + ? { + excluded_manufacturers: [], + excluded_device_types: [], + unsupported_providers: [], + capability, + capability_exclusions: [], + } + : exclusionsFromSupportRows( + partner.support.map((row) => toSupportRow(partner, row)), + capability + ) + return { + embed_key: { + embed_key_id: uuid(`embed-key:${embedKey.key}`), + display_name: embedKey.display_name, + owner_kind: embedKey.owner_kind, + partner_id: partner == null ? null : uuid(`partner:${partner.slug}`), + manufacturer_slug: embedKey.manufacturer_slug, + allowed_origins: embedKey.allowed_origins ?? [ + 'https://react.seam.co', + 'http://localhost:*', + 'http://127.0.0.1:*', + ], + config: {}, + revoked_at: null, + created_at: timestamp, + }, + exclusions, + } + }, +} + +// Images + +const escapeXml = (value) => + value.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`) + +const initials = (name) => + name + .split(/[\s&]+/) + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0]) + .join('') + .toUpperCase() + +const logoSvg = ({ name, color, text = '#ffffff', mono = false }) => + `${ + mono ? '' : `` + }${escapeXml(initials(name))}` + +const deviceShapes = { + smartlock: (c) => + ``, + thermostat: (c) => + `72`, + sensor: (c) => + ``, + intercom: (c) => + ``, + bridge: (c) => + ``, +} +deviceShapes.gateway = deviceShapes.bridge + +const deviceSvg = (type, color) => + `${( + deviceShapes[type] ?? deviceShapes.bridge + )(color)}` + +const getAsset = (path) => { + const [, kind, ...rest] = path.split('/') + const slug = rest.at(-1)?.replace(/\.svg$/, '') ?? '' + if (kind === 'partner' || kind === 'partner-mono') { + const partner = partners.find((p) => p.slug === slug) + if (partner == null) return null + return logoSvg({ ...partner, mono: kind === 'partner-mono' }) + } + if (kind === 'manufacturer' || kind === 'manufacturer-mono') { + const manufacturer = manufacturers.find((m) => m.slug === slug) + if (manufacturer == null) return null + return logoSvg({ ...manufacturer, mono: kind === 'manufacturer-mono' }) + } + if (kind === 'device' && rest.length === 2) { + const manufacturer = manufacturers.find((m) => m.slug === slug) + return deviceSvg(rest[0], manufacturer?.color ?? '#007afc') + } + return null +} + +const corsHeaders = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, OPTIONS', + 'access-control-allow-headers': + 'Content-Type, Authorization, X-Actor-Id, X-Embed-Key, X-Library-Version', +} + +/** + * Handles one request to the fake Ecosystem API. + * @param {{ + * method: string, + * path: string, + * searchParams: URLSearchParams, + * headers: Record, + * assetBaseUrl: string, + * }} request + * @returns {{ status: number, headers: Record, body: string | null }} + */ +export const handleFakeEcosystemRequest = ({ + method, + path, + searchParams, + headers, + assetBaseUrl, +}) => { + const normalizedPath = path.replace(/^\/+|\/+$/g, '') + + if (method === 'OPTIONS') { + return { + status: 204, + headers: { ...corsHeaders, 'access-control-max-age': '86400' }, + body: null, + } + } + + if (normalizedPath.startsWith('assets/')) { + const svg = getAsset(normalizedPath) + if (svg == null) { + return { status: 404, headers: corsHeaders, body: 'Not found' } + } + return { + status: 200, + headers: { + ...corsHeaders, + 'content-type': 'image/svg+xml', + 'cache-control': 'public, max-age=86400', + }, + body: svg, + } + } + + const json = (status, data) => ({ + status, + headers: { + ...corsHeaders, + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + body: JSON.stringify(data), + }) + + const route = routes[normalizedPath] + if (route == null) { + return json(404, { + ok: false, + error: { type: 'not_found', message: 'Route not found' }, + }) + } + + if (method !== 'GET' && method !== 'HEAD') { + return json(405, { + ok: false, + error: { + type: 'method_not_allowed', + message: `${method} is not allowed by the fake`, + }, + }) + } + + try { + return json(200, { + ...route({ searchParams, headers, assetBaseUrl }), + ok: true, + }) + } catch (err) { + if (err instanceof FakeApiError) { + return json(err.status, { + ok: false, + error: { + type: err.type, + message: err.message, + data: err.data, + request_id: uuid(`request:${normalizedPath}`), + }, + }) + } + throw err + } +} + +const normalizeHeaders = (headers) => + Object.fromEntries( + Object.entries(headers).map(([k, v]) => [ + k.toLowerCase(), + Array.isArray(v) ? v.join(', ') : v, + ]) + ) + +/** + * Express or Node.js middleware serving the fake Ecosystem API. + * Mount it on a path, e.g., `app.use('/api/ecosystem', middleware)`. + * @param {{ assetBaseUrl: string }} options + */ +export const createFakeEcosystemMiddleware = + ({ assetBaseUrl }) => + (req, res) => { + const url = new URL(req.url ?? '/', 'http://fake-ecosystem.local') + const { status, headers, body } = handleFakeEcosystemRequest({ + method: req.method ?? 'GET', + path: url.pathname, + searchParams: url.searchParams, + headers: normalizeHeaders(req.headers), + assetBaseUrl, + }) + res.writeHead(status, headers) + res.end(req.method === 'HEAD' ? undefined : (body ?? undefined)) + } + +/** + * Starts the fake Ecosystem API on a random local port. + * @returns {Promise<{ endpoint: string, serverUrl: string, close: () => Promise }>} + */ +export const startFakeEcosystemServer = async () => { + let serverUrl = '' + const server = createServer((req, res) => { + createFakeEcosystemMiddleware({ assetBaseUrl: serverUrl })(req, res) + }) + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve(undefined) + }) + }) + const address = server.address() + if (address == null || typeof address === 'string') { + throw new Error('Fake Ecosystem server has no port') + } + serverUrl = `http://127.0.0.1:${address.port}` + return { + serverUrl, + endpoint: `${serverUrl}/v1/`, + close: async () => { + await new Promise((resolve) => { + server.close(() => { + resolve(undefined) + }) + }) + }, + } +} diff --git a/.storybook/middleware.js b/.storybook/middleware.js index 53b52e057..c0a5bac38 100644 --- a/.storybook/middleware.js +++ b/.storybook/middleware.js @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import { createProxyMiddleware } from 'http-proxy-middleware' +import { createFakeEcosystemMiddleware } from './fake-ecosystem.js' import { seedFake } from './seed-fake.js' // UPSTREAM: This file must use the .js extension. @@ -14,6 +15,12 @@ export default async (app) => { const fakeUrl = fake.serverUrl if (fakeUrl == null) throw new Error('Missing fake url') + // Must be mounted before the /api proxy to Fake Seam Connect. + app.use( + '/api/ecosystem', + createFakeEcosystemMiddleware({ assetBaseUrl: '/api/ecosystem' }) + ) + app.use( '/', createProxyMiddleware('/api', { diff --git a/CLAUDE.md b/CLAUDE.md index 9b3e74ff8..e83fb1c21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ src/ ├── index.ts # Main export entry point ├── hooks.ts # Hooks-only entry point ├── elements.ts # Web Components entry point +├── ecosystem.ts # Lightweight Seam Ecosystem Web Components entry point ├── index.scss # Main stylesheet └── lib/ ├── seam/ @@ -52,15 +53,18 @@ src/ │ ├── components/ # UI components │ │ ├── AccessCodeDetails/ │ │ ├── AccessCodeTable/ + │ │ ├── CompatibleApps/ # Seam Ecosystem (PartnerDB) │ │ ├── ConnectAccountButton/ │ │ ├── CreateAccessCodeForm/ │ │ ├── DeviceDetails/ │ │ ├── DeviceTable/ - │ │ └── EditAccessCodeForm/ + │ │ ├── EditAccessCodeForm/ + │ │ └── SupportedDevices/ # Seam Ecosystem (DeviceDB) │ ├── access-codes/ # Access code hooks │ ├── client-sessions/ # Session hooks │ ├── connected-accounts/ # Account hooks │ ├── devices/ # Device hooks + │ ├── ecosystem/ # Seam Ecosystem client, EcosystemProvider and hooks │ └── thermostats/ # Thermostat hooks └── icons/ # Generated SVG icon components ``` @@ -81,6 +85,9 @@ Each component typically has: - `@seamapi/react` - Full library (components + hooks) - `@seamapi/react/hooks` - Hooks only (lighter bundle) - `@seamapi/react/elements` - Web Components bundle +- `@seamapi/react/ecosystem.js` - Lightweight bundle with only the Seam Ecosystem Web Components + (``, ``); it must not import `SeamProvider` or `@seamapi/http` + (`npm run size` checks this and the size budget after `npm run build`) ### Path Aliases @@ -112,6 +119,12 @@ npm run test:ui # Open Vitest UI The project uses @seamapi/fake-seam-connect for deterministic testing and Storybook development. Seed data is in `.storybook/seed-fake.js`. +## Fake Seam Ecosystem + +The Ecosystem components use a fake of the public Seam Ecosystem API in `.storybook/fake-ecosystem.js`, +served at `/api/ecosystem` by the Storybook middleware and `api/fake-ecosystem.ts` on Vercel. +Tests start it with `setupFakeEcosystem()` from `test/fixtures/ecosystem.tsx`. Never use a real embed key or production data. + ## Publishing - Automatic releases via semantic-release based on Angular commit message conventions diff --git a/README.md b/README.md index 03a41ca94..80fb12b48 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,98 @@ globalThis.customElements.whenDefined('seam-device-table').then(() => { [Seam Console]: https://console.seam.co/ +### Seam Ecosystem Components + +Two components show [Seam Ecosystem] data on any website, +e.g., a manufacturer's product page or a partner's integrations page: + +- `` (`CompatibleApps`): + the apps (Seam partners) that work with a device type, manufacturer, provider or capability through Seam. + A partner is listed only where Seam or the partner has declared support. +- `` (`SupportedDevices`): + the device models that work with Seam, grouped by manufacturer, + with device type and capability badges, search and filters. + +These components use an Ecosystem embed key (`eco_live_…`) instead of a publishable key. +The page origin must be one of the key's allowed origins. +With a manufacturer key, `` defaults to that manufacturer. +With a partner key, it hides what the partner declared unsupported. + +Load the lightweight `ecosystem.js` bundle, which only contains these two elements +(the full `elements.js` bundle defines them too): + +```html + + + + + +``` + +`` attributes: + +| Attribute | Description | +| -------------- | ----------------------------------------------------------------------------------- | +| `device-type` | Required. Device category, e.g., `smartlock`, `thermostat`, `sensor` or `intercom`. | +| `manufacturer` | DeviceDB manufacturer slug, e.g., `schlage`. | +| `provider` | DeviceDB provider slug, e.g., `salto-ks`. Shows each app's support note. | +| `capability` | DeviceDB capability, e.g., `can_provision_mobile_keys`. Shows each app's note. | +| `industry` | Industry slug, e.g., `short-term-rental`. | +| `max-visible` | Apps shown before the show more button. Defaults to 24. | +| `hide-search` | Hide the search field. | +| `hide-title` | Hide the title. | + +`` attributes: + +| Attribute | Description | +| -------------- | ------------------------------------------------------------------------------------- | +| `manufacturer` | DeviceDB manufacturer slug. Defaults to the manufacturer of a manufacturer embed key. | +| `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. | +| `hide-filters` | Hide the manufacturer, device type and capability filters. | +| `hide-title` | Hide the title. | + +Both elements also accept `embed-key`, `theme` (`light`, `dark` or `auto` to follow the visitor's color scheme), +`ecosystem-endpoint` (defaults to `https://ecosystem.seam.co/v1/`), +`class-name`, `disable-css-injection`, `disable-font-injection` and `unminifiy-css`. +Boolean attributes may be set without a value, e.g., ``. + +With React, wrap the components in an `EcosystemProvider`, +or pass `embedKey` to an existing `SeamProvider`: + +```tsx +import { + CompatibleApps, + EcosystemProvider, + SupportedDevices, +} from '@seamapi/react' + +export function ProductPage() { + return ( + + + + + ) +} +``` + +The data is also available with the `useCompatiblePartners`, `useEcosystemDeviceModels`, +`useEcosystemManufacturers` and `useEmbedKeyResolution` hooks. + +[Seam Ecosystem]: https://ecosystem.seam.co/ + ### React Hooks All components interact with the Seam API via a set of React Hooks. @@ -339,6 +431,15 @@ When serving the CSS styles from the default CDN, extend `style-src` with style-src https://react.seam.co ``` +#### Seam Ecosystem Components CSP + +The Ecosystem components call the Ecosystem API and show logos and device images from its media storage. +Extend the CSP with + +``` +connect-src https://ecosystem.seam.co; img-src https://seam-ecosystem-media.fly.storage.tigris.dev +``` + ### Telemetry By default, this library reports basic usage telemetry to the Seam API. @@ -482,6 +583,14 @@ The tests use the [default seed](https://github.com/seamapi/fake-seam-connect/bl Edit the Storybook seed data for the fake or find relevant ids for testing components here: [Storybook fake seed](./.storybook/seed-fake.js). +### Fake Seam Ecosystem + +The Ecosystem components are developed and tested against a fake of the public Seam Ecosystem API +with fictional partners and sample device models: [.storybook/fake-ecosystem.js](./.storybook/fake-ecosystem.js). +Storybook serves it at `/api/ecosystem` (locally via the Storybook middleware, and on react.seam.co via `api/fake-ecosystem.ts`), +so neither the Storybook nor the tests use a real embed key or production data. +It accepts the embed keys exported from that file. + ### Fake Devicedb Fake Seam Connect optionally depends on a [fake version of the Seam Devicedb](https://github.com/seamapi/fake-devicedb). diff --git a/api/fake-ecosystem.ts b/api/fake-ecosystem.ts new file mode 100644 index 000000000..e6f6da128 --- /dev/null +++ b/api/fake-ecosystem.ts @@ -0,0 +1,45 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' + +// eslint-disable-next-line import/no-relative-parent-imports +import { handleFakeEcosystemRequest } from '../.storybook/fake-ecosystem.js' + +// Serves the fake Seam Ecosystem API on react.seam.co at /api/ecosystem/* +// (see the rewrite in vercel.json), so the Storybook never needs a real embed key. +export default async ( + req: VercelRequest, + res: VercelResponse +): Promise => { + const { ecosystempath, ...query } = req.query + + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + for (const v of Array.isArray(value) ? value : [value]) { + searchParams.append(key, v) + } + } + + const headers: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + headers[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value + } + + const { + status, + headers: resHeaders, + body, + } = handleFakeEcosystemRequest({ + method: req.method ?? 'GET', + path: Array.isArray(ecosystempath) + ? ecosystempath.join('/') + : (ecosystempath ?? ''), + searchParams, + headers, + assetBaseUrl: '/api/ecosystem', + }) + + res.status(status) + for (const [key, value] of Object.entries(resHeaders)) { + res.setHeader(key, value) + } + res.end(body ?? undefined) +} diff --git a/package.json b/package.json index 6ce609290..74aaf6580 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "import": "./dist/elements.js", "types": "./src/elements.d.ts" }, + "./ecosystem.js": { + "import": "./dist/ecosystem.js", + "types": "./src/ecosystem.d.ts" + }, "./index.css": { "import": "./dist/index.css" } @@ -26,7 +30,8 @@ "sideEffects": [ "**/*.css", "./elements.js", - "./dist/elements.js" + "./dist/elements.js", + "./dist/ecosystem.js" ], "keywords": [ "node" @@ -64,7 +69,10 @@ "postbuild": "concurrently --raw --group 'node ./index.js' 'node ./hooks.js'", "build:entrypoints": "npm run build:js", "prebuild:entrypoints": "npm run build:css", - "postbuild:entrypoints": "vite build", + "postbuild:entrypoints": "npm run build:elements && npm run build:ecosystem", + "build:elements": "vite build", + "build:ecosystem": "vite build --mode ecosystem", + "size": "tsx ./size.ts", "build:js": "tsc --project tsconfig.build.json", "postbuild:js": "tsc-alias --project tsconfig.build.json", "build:css": "sass --load-path=node_modules src/index.scss:dist/index.css", diff --git a/size.ts b/size.ts new file mode 100644 index 000000000..5ee0f6bd7 --- /dev/null +++ b/size.ts @@ -0,0 +1,52 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { gzipSync } from 'node:zlib' + +// Size budgets for the web component bundles, in gzipped bytes (1 kB = 1000 bytes, as Vite reports). +// The ecosystem bundle is meant for marketing and product pages: +// keep it small and free of the Seam API client. +const bundles = [ + { file: 'dist/elements.js', maxGzipBytes: null }, + { file: 'dist/ecosystem.js', maxGzipBytes: 80_000 }, +] as const + +// Strings that only appear when the Seam API client or telemetry is bundled. +const forbiddenInEcosystem = ['connect.getseam.com'] + +const main = async (): Promise => { + const errors: string[] = [] + + for (const { file, maxGzipBytes } of bundles) { + const buff = await readFile(fileURLToPath(new URL(file, import.meta.url))) + const gzipBytes = gzipSync(buff).length + const budget = + maxGzipBytes == null ? '' : ` (budget ${formatBytes(maxGzipBytes)})` + // eslint-disable-next-line no-console + console.log( + `${file}: ${formatBytes(buff.length)} raw, ${formatBytes(gzipBytes)} gzip${budget}` + ) + if (maxGzipBytes != null && gzipBytes > maxGzipBytes) { + errors.push( + `${file} is ${formatBytes(gzipBytes)} gzip, over the ${formatBytes(maxGzipBytes)} budget` + ) + } + if (file === 'dist/ecosystem.js') { + const code = buff.toString() + for (const str of forbiddenInEcosystem) { + if (code.includes(str)) { + errors.push( + `${file} includes '${str}': the Seam API client or telemetry was bundled` + ) + } + } + } + } + + if (errors.length > 0) { + throw new Error(errors.join('\n')) + } +} + +const formatBytes = (bytes: number): string => `${(bytes / 1000).toFixed(2)} kB` + +await main() diff --git a/src/ecosystem.d.ts b/src/ecosystem.d.ts new file mode 100644 index 000000000..d56a751c9 --- /dev/null +++ b/src/ecosystem.d.ts @@ -0,0 +1,2 @@ +export const elementNames: string[] +export default elementNames diff --git a/src/ecosystem.ts b/src/ecosystem.ts new file mode 100644 index 000000000..6c1dcded1 --- /dev/null +++ b/src/ecosystem.ts @@ -0,0 +1,12 @@ +import { defineEcosystemElements } from 'lib/ecosystem-element.js' +import * as components from 'lib/seam/components/ecosystem-elements.js' + +/** + * The lightweight Seam Ecosystem web components bundle: + * only and , + * without the Seam API client used by the other Seam Components. + * These elements are also defined by elements.js. + */ +export const elementNames: string[] = defineEcosystemElements(components) + +export default elementNames diff --git a/src/elements.ts b/src/elements.ts index 59c361db7..baf42bdb9 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -1,4 +1,6 @@ +import { defineEcosystemElements } from 'lib/ecosystem-element.js' import { defineCustomElement, type ElementDefinition } from 'lib/element.js' +import * as ecosystemComponents from 'lib/seam/components/ecosystem-elements.js' import * as components from 'lib/seam/components/elements.js' const elementDefinitions = components as unknown as Record< @@ -30,3 +32,5 @@ for (const key of Object.keys(elementDefinitions)) { elementNames.push(name) defineCustomElement({ name, Component, props }) } + +elementNames.push(...defineEcosystemElements(ecosystemComponents)) diff --git a/src/lib/ecosystem-element.tsx b/src/lib/ecosystem-element.tsx new file mode 100644 index 000000000..f1e7473d8 --- /dev/null +++ b/src/lib/ecosystem-element.tsx @@ -0,0 +1,154 @@ +import r2wc from '@rxfork/r2wc-react-to-web-component' +import type { ComponentType } from 'react' + +import { + EcosystemProvider, + type EcosystemProviderProps, +} from 'lib/seam/ecosystem/EcosystemProvider.js' + +declare global { + // eslint-disable-next-line no-var + var disableSeamCssInjection: boolean | undefined + // eslint-disable-next-line no-var + var disableSeamFontInjection: boolean | undefined + // eslint-disable-next-line no-var + var unminifiySeamCss: boolean | undefined +} + +type PropType = 'string' | 'number' | 'boolean' | 'array' | 'object' + +export type EcosystemElementProps = Record< + Exclude, + PropType +> + +export interface EcosystemElementDefinition { + name: string + Component: ComponentType + props?: Record +} + +type ProviderProps = Omit + +const providerProps: Record = { + embedKey: 'string', + ecosystemEndpoint: 'string', + queryClient: 'object', + disableCssInjection: 'boolean', + disableFontInjection: 'boolean', + unminifiyCss: 'boolean', +} + +const commonProps: Record = { + className: 'string', +} + +/** + * Defines the custom elements for the Seam Ecosystem components. + * Each element is wrapped in an ``, not a ``, + * so it needs no publishable key and does not load the Seam API client. + * Elements that are already defined are skipped, + * so `elements.js` and `ecosystem.js` may both be loaded on one page. + */ +export const defineEcosystemElements = ( + definitions: Record> +): string[] => { + const names: string[] = [] + for (const [key, definition] of Object.entries(definitions)) { + const { name, Component, props } = definition + if (name == null) throw new Error(`Missing element name for ${key}`) + if (Component == null) { + throw new Error(`Missing element Component for ${key}`) + } + names.push(name) + defineEcosystemElement({ name, Component, props }) + } + return names +} + +export const defineEcosystemElement = ({ + name, + Component, + props = {}, +}: EcosystemElementDefinition): void => { + const registry = globalThis.customElements + if (registry == null || registry.get(name) != null) return + + const allProps = { ...props, ...providerProps, ...commonProps } + + // UPSTREAM: r2wc parses boolean attributes as false when present without a value, + // e.g., , so they are parsed here instead. + const booleanProps = Object.entries(allProps) + .filter(([, type]) => type === 'boolean') + .map(([prop]) => prop) + + const element = r2wc(withEcosystemProvider(Component, booleanProps), { + props: Object.fromEntries( + Object.entries(allProps).map(([prop, type]) => [ + prop, + type === 'boolean' ? 'string' : type, + ]) + ), + }) + + registry.define(name, element) +} + +/** + * Parses a boolean attribute or property: + * present without a value or `true` is true; absent or `false` is false. + */ +export const parseBooleanProp = (value: unknown): boolean | undefined => { + if (value == null) return undefined + if (typeof value === 'boolean') return value + if (typeof value !== 'string') return Boolean(value) + return !/^(false|0|no|off)$/i.test(value.trim()) +} + +function withEcosystemProvider( + Component: ComponentType, + booleanProps: string[] +): (props: Record) => JSX.Element { + const name = Component.displayName ?? Component.name ?? 'Component' + + function WithEcosystemProvider({ + container: _container, + ...rawProps + }: Record): JSX.Element { + const props: Record = { ...rawProps } + for (const prop of booleanProps) { + props[prop] = parseBooleanProp(props[prop]) + } + + const { + embedKey, + ecosystemEndpoint, + queryClient, + disableCssInjection, + disableFontInjection, + unminifiyCss, + ...componentProps + } = props as ProviderProps & Record + + return ( + + + + ) + } + + WithEcosystemProvider.displayName = `WithEcosystemProvider(${name})` + + return WithEcosystemProvider +} diff --git a/src/lib/element.tsx b/src/lib/element.tsx index c188f3306..c267eb341 100644 --- a/src/lib/element.tsx +++ b/src/lib/element.tsx @@ -64,6 +64,8 @@ const providerProps: R2wcProps = { disableFontInjection: 'boolean', unminifiyCss: 'boolean', onSessionUpdate: 'object', + embedKey: 'string', + ecosystemEndpoint: 'string', } export const defineCustomElement = ({ @@ -96,6 +98,8 @@ function withProvider

( disableFontInjection, unminifiyCss, onSessionUpdate, + embedKey, + ecosystemEndpoint, container: _container, ...props }: ProviderProps & { container: Container } & P): JSX.Element | null { @@ -114,6 +118,8 @@ function withProvider

( } unminifiyCss={unminifiyCss ?? globalThis.unminifiySeamCss} onSessionUpdate={onSessionUpdate} + embedKey={embedKey} + ecosystemEndpoint={ecosystemEndpoint} > diff --git a/src/lib/seam/SeamProvider.tsx b/src/lib/seam/SeamProvider.tsx index ca96caa8b..b7ede9e05 100644 --- a/src/lib/seam/SeamProvider.tsx +++ b/src/lib/seam/SeamProvider.tsx @@ -8,6 +8,8 @@ import { import type { QueryClient } from '@tanstack/react-query' import { createContext, type PropsWithChildren, useMemo } from 'react' +import { EcosystemContextProvider } from 'lib/seam/ecosystem/EcosystemProvider.js' +import { seamComponentsClassName } from 'lib/seam/seam-components-class-name.js' import { useSeamFont } from 'lib/seam/use-seam-font.js' import { useSeamStyles } from 'lib/seam/use-seam-styles.js' import { @@ -58,9 +60,16 @@ interface SeamProviderBaseProps extends PropsWithChildren { queryClient?: QueryClient | undefined telemetryClient?: TelemetryClient | undefined onSessionUpdate?: (client: SeamHttp) => void + /** + * Seam Ecosystem embed key (`eco_live_…`) used by the Ecosystem components, + * e.g., `CompatibleApps` and `SupportedDevices`. + */ + embedKey?: string | undefined + /** Seam Ecosystem API endpoint. Defaults to `https://ecosystem.seam.co/v1/`. */ + ecosystemEndpoint?: string | undefined } -export const seamComponentsClassName = 'seam-components' +export { seamComponentsClassName } export function SeamProvider({ children, @@ -70,6 +79,8 @@ export function SeamProvider({ unminifiyCss = false, telemetryClient, queryClient, + embedKey, + ecosystemEndpoint, ...props }: SeamProviderProps): JSX.Element { useSeamStyles({ disabled: disableCssInjection, unminified: unminifiyCss }) @@ -95,7 +106,12 @@ export function SeamProvider({ {...props} > - {children} + + {children} + diff --git a/src/lib/seam/components/CompatibleApps/CompatibleApps.element.ts b/src/lib/seam/components/CompatibleApps/CompatibleApps.element.ts new file mode 100644 index 000000000..004243565 --- /dev/null +++ b/src/lib/seam/components/CompatibleApps/CompatibleApps.element.ts @@ -0,0 +1,19 @@ +import type { EcosystemElementProps } from 'lib/ecosystem-element.js' + +import type { CompatibleAppsProps } from './CompatibleApps.js' + +export const name = 'seam-compatible-apps' + +export const props: EcosystemElementProps = { + deviceType: 'string', + manufacturer: 'string', + provider: 'string', + capability: 'string', + industry: 'string', + maxVisible: 'number', + hideSearch: 'boolean', + hideTitle: 'boolean', + theme: 'string', +} + +export { CompatibleApps as Component } from './CompatibleApps.js' diff --git a/src/lib/seam/components/CompatibleApps/CompatibleApps.stories.tsx b/src/lib/seam/components/CompatibleApps/CompatibleApps.stories.tsx new file mode 100644 index 000000000..605e7b79f --- /dev/null +++ b/src/lib/seam/components/CompatibleApps/CompatibleApps.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { + CompatibleApps, + type CompatibleAppsProps, +} from 'lib/seam/components/CompatibleApps/CompatibleApps.js' +import { + type EcosystemStoryArgs, + storybookEmbedKeys, + withDarkBackground, + withFakeEcosystem, +} from 'lib/ui/ecosystem/storybook.js' + +type Args = CompatibleAppsProps & EcosystemStoryArgs + +/** + * The apps (Seam partners) that work with a device category, manufacturer, + * provider or capability through Seam, from the Seam Ecosystem PartnerDB. + * + * > **💡 The Storybook uses a fake Ecosystem API with fictional partners.** + * + * Add it to any page with the lightweight Ecosystem bundle: + * + * ```html + * + * + * ``` + * + * Replace `x.y.z` with the exact version of `@seamapi/react` to use. + */ +const meta: Meta = { + title: 'Ecosystem/CompatibleApps', + component: CompatibleApps, + tags: ['autodocs'], + decorators: [withFakeEcosystem], + args: { + embedKey: storybookEmbedKeys.seam, + deviceType: 'smartlock', + }, + argTypes: { + embedKey: { + control: 'text', + description: + 'Seam Ecosystem embed key. Set on the provider, or as the embed-key attribute.', + }, + theme: { + control: 'inline-radio', + options: ['light', 'dark', 'auto'], + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Smartlocks: Story = {} + +export const SchlageOnly: Story = { + args: { manufacturer: 'schlage' }, +} + +export const ThermostatsForShortTermRental: Story = { + args: { deviceType: 'thermostat', industry: 'short-term-rental' }, +} + +export const SaltoKsMobileKeys: Story = { + args: { provider: 'salto-ks', capability: 'can_provision_mobile_keys' }, +} + +export const ShowMore: Story = { + args: { maxVisible: 4 }, +} + +export const DarkMode: Story = { + args: { theme: 'dark' }, + decorators: [withDarkBackground], +} + +export const WithoutTitleOrSearch: Story = { + args: { hideTitle: true, hideSearch: true }, +} + +export const EmptyState: Story = { + args: { deviceType: 'camera' }, +} + +export const InvalidEmbedKey: Story = { + args: { embedKey: storybookEmbedKeys.invalid }, +} + +export const MissingEmbedKey: Story = { + args: { embedKey: '' }, +} + +export const DisallowedOrigin: Story = { + args: { embedKey: storybookEmbedKeys.wrongOrigin }, +} diff --git a/src/lib/seam/components/CompatibleApps/CompatibleApps.test.tsx b/src/lib/seam/components/CompatibleApps/CompatibleApps.test.tsx new file mode 100644 index 000000000..454d4b696 --- /dev/null +++ b/src/lib/seam/components/CompatibleApps/CompatibleApps.test.tsx @@ -0,0 +1,171 @@ +import { SeamProvider } from '@seamapi/react' +import { QueryClient } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { expect, test } from 'vitest' + +import type { ApiTestContext } from 'fixtures/api.js' +import { + type EcosystemTestContext, + fakeEmbedKey, + fakeInvalidEmbedKey, + fakeWrongOriginEmbedKey, + renderWithEcosystem, + setupFakeEcosystem, +} from 'fixtures/ecosystem.js' + +import { CompatibleApps } from './CompatibleApps.js' + +setupFakeEcosystem() + +test('CompatibleApps lists live partners for a device type', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeEmbedKey, + }) + await screen.findByText('StayPilot') + screen.getByText('Works with these apps') + screen.getByText('Gatehouse') + expect(screen.queryByText('Lockbox Labs')).toBeNull() + expect(screen.queryByText('Hearth & Key')).toBeNull() + const names = screen + .getAllByRole('listitem') + .map( + (item) => item.querySelector('.seam-compatible-apps-name')?.textContent + ) + expect(names[0]).toBe('StayPilot') +}) + +test('CompatibleApps filters by manufacturer', async (ctx) => { + renderWithEcosystem( + , + { ...ctx, embedKey: fakeEmbedKey } + ) + await screen.findByText('Hearth & Key') + screen.getByText('Keyturn') +}) + +test('CompatibleApps shows notes for a provider capability', async (ctx) => { + renderWithEcosystem( + , + { ...ctx, embedKey: fakeEmbedKey } + ) + await screen.findByText( + 'Residents unlock Salto KS doors from the Gatehouse app.' + ) + expect(screen.queryByText('StayPilot')).toBeNull() + expect(screen.queryByText('Tenantry')).toBeNull() +}) + +test('CompatibleApps filters by industry', async (ctx) => { + renderWithEcosystem( + , + { ...ctx, embedKey: fakeEmbedKey } + ) + await screen.findByText('ThermoStay') + expect(screen.queryByText('Brightside Suites')).toBeNull() +}) + +test('CompatibleApps searches partners', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeEmbedKey, + }) + await screen.findByText('StayPilot') + const search = screen.getByRole('searchbox', { name: 'Search apps' }) + fireEvent.change(search, { target: { value: 'gate' } }) + await screen.findByText('Gatehouse') + expect(screen.queryByText('StayPilot')).toBeNull() + fireEvent.change(search, { target: { value: 'nothing matches' } }) + await screen.findByText('No apps found for "nothing matches".') + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + await screen.findByText('StayPilot') +}) + +test('CompatibleApps shows more partners on request', async (ctx) => { + renderWithEcosystem( + , + { + ...ctx, + embedKey: fakeEmbedKey, + } + ) + const button = await screen.findByRole('button', { name: 'Show 7 more apps' }) + expect(screen.getAllByRole('listitem')).toHaveLength(3) + fireEvent.click(button) + expect(screen.getAllByRole('listitem')).toHaveLength(10) +}) + +test('CompatibleApps shows an empty state', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeEmbedKey, + }) + await screen.findByText('No apps are listed for this selection yet.') +}) + +test('CompatibleApps shows an error for an invalid embed key', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeInvalidEmbedKey, + }) + await screen.findByText('The embed key is invalid or has been revoked.') + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() +}) + +test('CompatibleApps shows an error for a missing embed key', async (ctx) => { + renderWithEcosystem(, ctx) + await screen.findByText( + 'An embed key is required to show this list on this website.' + ) +}) + +test('CompatibleApps shows an error for a disallowed origin', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeWrongOriginEmbedKey, + }) + await screen.findByText('The embed key is not allowed on this website.') +}) + +test('CompatibleApps requires a device type', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeEmbedKey, + }) + await screen.findByText('Set a device type to list compatible apps.') +}) + +test('CompatibleApps hides the title and search', async (ctx) => { + renderWithEcosystem( + , + { ...ctx, embedKey: fakeEmbedKey } + ) + await screen.findByText('StayPilot') + expect(screen.queryByText('Works with these apps')).toBeNull() + expect(screen.queryByRole('searchbox')).toBeNull() +}) + +test< + ApiTestContext & EcosystemTestContext +>('CompatibleApps works inside a SeamProvider with an embed key', async (ctx) => { + render( + + + + ) + await screen.findByText('StayPilot') +}) diff --git a/src/lib/seam/components/CompatibleApps/CompatibleApps.tsx b/src/lib/seam/components/CompatibleApps/CompatibleApps.tsx new file mode 100644 index 000000000..f501f4e1d --- /dev/null +++ b/src/lib/seam/components/CompatibleApps/CompatibleApps.tsx @@ -0,0 +1,344 @@ +import classNames from 'classnames' +import { useDeferredValue, useMemo, useState } from 'react' + +import type { + CompatiblePartner, + PartnerAsset, + PartnerAssetKind, +} from 'lib/seam/ecosystem/types.js' +import { useCompatiblePartners } from 'lib/seam/ecosystem/use-compatible-partners.js' +import { getEcosystemErrorMessage } from 'lib/ui/ecosystem/errors.js' +import { SearchField } from 'lib/ui/ecosystem/SearchField.js' +import { StateMessage } from 'lib/ui/ecosystem/StateMessage.js' +import { + type EcosystemTheme, + getEcosystemThemeClassName, +} from 'lib/ui/ecosystem/theme.js' + +export interface CompatibleAppsProps { + /** Device category, e.g., `smartlock`, `thermostat`, `sensor`, `intercom`. */ + deviceType: string + /** DeviceDB manufacturer slug. Lists the apps that support this brand. */ + manufacturer?: string | undefined + /** DeviceDB provider slug, i.e., the cloud the devices connect through. */ + provider?: string | undefined + /** DeviceDB capability, e.g., `can_provision_mobile_keys`. Shows per-app notes. */ + capability?: string | undefined + /** Industry slug to restrict the apps, e.g., `short-term-rental`. */ + industry?: string | undefined + /** Apps shown before the show more button. */ + maxVisible?: number | undefined + hideSearch?: boolean | undefined + hideTitle?: boolean | undefined + /** Color theme. `auto` follows the visitor's color scheme. */ + theme?: EcosystemTheme | undefined + className?: string | undefined +} + +const defaultMaxVisible = 24 + +/** + * The live apps (Seam partners) that work with a device category, + * manufacturer, provider or capability through Seam. + */ +export function CompatibleApps({ + deviceType, + manufacturer = '', + provider = '', + capability = '', + industry = '', + maxVisible = defaultMaxVisible, + hideSearch = false, + hideTitle = false, + theme = 'light', + className, +}: CompatibleAppsProps): JSX.Element { + const { partners, isPending, isError, error, refetch } = + useCompatiblePartners({ + deviceType: deviceType ?? '', + manufacturer, + provider, + capability, + industry, + }) + + const [searchValue, setSearchValue] = useState('') + const [showAll, setShowAll] = useState(false) + const query = useDeferredValue(searchValue.toLowerCase().trim()) + + const sortedPartners = useMemo( + () => [...(partners ?? [])].sort(comparePartners), + [partners] + ) + + const filteredPartners = useMemo( + () => + query.length === 0 + ? sortedPartners + : sortedPartners.filter((partner) => matchesSearch(partner, query)), + [sortedPartners, query] + ) + + const visibleCount = getVisibleCount(maxVisible) + const visiblePartners = showAll + ? filteredPartners + : filteredPartners.slice(0, visibleCount) + const hiddenCount = filteredPartners.length - visiblePartners.length + const showNotes = capability.length > 0 || provider.length > 0 + const isMissingDeviceType = (deviceType ?? '').trim().length === 0 + + const content = (() => { + if (isMissingDeviceType) { + return {t.missingDeviceType} + } + + if (isError) { + const { message, canRetry } = getEcosystemErrorMessage(error, t.error) + return ( + { + void refetch() + }, + } + : {})} + > + {message} + + ) + } + + if (isPending) { + return + } + + if (sortedPartners.length === 0) { + return {t.noApps} + } + + if (filteredPartners.length === 0) { + return ( + { + setSearchValue('') + }} + > + {t.noSearchResults(searchValue.trim())} + + ) + } + + return ( + <> +

    + {visiblePartners.map((partner) => ( + + ))} +
+ {hiddenCount > 0 && ( +
+ +
+ )} + + ) + })() + + const showHeader = !hideTitle || !hideSearch + + return ( +
+ {showHeader && ( +
+ {!hideTitle &&

{t.title}

} + {!hideSearch && ( + + )} +
+ )} + {content} +
+ ) +} + +function PartnerCard({ + partner, + showNote, +}: { + partner: CompatiblePartner + showNote: boolean +}): JSX.Element { + const logo = getPartnerLogo(partner, lightLogoKinds) + const darkLogo = getPartnerLogo(partner, darkLogoKinds) + const hasDarkLogo = darkLogo != null && darkLogo.url !== logo?.url + const note = showNote ? partner.support.note : null + const subtitle = partner.industries.map((i) => i.display_name).join(' · ') + + const body = ( + <> +
+ {logo == null ? ( + + {partner.display_name.slice(0, 1)} + + ) : ( + <> + {t.logoAlt(partner.display_name)} + {hasDarkLogo && ( + + )} + + )} +
+
+ + {partner.display_name} + + {subtitle.length > 0 && ( + {subtitle} + )} + {note != null && note.length > 0 && ( + {note} + )} +
+ + ) + + return ( +
  • + {partner.website == null ? ( +
    {body}
    + ) : ( + + {body} + + )} +
  • + ) +} + +function LoadingGrid({ count }: { count: number }): JSX.Element { + return ( +
      + {Array.from({ length: count }, (_, index) => ( +
    • +
      +
      +
      +
      +
      +
      +
      +
    • + ))} +
    + ) +} + +const lightLogoKinds: PartnerAssetKind[] = [ + 'square_logo', + 'horizontal_logo', + 'square_logo_mono', + 'horizontal_logo_mono', +] + +const darkLogoKinds: PartnerAssetKind[] = [ + 'square_logo_mono', + 'square_logo', + 'horizontal_logo_mono', + 'horizontal_logo', +] + +const getPartnerLogo = ( + partner: CompatiblePartner, + kinds: PartnerAssetKind[] +): PartnerAsset | null => { + for (const kind of kinds) { + const asset = partner.assets.find((a) => a.kind === kind) + if (asset != null) return asset + } + return null +} + +const comparePartners = ( + a: CompatiblePartner, + b: CompatiblePartner +): number => { + if (a.is_featured !== b.is_featured) return a.is_featured ? -1 : 1 + return a.display_name.localeCompare(b.display_name) +} + +const matchesSearch = (partner: CompatiblePartner, query: string): boolean => + partner.display_name.toLowerCase().includes(query) || + partner.industries.some((i) => + i.display_name.toLowerCase().includes(query) + ) || + partner.solutions.some((s) => s.display_name.toLowerCase().includes(query)) + +const getVisibleCount = (maxVisible: number): number => + Number.isFinite(maxVisible) && maxVisible > 0 + ? Math.floor(maxVisible) + : defaultMaxVisible + +const t = { + title: 'Works with these apps', + search: 'Search apps', + error: 'An error occurred while loading compatible apps.', + retry: 'Retry', + noApps: 'No apps are listed for this selection yet.', + noSearchResults: (query: string) => `No apps found for "${query}".`, + clearSearch: 'Clear search', + showMore: (count: number) => + `Show ${count} more ${count === 1 ? 'app' : 'apps'}`, + logoAlt: (name: string) => `${name} logo`, + missingDeviceType: 'Set a device type to list compatible apps.', +} diff --git a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx new file mode 100644 index 000000000..e3da18478 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx @@ -0,0 +1,105 @@ +import classNames from 'classnames' + +import { + getCapabilityLabel, + getDeviceModelImageUrl, + getDeviceModelName, + getSupportedCapabilities, +} from 'lib/seam/components/SupportedDevices/device-models.js' +import type { DeviceModel, Manufacturer } from 'lib/seam/ecosystem/types.js' + +interface DeviceModelRowProps { + deviceModel: DeviceModel + manufacturer: Manufacturer | null +} + +export function DeviceModelRow({ + deviceModel, + manufacturer, +}: DeviceModelRowProps): JSX.Element { + const name = getDeviceModelName(deviceModel) + const imageUrl = getDeviceModelImageUrl(deviceModel, manufacturer) + const modelIds = [ + ...new Set( + deviceModel.variants + .map((v) => v.manufacturer_model_id) + .filter((id): id is string => id != null && id.length > 0) + ), + ] + const providers = deviceModel.providers + .map((p) => p.display_name ?? p.slug) + .join(', ') + const capabilities = getSupportedCapabilities(deviceModel).filter( + (c) => c.name !== 'device_connectivity' + ) + + const meta = [ + modelIds.length > 0 ? modelIds.slice(0, 2).join(', ') : null, + providers.length > 0 ? t.via(providers) : null, + ].filter((value): value is string => value != null) + + return ( +
  • +
    + {imageUrl == null ? ( + {name.slice(0, 1)} + ) : ( + + )} +
    +
    + {deviceModel.product_url == null ? ( + {name} + ) : ( + + {name} + + )} + {meta.length > 0 && ( + + {meta.join(' · ')} + + )} +
    +
      + {deviceModel.device_type != null && ( +
    • + {deviceModel.device_type.display_name} +
    • + )} + {capabilities.map((capability) => ( +
    • + {getCapabilityLabel(capability.name)} + {capability.supported === 'conditionally' && ( + + {` (${t.conditional})`} + + )} +
    • + ))} +
    +
  • + ) +} + +const t = { + via: (providers: string) => `via ${providers}`, + badges: 'Device type and capabilities', + conditional: 'conditionally supported', +} diff --git a/src/lib/seam/components/SupportedDevices/FilterSelect.tsx b/src/lib/seam/components/SupportedDevices/FilterSelect.tsx new file mode 100644 index 000000000..57ed98251 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/FilterSelect.tsx @@ -0,0 +1,43 @@ +import { ChevronDownIcon } from 'lib/icons/ChevronDown.js' +import type { FilterOption } from 'lib/seam/components/SupportedDevices/device-models.js' + +interface FilterSelectProps { + label: string + allLabel: string + options: FilterOption[] + value: string | null + onChange: (value: string | null) => void + disabled?: boolean +} + +export function FilterSelect({ + label, + allLabel, + options, + value, + onChange, + disabled = false, +}: FilterSelectProps): JSX.Element { + return ( + + ) +} diff --git a/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx b/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx new file mode 100644 index 000000000..172f7b98b --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx @@ -0,0 +1,114 @@ +import classNames from 'classnames' +import { useState } from 'react' + +import { + getManufacturerLogoUrl, + getManufacturerName, + type ManufacturerGroup, +} from 'lib/seam/components/SupportedDevices/device-models.js' +import { DeviceModelRow } from 'lib/seam/components/SupportedDevices/DeviceModelRow.js' +import type { Manufacturer } from 'lib/seam/ecosystem/types.js' + +/** Device models shown per manufacturer before the show all button. */ +const collapsedCount = 5 + +interface ManufacturerSectionProps { + group: ManufacturerGroup + manufacturer: Manufacturer | null + collapsible: boolean +} + +export function ManufacturerSection({ + group, + manufacturer, + collapsible, +}: ManufacturerSectionProps): JSX.Element { + const [expanded, setExpanded] = useState(false) + + const { deviceModels } = group + const canExpand = collapsible && deviceModels.length > collapsedCount + const visibleDeviceModels = + canExpand && !expanded + ? deviceModels.slice(0, collapsedCount) + : deviceModels + + const name = + manufacturer != null + ? getManufacturerName(manufacturer) + : group.manufacturer != null + ? getManufacturerName(group.manufacturer) + : t.other + + const logoUrl = getManufacturerLogoUrl(manufacturer) + const darkLogoUrl = getManufacturerLogoUrl(manufacturer, { mono: true }) + const hasDarkLogo = darkLogoUrl != null && darkLogoUrl !== logoUrl + + return ( +
    +
    +
    + {logoUrl == null ? ( + {name.slice(0, 1)} + ) : ( + <> + + {hasDarkLogo && ( + + )} + + )} +
    +

    {name}

    + + {t.count(deviceModels.length)} + +
    +
      + {visibleDeviceModels.map((deviceModel) => ( + + ))} +
    + {canExpand && ( +
    + +
    + )} +
    + ) +} + +const t = { + other: 'Other devices', + count: (count: number) => `${count} ${count === 1 ? 'device' : 'devices'}`, + showAll: (name: string, count: number) => `Show all ${count} ${name} devices`, + showLess: 'Show less', +} diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts b/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts new file mode 100644 index 000000000..e62783d2b --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts @@ -0,0 +1,17 @@ +import type { EcosystemElementProps } from 'lib/ecosystem-element.js' + +import type { SupportedDevicesProps } from './SupportedDevices.js' + +export const name = 'seam-supported-devices' + +export const props: EcosystemElementProps = { + manufacturer: 'string', + deviceType: 'string', + capability: 'string', + hideSearch: 'boolean', + hideFilters: 'boolean', + hideTitle: 'boolean', + theme: 'string', +} + +export { SupportedDevices as Component } from './SupportedDevices.js' diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx new file mode 100644 index 000000000..37fc59bc6 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { + SupportedDevices, + type SupportedDevicesProps, +} from 'lib/seam/components/SupportedDevices/SupportedDevices.js' +import { + type EcosystemStoryArgs, + storybookEmbedKeys, + withDarkBackground, + withFakeEcosystem, +} from 'lib/ui/ecosystem/storybook.js' + +type Args = SupportedDevicesProps & EcosystemStoryArgs + +/** + * Device models that work with Seam, grouped by manufacturer, + * from the Seam Ecosystem DeviceDB. + * + * > **💡 The Storybook uses a fake Ecosystem API with sample device models.** + * + * Add a manufacturer's device list to a product page with the lightweight Ecosystem bundle: + * + * ```html + * + * + * ``` + * + * Replace `x.y.z` with the exact version of `@seamapi/react` to use. + * + * With a manufacturer embed key, the list defaults to that manufacturer. + * With a partner embed key, the partner's declared exclusions are hidden. + */ +const meta: Meta = { + title: 'Ecosystem/SupportedDevices', + component: SupportedDevices, + tags: ['autodocs'], + decorators: [withFakeEcosystem], + args: { + embedKey: storybookEmbedKeys.seam, + }, + argTypes: { + embedKey: { + control: 'text', + description: + 'Seam Ecosystem embed key. Set on the provider, or as the embed-key attribute.', + }, + theme: { + control: 'inline-radio', + options: ['light', 'dark', 'auto'], + }, + }, +} + +export default meta + +type Story = StoryObj + +export const AllDevices: Story = {} + +export const SingleManufacturer: Story = { + args: { manufacturer: 'schlage' }, +} + +export const ManufacturerEmbedKey: Story = { + args: { embedKey: storybookEmbedKeys.manufacturer }, +} + +export const Thermostats: Story = { + args: { deviceType: 'thermostat' }, +} + +export const MobileKeys: Story = { + args: { capability: 'can_provision_mobile_keys' }, +} + +export const PartnerEmbedKey: Story = { + args: { embedKey: storybookEmbedKeys.partner, deviceType: 'smartlock' }, +} + +export const DarkMode: Story = { + args: { manufacturer: 'schlage', theme: 'dark' }, + decorators: [withDarkBackground], +} + +export const WithoutFilters: Story = { + args: { hideFilters: true, hideSearch: true }, +} + +export const EmptyState: Story = { + args: { manufacturer: 'not-a-manufacturer' }, +} + +export const InvalidEmbedKey: Story = { + args: { embedKey: storybookEmbedKeys.invalid }, +} diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx new file mode 100644 index 000000000..19fffa7b2 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, screen, within } from '@testing-library/react' +import { expect, test } from 'vitest' + +import { + type EcosystemTestContext, + fakeEmbedKey, + fakeInvalidEmbedKey, + fakeManufacturerEmbedKey, + fakePartnerEmbedKey, + renderWithEcosystem, + setupFakeEcosystem, +} from 'fixtures/ecosystem.js' + +import { SupportedDevices } from './SupportedDevices.js' + +setupFakeEcosystem() + +const getSectionTitles = (): string[] => + screen + .getAllByRole('heading', { level: 3 }) + .map((heading) => heading.textContent ?? '') + +test('SupportedDevices groups device models by manufacturer', async (ctx) => { + renderWithEcosystem(, ctx) + await screen.findByText('Encode Plus Smart WiFi Deadbolt') + screen.getByRole('heading', { name: 'Supported devices' }) + expect(getSectionTitles()).toEqual([ + '2N', + 'August', + 'ecobee', + 'Google Nest', + 'Honeywell', + 'igloohome', + 'Minut', + 'NoiseAware', + 'Salto', + 'Schlage', + 'Yale', + ]) + expect(screen.queryByText('Internal Test Lock')).toBeNull() + screen.getByRole('button', { name: 'Show all 7 Schlage devices' }) +}) + +test('SupportedDevices shows one manufacturer', async (ctx) => { + renderWithEcosystem(, ctx) + await screen.findByText('Control Smart Lock') + screen.getByRole('heading', { name: 'Supported Schlage devices' }) + expect(getSectionTitles()).toEqual(['Schlage']) + expect(screen.queryByRole('combobox', { name: 'Manufacturer' })).toBeNull() + expect(screen.queryByRole('button', { name: /Show all/ })).toBeNull() + screen.getByText('Engage Gateway') +}) + +test('SupportedDevices shows device type and capability badges', async (ctx) => { + renderWithEcosystem(, ctx) + const name = await screen.findByText('Encode Plus Smart WiFi Deadbolt') + const row = name.closest('li') + if (row == null) throw new Error('Missing row') + const badges = within(row).getByRole('list', { + name: 'Device type and capabilities', + }) + expect( + within(badges) + .getAllByRole('listitem') + .map((badge) => badge.textContent) + ).toEqual(['Smartlock', 'Remote unlock', 'Remote lock', 'Access codes']) + within(row).getByText('BE499WB · via Schlage Home') +}) + +test('SupportedDevices defaults to the manufacturer of a manufacturer key', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeManufacturerEmbedKey, + }) + await screen.findByText('Control Smart Lock') + screen.getByRole('heading', { name: 'Supported Schlage devices' }) + expect(getSectionTitles()).toEqual(['Schlage']) +}) + +test('SupportedDevices prefers the manufacturer attribute over the key', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeManufacturerEmbedKey, + }) + await screen.findByText('Assure Lock 2') + expect(getSectionTitles()).toEqual(['Yale']) +}) + +test("SupportedDevices honors a partner key's exclusions", async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakePartnerEmbedKey, + }) + await screen.findByText('Encode Plus Smart WiFi Deadbolt') + expect(screen.queryByText('Wi-Fi Smart Lock')).toBeNull() + expect(screen.queryByText('Connect Smart Deadbolt')).toBeNull() + expect(screen.queryByText('Assure Lock SL (Z-Wave)')).toBeNull() + screen.getByText('XS4 One') + expect(getSectionTitles()).not.toContain('August') +}) + +test("SupportedDevices honors a partner key's capability exclusions", async (ctx) => { + renderWithEcosystem( + , + { ...ctx, embedKey: fakePartnerEmbedKey } + ) + await screen.findByText('Control Smart Lock') + expect(screen.queryByText('XS4 One')).toBeNull() + expect(getSectionTitles()).toEqual(['Schlage']) +}) + +test('SupportedDevices filters by capability', async (ctx) => { + renderWithEcosystem( + , + { ...ctx, embedKey: fakeEmbedKey } + ) + await screen.findByText('XS4 One') + expect(getSectionTitles()).toEqual(['Salto', 'Schlage']) +}) + +test('SupportedDevices searches and filters', async (ctx) => { + renderWithEcosystem(, ctx) + await screen.findByText('Encode Plus Smart WiFi Deadbolt') + + fireEvent.change(screen.getByRole('combobox', { name: 'Device type' }), { + target: { value: 'thermostat' }, + }) + await screen.findByText('Nest Thermostat') + expect(getSectionTitles()).toEqual(['ecobee', 'Google Nest', 'Honeywell']) + + const search = screen.getByRole('searchbox', { name: 'Search devices' }) + fireEvent.change(search, { target: { value: 'nest learning' } }) + await screen.findByText('Nest Learning Thermostat') + expect(screen.queryByText('Nest Thermostat')).toBeNull() + + fireEvent.change(search, { target: { value: 'nothing matches' } }) + await screen.findByText('No devices match your search or filters.') + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) + await screen.findByText('Encode Plus Smart WiFi Deadbolt') +}) + +test('SupportedDevices shows an empty state', async (ctx) => { + renderWithEcosystem(, ctx) + await screen.findByText('No supported devices found.') +}) + +test('SupportedDevices shows an error for an invalid embed key', async (ctx) => { + renderWithEcosystem(, { + ...ctx, + embedKey: fakeInvalidEmbedKey, + }) + await screen.findByText('The embed key is invalid or has been revoked.') +}) diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx new file mode 100644 index 000000000..da2beb87c --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx @@ -0,0 +1,382 @@ +import classNames from 'classnames' +import { useDeferredValue, useMemo, useState } from 'react' + +import { + applyEmbedExclusions, + capabilityLabels, + filterDeviceModels, + getCapabilityOptions, + getDeviceTypeOptions, + getManufacturerName, + getManufacturerOptions, + groupDeviceModelsByManufacturer, +} 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' +import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' +import type { Manufacturer } from 'lib/seam/ecosystem/types.js' +import { useEcosystemDeviceModels } from 'lib/seam/ecosystem/use-ecosystem-device-models.js' +import { useEcosystemManufacturers } from 'lib/seam/ecosystem/use-ecosystem-manufacturers.js' +import { + getEmbedKeyManufacturer, + useEmbedKeyResolution, +} from 'lib/seam/ecosystem/use-embed-key-resolution.js' +import { getEcosystemErrorMessage } from 'lib/ui/ecosystem/errors.js' +import { SearchField } from 'lib/ui/ecosystem/SearchField.js' +import { StateMessage } from 'lib/ui/ecosystem/StateMessage.js' +import { + type EcosystemTheme, + getEcosystemThemeClassName, +} from 'lib/ui/ecosystem/theme.js' + +export interface SupportedDevicesProps { + /** + * DeviceDB manufacturer slug, e.g., `schlage`. + * Defaults to the manufacturer of a manufacturer embed key. + */ + manufacturer?: 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`. */ + capability?: string | undefined + hideSearch?: boolean | undefined + hideFilters?: boolean | undefined + hideTitle?: boolean | undefined + /** Color theme. `auto` follows the visitor's color scheme. */ + theme?: EcosystemTheme | undefined + className?: string | undefined +} + +/** + * Device models that work with Seam, grouped by manufacturer, + * with device type and capability badges. + */ +export function SupportedDevices({ + manufacturer, + deviceType, + capability, + hideSearch = false, + hideFilters = false, + hideTitle = false, + theme = 'light', + className, +}: SupportedDevicesProps): JSX.Element { + const { embedKey } = useEcosystemContext() + const explicitManufacturer = normalizeSlug(manufacturer) + const fixedDeviceType = normalizeSlug(deviceType) + const fixedCapability = normalizeSlug(capability) + + const { + embedResolution, + isError: isResolutionError, + error: resolutionError, + refetch: refetchResolution, + } = useEmbedKeyResolution({ capability: fixedCapability ?? '' }) + + const isResolved = embedKey == null || embedResolution != null + const scopeManufacturer = + explicitManufacturer ?? getEmbedKeyManufacturer(embedResolution) + + const { + deviceModels, + isError: isDeviceModelsError, + error: deviceModelsError, + refetch: refetchDeviceModels, + } = useEcosystemDeviceModels({ + manufacturer: scopeManufacturer ?? '', + deviceType: fixedDeviceType ?? '', + capability: + fixedCapability != null && fixedCapability in capabilityLabels + ? fixedCapability + : '', + enabled: (explicitManufacturer != null || isResolved) && !isResolutionError, + }) + + const availableDeviceModels = useMemo( + () => applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions), + [deviceModels, embedResolution] + ) + + const manufacturerSlugs = useMemo( + () => + availableDeviceModels + .map((m) => m.manufacturer?.slug) + .filter((slug): slug is string => slug != null), + [availableDeviceModels] + ) + + const { manufacturers } = useEcosystemManufacturers({ + slugs: manufacturerSlugs, + enabled: manufacturerSlugs.length > 0, + }) + + const manufacturersBySlug = useMemo( + () => + new Map( + (manufacturers ?? []).map((m) => [m.slug, m]) + ), + [manufacturers] + ) + + const [searchValue, setSearchValue] = useState('') + const [selectedManufacturer, setSelectedManufacturer] = useState< + string | null + >(null) + const [selectedDeviceType, setSelectedDeviceType] = useState( + null + ) + const [selectedCapability, setSelectedCapability] = useState( + null + ) + const search = useDeferredValue(searchValue) + + const filteredDeviceModels = useMemo( + () => + filterDeviceModels(availableDeviceModels, { + manufacturer: scopeManufacturer ?? selectedManufacturer, + deviceType: fixedDeviceType ?? selectedDeviceType, + capability: fixedCapability ?? selectedCapability, + search, + }), + [ + availableDeviceModels, + scopeManufacturer, + selectedManufacturer, + fixedDeviceType, + selectedDeviceType, + fixedCapability, + selectedCapability, + search, + ] + ) + + const groups = useMemo( + () => groupDeviceModelsByManufacturer(filteredDeviceModels), + [filteredDeviceModels] + ) + + const manufacturerOptions = useMemo( + () => getManufacturerOptions(availableDeviceModels), + [availableDeviceModels] + ) + const deviceTypeOptions = useMemo( + () => getDeviceTypeOptions(availableDeviceModels), + [availableDeviceModels] + ) + const capabilityOptions = useMemo( + () => getCapabilityOptions(availableDeviceModels), + [availableDeviceModels] + ) + + const showManufacturerFilter = + scopeManufacturer == null && manufacturerOptions.length > 1 + const showDeviceTypeFilter = + fixedDeviceType == null && deviceTypeOptions.length > 1 + const showCapabilityFilter = + fixedCapability == null && capabilityOptions.length > 1 + + const hasUserFilters = + searchValue.trim().length > 0 || + selectedManufacturer != null || + selectedDeviceType != null || + selectedCapability != null + + const resetFilters = (): void => { + setSearchValue('') + setSelectedManufacturer(null) + setSelectedDeviceType(null) + setSelectedCapability(null) + } + + const isError = isResolutionError || isDeviceModelsError + const isLoading = !isError && (!isResolved || deviceModels == null) + + const scopedManufacturerName = + scopeManufacturer == null + ? null + : getScopedManufacturerName( + scopeManufacturer, + manufacturersBySlug.get(scopeManufacturer), + availableDeviceModels[0]?.manufacturer?.display_name + ) + + const title = + scopedManufacturerName == null + ? t.title + : t.manufacturerTitle(scopedManufacturerName) + + const content = (() => { + if (isError) { + const { message, canRetry } = getEcosystemErrorMessage( + isResolutionError ? resolutionError : deviceModelsError, + t.error + ) + return ( + { + if (isResolutionError) void refetchResolution() + if (isDeviceModelsError) void refetchDeviceModels() + }, + } + : {})} + > + {message} + + ) + } + + if (isLoading) return + + if (availableDeviceModels.length === 0) { + return {t.noDevices} + } + + if (filteredDeviceModels.length === 0) { + return ( + + {hasUserFilters ? t.noMatches : t.noDevices} + + ) + } + + return ( +
    + {groups.map((group) => ( + 1} + /> + ))} +
    + ) + })() + + const showFilters = + !hideFilters && + (showManufacturerFilter || showDeviceTypeFilter || showCapabilityFilter) + const controlsDisabled = isLoading || isError + + return ( +
    + {(!hideTitle || !hideSearch || showFilters) && ( +
    + {!hideTitle &&

    {title}

    } +
    + {showFilters && showManufacturerFilter && ( + + )} + {showFilters && showDeviceTypeFilter && ( + + )} + {showFilters && showCapabilityFilter && ( + + )} + {!hideSearch && ( + + )} +
    +
    + )} + {content} +
    + ) +} + +function LoadingRows(): JSX.Element { + return ( +
    +
    +
    +
    +
    +
    +
      + {Array.from({ length: 3 }, (_, index) => ( +
    • +
      +
      +
      +
      +
      +
    • + ))} +
    +
    +
    + ) +} + +const normalizeSlug = (value: string | undefined): string | null => { + const slug = value?.trim() + if (slug == null || slug.length === 0) return null + return slug +} + +const getScopedManufacturerName = ( + slug: string, + manufacturer: Manufacturer | undefined, + fallback: string | null | undefined +): string => { + if (manufacturer != null) return getManufacturerName(manufacturer) + return fallback ?? slug +} + +const t = { + title: 'Supported devices', + manufacturerTitle: (name: string) => `Supported ${name} devices`, + search: 'Search devices', + manufacturer: 'Manufacturer', + allManufacturers: 'All manufacturers', + deviceType: 'Device type', + allDeviceTypes: 'All device types', + capability: 'Capability', + allCapabilities: 'All capabilities', + error: 'An error occurred while loading supported devices.', + retry: 'Retry', + noDevices: 'No supported devices found.', + noMatches: 'No devices match your search or filters.', + clearFilters: 'Clear filters', +} diff --git a/src/lib/seam/components/SupportedDevices/device-models.test.ts b/src/lib/seam/components/SupportedDevices/device-models.test.ts new file mode 100644 index 000000000..8d8e1c3a5 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/device-models.test.ts @@ -0,0 +1,228 @@ +import { expect, test } from 'vitest' + +import type { + CapabilityName, + CapabilitySupported, + DeviceModel, + EmbedExclusions, +} from 'lib/seam/ecosystem/types.js' + +import { + applyEmbedExclusions, + filterDeviceModels, + getCapabilityOptions, + getSupportedCapabilities, + groupDeviceModelsByManufacturer, +} from './device-models.js' + +const ref = (slug: string): { slug: string; display_name: string } => ({ + slug, + display_name: slug.toUpperCase(), +}) + +const deviceModel = ({ + slug, + manufacturer = 'acme', + deviceType = 'smartlock', + capabilities = {}, +}: { + slug: string + manufacturer?: string | null + deviceType?: string | null + capabilities?: Record< + string, + Partial> + > +}): DeviceModel => ({ + device_model_id: slug, + slug, + display_name: `Model ${slug}`, + description_md: null, + product_url: null, + is_forbidden_to_3dparty: false, + no_longer_sold: false, + shop_listings: null, + last_commerce_search_at: null, + manufacturer: + manufacturer == null + ? null + : { manufacturer_id: manufacturer, ...ref(manufacturer) }, + device_type: + deviceType == null + ? null + : { device_type_id: deviceType, is_accessory: false, ...ref(deviceType) }, + device_type_properties: null, + variants: [], + featured_image: null, + providers: Object.keys(capabilities).map((p) => ({ + provider_id: p, + ...ref(p), + })), + capabilities_by_provider: Object.entries(capabilities).map( + ([provider, caps]) => ({ + provider: { provider_id: provider, ...ref(provider) }, + capabilities: Object.entries(caps).map(([name, supported]) => ({ + capability_id: `${provider}:${name}`, + name: name as CapabilityName, + supported, + integration_support_level: 'stable', + report: { details: `${name} via ${provider}` }, + report_raw: null, + locale: 'en', + computed_at: '2026-10-01T00:00:00.000Z', + })), + }) + ), + capabilities_stale: false, + created_at: '2026-10-01T00:00:00.000Z', + updated_at: '2026-10-01T00:00:00.000Z', +}) + +const at = (list: DeviceModel[], index: number): DeviceModel => { + const deviceModel = list[index] + if (deviceModel == null) throw new Error(`Missing device model ${index}`) + return deviceModel +} + +const noExclusions: EmbedExclusions = { + excluded_manufacturers: [], + excluded_device_types: [], + unsupported_providers: [], + capability: '', + capability_exclusions: [], +} + +const models = [ + deviceModel({ + slug: 'a', + manufacturer: 'acme', + capabilities: { + cloud: { can_remotely_unlock: 'yes', can_provision_mobile_keys: 'yes' }, + hub: { can_remotely_unlock: 'conditionally' }, + }, + }), + deviceModel({ + slug: 'b', + manufacturer: 'bolt', + capabilities: { hub: { can_remotely_unlock: 'conditionally' } }, + }), + deviceModel({ + slug: 'c', + manufacturer: 'acme', + deviceType: 'thermostat', + capabilities: { cloud: { can_hvac_heat: 'yes', can_hvac_cool: 'no' } }, + }), + deviceModel({ slug: 'd', manufacturer: null, deviceType: null }), +] + +test('applyEmbedExclusions: without exclusions returns the models', () => { + expect(applyEmbedExclusions(models, null)).toBe(models) + expect(applyEmbedExclusions(models, noExclusions)).toHaveLength(4) +}) + +test('applyEmbedExclusions: removes excluded manufacturers and device types', () => { + const result = applyEmbedExclusions(models, { + ...noExclusions, + excluded_manufacturers: ['bolt'], + excluded_device_types: ['thermostat'], + }) + expect(result.map((m) => m.slug)).toEqual(['a', 'd']) +}) + +test('applyEmbedExclusions: removes unsupported providers and models left without one', () => { + const result = applyEmbedExclusions(models, { + ...noExclusions, + unsupported_providers: ['hub'], + }) + expect(result.map((m) => m.slug)).toEqual(['a', 'c', 'd']) + expect(result[0]?.providers.map((p) => p.slug)).toEqual(['cloud']) + expect( + result[0]?.capabilities_by_provider.map(({ provider }) => provider.slug) + ).toEqual(['cloud']) +}) + +test('applyEmbedExclusions: removes the excluded capability from a provider', () => { + const result = applyEmbedExclusions(models, { + ...noExclusions, + capability: 'can_provision_mobile_keys', + capability_exclusions: [ + { + device_category: 'smartlock', + manufacturer_slug: '', + provider_slug: 'cloud', + }, + ], + }) + expect(result.map((m) => m.slug)).toEqual(['a', 'b', 'c', 'd']) + expect(getSupportedCapabilities(at(result, 0)).map((c) => c.name)).toEqual([ + 'can_remotely_unlock', + ]) +}) + +test('applyEmbedExclusions: removes models excluded for every provider', () => { + const result = applyEmbedExclusions(models, { + ...noExclusions, + capability: 'can_provision_mobile_keys', + capability_exclusions: [ + { + device_category: 'smartlock', + manufacturer_slug: 'acme', + provider_slug: '', + }, + ], + }) + expect(result.map((m) => m.slug)).toEqual(['b', 'c', 'd']) +}) + +test('getSupportedCapabilities: takes the best support across providers', () => { + expect(getSupportedCapabilities(at(models, 0))).toEqual([ + { + name: 'can_remotely_unlock', + supported: 'yes', + details: 'can_remotely_unlock via cloud', + }, + { + name: 'can_provision_mobile_keys', + supported: 'yes', + details: 'can_provision_mobile_keys via cloud', + }, + ]) + expect(getSupportedCapabilities(at(models, 1))).toEqual([ + { + name: 'can_remotely_unlock', + supported: 'conditionally', + details: 'can_remotely_unlock via hub', + }, + ]) + expect(getSupportedCapabilities(at(models, 2)).map((c) => c.name)).toEqual([ + 'can_hvac_heat', + ]) +}) + +test('filterDeviceModels: filters by manufacturer, device type, capability and search', () => { + const slugs = (filters: Parameters[1]): string[] => + filterDeviceModels(models, filters).map((m) => m.slug) + expect(slugs({})).toEqual(['a', 'b', 'c', 'd']) + expect(slugs({ manufacturer: 'acme' })).toEqual(['a', 'c']) + expect(slugs({ deviceType: 'thermostat' })).toEqual(['c']) + expect(slugs({ capability: 'can_remotely_unlock' })).toEqual(['a', 'b']) + expect(slugs({ capability: 'can_hvac_cool' })).toEqual([]) + expect(slugs({ search: 'thermostat' })).toEqual(['c']) + expect(slugs({ search: 'model unlock' })).toEqual([]) + expect(slugs({ search: 'BOLT' })).toEqual(['b']) + expect(slugs({ search: 'hub' })).toEqual(['a', 'b']) +}) + +test('groupDeviceModelsByManufacturer: sorts groups and puts unknown manufacturers last', () => { + const groups = groupDeviceModelsByManufacturer(models) + expect(groups.map((g) => g.key)).toEqual(['acme', 'bolt', '']) + expect(groups[0]?.deviceModels.map((m) => m.slug)).toEqual(['a', 'c']) +}) + +test('getCapabilityOptions: lists supported capabilities in display order', () => { + expect(getCapabilityOptions(models).map((o) => o.value)).toEqual([ + 'can_remotely_unlock', + 'can_provision_mobile_keys', + 'can_hvac_heat', + ]) +}) diff --git a/src/lib/seam/components/SupportedDevices/device-models.ts b/src/lib/seam/components/SupportedDevices/device-models.ts new file mode 100644 index 000000000..dccc960db --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/device-models.ts @@ -0,0 +1,326 @@ +import type { + CapabilityDetail, + CapabilityName, + DeviceModel, + EmbedExclusions, + Manufacturer, + ManufacturerRef, +} from 'lib/seam/ecosystem/types.js' + +export interface DeviceModelFilters { + manufacturer?: string | null + deviceType?: string | null + capability?: string | null + search?: string +} + +export interface SupportedCapability { + name: CapabilityName + supported: 'yes' | 'conditionally' + details: string | null +} + +export interface ManufacturerGroup { + key: string + manufacturer: ManufacturerRef | null + deviceModels: DeviceModel[] +} + +export interface FilterOption { + value: string + label: string +} + +/** Capabilities shown as badges, in display order. */ +export const capabilityLabels: Partial> = { + can_remotely_unlock: 'Remote unlock', + can_remotely_lock: 'Remote lock', + can_program_online_access_codes: 'Access codes', + can_program_offline_access_codes: 'Offline access codes', + can_provision_mobile_keys: 'Mobile keys', + can_provision_wallet_keys: 'Wallet keys', + can_encode_plastic_cards: 'Key cards', + can_hvac_heat: 'Heat', + can_hvac_cool: 'Cool', + can_hvac_auto: 'Heat and cool', + can_hvac_fan: 'Fan', + can_program_thermostat_weekly_programs: 'Weekly programs', + can_monitor: 'Monitoring', +} + +const capabilityOrder = Object.keys(capabilityLabels) + +export const getCapabilityLabel = (name: string): string => + capabilityLabels[name as CapabilityName] ?? + name.replace(/^can_/, '').replaceAll('_', ' ') + +/** + * Removes what a partner's embed key says its widgets must not show + * (Ecosystem `embed/resolve`): excluded manufacturers and device types, + * unsupported providers, and the provider combinations excluded for the + * resolved capability. A model left with no provider is removed. + */ +export const applyEmbedExclusions = ( + deviceModels: DeviceModel[], + exclusions: EmbedExclusions | null | undefined +): DeviceModel[] => { + if (exclusions == null) return deviceModels + + const excludedManufacturers = new Set(exclusions.excluded_manufacturers) + const excludedDeviceTypes = new Set(exclusions.excluded_device_types) + const unsupportedProviders = new Set(exclusions.unsupported_providers) + const { capability } = exclusions + + const result: DeviceModel[] = [] + for (const deviceModel of deviceModels) { + const manufacturerSlug = deviceModel.manufacturer?.slug ?? '' + const deviceTypeSlug = deviceModel.device_type?.slug ?? '' + + if (excludedManufacturers.has(manufacturerSlug)) continue + if (excludedDeviceTypes.has(deviceTypeSlug)) continue + + const capabilityExclusions = exclusions.capability_exclusions.filter( + (exclusion) => + capability.length > 0 && + exclusion.device_category === deviceTypeSlug && + (exclusion.manufacturer_slug === '' || + exclusion.manufacturer_slug === manufacturerSlug) + ) + + if (capabilityExclusions.some((e) => e.provider_slug === '')) continue + + const capabilityExcludedProviders = new Set( + capabilityExclusions.map((e) => e.provider_slug) + ) + + const providers = deviceModel.providers.filter( + (provider) => !unsupportedProviders.has(provider.slug) + ) + + if (deviceModel.providers.length > 0 && providers.length === 0) continue + + const capabilitiesByProvider = deviceModel.capabilities_by_provider + .filter(({ provider }) => !unsupportedProviders.has(provider.slug)) + .map((entry) => + capabilityExcludedProviders.has(entry.provider.slug) + ? { + ...entry, + capabilities: entry.capabilities.filter( + (c) => c.name !== capability + ), + } + : entry + ) + + result.push({ + ...deviceModel, + providers, + capabilities_by_provider: capabilitiesByProvider, + }) + } + return result +} + +/** The best support for each capability across the model's providers. */ +export const getSupportedCapabilities = ( + deviceModel: DeviceModel +): SupportedCapability[] => { + 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 + best.set(capability.name, { + name: capability.name, + supported, + details: capability.report?.details ?? null, + }) + } + } + return [...best.values()].sort( + (a, b) => capabilityRank(a.name) - capabilityRank(b.name) + ) +} + +export const supportsCapability = ( + deviceModel: DeviceModel, + capability: string +): boolean => + getSupportedCapabilities(deviceModel).some((c) => c.name === capability) + +export const filterDeviceModels = ( + deviceModels: DeviceModel[], + { manufacturer, deviceType, capability, search = '' }: DeviceModelFilters +): DeviceModel[] => { + const terms = search.toLowerCase().trim().split(/\s+/).filter(Boolean) + return deviceModels.filter((deviceModel) => { + if (manufacturer != null && deviceModel.manufacturer?.slug !== manufacturer) + return false + if (deviceType != null && deviceModel.device_type?.slug !== deviceType) + return false + if (capability != null && !supportsCapability(deviceModel, capability)) + return false + if (terms.length === 0) return true + const haystack = getSearchText(deviceModel) + return terms.every((term) => haystack.includes(term)) + }) +} + +const getSearchText = (deviceModel: DeviceModel): string => + [ + deviceModel.display_name, + deviceModel.slug, + deviceModel.manufacturer?.display_name, + deviceModel.device_type?.display_name, + ...deviceModel.providers.map((p) => p.display_name), + ...deviceModel.variants.map((v) => v.manufacturer_model_id), + ] + .filter((value): value is string => value != null) + .join(' ') + .toLowerCase() + +export const groupDeviceModelsByManufacturer = ( + deviceModels: DeviceModel[] +): ManufacturerGroup[] => { + const groups = new Map() + for (const deviceModel of deviceModels) { + const key = deviceModel.manufacturer?.slug ?? '' + const group = groups.get(key) ?? { + key, + manufacturer: deviceModel.manufacturer, + deviceModels: [], + } + group.deviceModels.push(deviceModel) + groups.set(key, group) + } + for (const group of groups.values()) { + group.deviceModels.sort((a, b) => + getDeviceModelName(a).localeCompare(getDeviceModelName(b)) + ) + } + return [...groups.values()].sort((a, b) => { + if (a.manufacturer == null) return 1 + if (b.manufacturer == null) return -1 + return getManufacturerName(a.manufacturer).localeCompare( + getManufacturerName(b.manufacturer) + ) + }) +} + +export const getManufacturerOptions = ( + deviceModels: DeviceModel[] +): FilterOption[] => + uniqueOptions( + deviceModels.map((m) => + m.manufacturer == null + ? null + : { + value: m.manufacturer.slug, + label: getManufacturerName(m.manufacturer), + } + ) + ) + +export const getDeviceTypeOptions = ( + deviceModels: DeviceModel[] +): FilterOption[] => + uniqueOptions( + deviceModels.map((m) => + m.device_type == null + ? null + : { value: m.device_type.slug, label: m.device_type.display_name } + ) + ) + +export const getCapabilityOptions = ( + deviceModels: DeviceModel[] +): FilterOption[] => { + const names = new Set() + for (const deviceModel of deviceModels) { + for (const { name } of getSupportedCapabilities(deviceModel)) { + if (capabilityLabels[name] != null) names.add(name) + } + } + return [...names] + .sort((a, b) => capabilityRank(a) - capabilityRank(b)) + .map((name) => ({ value: name, label: getCapabilityLabel(name) })) +} + +export const getDeviceModelName = (deviceModel: DeviceModel): string => + deviceModel.display_name ?? deviceModel.slug + +export const getManufacturerName = ( + manufacturer: ManufacturerRef | Manufacturer +): string => manufacturer.display_name ?? manufacturer.slug + +/** The best available image URL for a device model. */ +export const getDeviceModelImageUrl = ( + deviceModel: DeviceModel, + manufacturer?: Manufacturer | null +): string | null => { + const image = + deviceModel.featured_image ?? + deviceModel.variants.flatMap((variant) => variant.images)[0] ?? + manufacturer?.default_device_images.find( + (d) => d.device_type.slug === deviceModel.device_type?.slug + )?.asset ?? + null + if (image == null) return null + return ( + image.formats.thumbnail?.url ?? + image.formats.small?.url ?? + image.formats.medium?.url ?? + image.url + ) +} + +/** The manufacturer's logo, preferring a square logo. */ +export const getManufacturerLogoUrl = ( + manufacturer: Manufacturer | null | undefined, + { mono = false }: { mono?: boolean } = {} +): string | null => { + if (manufacturer == null) return null + const kinds = mono + ? ([ + 'square_logo_mono', + 'horizontal_logo_mono', + 'square_logo', + 'horizontal_logo', + ] as const) + : ([ + 'square_logo', + 'horizontal_logo', + 'square_logo_mono', + 'horizontal_logo_mono', + ] as const) + for (const kind of kinds) { + const logo = manufacturer.logos.find((l) => l.kind === kind) + if (logo != null) return logo.formats.small?.url ?? logo.url + } + return null +} + +const toSupported = ( + capability: CapabilityDetail +): SupportedCapability['supported'] | null => { + if (capability.supported === 'yes') return 'yes' + if (capability.supported === 'conditionally') return 'conditionally' + return null +} + +const capabilityRank = (name: string): number => { + const index = capabilityOrder.indexOf(name) + return index === -1 ? capabilityOrder.length : index +} + +const uniqueOptions = (options: Array): FilterOption[] => { + const byValue = new Map() + for (const option of options) { + if (option != null && !byValue.has(option.value)) { + byValue.set(option.value, option) + } + } + return [...byValue.values()].sort((a, b) => a.label.localeCompare(b.label)) +} diff --git a/src/lib/seam/components/ecosystem-elements.ts b/src/lib/seam/components/ecosystem-elements.ts new file mode 100644 index 000000000..91a80e194 --- /dev/null +++ b/src/lib/seam/components/ecosystem-elements.ts @@ -0,0 +1,2 @@ +export * as CompatibleApps from './CompatibleApps/CompatibleApps.element.js' +export * as SupportedDevices from './SupportedDevices/SupportedDevices.element.js' diff --git a/src/lib/seam/components/index.ts b/src/lib/seam/components/index.ts index 5fb379b74..f8a260603 100644 --- a/src/lib/seam/components/index.ts +++ b/src/lib/seam/components/index.ts @@ -1,8 +1,10 @@ export * from './AccessCodeDetails/AccessCodeDetails.js' export * from './AccessCodeTable/AccessCodeTable.js' export * from './common-props.js' +export * from './CompatibleApps/CompatibleApps.js' export * from './ConnectAccountButton/ConnectAccountButton.js' export * from './CreateAccessCodeForm/CreateAccessCodeForm.js' export * from './DeviceDetails/DeviceDetails.js' export * from './DeviceTable/DeviceTable.js' export * from './EditAccessCodeForm/EditAccessCodeForm.js' +export * from './SupportedDevices/SupportedDevices.js' diff --git a/src/lib/seam/ecosystem/EcosystemProvider.tsx b/src/lib/seam/ecosystem/EcosystemProvider.tsx new file mode 100644 index 000000000..b34c5aeb5 --- /dev/null +++ b/src/lib/seam/ecosystem/EcosystemProvider.tsx @@ -0,0 +1,133 @@ +import { + QueryClient, + QueryClientContext, + QueryClientProvider, +} from '@tanstack/react-query' +import { + createContext, + type PropsWithChildren, + useContext, + useMemo, +} from 'react' + +import { defaultEcosystemEndpoint } from 'lib/seam/ecosystem/client.js' +import { seamComponentsClassName } from 'lib/seam/seam-components-class-name.js' +import { useSeamFont } from 'lib/seam/use-seam-font.js' +import { useSeamStyles } from 'lib/seam/use-seam-styles.js' + +export interface EcosystemContext { + /** The embed key sent as `X-Embed-Key`, if any. */ + embedKey: string | undefined + /** The Ecosystem API endpoint, ending in `/`. */ + endpoint: string +} + +export interface EcosystemOptions { + /** + * Seam Ecosystem embed key (`eco_live_…`). + * Required outside seam.co: the key's allowed origins must include the page origin. + */ + embedKey?: string | undefined + /** Seam Ecosystem API endpoint. Defaults to `https://ecosystem.seam.co/v1/`. */ + ecosystemEndpoint?: string | undefined +} + +export interface EcosystemProviderProps + extends EcosystemOptions, + PropsWithChildren { + queryClient?: QueryClient | undefined + disableCssInjection?: boolean | undefined + disableFontInjection?: boolean | undefined + unminifiyCss?: boolean | undefined +} + +const defaultQueryClient = new QueryClient() + +/** + * Provides the Seam Ecosystem context for `CompatibleApps` and `SupportedDevices`. + * + * Unlike `SeamProvider`, this does not need a Seam publishable key + * and does not load the Seam API client, so it stays lightweight. + * Inside a `SeamProvider`, pass `embedKey` to the `SeamProvider` instead. + */ +export function EcosystemProvider({ + children, + queryClient, + disableCssInjection = false, + disableFontInjection = false, + unminifiyCss = false, + ...options +}: EcosystemProviderProps): JSX.Element { + useSeamStyles({ disabled: disableCssInjection, unminified: unminifiyCss }) + useSeamFont({ disabled: disableFontInjection }) + + const queryClientFromContext = useContext(QueryClientContext) + + return ( +
    + + + {children} + + +
    + ) +} + +/** + * Provides only the Ecosystem context; used by `SeamProvider`. + * Unset options inherit from an enclosing provider. + */ +export function EcosystemContextProvider({ + children, + embedKey, + ecosystemEndpoint, +}: EcosystemOptions & PropsWithChildren): JSX.Element { + const parent = useContext(ecosystemContext) + + const value = useMemo( + () => ({ + embedKey: normalizeEmbedKey(embedKey) ?? parent.embedKey, + endpoint: normalizeEndpoint(ecosystemEndpoint) ?? parent.endpoint, + }), + [embedKey, ecosystemEndpoint, parent] + ) + + return ( + + {children} + + ) +} + +export function useEcosystemContext(): EcosystemContext { + return useContext(ecosystemContext) +} + +const normalizeEmbedKey = ( + embedKey: string | undefined +): string | undefined => { + const key = embedKey?.trim() + if (key == null || key.length === 0) return undefined + return key +} + +const normalizeEndpoint = ( + endpoint: string | undefined +): string | undefined => { + const value = endpoint?.trim() + if (value == null || value.length === 0) return undefined + return value.endsWith('/') ? value : `${value}/` +} + +const ecosystemContext = createContext({ + embedKey: undefined, + endpoint: defaultEcosystemEndpoint, +}) diff --git a/src/lib/seam/ecosystem/client.ts b/src/lib/seam/ecosystem/client.ts new file mode 100644 index 000000000..66bde1be9 --- /dev/null +++ b/src/lib/seam/ecosystem/client.ts @@ -0,0 +1,116 @@ +import type { EcosystemErrorResponse } from './types.js' + +export const defaultEcosystemEndpoint = 'https://ecosystem.seam.co/v1/' + +export type EcosystemQueryParams = Record< + string, + string | number | boolean | string[] | null | undefined +> + +export interface EcosystemRequestOptions { + endpoint: string + embedKey?: string | undefined + params?: EcosystemQueryParams + signal?: AbortSignal +} + +/** + * An error response from the Ecosystem API. + * The `type` is the API error type, e.g., `embed_key_required`, + * `invalid_embed_key` or `origin_not_allowed`. + */ +export class EcosystemApiError extends Error { + readonly status: number + readonly type: string + readonly requestId: string | null + + constructor({ + status, + type, + message, + requestId = null, + }: { + status: number + type: string + message: string + requestId?: string | null + }) { + super(message) + this.name = 'EcosystemApiError' + this.status = status + this.type = type + this.requestId = requestId + } +} + +/** True when the error means the embed key is missing, invalid or not allowed here. */ +export const isEmbedKeyError = (error: unknown): boolean => + error instanceof EcosystemApiError && + (error.status === 401 || error.status === 403) + +export const getEcosystemUrl = ( + endpoint: string, + path: string, + params: EcosystemQueryParams = {} +): URL => { + const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/` + const url = new URL(path, new URL(base, globalThis.location?.href)) + for (const [key, value] of Object.entries(params)) { + if (value == null) continue + if (Array.isArray(value)) { + if (value.length === 0) continue + url.searchParams.set(key, value.join(',')) + continue + } + if (typeof value === 'string' && value.length === 0) continue + url.searchParams.set(key, String(value)) + } + return url +} + +export const fetchEcosystem = async ( + path: string, + { endpoint, embedKey, params, signal }: EcosystemRequestOptions +): Promise => { + const url = getEcosystemUrl(endpoint, path, params) + + const headers: Record = { Accept: 'application/json' } + if (embedKey != null && embedKey.length > 0) { + headers['X-Embed-Key'] = embedKey + } + + const res = await globalThis.fetch(url, { + headers, + ...(signal == null ? {} : { signal }), + }) + + const body = (await res.json().catch(() => null)) as unknown + + if (!res.ok) { + const error = isErrorResponse(body) ? body.error : null + throw new EcosystemApiError({ + status: res.status, + type: error?.type ?? 'unknown_error', + message: + error?.message ?? `Ecosystem request failed with status ${res.status}`, + requestId: error?.request_id ?? null, + }) + } + + if (body == null || typeof body !== 'object') { + throw new EcosystemApiError({ + status: res.status, + type: 'invalid_response', + message: 'Ecosystem returned an invalid response', + }) + } + + return body as T +} + +const isErrorResponse = (body: unknown): body is EcosystemErrorResponse => + body != null && + typeof body === 'object' && + 'error' in body && + body.error != null && + typeof body.error === 'object' diff --git a/src/lib/seam/ecosystem/index.ts b/src/lib/seam/ecosystem/index.ts new file mode 100644 index 000000000..9fc6e2e96 --- /dev/null +++ b/src/lib/seam/ecosystem/index.ts @@ -0,0 +1,19 @@ +export { + defaultEcosystemEndpoint, + EcosystemApiError, + isEmbedKeyError, +} from './client.js' +export { + type EcosystemContext, + type EcosystemOptions, + EcosystemProvider, + type EcosystemProviderProps, + useEcosystemContext, +} from './EcosystemProvider.js' +export * from './types.js' +export * from './use-compatible-partners.js' +export * from './use-ecosystem-device-models.js' +export * from './use-ecosystem-manufacturers.js' +export type { UseEcosystemQueryResult } from './use-ecosystem-query-result.js' +export * from './use-embed-key-resolution.js' +export type { EcosystemTheme } from 'lib/ui/ecosystem/theme.js' diff --git a/src/lib/seam/ecosystem/types.ts b/src/lib/seam/ecosystem/types.ts new file mode 100644 index 000000000..fd5bad95c --- /dev/null +++ b/src/lib/seam/ecosystem/types.ts @@ -0,0 +1,334 @@ +/** + * Response types for the public Seam Ecosystem API (PartnerDB and DeviceDB). + * + * Hand-copied from the zod schemas in `seamapi/ecosystem` `public-types/` + * (origin/main 733edf3, 2026-10-16) to avoid a zod runtime dependency. + * Fields stored verbatim from the source as JSON are typed as `unknown`. + * Keep in sync with Ecosystem until it publishes its types as a package. + */ + +// Partners (PartnerDB) + +export interface EcosystemIndustry { + industry_id: string + slug: string + display_name: string + short_name: string | null +} + +export interface EcosystemSolution { + solution_id: string + slug: string + display_name: string + short_name: string | null + type: 'software' | 'service' +} + +export type PartnerAssetKind = + | 'square_logo' + | 'square_logo_mono' + | 'horizontal_logo' + | 'horizontal_logo_mono' + | 'product_image' + +export interface PartnerAsset { + partner_asset_id: string + kind: PartnerAssetKind + url: string + mime: string + width: number | null + height: number | null + sort_order: number +} + +export interface Partner { + partner_id: string + slug: string + display_name: string + website: string | null + description_md: string | null + is_live: boolean + is_featured: boolean + legacy_industry_featured: boolean + industries: EcosystemIndustry[] + solutions: EcosystemSolution[] + assets: PartnerAsset[] + created_at: string + updated_at: string +} + +export type SupportLevel = + | 'device_category' + | 'manufacturer' + | 'provider' + | 'capability' + +export type ObservedStatus = 'active' | 'trace' | 'none' | 'unknown' + +export type DeclaredStatus = 'enabled' | 'disabled' | 'not_via_seam' + +export interface SupportResolution { + supported: boolean + resolved_level: SupportLevel | null + declared_status: DeclaredStatus | null + observed_status: ObservedStatus | null + note: string | null +} + +export interface CompatiblePartner extends Partner { + support: SupportResolution +} + +// Embed keys + +export type EmbedKeyOwnerKind = 'partner' | 'manufacturer' | 'seam' + +/** The public record of an embed key, as returned by `embed/resolve`. */ +export interface EmbedKey { + embed_key_id: string + display_name: string + owner_kind: EmbedKeyOwnerKind + partner_id: string | null + manufacturer_slug: string | null + allowed_origins: string[] + config: Record + revoked_at: string | null + created_at: string +} + +export interface EmbedCapabilityExclusion { + device_category: string + /** Empty means every manufacturer in the category. */ + manufacturer_slug: string + /** Empty means every provider in the category. */ + provider_slug: string +} + +export interface EmbedExclusions { + excluded_manufacturers: string[] + excluded_device_types: string[] + unsupported_providers: string[] + capability: string + capability_exclusions: EmbedCapabilityExclusion[] +} + +/** + * Manufacturer context for manufacturer-owned keys. + * Not returned by Ecosystem yet (DEVDB-439); read when present. + */ +export interface EmbedManufacturerContext { + manufacturer_slug: string +} + +export interface EmbedResolution { + embed_key: EmbedKey + exclusions: EmbedExclusions + manufacturer_context?: EmbedManufacturerContext | null +} + +// Devices (DeviceDB) + +export interface MediaFormat { + object_key: string + url: string + mime: string | null + width: number | null + height: number | null + bytes: number | null + sha256: string | null +} + +export type MediaFormatName = 'thumbnail' | 'small' | 'medium' | 'large' + +export type DeviceAssetKind = + | 'square_logo' + | 'square_logo_mono' + | 'horizontal_logo' + | 'horizontal_logo_mono' + | 'default_device_image' + | 'featured' + | 'variant_image' + +export interface DeviceAsset { + asset_id: string + kind: DeviceAssetKind + object_key: string + url: string + mime: string | null + width: number | null + height: number | null + bytes: number | null + sha256: string | null + sort_order: number + formats: Partial> + created_at: string + updated_at: string +} + +export interface DeviceTypeRef { + device_type_id: string + slug: string + display_name: string + is_accessory: boolean +} + +export interface ManufacturerRef { + manufacturer_id: string + slug: string + display_name: string | null +} + +export interface ProviderRef { + provider_id: string + slug: string + display_name: string | null +} + +export type CapabilityName = + | 'device_connectivity' + | 'can_program_online_access_codes' + | 'can_program_offline_access_codes' + | 'can_remotely_unlock' + | 'can_remotely_lock' + | 'can_encode_plastic_cards' + | 'can_provision_mobile_keys' + | 'can_provision_wallet_keys' + | 'can_hvac_cool' + | 'can_hvac_heat' + | 'can_hvac_auto' + | 'can_hvac_fan' + | 'can_program_thermostat_weekly_programs' + | 'can_monitor' + +export type CapabilitySupported = 'yes' | 'no' | 'conditionally' + +export type IntegrationSupportLevel = + | 'inquire' + | 'unavailable' + | 'planned' + | 'in-progress' + | 'beta' + | 'stable' + +export interface CapabilityReport { + details: string + requirements?: Record< + string, + { message: string; device_slugs?: string[]; [key: string]: unknown } + > + dependencies?: string[] + [key: string]: unknown +} + +export interface CapabilityDetail { + capability_id: string + name: CapabilityName + supported: CapabilitySupported + integration_support_level: IntegrationSupportLevel | null + report: CapabilityReport | null + report_raw: CapabilityReport | null + locale: string + computed_at: string +} + +export interface ProviderCapabilities { + provider: ProviderRef + capabilities: CapabilityDetail[] +} + +export interface DeviceModelVariant { + device_model_variant_id: string + color_name: string | null + style_name: string | null + primary_color_hex: string | null + manufacturer_model_id: string | null + asin: string | null + gtins: unknown + marketplace_listings: unknown + sort_order: number + images: DeviceAsset[] + created_at: string + updated_at: string +} + +export interface DeviceModel { + device_model_id: string + slug: string + display_name: string | null + description_md: string | null + product_url: string | null + is_forbidden_to_3dparty: boolean | null + no_longer_sold: boolean | null + shop_listings: unknown + last_commerce_search_at: string | null + manufacturer: ManufacturerRef | null + device_type: DeviceTypeRef | null + device_type_properties: unknown + variants: DeviceModelVariant[] + featured_image: DeviceAsset | null + providers: ProviderRef[] + capabilities_by_provider: ProviderCapabilities[] + capabilities_stale: boolean + created_at: string + updated_at: string +} + +export interface ManufacturerDefaultDeviceImage { + device_type: DeviceTypeRef + asset: DeviceAsset +} + +export interface Manufacturer { + manufacturer_id: string + slug: string + display_name: string | null + website: string | null + headquarters: string | null + country_of_origin: string | null + parent_org: string | null + inc_date: string | null + customer_support_phone: string | null + customer_support_email: string | null + customer_support_url: string | null + description_md: string | null + seam_api_guide: string | null + commerce_footprint: unknown + last_commerce_search_at: string | null + logos: DeviceAsset[] + default_device_images: ManufacturerDefaultDeviceImage[] + providers: ProviderRef[] + device_model_count: number + created_at: string + updated_at: string +} + +export interface Pagination { + has_next_page: boolean + next_page_cursor: string | null +} + +// Responses + +export interface CompatibilityResponse { + partners: CompatiblePartner[] +} + +export interface DeviceModelsListResponse { + device_models: DeviceModel[] + pagination: Pagination +} + +export interface ManufacturersListResponse { + manufacturers: Manufacturer[] + pagination: Pagination +} + +export interface EcosystemErrorResponse { + ok: false + error: { + type: string + message: string + data?: Record + request_id?: string + } +} diff --git a/src/lib/seam/ecosystem/use-compatible-partners.ts b/src/lib/seam/ecosystem/use-compatible-partners.ts new file mode 100644 index 000000000..19583f318 --- /dev/null +++ b/src/lib/seam/ecosystem/use-compatible-partners.ts @@ -0,0 +1,80 @@ +import { useQuery } from '@tanstack/react-query' + +import { + type EcosystemApiError, + fetchEcosystem, +} from 'lib/seam/ecosystem/client.js' +import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' +import type { + CompatibilityResponse, + CompatiblePartner, +} from 'lib/seam/ecosystem/types.js' +import { + retryEcosystemQuery, + type UseEcosystemQueryResult, +} from 'lib/seam/ecosystem/use-ecosystem-query-result.js' + +export interface UseCompatiblePartnersParams { + /** Device category, e.g., `smartlock`, `thermostat`, `sensor`, `intercom`. */ + deviceType: string + /** DeviceDB manufacturer slug, e.g., `schlage`. */ + manufacturer?: string | undefined + /** DeviceDB provider slug, e.g., `salto-ks`. */ + provider?: string | undefined + /** DeviceDB capability, e.g., `can_provision_mobile_keys`. */ + capability?: string | undefined + /** Industry slug, e.g., `short-term-rental`. */ + industry?: string | undefined +} + +export type UseCompatiblePartnersData = CompatiblePartner[] + +/** + * The live partners that support a device category, manufacturer, provider + * or capability through Seam, from Ecosystem `GET /v1/compatibility`. + * Declared-first: a partner is listed only where Seam or the partner declared support. + */ +export function useCompatiblePartners({ + deviceType, + manufacturer = '', + provider = '', + capability = '', + industry = '', +}: UseCompatiblePartnersParams): UseEcosystemQueryResult< + 'partners', + UseCompatiblePartnersData +> { + const { embedKey, endpoint } = useEcosystemContext() + + const params = { + device_category: deviceType.trim(), + manufacturer_slug: manufacturer.trim(), + provider_slug: provider.trim(), + capability: capability.trim(), + industry: industry.trim(), + } + + const { data, ...rest } = useQuery< + UseCompatiblePartnersData, + EcosystemApiError + >({ + enabled: params.device_category.length > 0, + queryKey: [ + 'ecosystem', + endpoint, + embedKey ?? null, + 'compatibility', + params, + ], + queryFn: async ({ signal }) => { + const { partners } = await fetchEcosystem( + 'compatibility', + { endpoint, embedKey, params, signal } + ) + return partners + }, + retry: retryEcosystemQuery, + }) + + return { ...rest, partners: data } +} diff --git a/src/lib/seam/ecosystem/use-ecosystem-device-models.ts b/src/lib/seam/ecosystem/use-ecosystem-device-models.ts new file mode 100644 index 000000000..01ebbe692 --- /dev/null +++ b/src/lib/seam/ecosystem/use-ecosystem-device-models.ts @@ -0,0 +1,96 @@ +import { useQuery } from '@tanstack/react-query' + +import { + type EcosystemApiError, + fetchEcosystem, +} from 'lib/seam/ecosystem/client.js' +import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' +import type { + DeviceModel, + DeviceModelsListResponse, +} from 'lib/seam/ecosystem/types.js' +import { + retryEcosystemQuery, + type UseEcosystemQueryResult, +} from 'lib/seam/ecosystem/use-ecosystem-query-result.js' + +export interface UseEcosystemDeviceModelsParams { + /** DeviceDB manufacturer slug, e.g., `schlage`. */ + manufacturer?: string | undefined + /** DeviceDB device type slug, e.g., `smartlock`. */ + deviceType?: string | undefined + /** DeviceDB provider slug, e.g., `salto-ks`. */ + provider?: string | undefined + /** Only device models with a computed capability of this name. */ + capability?: string | undefined + /** Set to false to wait before fetching. */ + enabled?: boolean | undefined +} + +export type UseEcosystemDeviceModelsData = DeviceModel[] + +const pageSize = 500 +const maxPages = 20 + +/** + * Published device models from Ecosystem `GET /v1/device_models/list`, + * following pagination to return every matching model. + */ +export function useEcosystemDeviceModels({ + manufacturer = '', + deviceType = '', + provider = '', + capability = '', + enabled = true, +}: UseEcosystemDeviceModelsParams = {}): UseEcosystemQueryResult< + 'deviceModels', + UseEcosystemDeviceModelsData +> { + const { embedKey, endpoint } = useEcosystemContext() + + const params = { + manufacturer: manufacturer.trim(), + device_type: deviceType.trim(), + provider: provider.trim(), + capability: capability.trim(), + } + + const { data, ...rest } = useQuery< + UseEcosystemDeviceModelsData, + EcosystemApiError + >({ + enabled, + queryKey: [ + 'ecosystem', + endpoint, + embedKey ?? null, + 'device_models', + 'list', + params, + ], + queryFn: async ({ signal }) => { + const deviceModels: DeviceModel[] = [] + let pageCursor: string | undefined + for (let page = 0; page < maxPages; page++) { + const res = await fetchEcosystem( + 'device_models/list', + { + endpoint, + embedKey, + params: { ...params, limit: pageSize, page_cursor: pageCursor }, + signal, + } + ) + deviceModels.push(...res.device_models) + const { has_next_page: hasNextPage, next_page_cursor: nextPageCursor } = + res.pagination + if (!hasNextPage || nextPageCursor == null) break + pageCursor = nextPageCursor + } + return deviceModels + }, + retry: retryEcosystemQuery, + }) + + return { ...rest, deviceModels: data } +} diff --git a/src/lib/seam/ecosystem/use-ecosystem-manufacturers.ts b/src/lib/seam/ecosystem/use-ecosystem-manufacturers.ts new file mode 100644 index 000000000..81265789f --- /dev/null +++ b/src/lib/seam/ecosystem/use-ecosystem-manufacturers.ts @@ -0,0 +1,86 @@ +import { useQuery } from '@tanstack/react-query' + +import { + type EcosystemApiError, + fetchEcosystem, +} from 'lib/seam/ecosystem/client.js' +import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' +import type { + Manufacturer, + ManufacturersListResponse, +} from 'lib/seam/ecosystem/types.js' +import { + retryEcosystemQuery, + type UseEcosystemQueryResult, +} from 'lib/seam/ecosystem/use-ecosystem-query-result.js' + +export interface UseEcosystemManufacturersParams { + /** Only manufacturers with these slugs. */ + slugs?: string[] | undefined + /** Set to false to wait before fetching. */ + enabled?: boolean | undefined +} + +export type UseEcosystemManufacturersData = Manufacturer[] + +const pageSize = 500 +const maxPages = 10 + +/** Published manufacturers from Ecosystem `GET /v1/manufacturers/list`. */ +export function useEcosystemManufacturers({ + slugs, + enabled = true, +}: UseEcosystemManufacturersParams = {}): UseEcosystemQueryResult< + 'manufacturers', + UseEcosystemManufacturersData +> { + const { embedKey, endpoint } = useEcosystemContext() + + const sortedSlugs = + slugs == null + ? null + : [...new Set(slugs)].sort((a, b) => a.localeCompare(b)) + + const { data, ...rest } = useQuery< + UseEcosystemManufacturersData, + EcosystemApiError + >({ + enabled: enabled && (sortedSlugs == null || sortedSlugs.length > 0), + queryKey: [ + 'ecosystem', + endpoint, + embedKey ?? null, + 'manufacturers', + 'list', + { slugs: sortedSlugs }, + ], + queryFn: async ({ signal }) => { + const manufacturers: Manufacturer[] = [] + let pageCursor: string | undefined + for (let page = 0; page < maxPages; page++) { + const res = await fetchEcosystem( + 'manufacturers/list', + { + endpoint, + embedKey, + params: { + slugs: sortedSlugs ?? undefined, + limit: pageSize, + page_cursor: pageCursor, + }, + signal, + } + ) + manufacturers.push(...res.manufacturers) + const { has_next_page: hasNextPage, next_page_cursor: nextPageCursor } = + res.pagination + if (!hasNextPage || nextPageCursor == null) break + pageCursor = nextPageCursor + } + return manufacturers + }, + retry: retryEcosystemQuery, + }) + + return { ...rest, manufacturers: data } +} diff --git a/src/lib/seam/ecosystem/use-ecosystem-query-result.ts b/src/lib/seam/ecosystem/use-ecosystem-query-result.ts new file mode 100644 index 000000000..555f54141 --- /dev/null +++ b/src/lib/seam/ecosystem/use-ecosystem-query-result.ts @@ -0,0 +1,19 @@ +import type { UseQueryResult } from '@tanstack/react-query' + +import type { EcosystemApiError } from 'lib/seam/ecosystem/client.js' + +export type UseEcosystemQueryResult = Omit< + UseQueryResult, + 'data' +> & { + [key in Field]?: Data +} + +/** Retry server and network errors, but not client errors such as an invalid embed key. */ +export const retryEcosystemQuery = ( + failureCount: number, + error: EcosystemApiError +): boolean => { + if (error.status >= 400 && error.status < 500) return false + return failureCount < 2 +} diff --git a/src/lib/seam/ecosystem/use-embed-key-resolution.test.ts b/src/lib/seam/ecosystem/use-embed-key-resolution.test.ts new file mode 100644 index 000000000..b2df09b27 --- /dev/null +++ b/src/lib/seam/ecosystem/use-embed-key-resolution.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from 'vitest' + +import type { EmbedResolution } from './types.js' +import { getEmbedKeyManufacturer } from './use-embed-key-resolution.js' + +const resolution = ( + embedKey: Partial, + extra: Partial = {} +): EmbedResolution => ({ + embed_key: { + embed_key_id: 'key', + display_name: 'Key', + owner_kind: 'seam', + partner_id: null, + manufacturer_slug: null, + allowed_origins: [], + config: {}, + revoked_at: null, + created_at: '2026-10-01T00:00:00.000Z', + ...embedKey, + }, + exclusions: { + excluded_manufacturers: [], + excluded_device_types: [], + unsupported_providers: [], + capability: '', + capability_exclusions: [], + }, + ...extra, +}) + +test('getEmbedKeyManufacturer: none without a resolution', () => { + expect(getEmbedKeyManufacturer(null)).toBeNull() + expect(getEmbedKeyManufacturer(undefined)).toBeNull() +}) + +test('getEmbedKeyManufacturer: none for seam and partner keys', () => { + expect(getEmbedKeyManufacturer(resolution({}))).toBeNull() + expect( + getEmbedKeyManufacturer( + resolution({ owner_kind: 'partner', manufacturer_slug: 'schlage' }) + ) + ).toBeNull() +}) + +test('getEmbedKeyManufacturer: the manufacturer of a manufacturer key', () => { + expect( + getEmbedKeyManufacturer( + resolution({ owner_kind: 'manufacturer', manufacturer_slug: 'schlage' }) + ) + ).toBe('schlage') +}) + +test('getEmbedKeyManufacturer: prefers the manufacturer context', () => { + expect( + getEmbedKeyManufacturer( + resolution( + { owner_kind: 'manufacturer', manufacturer_slug: 'schlage' }, + { manufacturer_context: { manufacturer_slug: 'yale' } } + ) + ) + ).toBe('yale') + expect( + getEmbedKeyManufacturer( + resolution( + { owner_kind: 'manufacturer', manufacturer_slug: 'schlage' }, + { manufacturer_context: null } + ) + ) + ).toBe('schlage') +}) diff --git a/src/lib/seam/ecosystem/use-embed-key-resolution.ts b/src/lib/seam/ecosystem/use-embed-key-resolution.ts new file mode 100644 index 000000000..8fbf78b00 --- /dev/null +++ b/src/lib/seam/ecosystem/use-embed-key-resolution.ts @@ -0,0 +1,80 @@ +import { useQuery } from '@tanstack/react-query' + +import { + type EcosystemApiError, + fetchEcosystem, +} from 'lib/seam/ecosystem/client.js' +import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' +import type { EmbedResolution } from 'lib/seam/ecosystem/types.js' +import { + retryEcosystemQuery, + type UseEcosystemQueryResult, +} from 'lib/seam/ecosystem/use-ecosystem-query-result.js' + +export interface UseEmbedKeyResolutionParams { + /** Resolve the partner's capability exclusions for this capability. */ + capability?: string | undefined +} + +export type UseEmbedKeyResolutionData = EmbedResolution + +/** + * Validates the provider's embed key against the page origin with + * Ecosystem `GET /v1/embed/resolve` and returns the key's public record + * and, for partner keys, what the partner's widgets must not show. + * Disabled when there is no embed key. + */ +export function useEmbedKeyResolution({ + capability = '', +}: UseEmbedKeyResolutionParams = {}): UseEcosystemQueryResult< + 'embedResolution', + UseEmbedKeyResolutionData +> { + const { embedKey, endpoint } = useEcosystemContext() + + const params = { capability: capability.trim() } + + const { data, ...rest } = useQuery< + UseEmbedKeyResolutionData, + EcosystemApiError + >({ + enabled: embedKey != null, + queryKey: [ + 'ecosystem', + endpoint, + embedKey ?? null, + 'embed', + 'resolve', + params, + ], + queryFn: async ({ signal }) => + await fetchEcosystem('embed/resolve', { + endpoint, + embedKey, + params, + signal, + }), + retry: retryEcosystemQuery, + }) + + return { ...rest, embedResolution: data } +} + +/** + * The manufacturer an embed key is scoped to, if any. + * Prefers the `manufacturer_context` Ecosystem returns for manufacturer keys (DEVDB-439), + * and falls back to the key's own `manufacturer_slug`. + */ +export const getEmbedKeyManufacturer = ( + resolution: EmbedResolution | null | undefined +): string | null => { + if (resolution == null) return null + const contextSlug = resolution.manufacturer_context?.manufacturer_slug + if (contextSlug != null && contextSlug.length > 0) return contextSlug + const { owner_kind: ownerKind, manufacturer_slug: slug } = + resolution.embed_key + if (ownerKind === 'manufacturer' && slug != null && slug.length > 0) { + return slug + } + return null +} diff --git a/src/lib/seam/index.ts b/src/lib/seam/index.ts index 777551aca..119da052f 100644 --- a/src/lib/seam/index.ts +++ b/src/lib/seam/index.ts @@ -10,5 +10,6 @@ export * from './connected-accounts/use-connected-account.js' export * from './devices/use-device.js' export * from './devices/use-device-providers.js' export * from './devices/use-devices.js' +export * from './ecosystem/index.js' export * from './SeamProvider.js' export * from './use-seam-query-result.js' diff --git a/src/lib/seam/seam-components-class-name.ts b/src/lib/seam/seam-components-class-name.ts new file mode 100644 index 000000000..b34444f93 --- /dev/null +++ b/src/lib/seam/seam-components-class-name.ts @@ -0,0 +1 @@ +export const seamComponentsClassName = 'seam-components' diff --git a/src/lib/ui/ecosystem/SearchField.tsx b/src/lib/ui/ecosystem/SearchField.tsx new file mode 100644 index 000000000..a82fe22c9 --- /dev/null +++ b/src/lib/ui/ecosystem/SearchField.tsx @@ -0,0 +1,32 @@ +import { SearchIcon } from 'lib/icons/Search.js' + +interface SearchFieldProps { + value: string + onChange: (value: string) => void + label: string + disabled?: boolean +} + +export function SearchField({ + value, + onChange, + label, + disabled = false, +}: SearchFieldProps): JSX.Element { + return ( + + ) +} diff --git a/src/lib/ui/ecosystem/StateMessage.tsx b/src/lib/ui/ecosystem/StateMessage.tsx new file mode 100644 index 000000000..bf3d31e70 --- /dev/null +++ b/src/lib/ui/ecosystem/StateMessage.tsx @@ -0,0 +1,29 @@ +import type { PropsWithChildren } from 'react' + +interface StateMessageProps extends PropsWithChildren { + actionLabel?: string + onAction?: () => void + role?: 'status' | 'alert' +} + +export function StateMessage({ + children, + actionLabel, + onAction, + role = 'status', +}: StateMessageProps): JSX.Element { + return ( +
    +

    {children}

    + {actionLabel != null && onAction != null && ( + + )} +
    + ) +} diff --git a/src/lib/ui/ecosystem/errors.ts b/src/lib/ui/ecosystem/errors.ts new file mode 100644 index 000000000..301ccb3c7 --- /dev/null +++ b/src/lib/ui/ecosystem/errors.ts @@ -0,0 +1,30 @@ +import { EcosystemApiError } from 'lib/seam/ecosystem/client.js' + +/** A message for an Ecosystem error, and whether retrying could help. */ +export const getEcosystemErrorMessage = ( + error: unknown, + fallback: string +): { message: string; canRetry: boolean } => { + if (error instanceof EcosystemApiError) { + if (error.type === 'embed_key_required') { + return { message: t.embedKeyRequired, canRetry: false } + } + if (error.type === 'origin_not_allowed') { + return { message: t.originNotAllowed, canRetry: false } + } + if (error.status === 401) { + return { message: t.invalidEmbedKey, canRetry: false } + } + if (error.status === 403) { + return { message: t.originNotAllowed, canRetry: false } + } + } + return { message: fallback, canRetry: true } +} + +const t = { + embedKeyRequired: + 'An embed key is required to show this list on this website.', + invalidEmbedKey: 'The embed key is invalid or has been revoked.', + originNotAllowed: 'The embed key is not allowed on this website.', +} diff --git a/src/lib/ui/ecosystem/storybook.tsx b/src/lib/ui/ecosystem/storybook.tsx new file mode 100644 index 000000000..337f6253d --- /dev/null +++ b/src/lib/ui/ecosystem/storybook.tsx @@ -0,0 +1,40 @@ +import type { Decorator } from '@storybook/react' + +import { EcosystemProvider } from 'lib/seam/ecosystem/EcosystemProvider.js' + +// Embed keys accepted by the fake Ecosystem API served at /api/ecosystem +// (.storybook/fake-ecosystem.js). The Storybook never uses a real key or production data. +export const storybookEmbedKeys = { + seam: 'eco_live_storybook', + partner: 'eco_live_storybook_partner', + manufacturer: 'eco_live_storybook_schlage', + wrongOrigin: 'eco_live_storybook_wrong_origin', + invalid: 'eco_live_invalid', +} as const + +export const storybookEcosystemEndpoint = '/api/ecosystem/v1/' + +export interface EcosystemStoryArgs { + embedKey?: string | undefined +} + +/** Wraps a story in an EcosystemProvider pointed at the fake Ecosystem API. */ +export const withFakeEcosystem: Decorator = ( + Story, + context +) => ( + + + +) + +export const withDarkBackground: Decorator = (Story) => ( +
    + +
    +) diff --git a/src/lib/ui/ecosystem/theme.ts b/src/lib/ui/ecosystem/theme.ts new file mode 100644 index 000000000..d93500f52 --- /dev/null +++ b/src/lib/ui/ecosystem/theme.ts @@ -0,0 +1,14 @@ +export type EcosystemTheme = 'light' | 'dark' | 'auto' + +/** + * The class name for an Ecosystem component theme. + * `auto` follows the visitor's `prefers-color-scheme`. + */ +export const getEcosystemThemeClassName = ( + theme: string | undefined +): string => { + if (theme === 'dark' || theme === 'auto') { + return `seam-ecosystem-theme-${theme}` + } + return 'seam-ecosystem-theme-light' +} diff --git a/src/styles/_compatible-apps.scss b/src/styles/_compatible-apps.scss new file mode 100644 index 000000000..d98149393 --- /dev/null +++ b/src/styles/_compatible-apps.scss @@ -0,0 +1,102 @@ +@mixin all { + .seam-compatible-apps { + .seam-compatible-apps-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + margin: 0; + padding: 0; + list-style: none; + } + + .seam-compatible-apps-card { + border: 1px solid var(--seam-ecosystem-line-1); + border-radius: 12px; + background: var(--seam-ecosystem-surface-1); + transition: border-color 0.15s ease; + + &:hover, + &:focus-within { + border-color: var(--seam-ecosystem-line-2); + } + } + + .seam-compatible-apps-card-body { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + height: 100%; + padding: 12px; + border-radius: 12px; + + &:focus-visible { + outline: 2px solid var(--seam-ecosystem-accent); + outline-offset: 2px; + } + } + + .seam-compatible-apps-logo { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + overflow: hidden; + border: 1px solid var(--seam-ecosystem-line-1); + border-radius: 8px; + background: var(--seam-ecosystem-surface-2); + + img { + width: 100%; + height: 100%; + padding: 4px; + object-fit: contain; + object-position: center; + } + } + + .seam-compatible-apps-logo-initial { + color: var(--seam-ecosystem-fg-2); + font-size: 18px; + font-weight: 600; + } + + .seam-compatible-apps-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; + } + + .seam-compatible-apps-name { + overflow: hidden; + color: var(--seam-ecosystem-fg-1); + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; + } + + .seam-compatible-apps-subtitle { + overflow: hidden; + color: var(--seam-ecosystem-fg-2); + font-size: 14px; + white-space: nowrap; + text-overflow: ellipsis; + } + + .seam-compatible-apps-note { + padding-top: 4px; + color: var(--seam-ecosystem-fg-2); + font-size: 12px; + } + + .seam-compatible-apps-footer { + display: flex; + justify-content: center; + padding-top: 16px; + } + } +} diff --git a/src/styles/_ecosystem.scss b/src/styles/_ecosystem.scss new file mode 100644 index 000000000..6cb6fe314 --- /dev/null +++ b/src/styles/_ecosystem.scss @@ -0,0 +1,295 @@ +@use './colors'; + +// Shared styles for the Seam Ecosystem components (CompatibleApps, SupportedDevices). +// Colors are CSS custom properties so the light, dark and auto themes share one set of rules. + +@mixin light-tokens { + --seam-ecosystem-fg-1: #{colors.$text-default}; + --seam-ecosystem-fg-2: #{colors.$text-gray-1}; + --seam-ecosystem-fg-3: #{colors.$text-gray-2}; + --seam-ecosystem-surface-1: #{colors.$white}; + --seam-ecosystem-surface-2: #{colors.$bg-a}; + --seam-ecosystem-line-1: #{colors.$bg-b}; + --seam-ecosystem-line-2: #{colors.$bg-d}; + --seam-ecosystem-accent: #{colors.$primary}; + --seam-ecosystem-accent-soft: #{colors.$primary-transparent}; + --seam-ecosystem-skeleton: #{colors.$bg-b}; +} + +@mixin dark-tokens { + --seam-ecosystem-fg-1: #f1f3f4; + --seam-ecosystem-fg-2: #b9bec5; + --seam-ecosystem-fg-3: #8a9099; + --seam-ecosystem-surface-1: #16191d; + --seam-ecosystem-surface-2: #22262c; + --seam-ecosystem-line-1: #2c3138; + --seam-ecosystem-line-2: #434a54; + --seam-ecosystem-accent: #4da3ff; + --seam-ecosystem-accent-soft: rgb(77 163 255 / 14%); + --seam-ecosystem-skeleton: #2c3138; +} + +@mixin dark-images { + .seam-ecosystem-light-only { + display: none; + } + + .seam-ecosystem-dark-only { + display: block; + } +} + +@mixin all { + .seam-ecosystem { + @include light-tokens; + + width: 100%; + color: var(--seam-ecosystem-fg-1); + font-size: 16px; + line-height: 1.4; + + &.seam-ecosystem-theme-dark { + @include dark-tokens; + @include dark-images; + } + + @media (prefers-color-scheme: dark) { + &.seam-ecosystem-theme-auto { + @include dark-tokens; + @include dark-images; + } + } + + .seam-ecosystem-dark-only { + display: none; + } + + a { + color: inherit; + text-decoration: none; + } + + img { + max-width: 100%; + } + } + + .seam-ecosystem-header { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 16px; + } + + .seam-ecosystem-title { + margin: 0 auto 0 0; + font-size: 20px; + font-weight: 600; + line-height: 1.3; + color: var(--seam-ecosystem-fg-1); + } + + .seam-ecosystem-controls { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; + margin-left: auto; + } + + .seam-ecosystem-search { + position: relative; + display: flex; + align-items: center; + min-width: 200px; + + .seam-ecosystem-search-icon { + position: absolute; + left: 10px; + font-size: 18px; + pointer-events: none; + + path { + fill: var(--seam-ecosystem-fg-3); + } + } + + .seam-ecosystem-search-input { + width: 100%; + height: 36px; + padding: 0 12px 0 34px; + border: 1px solid var(--seam-ecosystem-line-2); + border-radius: 8px; + background: var(--seam-ecosystem-surface-1); + color: var(--seam-ecosystem-fg-1); + font: inherit; + font-size: 14px; + + &::placeholder { + color: var(--seam-ecosystem-fg-3); + } + + &:focus { + outline: 2px solid var(--seam-ecosystem-accent); + outline-offset: -1px; + border-color: transparent; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + } + + .seam-ecosystem-select { + position: relative; + display: inline-flex; + align-items: center; + + .seam-ecosystem-select-input { + appearance: none; + height: 36px; + max-width: 220px; + padding: 0 32px 0 12px; + border: 1px solid var(--seam-ecosystem-line-2); + border-radius: 8px; + background: var(--seam-ecosystem-surface-1); + color: var(--seam-ecosystem-fg-1); + font: inherit; + font-size: 14px; + text-overflow: ellipsis; + cursor: pointer; + + &:focus { + outline: 2px solid var(--seam-ecosystem-accent); + outline-offset: -1px; + border-color: transparent; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + .seam-ecosystem-select-icon { + position: absolute; + right: 8px; + font-size: 18px; + pointer-events: none; + + path { + fill: var(--seam-ecosystem-fg-2); + } + } + } + + .seam-ecosystem-button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 14px; + border: 1px solid var(--seam-ecosystem-line-2); + border-radius: 8px; + background: var(--seam-ecosystem-surface-1); + color: var(--seam-ecosystem-fg-1); + font: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + + &:hover { + background: var(--seam-ecosystem-surface-2); + } + + &:focus-visible { + outline: 2px solid var(--seam-ecosystem-accent); + outline-offset: 2px; + } + } + + .seam-ecosystem-message { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 32px 16px; + border: 1px dashed var(--seam-ecosystem-line-2); + border-radius: 12px; + color: var(--seam-ecosystem-fg-2); + font-size: 14px; + text-align: center; + + p { + margin: 0; + } + } + + .seam-ecosystem-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border: 1px solid transparent; + border-radius: 999px; + background: var(--seam-ecosystem-accent-soft); + color: var(--seam-ecosystem-accent); + font-size: 12px; + font-weight: 600; + line-height: 18px; + white-space: nowrap; + + &.seam-ecosystem-badge-type { + background: var(--seam-ecosystem-surface-2); + color: var(--seam-ecosystem-fg-1); + } + + &.seam-ecosystem-badge-conditional { + border: 1px dashed var(--seam-ecosystem-accent); + background: transparent; + cursor: help; + } + } + + .seam-ecosystem-skeleton { + border-radius: 6px; + background: var(--seam-ecosystem-skeleton); + animation: seam-ecosystem-pulse 1.5s ease-in-out infinite; + } + + .seam-ecosystem-skeleton-line { + width: 60%; + height: 14px; + } + + .seam-ecosystem-skeleton-line-short { + width: 35%; + height: 12px; + } + + .seam-ecosystem-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + + @keyframes seam-ecosystem-pulse { + 50% { + opacity: 0.5; + } + } + + @media (prefers-reduced-motion: reduce) { + .seam-ecosystem-skeleton { + animation: none; + } + } +} diff --git a/src/styles/_main.scss b/src/styles/_main.scss index 641eba01c..df20a73c4 100644 --- a/src/styles/_main.scss +++ b/src/styles/_main.scss @@ -29,6 +29,9 @@ @use './noise-sensor'; @use './seam-editable-device-name'; @use './popover'; +@use './ecosystem'; +@use './compatible-apps'; +@use './supported-devices'; .seam-components { // Reset @@ -66,4 +69,7 @@ @include seam-table.all; @include noise-sensor.all; @include popover.all; + @include ecosystem.all; + @include compatible-apps.all; + @include supported-devices.all; } diff --git a/src/styles/_supported-devices.scss b/src/styles/_supported-devices.scss new file mode 100644 index 000000000..aefa2b854 --- /dev/null +++ b/src/styles/_supported-devices.scss @@ -0,0 +1,153 @@ +@mixin all { + .seam-supported-devices { + container-type: inline-size; + + .seam-supported-devices-sections { + display: flex; + flex-direction: column; + gap: 24px; + } + + .seam-supported-devices-section { + overflow: hidden; + border: 1px solid var(--seam-ecosystem-line-1); + border-radius: 12px; + background: var(--seam-ecosystem-surface-1); + } + + .seam-supported-devices-section-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--seam-ecosystem-line-1); + } + + .seam-supported-devices-logo { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + overflow: hidden; + border-radius: 8px; + background: var(--seam-ecosystem-surface-2); + color: var(--seam-ecosystem-fg-2); + font-weight: 600; + + img { + width: 100%; + height: 100%; + padding: 4px; + object-fit: contain; + } + } + + .seam-supported-devices-section-title { + flex: 1; + min-width: 0; + margin: 0; + overflow: hidden; + font-size: 18px; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; + } + + .seam-supported-devices-count { + flex-shrink: 0; + color: var(--seam-ecosystem-fg-2); + font-size: 14px; + } + + .seam-supported-devices-rows { + margin: 0; + padding: 0; + list-style: none; + } + + .seam-supported-devices-row { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + align-items: center; + gap: 8px 12px; + padding: 12px 16px; + + &:not(:last-child) { + border-bottom: 1px solid var(--seam-ecosystem-line-1); + } + } + + .seam-supported-devices-image { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + overflow: hidden; + border-radius: 8px; + background: var(--seam-ecosystem-surface-2); + color: var(--seam-ecosystem-fg-3); + font-weight: 600; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + + .seam-supported-devices-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .seam-supported-devices-name { + color: var(--seam-ecosystem-fg-1); + font-weight: 600; + } + + a.seam-supported-devices-name:hover { + text-decoration: underline; + } + + .seam-supported-devices-meta { + overflow: hidden; + color: var(--seam-ecosystem-fg-2); + font-size: 13px; + white-space: nowrap; + text-overflow: ellipsis; + } + + .seam-supported-devices-badges { + display: flex; + flex-wrap: wrap; + grid-column: 2; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + } + + .seam-supported-devices-section-footer { + display: flex; + justify-content: center; + padding: 12px 16px; + border-top: 1px solid var(--seam-ecosystem-line-1); + } + + @container (width >= 720px) { + .seam-supported-devices-row { + grid-template-columns: 48px minmax(0, 1fr) minmax(0, 45%); + } + + .seam-supported-devices-badges { + grid-column: 3; + justify-content: flex-end; + } + } + } +} diff --git a/test/ecosystem.test.ts b/test/ecosystem.test.ts new file mode 100644 index 000000000..95ef28f04 --- /dev/null +++ b/test/ecosystem.test.ts @@ -0,0 +1,72 @@ +import { screen, waitFor } from '@testing-library/react' +import { expect, test } from 'vitest' + +import { + type EcosystemTestContext, + fakeEmbedKey, + setupFakeEcosystem, +} from 'fixtures/ecosystem.js' +import { parseBooleanProp } from 'lib/ecosystem-element.js' + +setupFakeEcosystem() + +test('registers the ecosystem custom elements', async () => { + const { elementNames } = await import('@seamapi/react/ecosystem') + expect(elementNames).toEqual([ + 'seam-compatible-apps', + 'seam-supported-devices', + ]) + for (const elementName of elementNames) { + expect(globalThis.customElements.get(elementName)).toBeDefined() + } +}) + +test('elements.js also registers the ecosystem custom elements', async () => { + await import('@seamapi/react/ecosystem') + const { elementNames } = await import('@seamapi/react/elements') + expect(elementNames).toContain('seam-compatible-apps') + expect(elementNames).toContain('seam-supported-devices') +}) + +test('parseBooleanProp', () => { + expect(parseBooleanProp(undefined)).toBeUndefined() + expect(parseBooleanProp(null)).toBeUndefined() + expect(parseBooleanProp(true)).toBe(true) + expect(parseBooleanProp(false)).toBe(false) + expect(parseBooleanProp('')).toBe(true) + expect(parseBooleanProp('true')).toBe(true) + expect(parseBooleanProp('hide-search')).toBe(true) + expect(parseBooleanProp('false')).toBe(false) + expect(parseBooleanProp('FALSE')).toBe(false) + expect(parseBooleanProp('0')).toBe(false) +}) + +test(' renders from attributes', async (ctx) => { + await import('@seamapi/react/ecosystem') + const element = globalThis.document.createElement('seam-compatible-apps') + element.setAttribute('device-type', 'smartlock') + element.setAttribute('embed-key', fakeEmbedKey) + element.setAttribute('ecosystem-endpoint', ctx.ecosystemEndpoint) + element.setAttribute('disable-css-injection', '') + element.setAttribute('disable-font-injection', '') + element.setAttribute('hide-search', '') + element.setAttribute('max-visible', '2') + globalThis.document.body.appendChild(element) + + await screen.findByText('StayPilot') + screen.getByText('Works with these apps') + expect(screen.queryByRole('searchbox')).toBeNull() + screen.getByRole('button', { name: 'Show 8 more apps' }) + expect( + globalThis.document.querySelector( + 'link[href*="react.seam.co"], link[href*="fonts.googleapis.com"]' + ) + ).toBeNull() + + element.setAttribute('hide-search', 'false') + await waitFor(() => { + screen.getByRole('searchbox', { name: 'Search apps' }) + }) + + element.remove() +}) diff --git a/test/fixtures/ecosystem.tsx b/test/fixtures/ecosystem.tsx new file mode 100644 index 000000000..75f824e95 --- /dev/null +++ b/test/fixtures/ecosystem.tsx @@ -0,0 +1,74 @@ +import { QueryClient } from '@tanstack/react-query' +import { cleanup, render } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { afterEach, beforeEach } from 'vitest' + +import { EcosystemProvider } from 'lib/seam/ecosystem/EcosystemProvider.js' + +// eslint-disable-next-line import/no-relative-parent-imports +import { startFakeEcosystemServer } from '../../.storybook/fake-ecosystem.js' + +export { + fakeEmbedKey, + fakeInvalidEmbedKey, + fakeManufacturerEmbedKey, + fakePartnerEmbedKey, + fakeWrongOriginEmbedKey, + // eslint-disable-next-line import/no-relative-parent-imports +} from '../../.storybook/fake-ecosystem.js' + +export interface EcosystemTestContext { + ecosystemEndpoint: string +} + +/** Starts a fake Ecosystem API for each test in the calling file. */ +export const setupFakeEcosystem = (): void => { + beforeEach(async (ctx) => { + const fake = await startFakeEcosystemServer() + ctx.ecosystemEndpoint = fake.endpoint + + // UPSTREAM: Under happy-dom, the global fetch is Node's, + // which rejects the AbortSignal from happy-dom's AbortController. + const { fetch } = globalThis + globalThis.fetch = async (input, { signal: _signal, ...init } = {}) => + await fetch(input, init) + + return async () => { + globalThis.fetch = fetch + await fake.close() + } + }) + + afterEach(() => { + cleanup() + }) +} + +type Render = typeof render + +export const renderWithEcosystem = ( + ui: Parameters[0], + { + ecosystemEndpoint, + embedKey, + ...options + }: Parameters[1] & EcosystemTestContext & { embedKey?: string } +): ReturnType => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + function Providers({ children }: PropsWithChildren): JSX.Element { + return ( + + {children} + + ) + } + return render(ui, { wrapper: Providers, ...options }) +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 099eede4c..7ad089195 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -14,8 +14,11 @@ "include": ["src/**/*"], "exclude": [ "src/elements.ts", + "src/ecosystem.ts", "src/lib/element.tsx", + "src/lib/ecosystem-element.tsx", "src/lib/seam/components/elements.ts", + "src/lib/seam/components/ecosystem-elements.ts", "**/*.element.ts", "**/*.test.ts", "**/*.test.tsx", diff --git a/tsconfig.json b/tsconfig.json index c162dda92..4ce63ae42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,13 +26,14 @@ "skipLibCheck": true, "paths": { "@seamapi/react": ["./src/index.ts"], + "@seamapi/react/ecosystem": ["./src/ecosystem.ts"], "@seamapi/react/elements": ["./src/elements.ts"], "@seamapi/react/hooks": ["./src/hooks.ts"], "fixtures/*": ["./test/fixtures/*"], "lib/*": ["./src/lib/*"] } }, - "files": ["src/index.ts"], + "files": ["src/index.ts", "src/ecosystem.d.ts"], "include": [ "src/**/*", "test/**/*", @@ -41,6 +42,7 @@ ".storybook/**/*", "update-devicedb-seed.ts", "prepack.ts", + "size.ts", "preversion.ts", "version.ts", "vite.config.ts" diff --git a/vercel.json b/vercel.json index 06907932e..ec1b37458 100644 --- a/vercel.json +++ b/vercel.json @@ -11,6 +11,10 @@ "source": "/v/:version/:asset*", "destination": "https://react.seam.net/v/:version/:asset" }, + { + "source": "/api/ecosystem/:ecosystempath*", + "destination": "/api/fake-ecosystem" + }, { "source": "/api/:apipath*", "destination": "/api/fake-seam-connect" diff --git a/vite.config.ts b/vite.config.ts index 6d4537f13..6e351ad64 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,11 +3,24 @@ import { fileURLToPath } from 'node:url' import replace, { type RollupReplaceOptions } from '@rollup/plugin-replace' import react from '@vitejs/plugin-react' -import { defineConfig, type Plugin, type UserConfig } from 'vite' +import { + defineConfig, + type Plugin, + transformWithEsbuild, + type UserConfig, +} from 'vite' import tsconfigPaths from 'vite-tsconfig-paths' +// Each library entry is built separately so every bundle is self-contained. +// Build the ecosystem entry with `vite build --mode ecosystem`. +const libraryEntries = { + production: 'elements', + ecosystem: 'ecosystem', +} as const + export default defineConfig(async ({ mode }) => { - const isBuild = mode === 'production' + const libraryEntry = isLibraryMode(mode) ? libraryEntries[mode] : 'elements' + const isBuild = isLibraryMode(mode) const { version = null } = isBuild ? await readPackageJson() : {} if (isBuild && version == null) { throw new Error('Missing version in package.json') @@ -27,14 +40,17 @@ export default defineConfig(async ({ mode }) => { } : { preventAssignment: true } ), + ...(libraryEntry === 'ecosystem' ? [minifyLibrary()] : []), ], build: { outDir: fileURLToPath(new URL('./dist', import.meta.url)), sourcemap: true, emptyOutDir: false, lib: { - entry: fileURLToPath(new URL('./src/elements.ts', import.meta.url)), - fileName: 'elements', + entry: fileURLToPath( + new URL(`./src/${libraryEntry}.ts`, import.meta.url) + ), + fileName: libraryEntry, formats: ['es'], }, }, @@ -56,6 +72,28 @@ export default defineConfig(async ({ mode }) => { return config }) +// UPSTREAM: Vite never minifies whitespace in ES library builds. +// The ecosystem bundle is only loaded as a script, so minify it fully. +// https://github.com/vitejs/vite/issues/6555 +const minifyLibrary = (): Plugin => ({ + name: 'seam:minify-library', + apply: 'build', + renderChunk: { + order: 'post', + async handler(code, chunk) { + const { code: minified, map } = await transformWithEsbuild( + code, + chunk.fileName, + { minify: true, format: 'esm', sourcemap: true } + ) + return { code: minified, map } + }, + }, +}) + +const isLibraryMode = (mode: string): mode is keyof typeof libraryEntries => + Object.keys(libraryEntries).includes(mode) + export const readPackageJson = async (): Promise<{ version?: string }> => { const pkgBuff = await readFile( fileURLToPath(new URL('package.json', import.meta.url))