@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.
- package/README.md +111 -2
- package/dist/ecosystem.js +45 -0
- package/dist/ecosystem.js.map +1 -0
- package/dist/elements.js +12872 -11737
- package/dist/elements.js.map +1 -1
- package/dist/index.css +469 -0
- package/dist/index.css.map +1 -1
- package/dist/index.min.css +1 -1
- package/dist/index.min.css.map +1 -1
- package/lib/seam/SeamProvider.d.ts +10 -3
- package/lib/seam/SeamProvider.js +5 -3
- package/lib/seam/SeamProvider.js.map +1 -1
- package/lib/seam/components/CompatibleApps/CompatibleApps.d.ts +26 -0
- package/lib/seam/components/CompatibleApps/CompatibleApps.js +126 -0
- package/lib/seam/components/CompatibleApps/CompatibleApps.js.map +1 -0
- package/lib/seam/components/SupportedDevices/DeviceModelRow.d.ts +8 -0
- package/lib/seam/components/SupportedDevices/DeviceModelRow.js +31 -0
- package/lib/seam/components/SupportedDevices/DeviceModelRow.js.map +1 -0
- package/lib/seam/components/SupportedDevices/FilterSelect.d.ts +12 -0
- package/lib/seam/components/SupportedDevices/FilterSelect.js +9 -0
- package/lib/seam/components/SupportedDevices/FilterSelect.js.map +1 -0
- package/lib/seam/components/SupportedDevices/ManufacturerSection.d.ts +10 -0
- package/lib/seam/components/SupportedDevices/ManufacturerSection.js +35 -0
- package/lib/seam/components/SupportedDevices/ManufacturerSection.js.map +1 -0
- package/lib/seam/components/SupportedDevices/SupportedDevices.d.ts +24 -0
- package/lib/seam/components/SupportedDevices/SupportedDevices.js +151 -0
- package/lib/seam/components/SupportedDevices/SupportedDevices.js.map +1 -0
- package/lib/seam/components/SupportedDevices/device-models.d.ts +47 -0
- package/lib/seam/components/SupportedDevices/device-models.js +217 -0
- package/lib/seam/components/SupportedDevices/device-models.js.map +1 -0
- package/lib/seam/components/index.d.ts +2 -0
- package/lib/seam/components/index.js +2 -0
- package/lib/seam/components/index.js.map +1 -1
- package/lib/seam/ecosystem/EcosystemProvider.d.ts +37 -0
- package/lib/seam/ecosystem/EcosystemProvider.js +56 -0
- package/lib/seam/ecosystem/EcosystemProvider.js.map +1 -0
- package/lib/seam/ecosystem/client.d.ts +28 -0
- package/lib/seam/ecosystem/client.js +74 -0
- package/lib/seam/ecosystem/client.js.map +1 -0
- package/lib/seam/ecosystem/index.d.ts +9 -0
- package/lib/seam/ecosystem/index.js +8 -0
- package/lib/seam/ecosystem/index.js.map +1 -0
- package/lib/seam/ecosystem/types.d.ts +312 -0
- package/lib/seam/ecosystem/types.js +10 -0
- package/lib/seam/ecosystem/types.js.map +1 -0
- package/lib/seam/ecosystem/use-compatible-partners.d.ts +21 -0
- package/lib/seam/ecosystem/use-compatible-partners.js +36 -0
- package/lib/seam/ecosystem/use-compatible-partners.js.map +1 -0
- package/lib/seam/ecosystem/use-ecosystem-device-model-summaries.d.ts +11 -0
- package/lib/seam/ecosystem/use-ecosystem-device-model-summaries.js +53 -0
- package/lib/seam/ecosystem/use-ecosystem-device-model-summaries.js.map +1 -0
- package/lib/seam/ecosystem/use-ecosystem-device-models.d.ts +20 -0
- package/lib/seam/ecosystem/use-ecosystem-device-models.js +51 -0
- package/lib/seam/ecosystem/use-ecosystem-device-models.js.map +1 -0
- package/lib/seam/ecosystem/use-ecosystem-manufacturers.d.ts +11 -0
- package/lib/seam/ecosystem/use-ecosystem-manufacturers.js +49 -0
- package/lib/seam/ecosystem/use-ecosystem-manufacturers.js.map +1 -0
- package/lib/seam/ecosystem/use-ecosystem-query-result.d.ts +7 -0
- package/lib/seam/ecosystem/use-ecosystem-query-result.js +7 -0
- package/lib/seam/ecosystem/use-ecosystem-query-result.js.map +1 -0
- package/lib/seam/ecosystem/use-embed-key-resolution.d.ts +20 -0
- package/lib/seam/ecosystem/use-embed-key-resolution.js +51 -0
- package/lib/seam/ecosystem/use-embed-key-resolution.js.map +1 -0
- package/lib/seam/index.d.ts +1 -0
- package/lib/seam/index.js +1 -0
- package/lib/seam/index.js.map +1 -1
- package/lib/seam/seam-components-class-name.d.ts +1 -0
- package/lib/seam/seam-components-class-name.js +2 -0
- package/lib/seam/seam-components-class-name.js.map +1 -0
- package/lib/ui/ecosystem/SearchField.d.ts +9 -0
- package/lib/ui/ecosystem/SearchField.js +8 -0
- package/lib/ui/ecosystem/SearchField.js.map +1 -0
- package/lib/ui/ecosystem/StateMessage.d.ts +8 -0
- package/lib/ui/ecosystem/StateMessage.js +5 -0
- package/lib/ui/ecosystem/StateMessage.js.map +1 -0
- package/lib/ui/ecosystem/errors.d.ts +5 -0
- package/lib/ui/ecosystem/errors.js +25 -0
- package/lib/ui/ecosystem/errors.js.map +1 -0
- package/lib/ui/ecosystem/storybook.d.ts +15 -0
- package/lib/ui/ecosystem/storybook.js +16 -0
- package/lib/ui/ecosystem/storybook.js.map +1 -0
- package/lib/ui/ecosystem/theme.d.ts +6 -0
- package/lib/ui/ecosystem/theme.js +11 -0
- package/lib/ui/ecosystem/theme.js.map +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +11 -3
- package/src/ecosystem.d.ts +2 -0
- package/src/ecosystem.ts +12 -0
- package/src/elements.ts +4 -0
- package/src/lib/ecosystem-element.tsx +154 -0
- package/src/lib/element.tsx +6 -0
- package/src/lib/seam/SeamProvider.tsx +18 -2
- package/src/lib/seam/components/CompatibleApps/CompatibleApps.element.ts +19 -0
- package/src/lib/seam/components/CompatibleApps/CompatibleApps.tsx +344 -0
- package/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx +108 -0
- package/src/lib/seam/components/SupportedDevices/FilterSelect.tsx +43 -0
- package/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx +114 -0
- package/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts +17 -0
- package/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx +382 -0
- package/src/lib/seam/components/SupportedDevices/device-models.ts +326 -0
- package/src/lib/seam/components/ecosystem-elements.ts +2 -0
- package/src/lib/seam/components/index.ts +2 -0
- package/src/lib/seam/ecosystem/EcosystemProvider.tsx +133 -0
- package/src/lib/seam/ecosystem/client.ts +116 -0
- package/src/lib/seam/ecosystem/index.ts +19 -0
- package/src/lib/seam/ecosystem/types.ts +402 -0
- package/src/lib/seam/ecosystem/use-compatible-partners.ts +80 -0
- package/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts +89 -0
- package/src/lib/seam/ecosystem/use-ecosystem-device-models.ts +96 -0
- package/src/lib/seam/ecosystem/use-ecosystem-manufacturers.ts +86 -0
- package/src/lib/seam/ecosystem/use-ecosystem-query-result.ts +19 -0
- package/src/lib/seam/ecosystem/use-embed-key-resolution.ts +80 -0
- package/src/lib/seam/index.ts +1 -0
- package/src/lib/seam/seam-components-class-name.ts +1 -0
- package/src/lib/ui/ecosystem/SearchField.tsx +32 -0
- package/src/lib/ui/ecosystem/StateMessage.tsx +29 -0
- package/src/lib/ui/ecosystem/errors.ts +30 -0
- package/src/lib/ui/ecosystem/storybook.tsx +40 -0
- package/src/lib/ui/ecosystem/theme.ts +14 -0
- package/src/lib/version.ts +1 -1
- package/src/styles/_compatible-apps.scss +102 -0
- package/src/styles/_ecosystem.scss +295 -0
- package/src/styles/_main.scss +6 -0
- 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
|
+
}
|