@seamapi/react 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +111 -2
  2. package/dist/ecosystem.js +45 -0
  3. package/dist/ecosystem.js.map +1 -0
  4. package/dist/elements.js +12872 -11737
  5. package/dist/elements.js.map +1 -1
  6. package/dist/index.css +469 -0
  7. package/dist/index.css.map +1 -1
  8. package/dist/index.min.css +1 -1
  9. package/dist/index.min.css.map +1 -1
  10. package/lib/seam/SeamProvider.d.ts +10 -3
  11. package/lib/seam/SeamProvider.js +5 -3
  12. package/lib/seam/SeamProvider.js.map +1 -1
  13. package/lib/seam/components/CompatibleApps/CompatibleApps.d.ts +26 -0
  14. package/lib/seam/components/CompatibleApps/CompatibleApps.js +126 -0
  15. package/lib/seam/components/CompatibleApps/CompatibleApps.js.map +1 -0
  16. package/lib/seam/components/SupportedDevices/DeviceModelRow.d.ts +8 -0
  17. package/lib/seam/components/SupportedDevices/DeviceModelRow.js +31 -0
  18. package/lib/seam/components/SupportedDevices/DeviceModelRow.js.map +1 -0
  19. package/lib/seam/components/SupportedDevices/FilterSelect.d.ts +12 -0
  20. package/lib/seam/components/SupportedDevices/FilterSelect.js +9 -0
  21. package/lib/seam/components/SupportedDevices/FilterSelect.js.map +1 -0
  22. package/lib/seam/components/SupportedDevices/ManufacturerSection.d.ts +10 -0
  23. package/lib/seam/components/SupportedDevices/ManufacturerSection.js +35 -0
  24. package/lib/seam/components/SupportedDevices/ManufacturerSection.js.map +1 -0
  25. package/lib/seam/components/SupportedDevices/SupportedDevices.d.ts +24 -0
  26. package/lib/seam/components/SupportedDevices/SupportedDevices.js +151 -0
  27. package/lib/seam/components/SupportedDevices/SupportedDevices.js.map +1 -0
  28. package/lib/seam/components/SupportedDevices/device-models.d.ts +47 -0
  29. package/lib/seam/components/SupportedDevices/device-models.js +217 -0
  30. package/lib/seam/components/SupportedDevices/device-models.js.map +1 -0
  31. package/lib/seam/components/index.d.ts +2 -0
  32. package/lib/seam/components/index.js +2 -0
  33. package/lib/seam/components/index.js.map +1 -1
  34. package/lib/seam/ecosystem/EcosystemProvider.d.ts +37 -0
  35. package/lib/seam/ecosystem/EcosystemProvider.js +56 -0
  36. package/lib/seam/ecosystem/EcosystemProvider.js.map +1 -0
  37. package/lib/seam/ecosystem/client.d.ts +28 -0
  38. package/lib/seam/ecosystem/client.js +74 -0
  39. package/lib/seam/ecosystem/client.js.map +1 -0
  40. package/lib/seam/ecosystem/index.d.ts +9 -0
  41. package/lib/seam/ecosystem/index.js +8 -0
  42. package/lib/seam/ecosystem/index.js.map +1 -0
  43. package/lib/seam/ecosystem/types.d.ts +312 -0
  44. package/lib/seam/ecosystem/types.js +10 -0
  45. package/lib/seam/ecosystem/types.js.map +1 -0
  46. package/lib/seam/ecosystem/use-compatible-partners.d.ts +21 -0
  47. package/lib/seam/ecosystem/use-compatible-partners.js +36 -0
  48. package/lib/seam/ecosystem/use-compatible-partners.js.map +1 -0
  49. package/lib/seam/ecosystem/use-ecosystem-device-model-summaries.d.ts +11 -0
  50. package/lib/seam/ecosystem/use-ecosystem-device-model-summaries.js +53 -0
  51. package/lib/seam/ecosystem/use-ecosystem-device-model-summaries.js.map +1 -0
  52. package/lib/seam/ecosystem/use-ecosystem-device-models.d.ts +20 -0
  53. package/lib/seam/ecosystem/use-ecosystem-device-models.js +51 -0
  54. package/lib/seam/ecosystem/use-ecosystem-device-models.js.map +1 -0
  55. package/lib/seam/ecosystem/use-ecosystem-manufacturers.d.ts +11 -0
  56. package/lib/seam/ecosystem/use-ecosystem-manufacturers.js +49 -0
  57. package/lib/seam/ecosystem/use-ecosystem-manufacturers.js.map +1 -0
  58. package/lib/seam/ecosystem/use-ecosystem-query-result.d.ts +7 -0
  59. package/lib/seam/ecosystem/use-ecosystem-query-result.js +7 -0
  60. package/lib/seam/ecosystem/use-ecosystem-query-result.js.map +1 -0
  61. package/lib/seam/ecosystem/use-embed-key-resolution.d.ts +20 -0
  62. package/lib/seam/ecosystem/use-embed-key-resolution.js +51 -0
  63. package/lib/seam/ecosystem/use-embed-key-resolution.js.map +1 -0
  64. package/lib/seam/index.d.ts +1 -0
  65. package/lib/seam/index.js +1 -0
  66. package/lib/seam/index.js.map +1 -1
  67. package/lib/seam/seam-components-class-name.d.ts +1 -0
  68. package/lib/seam/seam-components-class-name.js +2 -0
  69. package/lib/seam/seam-components-class-name.js.map +1 -0
  70. package/lib/ui/ecosystem/SearchField.d.ts +9 -0
  71. package/lib/ui/ecosystem/SearchField.js +8 -0
  72. package/lib/ui/ecosystem/SearchField.js.map +1 -0
  73. package/lib/ui/ecosystem/StateMessage.d.ts +8 -0
  74. package/lib/ui/ecosystem/StateMessage.js +5 -0
  75. package/lib/ui/ecosystem/StateMessage.js.map +1 -0
  76. package/lib/ui/ecosystem/errors.d.ts +5 -0
  77. package/lib/ui/ecosystem/errors.js +25 -0
  78. package/lib/ui/ecosystem/errors.js.map +1 -0
  79. package/lib/ui/ecosystem/storybook.d.ts +15 -0
  80. package/lib/ui/ecosystem/storybook.js +16 -0
  81. package/lib/ui/ecosystem/storybook.js.map +1 -0
  82. package/lib/ui/ecosystem/theme.d.ts +6 -0
  83. package/lib/ui/ecosystem/theme.js +11 -0
  84. package/lib/ui/ecosystem/theme.js.map +1 -0
  85. package/lib/version.d.ts +1 -1
  86. package/lib/version.js +1 -1
  87. package/package.json +11 -3
  88. package/src/ecosystem.d.ts +2 -0
  89. package/src/ecosystem.ts +12 -0
  90. package/src/elements.ts +4 -0
  91. package/src/lib/ecosystem-element.tsx +154 -0
  92. package/src/lib/element.tsx +6 -0
  93. package/src/lib/seam/SeamProvider.tsx +18 -2
  94. package/src/lib/seam/components/CompatibleApps/CompatibleApps.element.ts +19 -0
  95. package/src/lib/seam/components/CompatibleApps/CompatibleApps.tsx +344 -0
  96. package/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx +108 -0
  97. package/src/lib/seam/components/SupportedDevices/FilterSelect.tsx +43 -0
  98. package/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx +114 -0
  99. package/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts +17 -0
  100. package/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx +382 -0
  101. package/src/lib/seam/components/SupportedDevices/device-models.ts +326 -0
  102. package/src/lib/seam/components/ecosystem-elements.ts +2 -0
  103. package/src/lib/seam/components/index.ts +2 -0
  104. package/src/lib/seam/ecosystem/EcosystemProvider.tsx +133 -0
  105. package/src/lib/seam/ecosystem/client.ts +116 -0
  106. package/src/lib/seam/ecosystem/index.ts +19 -0
  107. package/src/lib/seam/ecosystem/types.ts +402 -0
  108. package/src/lib/seam/ecosystem/use-compatible-partners.ts +80 -0
  109. package/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts +89 -0
  110. package/src/lib/seam/ecosystem/use-ecosystem-device-models.ts +96 -0
  111. package/src/lib/seam/ecosystem/use-ecosystem-manufacturers.ts +86 -0
  112. package/src/lib/seam/ecosystem/use-ecosystem-query-result.ts +19 -0
  113. package/src/lib/seam/ecosystem/use-embed-key-resolution.ts +80 -0
  114. package/src/lib/seam/index.ts +1 -0
  115. package/src/lib/seam/seam-components-class-name.ts +1 -0
  116. package/src/lib/ui/ecosystem/SearchField.tsx +32 -0
  117. package/src/lib/ui/ecosystem/StateMessage.tsx +29 -0
  118. package/src/lib/ui/ecosystem/errors.ts +30 -0
  119. package/src/lib/ui/ecosystem/storybook.tsx +40 -0
  120. package/src/lib/ui/ecosystem/theme.ts +14 -0
  121. package/src/lib/version.ts +1 -1
  122. package/src/styles/_compatible-apps.scss +102 -0
  123. package/src/styles/_ecosystem.scss +295 -0
  124. package/src/styles/_main.scss +6 -0
  125. package/src/styles/_supported-devices.scss +153 -0
