@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,344 @@
1
+ import classNames from 'classnames'
2
+ import { useDeferredValue, useMemo, useState } from 'react'
3
+
4
+ import type {
5
+ CompatiblePartner,
6
+ PartnerAsset,
7
+ PartnerAssetKind,
8
+ } from 'lib/seam/ecosystem/types.js'
9
+ import { useCompatiblePartners } from 'lib/seam/ecosystem/use-compatible-partners.js'
10
+ import { getEcosystemErrorMessage } from 'lib/ui/ecosystem/errors.js'
11
+ import { SearchField } from 'lib/ui/ecosystem/SearchField.js'
12
+ import { StateMessage } from 'lib/ui/ecosystem/StateMessage.js'
13
+ import {
14
+ type EcosystemTheme,
15
+ getEcosystemThemeClassName,
16
+ } from 'lib/ui/ecosystem/theme.js'
17
+
18
+ export interface CompatibleAppsProps {
19
+ /** Device category, e.g., `smartlock`, `thermostat`, `sensor`, `intercom`. */
20
+ deviceType: string
21
+ /** DeviceDB manufacturer slug. Lists the apps that support this brand. */
22
+ manufacturer?: string | undefined
23
+ /** DeviceDB provider slug, i.e., the cloud the devices connect through. */
24
+ provider?: string | undefined
25
+ /** DeviceDB capability, e.g., `can_provision_mobile_keys`. Shows per-app notes. */
26
+ capability?: string | undefined
27
+ /** Industry slug to restrict the apps, e.g., `short-term-rental`. */
28
+ industry?: string | undefined
29
+ /** Apps shown before the show more button. */
30
+ maxVisible?: number | undefined
31
+ hideSearch?: boolean | undefined
32
+ hideTitle?: boolean | undefined
33
+ /** Color theme. `auto` follows the visitor's color scheme. */
34
+ theme?: EcosystemTheme | undefined
35
+ className?: string | undefined
36
+ }
37
+
38
+ const defaultMaxVisible = 24
39
+
40
+ /**
41
+ * The live apps (Seam partners) that work with a device category,
42
+ * manufacturer, provider or capability through Seam.
43
+ */
44
+ export function CompatibleApps({
45
+ deviceType,
46
+ manufacturer = '',
47
+ provider = '',
48
+ capability = '',
49
+ industry = '',
50
+ maxVisible = defaultMaxVisible,
51
+ hideSearch = false,
52
+ hideTitle = false,
53
+ theme = 'light',
54
+ className,
55
+ }: CompatibleAppsProps): JSX.Element {
56
+ const { partners, isPending, isError, error, refetch } =
57
+ useCompatiblePartners({
58
+ deviceType: deviceType ?? '',
59
+ manufacturer,
60
+ provider,
61
+ capability,
62
+ industry,
63
+ })
64
+
65
+ const [searchValue, setSearchValue] = useState('')
66
+ const [showAll, setShowAll] = useState(false)
67
+ const query = useDeferredValue(searchValue.toLowerCase().trim())
68
+
69
+ const sortedPartners = useMemo(
70
+ () => [...(partners ?? [])].sort(comparePartners),
71
+ [partners]
72
+ )
73
+
74
+ const filteredPartners = useMemo(
75
+ () =>
76
+ query.length === 0
77
+ ? sortedPartners
78
+ : sortedPartners.filter((partner) => matchesSearch(partner, query)),
79
+ [sortedPartners, query]
80
+ )
81
+
82
+ const visibleCount = getVisibleCount(maxVisible)
83
+ const visiblePartners = showAll
84
+ ? filteredPartners
85
+ : filteredPartners.slice(0, visibleCount)
86
+ const hiddenCount = filteredPartners.length - visiblePartners.length
87
+ const showNotes = capability.length > 0 || provider.length > 0
88
+ const isMissingDeviceType = (deviceType ?? '').trim().length === 0
89
+
90
+ const content = (() => {
91
+ if (isMissingDeviceType) {
92
+ return <StateMessage role='alert'>{t.missingDeviceType}</StateMessage>
93
+ }
94
+
95
+ if (isError) {
96
+ const { message, canRetry } = getEcosystemErrorMessage(error, t.error)
97
+ return (
98
+ <StateMessage
99
+ role='alert'
100
+ {...(canRetry
101
+ ? {
102
+ actionLabel: t.retry,
103
+ onAction: () => {
104
+ void refetch()
105
+ },
106
+ }
107
+ : {})}
108
+ >
109
+ {message}
110
+ </StateMessage>
111
+ )
112
+ }
113
+
114
+ if (isPending) {
115
+ return <LoadingGrid count={Math.min(visibleCount, 8)} />
116
+ }
117
+
118
+ if (sortedPartners.length === 0) {
119
+ return <StateMessage>{t.noApps}</StateMessage>
120
+ }
121
+
122
+ if (filteredPartners.length === 0) {
123
+ return (
124
+ <StateMessage
125
+ actionLabel={t.clearSearch}
126
+ onAction={() => {
127
+ setSearchValue('')
128
+ }}
129
+ >
130
+ {t.noSearchResults(searchValue.trim())}
131
+ </StateMessage>
132
+ )
133
+ }
134
+
135
+ return (
136
+ <>
137
+ <ul className='seam-compatible-apps-grid'>
138
+ {visiblePartners.map((partner) => (
139
+ <PartnerCard
140
+ key={partner.partner_id}
141
+ partner={partner}
142
+ showNote={showNotes}
143
+ />
144
+ ))}
145
+ </ul>
146
+ {hiddenCount > 0 && (
147
+ <div className='seam-compatible-apps-footer'>
148
+ <button
149
+ type='button'
150
+ className='seam-ecosystem-button'
151
+ onClick={() => {
152
+ setShowAll(true)
153
+ }}
154
+ >
155
+ {t.showMore(hiddenCount)}
156
+ </button>
157
+ </div>
158
+ )}
159
+ </>
160
+ )
161
+ })()
162
+
163
+ const showHeader = !hideTitle || !hideSearch
164
+
165
+ return (
166
+ <div
167
+ className={classNames(
168
+ 'seam-ecosystem',
169
+ 'seam-compatible-apps',
170
+ getEcosystemThemeClassName(theme),
171
+ className
172
+ )}
173
+ >
174
+ {showHeader && (
175
+ <div className='seam-ecosystem-header'>
176
+ {!hideTitle && <h2 className='seam-ecosystem-title'>{t.title}</h2>}
177
+ {!hideSearch && (
178
+ <SearchField
179
+ label={t.search}
180
+ value={searchValue}
181
+ onChange={setSearchValue}
182
+ disabled={isPending || isError || isMissingDeviceType}
183
+ />
184
+ )}
185
+ </div>
186
+ )}
187
+ {content}
188
+ </div>
189
+ )
190
+ }
191
+
192
+ function PartnerCard({
193
+ partner,
194
+ showNote,
195
+ }: {
196
+ partner: CompatiblePartner
197
+ showNote: boolean
198
+ }): JSX.Element {
199
+ const logo = getPartnerLogo(partner, lightLogoKinds)
200
+ const darkLogo = getPartnerLogo(partner, darkLogoKinds)
201
+ const hasDarkLogo = darkLogo != null && darkLogo.url !== logo?.url
202
+ const note = showNote ? partner.support.note : null
203
+ const subtitle = partner.industries.map((i) => i.display_name).join(' · ')
204
+
205
+ const body = (
206
+ <>
207
+ <div className='seam-compatible-apps-logo'>
208
+ {logo == null ? (
209
+ <span className='seam-compatible-apps-logo-initial' aria-hidden>
210
+ {partner.display_name.slice(0, 1)}
211
+ </span>
212
+ ) : (
213
+ <>
214
+ <img
215
+ loading='lazy'
216
+ src={logo.url}
217
+ alt={t.logoAlt(partner.display_name)}
218
+ width={48}
219
+ height={48}
220
+ className={classNames({
221
+ 'seam-ecosystem-light-only': hasDarkLogo,
222
+ })}
223
+ />
224
+ {hasDarkLogo && (
225
+ <img
226
+ loading='lazy'
227
+ src={darkLogo.url}
228
+ alt=''
229
+ width={48}
230
+ height={48}
231
+ className='seam-ecosystem-dark-only'
232
+ />
233
+ )}
234
+ </>
235
+ )}
236
+ </div>
237
+ <div className='seam-compatible-apps-text'>
238
+ <span className='seam-compatible-apps-name'>
239
+ {partner.display_name}
240
+ </span>
241
+ {subtitle.length > 0 && (
242
+ <span className='seam-compatible-apps-subtitle'>{subtitle}</span>
243
+ )}
244
+ {note != null && note.length > 0 && (
245
+ <span className='seam-compatible-apps-note'>{note}</span>
246
+ )}
247
+ </div>
248
+ </>
249
+ )
250
+
251
+ return (
252
+ <li className='seam-compatible-apps-card'>
253
+ {partner.website == null ? (
254
+ <div className='seam-compatible-apps-card-body'>{body}</div>
255
+ ) : (
256
+ <a
257
+ href={partner.website}
258
+ target='_blank'
259
+ rel='noopener noreferrer'
260
+ className='seam-compatible-apps-card-body'
261
+ >
262
+ {body}
263
+ </a>
264
+ )}
265
+ </li>
266
+ )
267
+ }
268
+
269
+ function LoadingGrid({ count }: { count: number }): JSX.Element {
270
+ return (
271
+ <ul className='seam-compatible-apps-grid' aria-busy='true'>
272
+ {Array.from({ length: count }, (_, index) => (
273
+ <li key={index} className='seam-compatible-apps-card'>
274
+ <div className='seam-compatible-apps-card-body'>
275
+ <div className='seam-ecosystem-skeleton seam-compatible-apps-logo' />
276
+ <div className='seam-compatible-apps-text'>
277
+ <div className='seam-ecosystem-skeleton seam-ecosystem-skeleton-line' />
278
+ <div className='seam-ecosystem-skeleton seam-ecosystem-skeleton-line-short' />
279
+ </div>
280
+ </div>
281
+ </li>
282
+ ))}
283
+ </ul>
284
+ )
285
+ }
286
+
287
+ const lightLogoKinds: PartnerAssetKind[] = [
288
+ 'square_logo',
289
+ 'horizontal_logo',
290
+ 'square_logo_mono',
291
+ 'horizontal_logo_mono',
292
+ ]
293
+
294
+ const darkLogoKinds: PartnerAssetKind[] = [
295
+ 'square_logo_mono',
296
+ 'square_logo',
297
+ 'horizontal_logo_mono',
298
+ 'horizontal_logo',
299
+ ]
300
+
301
+ const getPartnerLogo = (
302
+ partner: CompatiblePartner,
303
+ kinds: PartnerAssetKind[]
304
+ ): PartnerAsset | null => {
305
+ for (const kind of kinds) {
306
+ const asset = partner.assets.find((a) => a.kind === kind)
307
+ if (asset != null) return asset
308
+ }
309
+ return null
310
+ }
311
+
312
+ const comparePartners = (
313
+ a: CompatiblePartner,
314
+ b: CompatiblePartner
315
+ ): number => {
316
+ if (a.is_featured !== b.is_featured) return a.is_featured ? -1 : 1
317
+ return a.display_name.localeCompare(b.display_name)
318
+ }
319
+
320
+ const matchesSearch = (partner: CompatiblePartner, query: string): boolean =>
321
+ partner.display_name.toLowerCase().includes(query) ||
322
+ partner.industries.some((i) =>
323
+ i.display_name.toLowerCase().includes(query)
324
+ ) ||
325
+ partner.solutions.some((s) => s.display_name.toLowerCase().includes(query))
326
+
327
+ const getVisibleCount = (maxVisible: number): number =>
328
+ Number.isFinite(maxVisible) && maxVisible > 0
329
+ ? Math.floor(maxVisible)
330
+ : defaultMaxVisible
331
+
332
+ const t = {
333
+ title: 'Works with these apps',
334
+ search: 'Search apps',
335
+ error: 'An error occurred while loading compatible apps.',
336
+ retry: 'Retry',
337
+ noApps: 'No apps are listed for this selection yet.',
338
+ noSearchResults: (query: string) => `No apps found for "${query}".`,
339
+ clearSearch: 'Clear search',
340
+ showMore: (count: number) =>
341
+ `Show ${count} more ${count === 1 ? 'app' : 'apps'}`,
342
+ logoAlt: (name: string) => `${name} logo`,
343
+ missingDeviceType: 'Set a device type to list compatible apps.',
344
+ }
@@ -0,0 +1,108 @@
1
+ import classNames from 'classnames'
2
+
3
+ import {
4
+ getCapabilityLabel,
5
+ getDeviceModelImageUrl,
6
+ getDeviceModelName,
7
+ getSupportedCapabilities,
8
+ } from 'lib/seam/components/SupportedDevices/device-models.js'
9
+ import type {
10
+ DeviceModelSummary,
11
+ Manufacturer,
12
+ } from 'lib/seam/ecosystem/types.js'
13
+
14
+ interface DeviceModelRowProps {
15
+ deviceModel: DeviceModelSummary
16
+ manufacturer: Manufacturer | null
17
+ }
18
+
19
+ export function DeviceModelRow({
20
+ deviceModel,
21
+ manufacturer,
22
+ }: DeviceModelRowProps): JSX.Element {
23
+ const name = getDeviceModelName(deviceModel)
24
+ const imageUrl = getDeviceModelImageUrl(deviceModel, manufacturer)
25
+ const modelIds = [
26
+ ...new Set(
27
+ deviceModel.variants
28
+ .map((v) => v.manufacturer_model_id)
29
+ .filter((id): id is string => id != null && id.length > 0)
30
+ ),
31
+ ]
32
+ const providers = deviceModel.providers
33
+ .map((p) => p.display_name ?? p.slug)
34
+ .join(', ')
35
+ const capabilities = getSupportedCapabilities(deviceModel).filter(
36
+ (c) => c.name !== 'device_connectivity'
37
+ )
38
+
39
+ const meta = [
40
+ modelIds.length > 0 ? modelIds.slice(0, 2).join(', ') : null,
41
+ providers.length > 0 ? t.via(providers) : null,
42
+ ].filter((value): value is string => value != null)
43
+
44
+ return (
45
+ <li className='seam-supported-devices-row'>
46
+ <div className='seam-supported-devices-image'>
47
+ {imageUrl == null ? (
48
+ <span aria-hidden>{name.slice(0, 1)}</span>
49
+ ) : (
50
+ <img loading='lazy' src={imageUrl} alt='' width={48} height={48} />
51
+ )}
52
+ </div>
53
+ <div className='seam-supported-devices-info'>
54
+ {deviceModel.product_url == null ? (
55
+ <span className='seam-supported-devices-name'>{name}</span>
56
+ ) : (
57
+ <a
58
+ className='seam-supported-devices-name'
59
+ href={deviceModel.product_url}
60
+ target='_blank'
61
+ rel='noopener noreferrer'
62
+ >
63
+ {name}
64
+ </a>
65
+ )}
66
+ {meta.length > 0 && (
67
+ <span className='seam-supported-devices-meta'>
68
+ {meta.join(' · ')}
69
+ </span>
70
+ )}
71
+ </div>
72
+ <ul className='seam-supported-devices-badges' aria-label={t.badges}>
73
+ {deviceModel.device_type != null && (
74
+ <li className='seam-ecosystem-badge seam-ecosystem-badge-type'>
75
+ {deviceModel.device_type.display_name}
76
+ </li>
77
+ )}
78
+ {capabilities.map((capability) => (
79
+ <li
80
+ key={capability.name}
81
+ className={classNames('seam-ecosystem-badge', {
82
+ 'seam-ecosystem-badge-conditional':
83
+ capability.supported === 'conditionally',
84
+ })}
85
+ title={
86
+ capability.supported === 'conditionally'
87
+ ? (capability.details ?? t.conditional)
88
+ : undefined
89
+ }
90
+ >
91
+ {getCapabilityLabel(capability.name)}
92
+ {capability.supported === 'conditionally' && (
93
+ <span className='seam-ecosystem-visually-hidden'>
94
+ {` (${t.conditional})`}
95
+ </span>
96
+ )}
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ </li>
101
+ )
102
+ }
103
+
104
+ const t = {
105
+ via: (providers: string) => `via ${providers}`,
106
+ badges: 'Device type and capabilities',
107
+ conditional: 'conditionally supported',
108
+ }
@@ -0,0 +1,43 @@
1
+ import { ChevronDownIcon } from 'lib/icons/ChevronDown.js'
2
+ import type { FilterOption } from 'lib/seam/components/SupportedDevices/device-models.js'
3
+
4
+ interface FilterSelectProps {
5
+ label: string
6
+ allLabel: string
7
+ options: FilterOption[]
8
+ value: string | null
9
+ onChange: (value: string | null) => void
10
+ disabled?: boolean
11
+ }
12
+
13
+ export function FilterSelect({
14
+ label,
15
+ allLabel,
16
+ options,
17
+ value,
18
+ onChange,
19
+ disabled = false,
20
+ }: FilterSelectProps): JSX.Element {
21
+ return (
22
+ <label className='seam-ecosystem-select'>
23
+ <span className='seam-ecosystem-visually-hidden'>{label}</span>
24
+ <select
25
+ className='seam-ecosystem-select-input'
26
+ value={value ?? ''}
27
+ disabled={disabled}
28
+ onChange={(event) => {
29
+ const { value } = event.target
30
+ onChange(value.length === 0 ? null : value)
31
+ }}
32
+ >
33
+ <option value=''>{allLabel}</option>
34
+ {options.map((option) => (
35
+ <option key={option.value} value={option.value}>
36
+ {option.label}
37
+ </option>
38
+ ))}
39
+ </select>
40
+ <ChevronDownIcon aria-hidden className='seam-ecosystem-select-icon' />
41
+ </label>
42
+ )
43
+ }
@@ -0,0 +1,114 @@
1
+ import classNames from 'classnames'
2
+ import { useState } from 'react'
3
+
4
+ import {
5
+ getManufacturerLogoUrl,
6
+ getManufacturerName,
7
+ type ManufacturerGroup,
8
+ } from 'lib/seam/components/SupportedDevices/device-models.js'
9
+ import { DeviceModelRow } from 'lib/seam/components/SupportedDevices/DeviceModelRow.js'
10
+ import type { Manufacturer } from 'lib/seam/ecosystem/types.js'
11
+
12
+ /** Device models shown per manufacturer before the show all button. */
13
+ const collapsedCount = 5
14
+
15
+ interface ManufacturerSectionProps {
16
+ group: ManufacturerGroup
17
+ manufacturer: Manufacturer | null
18
+ collapsible: boolean
19
+ }
20
+
21
+ export function ManufacturerSection({
22
+ group,
23
+ manufacturer,
24
+ collapsible,
25
+ }: ManufacturerSectionProps): JSX.Element {
26
+ const [expanded, setExpanded] = useState(false)
27
+
28
+ const { deviceModels } = group
29
+ const canExpand = collapsible && deviceModels.length > collapsedCount
30
+ const visibleDeviceModels =
31
+ canExpand && !expanded
32
+ ? deviceModels.slice(0, collapsedCount)
33
+ : deviceModels
34
+
35
+ const name =
36
+ manufacturer != null
37
+ ? getManufacturerName(manufacturer)
38
+ : group.manufacturer != null
39
+ ? getManufacturerName(group.manufacturer)
40
+ : t.other
41
+
42
+ const logoUrl = getManufacturerLogoUrl(manufacturer)
43
+ const darkLogoUrl = getManufacturerLogoUrl(manufacturer, { mono: true })
44
+ const hasDarkLogo = darkLogoUrl != null && darkLogoUrl !== logoUrl
45
+
46
+ return (
47
+ <section className='seam-supported-devices-section'>
48
+ <div className='seam-supported-devices-section-header'>
49
+ <div className='seam-supported-devices-logo'>
50
+ {logoUrl == null ? (
51
+ <span aria-hidden>{name.slice(0, 1)}</span>
52
+ ) : (
53
+ <>
54
+ <img
55
+ loading='lazy'
56
+ src={logoUrl}
57
+ alt=''
58
+ width={40}
59
+ height={40}
60
+ className={classNames({
61
+ 'seam-ecosystem-light-only': hasDarkLogo,
62
+ })}
63
+ />
64
+ {hasDarkLogo && (
65
+ <img
66
+ loading='lazy'
67
+ src={darkLogoUrl}
68
+ alt=''
69
+ width={40}
70
+ height={40}
71
+ className='seam-ecosystem-dark-only'
72
+ />
73
+ )}
74
+ </>
75
+ )}
76
+ </div>
77
+ <h3 className='seam-supported-devices-section-title'>{name}</h3>
78
+ <span className='seam-supported-devices-count'>
79
+ {t.count(deviceModels.length)}
80
+ </span>
81
+ </div>
82
+ <ul className='seam-supported-devices-rows'>
83
+ {visibleDeviceModels.map((deviceModel) => (
84
+ <DeviceModelRow
85
+ key={deviceModel.device_model_id}
86
+ deviceModel={deviceModel}
87
+ manufacturer={manufacturer}
88
+ />
89
+ ))}
90
+ </ul>
91
+ {canExpand && (
92
+ <div className='seam-supported-devices-section-footer'>
93
+ <button
94
+ type='button'
95
+ className='seam-ecosystem-button'
96
+ aria-expanded={expanded}
97
+ onClick={() => {
98
+ setExpanded((expanded) => !expanded)
99
+ }}
100
+ >
101
+ {expanded ? t.showLess : t.showAll(name, deviceModels.length)}
102
+ </button>
103
+ </div>
104
+ )}
105
+ </section>
106
+ )
107
+ }
108
+
109
+ const t = {
110
+ other: 'Other devices',
111
+ count: (count: number) => `${count} ${count === 1 ? 'device' : 'devices'}`,
112
+ showAll: (name: string, count: number) => `Show all ${count} ${name} devices`,
113
+ showLess: 'Show less',
114
+ }
@@ -0,0 +1,17 @@
1
+ import type { EcosystemElementProps } from 'lib/ecosystem-element.js'
2
+
3
+ import type { SupportedDevicesProps } from './SupportedDevices.js'
4
+
5
+ export const name = 'seam-supported-devices'
6
+
7
+ export const props: EcosystemElementProps<SupportedDevicesProps> = {
8
+ manufacturer: 'string',
9
+ deviceType: 'string',
10
+ capability: 'string',
11
+ hideSearch: 'boolean',
12
+ hideFilters: 'boolean',
13
+ hideTitle: 'boolean',
14
+ theme: 'string',
15
+ }
16
+
17
+ export { SupportedDevices as Component } from './SupportedDevices.js'