diff --git a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx index ccd7bb7d7..a3252fcd3 100644 --- a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx +++ b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx @@ -1,3 +1,5 @@ +import { useId, useState } from 'react' + import { CheckIcon } from 'lib/icons/Check.js' import { TriangleWarningOutlineIcon } from 'lib/icons/TriangleWarningOutline.js' import { @@ -22,6 +24,8 @@ export function DeviceModelRow({ deviceModel, manufacturer, }: DeviceModelRowProps): JSX.Element { + const detailsId = useId() + const [expanded, setExpanded] = useState(false) const name = getDeviceModelName(deviceModel) const imageUrl = getDeviceModelImageUrl(deviceModel, manufacturer) const modelIds = [ @@ -74,29 +78,51 @@ export function DeviceModelRow({ )} {capabilities.length > 0 && ( - + )} {deviceModel.capabilities_stale ? ( {t.refreshing} @@ -104,41 +130,52 @@ export function DeviceModelRow({ deviceModel.device_type?.is_accessory !== true ? ( {t.unconfirmed} ) : null} - {requirements.size > 0 && ( - - )} - {(modelIds.length > 0 || providers.length > 0) && ( -
{t.modelDetails} -
- {modelIds.length > 0 && ( -
-
{t.modelNumbers}
-
{modelIds.join(', ')}
-
- )} - {providers.length > 0 && ( -
-
{t.integration}
-
{providers.join(', ')}
-
- )} -
+ {requirements.size > 0 && ( + + )} + {(modelIds.length > 0 || providers.length > 0) && ( +
+ {modelIds.length > 0 && ( +
+
{t.modelNumbers}
+
{modelIds.join(', ')}
+
+ )} + {providers.length > 0 && ( +
+
{t.integration}
+
{providers.join(', ')}
+
+ )} +
+ )}
)} @@ -146,11 +183,13 @@ export function DeviceModelRow({ } const t = { - features: 'Supported features', + features: 'Supported capabilities', requirements: 'Requirements', - requirementsBelow: 'see requirements below', + requirementsDetails: 'expand model and requirements details', + toggleRequirements: (capability: string, device: string) => + `${capability} requirements for ${device}`, requiredFor: (features: string) => `For ${features}`, - modelDetails: 'Model details', + modelDetails: 'Model and requirements details', modelNumbers: 'Model numbers', integration: 'Integration', refreshing: 'Compatibility is being refreshed.', diff --git a/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx b/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx index 3d8075151..66f210f2d 100644 --- a/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx +++ b/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx @@ -13,7 +13,12 @@ export function RequirementIcon({ message }: { message: string }): JSX.Element { strokeLinecap='round' strokeLinejoin='round' > - {/keypad/i.test(message) ? ( + {/subscription|licen[sc]e|paying|paid|fees?/i.test(message) ? ( + <> + + + + ) : /keypad/i.test(message) ? ( <> diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx index e57e5e3ca..cf1846b03 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, within } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect, test, vi } from 'vitest' import { @@ -106,13 +106,34 @@ test('SupportedDevices scopes the query and badges to a pr expect(request?.searchParams.get('provider')).toBe('schlage-engage') }) -test('conditional requirements are readable without hovering', async (ctx) => { +test('warning buttons toggle requirements and model details together', async (ctx) => { renderWithEcosystem(, ctx) const name = await screen.findByText('Smart Lock Pro') const row = name.closest('li') if (row == null) throw new Error('Missing row') + const requirement = within(row).getByText(/Requires an August keypad/) + const details = requirement.closest('details') + expect(details?.hasAttribute('open')).toBe(false) + expect(within(row).getByText('Model numbers').closest('details')).toBe( + details + ) + const warnings = within(row).getAllByRole('button', { + name: /requirements for/, + }) + const warning = warnings[0] + if (warning == null) throw new Error('Missing warning control') + expect(warning.getAttribute('aria-controls')).toBe(details?.id) + fireEvent.click(warning) + await waitFor(() => { + expect(details?.hasAttribute('open')).toBe(true) + }) + for (const button of warnings) + expect(button.getAttribute('aria-expanded')).toBe('true') within(row).getByRole('list', { name: 'Requirements' }) - within(row).getByText(/Requires an August keypad/) + fireEvent.click(warning) + await waitFor(() => { + expect(details?.hasAttribute('open')).toBe(false) + }) }) test('SupportedDevices shows features and keeps identifiers in model details', async (ctx) => { @@ -121,7 +142,7 @@ test('SupportedDevices shows features and keeps identifier const row = name.closest('li') if (row == null) throw new Error('Missing row') const badges = within(row).getByRole('list', { - name: 'Supported features', + name: 'Supported capabilities', }) expect( within(badges) @@ -135,6 +156,87 @@ test('SupportedDevices shows features and keeps identifier expect(within(row).queryByText(/via Schlage Home/)).toBeNull() }) +for (const missingMetadata of [false, true]) { + test(`lost provider requirement text ${missingMetadata ? 'keeps a warning if metadata is unavailable' : 'is recovered from the matching provider'}`, async (ctx) => { + const fetch = globalThis.fetch + const providerRequests: URL[] = [] + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input, init) => { + const url = new URL(String(input)) + if (url.pathname.endsWith('providers/list')) { + providerRequests.push(url) + return new Response( + JSON.stringify({ + providers: missingMetadata + ? [] + : [ + { + provider_id: 'august-access', + slug: 'august-access', + display_name: 'August Access', + has_requirements: true, + requirements_md: 'A paid subscription is required.', + }, + ], + pagination: { has_next_page: false, next_page_cursor: null }, + }), + { status: 200 } + ) + } + const response = await fetch(input, init) + if (!url.pathname.endsWith('device_models/summaries/list')) + return response + const data = await response.json() + for (const model of data.device_models) { + for (const entry of model.capabilities_by_provider) { + for (const capability of entry.capabilities) { + if (capability.supported !== 'conditionally') continue + capability.report.requirements = { + ...capability.report.requirements, + provider: { message: 'Requirement message not found' }, + } + } + } + } + return new Response(JSON.stringify(data), { status: 200 }) + }) + try { + renderWithEcosystem( + , + ctx + ) + const name = await screen.findByText('Smart Lock Pro') + const row = name.closest('li') + if (row == null) throw new Error('Missing row') + await waitFor(() => { + expect(providerRequests).toHaveLength(1) + }) + expect(providerRequests[0]?.searchParams.get('slugs')).toBe( + 'august-access' + ) + const text = await within(row).findByText( + missingMetadata + ? 'Additional integration requirements apply. Check with your integration provider.' + : 'A paid subscription is required.' + ) + expect(text.closest('details')?.hasAttribute('open')).toBe(false) + const warning = within(row).getAllByRole('button', { + name: /requirements for/, + })[0] + if (warning == null) throw new Error('Missing warning control') + fireEvent.click(warning) + await waitFor(() => { + expect(text.closest('details')?.hasAttribute('open')).toBe(true) + }) + within(row).getByText('ASL-03') + expect(screen.queryByText('Requirement message not found')).toBeNull() + } finally { + fetchSpy.mockRestore() + } + }) +} + test('SupportedDevices defaults to the manufacturer of a manufacturer key', async (ctx) => { renderWithEcosystem(, { ...ctx, diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx index 008af7e57..c9123cd3d 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx @@ -11,7 +11,9 @@ import { getDeviceTypeOptions, getManufacturerName, getManufacturerOptions, + getProvidersWithMissingRequirements, groupDeviceModelsByManufacturer, + resolveProviderRequirements, scopeDeviceModelsToProvider, } from 'lib/seam/components/SupportedDevices/device-models.js' import { FilterSelect } from 'lib/seam/components/SupportedDevices/FilterSelect.js' @@ -20,6 +22,7 @@ import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' import type { Manufacturer } from 'lib/seam/ecosystem/types.js' import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js' import { useEcosystemManufacturers } from 'lib/seam/ecosystem/use-ecosystem-manufacturers.js' +import { useEcosystemProviderRequirements } from 'lib/seam/ecosystem/use-ecosystem-provider-requirements.js' import { getEmbedKeyManufacturer, useEmbedKeyResolution, @@ -100,7 +103,7 @@ export function SupportedDevices({ enabled: (explicitManufacturer != null || isResolved) && !isResolutionError, }) - const availableDeviceModels = useMemo( + const scopedDeviceModels = useMemo( () => scopeDeviceModelsToProvider( applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions), @@ -109,6 +112,16 @@ export function SupportedDevices({ [deviceModels, embedResolution, fixedProvider] ) + const missingProviderSlugs = useMemo( + () => getProvidersWithMissingRequirements(scopedDeviceModels), + [scopedDeviceModels] + ) + const { providers } = useEcosystemProviderRequirements(missingProviderSlugs) + const availableDeviceModels = useMemo( + () => resolveProviderRequirements(scopedDeviceModels, providers ?? []), + [scopedDeviceModels, providers] + ) + const manufacturerSlugs = useMemo( () => availableDeviceModels @@ -403,7 +416,7 @@ const t = { allCapabilities: 'All features', featureStatus: 'Feature status', supported: 'Supported', - requiresSetup: 'Requires setup · see requirements below', + requiresSetup: 'Requires setup · expand details', error: 'An error occurred while loading supported devices.', retry: 'Retry', noDevices: 'No supported devices found.', diff --git a/src/lib/seam/components/SupportedDevices/device-models.test.ts b/src/lib/seam/components/SupportedDevices/device-models.test.ts index b9103eabf..43c0d64db 100644 --- a/src/lib/seam/components/SupportedDevices/device-models.test.ts +++ b/src/lib/seam/components/SupportedDevices/device-models.test.ts @@ -12,8 +12,10 @@ import { filterDeviceModels, getCapabilityOptions, getCapabilityRequirementMessages, + getProvidersWithMissingRequirements, getSupportedCapabilities, groupDeviceModelsByManufacturer, + resolveProviderRequirements, scopeDeviceModelsToProvider, } from './device-models.js' @@ -297,6 +299,78 @@ test('requirement messages preserve specific constraints and flag unknown setup' expect( getCapabilityRequirementMessages({ ...generic, requirements: [] }) ).toEqual(['Additional setup is required.']) + expect( + getCapabilityRequirementMessages({ + ...generic, + details: 'Report details not found', + requirements: ['Requirement message not found'], + }) + ).toEqual(['Additional setup is required.']) +}) + +test('provider requirements resolve by slug without changing valid messages or source reports', () => { + const model = deviceModel({ + slug: 'lock', + capabilities: { + cloud: { + can_remotely_unlock: 'conditionally', + can_remotely_lock: 'conditionally', + }, + hub: { can_remotely_unlock: 'conditionally' }, + }, + }) + for (const entry of model.capabilities_by_provider) { + for (const capability of entry.capabilities) { + capability.report = { + details: '', + requirements: { + provider: { + message: + capability.name === 'can_remotely_lock' + ? 'Keep this specific requirement.' + : 'Requirement message not found', + }, + bridge: { message: 'A compatible bridge is required.' }, + }, + } + } + } + expect(getProvidersWithMissingRequirements([model])).toEqual(['cloud', 'hub']) + const resolved = resolveProviderRequirements( + [model], + [ + { + provider_id: 'cloud', + ...ref('cloud'), + has_requirements: true, + requirements_md: ' A paying SmartHosting subscription is required. ', + }, + ] + ) + expect( + resolved[0]?.capabilities_by_provider[0]?.capabilities[0]?.report + ?.requirements?.['provider']?.message + ).toBe('A paying SmartHosting subscription is required.') + expect( + resolved[0]?.capabilities_by_provider[0]?.capabilities[1]?.report + ?.requirements?.['provider']?.message + ).toBe('Keep this specific requirement.') + expect( + resolved[0]?.capabilities_by_provider[1]?.capabilities[0]?.report + ?.requirements?.['provider']?.message + ).toBe( + 'Additional integration requirements apply. Check with your integration provider.' + ) + expect( + resolved[0]?.capabilities_by_provider[0]?.capabilities[0]?.report + ?.requirements?.['bridge']?.message + ).toBe('A compatible bridge is required.') + expect( + model.capabilities_by_provider[0]?.capabilities[0]?.report?.requirements?.[ + 'provider' + ]?.message + ).toBe('Requirement message not found') + expect(getProvidersWithMissingRequirements(resolved)).toEqual([]) }) test('unavailable or stale capabilities never become supported badges or filters', () => { diff --git a/src/lib/seam/components/SupportedDevices/device-models.ts b/src/lib/seam/components/SupportedDevices/device-models.ts index 7bd0bd51e..3a65e4874 100644 --- a/src/lib/seam/components/SupportedDevices/device-models.ts +++ b/src/lib/seam/components/SupportedDevices/device-models.ts @@ -6,6 +6,7 @@ import type { IntegrationSupportLevel, Manufacturer, ManufacturerRef, + ProviderRequirements, } from 'lib/seam/ecosystem/types.js' export interface DeviceModelFilters { @@ -192,14 +193,89 @@ const genericSupportDetails = new Set([ const isGenericSupportDetail = (details: string): boolean => genericSupportDetails.has(details.trim()) +const isMissingMessage = (message: unknown): boolean => + typeof message !== 'string' || + message.trim().length === 0 || + message.trim() === 'Requirement message not found' || + message.trim() === 'Report details not found' + +export const getProvidersWithMissingRequirements = ( + models: DeviceModelSummary[] +): string[] => + [ + ...new Set( + models.flatMap((model) => + model.capabilities_by_provider + .filter((entry) => + entry.capabilities.some( + (capability) => + capability.report?.requirements?.['provider'] != null && + isMissingMessage( + capability.report.requirements['provider'].message + ) + ) + ) + .map((entry) => entry.provider.slug) + ) + ), + ].sort() + +/** Legacy translation treats provider prose as a dictionary key; the public provider retains the prose. */ +export const resolveProviderRequirements = ( + models: DeviceModelSummary[], + providers: ProviderRequirements[] +): DeviceModelSummary[] => { + const bySlug = new Map( + providers.map((provider) => [provider.slug, provider.requirements_md]) + ) + return models.map((model) => ({ + ...model, + capabilities_by_provider: model.capabilities_by_provider.map((entry) => ({ + ...entry, + capabilities: entry.capabilities.map((capability) => { + const report = capability.report + const requirement = report?.requirements?.['provider'] + if ( + report == null || + requirement == null || + !isMissingMessage(requirement.message) + ) + return capability + const message = bySlug.get(entry.provider.slug) + return { + ...capability, + report: { + ...report, + requirements: { + ...report.requirements, + provider: { + ...requirement, + message: + typeof message === 'string' && !isMissingMessage(message) + ? message.trim() + : 'Additional integration requirements apply. Check with your integration provider.', + }, + }, + }, + } + }), + })), + })) +} + /** Keep specific constraints; replace boilerplate with an actionable setup note. */ export const getCapabilityRequirementMessages = ( capability: SupportedCapability ): string[] => { - const messages = new Set(capability.requirements) + const messages = new Set( + capability.requirements.map((message) => + isMissingMessage(message) ? 'Additional setup is required.' : message + ) + ) if (capability.supported === 'conditionally') { if ( capability.details != null && + !isMissingMessage(capability.details) && !isGenericSupportDetail(capability.details) ) messages.add(capability.details) diff --git a/src/lib/seam/ecosystem/types.ts b/src/lib/seam/ecosystem/types.ts index 0ef934d26..e7c7d831c 100644 --- a/src/lib/seam/ecosystem/types.ts +++ b/src/lib/seam/ecosystem/types.ts @@ -182,6 +182,12 @@ export interface ProviderRef { display_name: string | null } +/** Fields used from the public `providers/list` response to recover legacy report text. */ +export interface ProviderRequirements extends ProviderRef { + has_requirements: boolean | null + requirements_md: string | null +} + export type CapabilityName = | 'device_connectivity' | 'can_program_online_access_codes' @@ -391,6 +397,11 @@ export interface ManufacturersListResponse { pagination: Pagination } +export interface ProvidersListResponse { + providers: ProviderRequirements[] + pagination: Pagination +} + export interface EcosystemErrorResponse { ok: false error: { diff --git a/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts b/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts new file mode 100644 index 000000000..3c7437a30 --- /dev/null +++ b/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts @@ -0,0 +1,62 @@ +import { useQuery } from '@tanstack/react-query' + +import { EcosystemApiError, fetchEcosystem } from 'lib/seam/ecosystem/client.js' +import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' +import type { + ProviderRequirements, + ProvidersListResponse, +} from 'lib/seam/ecosystem/types.js' +import { + retryEcosystemQuery, + type UseEcosystemQueryResult, +} from 'lib/seam/ecosystem/use-ecosystem-query-result.js' + +/** Only fetch providers whose legacy capability reports lost their free-text requirement. */ +export function useEcosystemProviderRequirements( + slugs: string[] +): UseEcosystemQueryResult<'providers', ProviderRequirements[]> { + const { endpoint, embedKey } = useEcosystemContext() + const sortedSlugs = [...new Set(slugs)].sort() + const { data, ...rest } = useQuery( + { + enabled: sortedSlugs.length > 0, + queryKey: [ + 'ecosystem', + endpoint, + embedKey ?? null, + 'providers', + 'list', + { slugs: sortedSlugs }, + ], + queryFn: async ({ signal }) => { + const providers: ProviderRequirements[] = [] + const seen = new Set() + let cursor: string | undefined + for (let page = 0; page < 10; page++) { + const res = await fetchEcosystem( + 'providers/list', + { + endpoint, + embedKey, + signal, + params: { slugs: sortedSlugs, limit: 500, page_cursor: cursor }, + } + ) + providers.push(...res.providers) + if (!res.pagination.has_next_page) return providers + const next = res.pagination.next_page_cursor + if (next == null || seen.has(next)) break + seen.add(next) + cursor = next + } + throw new EcosystemApiError({ + status: 502, + type: 'pagination_incomplete', + message: 'The provider catalog returned incomplete requirements.', + }) + }, + retry: retryEcosystemQuery, + } + ) + return { ...rest, providers: data } +} diff --git a/src/styles/_supported-devices.scss b/src/styles/_supported-devices.scss index 4685a4691..979aaeca9 100644 --- a/src/styles/_supported-devices.scss +++ b/src/styles/_supported-devices.scss @@ -152,10 +152,21 @@ font-size: 13px; } + .seam-supported-devices-capabilities { + display: grid; + grid-column: 2; + gap: 4px; + } + + .seam-supported-devices-capabilities-label { + color: var(--seam-ecosystem-fg-2); + font-size: 12px; + font-weight: 600; + } + .seam-supported-devices-features { display: flex; flex-wrap: wrap; - grid-column: 2; gap: 6px 16px; margin: 0; padding: 0; @@ -179,6 +190,29 @@ } } + .seam-supported-devices-requirement-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: -4px 0 -4px -4px; + padding: 4px; + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + + &:hover { + background: var(--seam-ecosystem-surface-2); + } + + &:focus-visible { + outline: 2px solid var(--seam-ecosystem-accent); + outline-offset: 2px; + } + } + .seam-supported-devices-section-footer { display: flex; justify-content: center; @@ -197,7 +231,7 @@ .seam-supported-devices-requirements { display: grid; gap: 12px; - margin: 4px 0; + margin: 12px 0; padding: 12px; border-radius: 8px; background: var(--seam-ecosystem-surface-2); diff --git a/vercel.json b/vercel.json index c32173724..537459a2c 100644 --- a/vercel.json +++ b/vercel.json @@ -7,6 +7,10 @@ }, "cleanUrls": true, "rewrites": [ + { + "source": "/ecosystem/v1/providers/list", + "destination": "https://ecosystem.seam.co/v1/providers/list" + }, { "source": "/ecosystem/v1/device_models/summaries/list", "destination": "https://ecosystem.seam.co/v1/device_models/summaries/list"