@@ -0,0 +1,382 @@
1
+ import classNames from 'classnames'
2
+ import { useDeferredValue, useMemo, useState } from 'react'
3
+
4
+ import {
5
+ applyEmbedExclusions,
6
+ capabilityLabels,
7
+ filterDeviceModels,
8
+ getCapabilityOptions,
9
+ getDeviceTypeOptions,
10
+ getManufacturerName,
11
+ getManufacturerOptions,
12
+ groupDeviceModelsByManufacturer,
13
+ } from 'lib/seam/components/SupportedDevices/device-models.js'
14
+ import { FilterSelect } from 'lib/seam/components/SupportedDevices/FilterSelect.js'
15
+ import { ManufacturerSection } from 'lib/seam/components/SupportedDevices/ManufacturerSection.js'
16
+ import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js'
17
+ import type { Manufacturer } from 'lib/seam/ecosystem/types.js'
18
+ import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js'
19
+ import { useEcosystemManufacturers } from 'lib/seam/ecosystem/use-ecosystem-manufacturers.js'
20
+ import {
21
+ getEmbedKeyManufacturer,
22
+ useEmbedKeyResolution,
23
+ } from 'lib/seam/ecosystem/use-embed-key-resolution.js'
24
+ import { getEcosystemErrorMessage } from 'lib/ui/ecosystem/errors.js'
25
+ import { SearchField } from 'lib/ui/ecosystem/SearchField.js'
26
+ import { StateMessage } from 'lib/ui/ecosystem/StateMessage.js'
27
+ import {
28
+ type EcosystemTheme,
29
+ getEcosystemThemeClassName,
30
+ } from 'lib/ui/ecosystem/theme.js'
31
+
32
+ export interface SupportedDevicesProps {
33
+ /**
34
+ * DeviceDB manufacturer slug, e.g., `schlage`.
35
+ * Defaults to the manufacturer of a manufacturer embed key.
36
+ */
37
+ manufacturer?: string | undefined
38
+ /** DeviceDB device type slug, e.g., `smartlock` or `thermostat`. */
39
+ deviceType?: string | undefined
40
+ /** Only devices supporting this capability, e.g., `can_program_online_access_codes`. */
41
+ capability?: string | undefined
42
+ hideSearch?: boolean | undefined
43
+ hideFilters?: boolean | undefined
44
+ hideTitle?: boolean | undefined
45
+ /** Color theme. `auto` follows the visitor's color scheme. */
46
+ theme?: EcosystemTheme | undefined
47
+ className?: string | undefined
48
+ }
49
+
50
+ /**
51
+ * Device models that work with Seam, grouped by manufacturer,
52
+ * with device type and capability badges.
53
+ */
54
+ export function SupportedDevices({
55
+ manufacturer,
56
+ deviceType,
57
+ capability,
58
+ hideSearch = false,
59
+ hideFilters = false,
60
+ hideTitle = false,
61
+ theme = 'light',
62
+ className,
63
+ }: SupportedDevicesProps): JSX.Element {
64
+ const { embedKey } = useEcosystemContext()
65
+ const explicitManufacturer = normalizeSlug(manufacturer)
66
+ const fixedDeviceType = normalizeSlug(deviceType)
67
+ const fixedCapability = normalizeSlug(capability)
68
+
69
+ const {
70
+ embedResolution,
71
+ isError: isResolutionError,
72
+ error: resolutionError,
73
+ refetch: refetchResolution,
74
+ } = useEmbedKeyResolution({ capability: fixedCapability ?? '' })
75
+
76
+ const isResolved = embedKey == null || embedResolution != null
77
+ const scopeManufacturer =
78
+ explicitManufacturer ?? getEmbedKeyManufacturer(embedResolution)
79
+
80
+ const {
81
+ deviceModels,
82
+ isError: isDeviceModelsError,
83
+ error: deviceModelsError,
84
+ refetch: refetchDeviceModels,
85
+ } = useEcosystemDeviceModelSummaries({
86
+ manufacturer: scopeManufacturer ?? '',
87
+ deviceType: fixedDeviceType ?? '',
88
+ capability:
89
+ fixedCapability != null && fixedCapability in capabilityLabels
90
+ ? fixedCapability
91
+ : '',
92
+ enabled: (explicitManufacturer != null || isResolved) && !isResolutionError,
93
+ })
94
+
95
+ const availableDeviceModels = useMemo(
96
+ () => applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions),
97
+ [deviceModels, embedResolution]
98
+ )
99
+
100
+ const manufacturerSlugs = useMemo(
101
+ () =>
102
+ availableDeviceModels
103
+ .map((m) => m.manufacturer?.slug)
104
+ .filter((slug): slug is string => slug != null),
105
+ [availableDeviceModels]
106
+ )
107
+
108
+ const { manufacturers } = useEcosystemManufacturers({
109
+ slugs: manufacturerSlugs,
110
+ enabled: manufacturerSlugs.length > 0,
111
+ })
112
+
113
+ const manufacturersBySlug = useMemo(
114
+ () =>
115
+ new Map<string, Manufacturer>(
116
+ (manufacturers ?? []).map((m) => [m.slug, m])
117
+ ),
118
+ [manufacturers]
119
+ )
120
+
121
+ const [searchValue, setSearchValue] = useState('')
122
+ const [selectedManufacturer, setSelectedManufacturer] = useState<
123
+ string | null
124
+ >(null)
125
+ const [selectedDeviceType, setSelectedDeviceType] = useState<string | null>(
126
+ null
127
+ )
128
+ const [selectedCapability, setSelectedCapability] = useState<string | null>(
129
+ null
130
+ )
131
+ const search = useDeferredValue(searchValue)
132
+
133
+ const filteredDeviceModels = useMemo(
134
+ () =>
135
+ filterDeviceModels(availableDeviceModels, {
136
+ manufacturer: scopeManufacturer ?? selectedManufacturer,
137
+ deviceType: fixedDeviceType ?? selectedDeviceType,
138
+ capability: fixedCapability ?? selectedCapability,
139
+ search,
140
+ }),
141
+ [
142
+ availableDeviceModels,
143
+ scopeManufacturer,
144
+ selectedManufacturer,
145
+ fixedDeviceType,
146
+ selectedDeviceType,
147
+ fixedCapability,
148
+ selectedCapability,
149
+ search,
150
+ ]
151
+ )
152
+
153
+ const groups = useMemo(
154
+ () => groupDeviceModelsByManufacturer(filteredDeviceModels),
155
+ [filteredDeviceModels]
156
+ )
157
+
158
+ const manufacturerOptions = useMemo(
159
+ () => getManufacturerOptions(availableDeviceModels),
160
+ [availableDeviceModels]
161
+ )
162
+ const deviceTypeOptions = useMemo(
163
+ () => getDeviceTypeOptions(availableDeviceModels),
164
+ [availableDeviceModels]
165
+ )
166
+ const capabilityOptions = useMemo(
167
+ () => getCapabilityOptions(availableDeviceModels),
168
+ [availableDeviceModels]
169
+ )
170
+
171
+ const showManufacturerFilter =
172
+ scopeManufacturer == null && manufacturerOptions.length > 1
173
+ const showDeviceTypeFilter =
174
+ fixedDeviceType == null && deviceTypeOptions.length > 1
175
+ const showCapabilityFilter =
176
+ fixedCapability == null && capabilityOptions.length > 1
177
+
178
+ const hasUserFilters =
179
+ searchValue.trim().length > 0 ||
180
+ selectedManufacturer != null ||
181
+ selectedDeviceType != null ||
182
+ selectedCapability != null
183
+
184
+ const resetFilters = (): void => {
185
+ setSearchValue('')
186
+ setSelectedManufacturer(null)
187
+ setSelectedDeviceType(null)
188
+ setSelectedCapability(null)
189
+ }
190
+
191
+ const isError = isResolutionError || isDeviceModelsError
192
+ const isLoading = !isError && (!isResolved || deviceModels == null)
193
+
194
+ const scopedManufacturerName =
195
+ scopeManufacturer == null
196
+ ? null
197
+ : getScopedManufacturerName(
198
+ scopeManufacturer,
199
+ manufacturersBySlug.get(scopeManufacturer),
200
+ availableDeviceModels[0]?.manufacturer?.display_name
201
+ )
202
+
203
+ const title =
204
+ scopedManufacturerName == null
205
+ ? t.title
206
+ : t.manufacturerTitle(scopedManufacturerName)
207
+
208
+ const content = (() => {
209
+ if (isError) {
210
+ const { message, canRetry } = getEcosystemErrorMessage(
211
+ isResolutionError ? resolutionError : deviceModelsError,
212
+ t.error
213
+ )
214
+ return (
215
+ <StateMessage
216
+ role='alert'
217
+ {...(canRetry
218
+ ? {
219
+ actionLabel: t.retry,
220
+ onAction: () => {
221
+ if (isResolutionError) void refetchResolution()
222
+ if (isDeviceModelsError) void refetchDeviceModels()
223
+ },
224
+ }
225
+ : {})}
226
+ >
227
+ {message}
228
+ </StateMessage>
229
+ )
230
+ }
231
+
232
+ if (isLoading) return <LoadingRows />
233
+
234
+ if (availableDeviceModels.length === 0) {
235
+ return <StateMessage>{t.noDevices}</StateMessage>
236
+ }
237
+
238
+ if (filteredDeviceModels.length === 0) {
239
+ return (
240
+ <StateMessage
241
+ {...(hasUserFilters
242
+ ? { actionLabel: t.clearFilters, onAction: resetFilters }
243
+ : {})}
244
+ >
245
+ {hasUserFilters ? t.noMatches : t.noDevices}
246
+ </StateMessage>
247
+ )
248
+ }
249
+
250
+ return (
251
+ <div className='seam-supported-devices-sections'>
252
+ {groups.map((group) => (
253
+ <ManufacturerSection
254
+ key={group.key}
255
+ group={group}
256
+ manufacturer={manufacturersBySlug.get(group.key) ?? null}
257
+ collapsible={groups.length > 1}
258
+ />
259
+ ))}
260
+ </div>
261
+ )
262
+ })()
263
+
264
+ const showFilters =
265
+ !hideFilters &&
266
+ (showManufacturerFilter || showDeviceTypeFilter || showCapabilityFilter)
267
+ const controlsDisabled = isLoading || isError
268
+
269
+ return (
270
+ <div
271
+ className={classNames(
272
+ 'seam-ecosystem',
273
+ 'seam-supported-devices',
274
+ getEcosystemThemeClassName(theme),
275
+ className
276
+ )}
277
+ >
278
+ {(!hideTitle || !hideSearch || showFilters) && (
279
+ <div className='seam-ecosystem-header'>
280
+ {!hideTitle && <h2 className='seam-ecosystem-title'>{title}</h2>}
281
+ <div className='seam-ecosystem-controls'>
282
+ {showFilters && showManufacturerFilter && (
283
+ <FilterSelect
284
+ label={t.manufacturer}
285
+ allLabel={t.allManufacturers}
286
+ options={manufacturerOptions}
287
+ value={selectedManufacturer}
288
+ onChange={setSelectedManufacturer}
289
+ disabled={controlsDisabled}
290
+ />
291
+ )}
292
+ {showFilters && showDeviceTypeFilter && (
293
+ <FilterSelect
294
+ label={t.deviceType}
295
+ allLabel={t.allDeviceTypes}
296
+ options={deviceTypeOptions}
297
+ value={selectedDeviceType}
298
+ onChange={setSelectedDeviceType}
299
+ disabled={controlsDisabled}
300
+ />
301
+ )}
302
+ {showFilters && showCapabilityFilter && (
303
+ <FilterSelect
304
+ label={t.capability}
305
+ allLabel={t.allCapabilities}
306
+ options={capabilityOptions}
307
+ value={selectedCapability}
308
+ onChange={setSelectedCapability}
309
+ disabled={controlsDisabled}
310
+ />
311
+ )}
312
+ {!hideSearch && (
313
+ <SearchField
314
+ label={t.search}
315
+ value={searchValue}
316
+ onChange={setSearchValue}
317
+ disabled={controlsDisabled}
318
+ />
319
+ )}
320
+ </div>
321
+ </div>
322
+ )}
323
+ {content}
324
+ </div>
325
+ )
326
+ }
327
+
328
+ function LoadingRows(): JSX.Element {
329
+ return (
330
+ <div className='seam-supported-devices-sections' aria-busy='true'>
331
+ <section className='seam-supported-devices-section'>
332
+ <div className='seam-supported-devices-section-header'>
333
+ <div className='seam-ecosystem-skeleton seam-supported-devices-logo' />
334
+ <div className='seam-ecosystem-skeleton seam-ecosystem-skeleton-line-short' />
335
+ </div>
336
+ <ul className='seam-supported-devices-rows'>
337
+ {Array.from({ length: 3 }, (_, index) => (
338
+ <li key={index} className='seam-supported-devices-row'>
339
+ <div className='seam-ecosystem-skeleton seam-supported-devices-image' />
340
+ <div className='seam-supported-devices-info'>
341
+ <div className='seam-ecosystem-skeleton seam-ecosystem-skeleton-line' />
342
+ <div className='seam-ecosystem-skeleton seam-ecosystem-skeleton-line-short' />
343
+ </div>
344
+ </li>
345
+ ))}
346
+ </ul>
347
+ </section>
348
+ </div>
349
+ )
350
+ }
351
+
352
+ const normalizeSlug = (value: string | undefined): string | null => {
353
+ const slug = value?.trim()
354
+ if (slug == null || slug.length === 0) return null
355
+ return slug
356
+ }
357
+
358
+ const getScopedManufacturerName = (
359
+ slug: string,
360
+ manufacturer: Manufacturer | undefined,
361
+ fallback: string | null | undefined
362
+ ): string => {
363
+ if (manufacturer != null) return getManufacturerName(manufacturer)
364
+ return fallback ?? slug
365
+ }
366
+
367
+ const t = {
368
+ title: 'Supported devices',
369
+ manufacturerTitle: (name: string) => `Supported ${name} devices`,
370
+ search: 'Search devices',
371
+ manufacturer: 'Manufacturer',
372
+ allManufacturers: 'All manufacturers',
373
+ deviceType: 'Device type',
374
+ allDeviceTypes: 'All device types',
375
+ capability: 'Capability',
376
+ allCapabilities: 'All capabilities',
377
+ error: 'An error occurred while loading supported devices.',
378
+ retry: 'Retry',
379
+ noDevices: 'No supported devices found.',
380
+ noMatches: 'No devices match your search or filters.',
381
+ clearFilters: 'Clear filters',
382
+ }
@@ -0,0 +1,326 @@
1
+ import type {
2
+ CapabilityName,
3
+ CapabilitySummary,
4
+ DeviceModelSummary,
5
+ EmbedExclusions,
6
+ Manufacturer,
7
+ ManufacturerRef,
8
+ } from 'lib/seam/ecosystem/types.js'
9
+
10
+ export interface DeviceModelFilters {
11
+ manufacturer?: string | null
12
+ deviceType?: string | null
13
+ capability?: string | null
14
+ search?: string
15
+ }
16
+
17
+ export interface SupportedCapability {
18
+ name: CapabilityName
19
+ supported: 'yes' | 'conditionally'
20
+ details: string | null
21
+ }
22
+
23
+ export interface ManufacturerGroup {
24
+ key: string
25
+ manufacturer: ManufacturerRef | null
26
+ deviceModels: DeviceModelSummary[]
27
+ }
28
+
29
+ export interface FilterOption {
30
+ value: string
31
+ label: string
32
+ }
33
+
34
+ /** Capabilities shown as badges, in display order. */
35
+ export const capabilityLabels: Partial<Record<CapabilityName, string>> = {
36
+ can_remotely_unlock: 'Remote unlock',
37
+ can_remotely_lock: 'Remote lock',
38
+ can_program_online_access_codes: 'Access codes',
39
+ can_program_offline_access_codes: 'Offline access codes',
40
+ can_provision_mobile_keys: 'Mobile keys',
41
+ can_provision_wallet_keys: 'Wallet keys',
42
+ can_encode_plastic_cards: 'Key cards',
43
+ can_hvac_heat: 'Heat',
44
+ can_hvac_cool: 'Cool',
45
+ can_hvac_auto: 'Heat and cool',
46
+ can_hvac_fan: 'Fan',
47
+ can_program_thermostat_weekly_programs: 'Weekly programs',
48
+ can_monitor: 'Monitoring',
49
+ }
50
+
51
+ const capabilityOrder = Object.keys(capabilityLabels)
52
+
53
+ export const getCapabilityLabel = (name: string): string =>
54
+ capabilityLabels[name as CapabilityName] ??
55
+ name.replace(/^can_/, '').replaceAll('_', ' ')
56
+
57
+ /**
58
+ * Removes what a partner's embed key says its widgets must not show
59
+ * (Ecosystem `embed/resolve`): excluded manufacturers and device types,
60
+ * unsupported providers, and the provider combinations excluded for the
61
+ * resolved capability. A model left with no provider is removed.
62
+ */
63
+ export const applyEmbedExclusions = (
64
+ deviceModels: DeviceModelSummary[],
65
+ exclusions: EmbedExclusions | null | undefined
66
+ ): DeviceModelSummary[] => {
67
+ if (exclusions == null) return deviceModels
68
+
69
+ const excludedManufacturers = new Set(exclusions.excluded_manufacturers)
70
+ const excludedDeviceTypes = new Set(exclusions.excluded_device_types)
71
+ const unsupportedProviders = new Set(exclusions.unsupported_providers)
72
+ const { capability } = exclusions
73
+
74
+ const result: DeviceModelSummary[] = []
75
+ for (const deviceModel of deviceModels) {
76
+ const manufacturerSlug = deviceModel.manufacturer?.slug ?? ''
77
+ const deviceTypeSlug = deviceModel.device_type?.slug ?? ''
78
+
79
+ if (excludedManufacturers.has(manufacturerSlug)) continue
80
+ if (excludedDeviceTypes.has(deviceTypeSlug)) continue
81
+
82
+ const capabilityExclusions = exclusions.capability_exclusions.filter(
83
+ (exclusion) =>
84
+ capability.length > 0 &&
85
+ exclusion.device_category === deviceTypeSlug &&
86
+ (exclusion.manufacturer_slug === '' ||
87
+ exclusion.manufacturer_slug === manufacturerSlug)
88
+ )
89
+
90
+ if (capabilityExclusions.some((e) => e.provider_slug === '')) continue
91
+
92
+ const capabilityExcludedProviders = new Set(
93
+ capabilityExclusions.map((e) => e.provider_slug)
94
+ )
95
+
96
+ const providers = deviceModel.providers.filter(
97
+ (provider) => !unsupportedProviders.has(provider.slug)
98
+ )
99
+
100
+ if (deviceModel.providers.length > 0 && providers.length === 0) continue
101
+
102
+ const capabilitiesByProvider = deviceModel.capabilities_by_provider
103
+ .filter(({ provider }) => !unsupportedProviders.has(provider.slug))
104
+ .map((entry) =>
105
+ capabilityExcludedProviders.has(entry.provider.slug)
106
+ ? {
107
+ ...entry,
108
+ capabilities: entry.capabilities.filter(
109
+ (c) => c.name !== capability
110
+ ),
111
+ }
112
+ : entry
113
+ )
114
+
115
+ result.push({
116
+ ...deviceModel,
117
+ providers,
118
+ capabilities_by_provider: capabilitiesByProvider,
119
+ })
120
+ }
121
+ return result
122
+ }
123
+
124
+ /** The best support for each capability across the model's providers. */
125
+ export const getSupportedCapabilities = (
126
+ deviceModel: DeviceModelSummary
127
+ ): SupportedCapability[] => {
128
+ const best = new Map<CapabilityName, SupportedCapability>()
129
+ for (const { capabilities } of deviceModel.capabilities_by_provider) {
130
+ for (const capability of capabilities) {
131
+ const supported = toSupported(capability)
132
+ if (supported == null) continue
133
+ const current = best.get(capability.name)
134
+ if (current?.supported === 'yes') continue
135
+ best.set(capability.name, {
136
+ name: capability.name,
137
+ supported,
138
+ details: capability.report?.details ?? null,
139
+ })
140
+ }
141
+ }
142
+ return [...best.values()].sort(
143
+ (a, b) => capabilityRank(a.name) - capabilityRank(b.name)
144
+ )
145
+ }
146
+
147
+ export const supportsCapability = (
148
+ deviceModel: DeviceModelSummary,
149
+ capability: string
150
+ ): boolean =>
151
+ getSupportedCapabilities(deviceModel).some((c) => c.name === capability)
152
+
153
+ export const filterDeviceModels = (
154
+ deviceModels: DeviceModelSummary[],
155
+ { manufacturer, deviceType, capability, search = '' }: DeviceModelFilters
156
+ ): DeviceModelSummary[] => {
157
+ const terms = search.toLowerCase().trim().split(/\s+/).filter(Boolean)
158
+ return deviceModels.filter((deviceModel) => {
159
+ if (manufacturer != null && deviceModel.manufacturer?.slug !== manufacturer)
160
+ return false
161
+ if (deviceType != null && deviceModel.device_type?.slug !== deviceType)
162
+ return false
163
+ if (capability != null && !supportsCapability(deviceModel, capability))
164
+ return false
165
+ if (terms.length === 0) return true
166
+ const haystack = getSearchText(deviceModel)
167
+ return terms.every((term) => haystack.includes(term))
168
+ })
169
+ }
170
+
171
+ const getSearchText = (deviceModel: DeviceModelSummary): string =>
172
+ [
173
+ deviceModel.display_name,
174
+ deviceModel.slug,
175
+ deviceModel.manufacturer?.display_name,
176
+ deviceModel.device_type?.display_name,
177
+ ...deviceModel.providers.map((p) => p.display_name),
178
+ ...deviceModel.variants.map((v) => v.manufacturer_model_id),
179
+ ]
180
+ .filter((value): value is string => value != null)
181
+ .join(' ')
182
+ .toLowerCase()
183
+
184
+ export const groupDeviceModelsByManufacturer = (
185
+ deviceModels: DeviceModelSummary[]
186
+ ): ManufacturerGroup[] => {
187
+ const groups = new Map<string, ManufacturerGroup>()
188
+ for (const deviceModel of deviceModels) {
189
+ const key = deviceModel.manufacturer?.slug ?? ''
190
+ const group = groups.get(key) ?? {
191
+ key,
192
+ manufacturer: deviceModel.manufacturer,
193
+ deviceModels: [],
194
+ }
195
+ group.deviceModels.push(deviceModel)
196
+ groups.set(key, group)
197
+ }
198
+ for (const group of groups.values()) {
199
+ group.deviceModels.sort((a, b) =>
200
+ getDeviceModelName(a).localeCompare(getDeviceModelName(b))
201
+ )
202
+ }
203
+ return [...groups.values()].sort((a, b) => {
204
+ if (a.manufacturer == null) return 1
205
+ if (b.manufacturer == null) return -1
206
+ return getManufacturerName(a.manufacturer).localeCompare(
207
+ getManufacturerName(b.manufacturer)
208
+ )
209
+ })
210
+ }
211
+
212
+ export const getManufacturerOptions = (
213
+ deviceModels: DeviceModelSummary[]
214
+ ): FilterOption[] =>
215
+ uniqueOptions(
216
+ deviceModels.map((m) =>
217
+ m.manufacturer == null
218
+ ? null
219
+ : {
220
+ value: m.manufacturer.slug,
221
+ label: getManufacturerName(m.manufacturer),
222
+ }
223
+ )
224
+ )
225
+
226
+ export const getDeviceTypeOptions = (
227
+ deviceModels: DeviceModelSummary[]
228
+ ): FilterOption[] =>
229
+ uniqueOptions(
230
+ deviceModels.map((m) =>
231
+ m.device_type == null
232
+ ? null
233
+ : { value: m.device_type.slug, label: m.device_type.display_name }
234
+ )
235
+ )
236
+
237
+ export const getCapabilityOptions = (
238
+ deviceModels: DeviceModelSummary[]
239
+ ): FilterOption[] => {
240
+ const names = new Set<CapabilityName>()
241
+ for (const deviceModel of deviceModels) {
242
+ for (const { name } of getSupportedCapabilities(deviceModel)) {
243
+ if (capabilityLabels[name] != null) names.add(name)
244
+ }
245
+ }
246
+ return [...names]
247
+ .sort((a, b) => capabilityRank(a) - capabilityRank(b))
248
+ .map((name) => ({ value: name, label: getCapabilityLabel(name) }))
249
+ }
250
+
251
+ export const getDeviceModelName = (deviceModel: DeviceModelSummary): string =>
252
+ deviceModel.display_name ?? deviceModel.slug
253
+
254
+ export const getManufacturerName = (
255
+ manufacturer: ManufacturerRef | Manufacturer
256
+ ): string => manufacturer.display_name ?? manufacturer.slug
257
+
258
+ /** The best available image URL for a device model. */
259
+ export const getDeviceModelImageUrl = (
260
+ deviceModel: DeviceModelSummary,
261
+ manufacturer?: Manufacturer | null
262
+ ): string | null => {
263
+ const image =
264
+ deviceModel.featured_image ??
265
+ deviceModel.variants.flatMap((variant) => variant.images)[0] ??
266
+ manufacturer?.default_device_images.find(
267
+ (d) => d.device_type.slug === deviceModel.device_type?.slug
268
+ )?.asset ??
269
+ null
270
+ if (image == null) return null
271
+ return (
272
+ image.formats.thumbnail?.url ??
273
+ image.formats.small?.url ??
274
+ image.formats.medium?.url ??
275
+ image.url
276
+ )
277
+ }
278
+
279
+ /** The manufacturer's logo, preferring a square logo. */
280
+ export const getManufacturerLogoUrl = (
281
+ manufacturer: Manufacturer | null | undefined,
282
+ { mono = false }: { mono?: boolean } = {}
283
+ ): string | null => {
284
+ if (manufacturer == null) return null
285
+ const kinds = mono
286
+ ? ([
287
+ 'square_logo_mono',
288
+ 'horizontal_logo_mono',
289
+ 'square_logo',
290
+ 'horizontal_logo',
291
+ ] as const)
292
+ : ([
293
+ 'square_logo',
294
+ 'horizontal_logo',
295
+ 'square_logo_mono',
296
+ 'horizontal_logo_mono',
297
+ ] as const)
298
+ for (const kind of kinds) {
299
+ const logo = manufacturer.logos.find((l) => l.kind === kind)
300
+ if (logo != null) return logo.formats.small?.url ?? logo.url
301
+ }
302
+ return null
303
+ }
304
+
305
+ const toSupported = (
306
+ capability: CapabilitySummary
307
+ ): SupportedCapability['supported'] | null => {
308
+ if (capability.supported === 'yes') return 'yes'
309
+ if (capability.supported === 'conditionally') return 'conditionally'
310
+ return null
311
+ }
312
+
313
+ const capabilityRank = (name: string): number => {
314
+ const index = capabilityOrder.indexOf(name)
315
+ return index === -1 ? capabilityOrder.length : index
316
+ }
317
+
318
+ const uniqueOptions = (options: Array<FilterOption | null>): FilterOption[] => {
319
+ const byValue = new Map<string, FilterOption>()
320
+ for (const option of options) {
321
+ if (option != null && !byValue.has(option.value)) {
322
+ byValue.set(option.value, option)
323
+ }
324
+ }
325
+ return [...byValue.values()].sort((a, b) => a.label.localeCompare(b.label))
326
+ }
@@ -0,0 +1,2 @@
1
+ export * as CompatibleApps from './CompatibleApps/CompatibleApps.element.js'
2
+ export * as SupportedDevices from './SupportedDevices/SupportedDevices.element.js'