diff --git a/examples/supported-devices-prototypes/index.html b/examples/supported-devices-prototypes/index.html new file mode 100644 index 000000000..129951e88 --- /dev/null +++ b/examples/supported-devices-prototypes/index.html @@ -0,0 +1,13 @@ + + + + + + + Two-line DeviceDB · Design review + + +
Loading design review…
+ + + diff --git a/examples/supported-devices-prototypes/src/AccessoryCatalog.test.tsx b/examples/supported-devices-prototypes/src/AccessoryCatalog.test.tsx new file mode 100644 index 000000000..420625892 --- /dev/null +++ b/examples/supported-devices-prototypes/src/AccessoryCatalog.test.tsx @@ -0,0 +1,73 @@ +import { screen } from '@testing-library/react' +import { expect, test } from 'vitest' + +import { + type EcosystemTestContext, + renderWithEcosystem, + setupFakeEcosystem, +} from 'fixtures/ecosystem.js' + +import { AccessoryCatalog, AccessoryLinks } from './AccessoryCatalog.js' + +setupFakeEcosystem() + +test('accessory lookups are batched by slug across manufacturers and use canonical device routes', async (ctx) => { + renderWithEcosystem( + + + + , + ctx + ) + const gateway = await screen.findByRole('link', { name: /Engage Gateway/ }) + const bridge = await screen.findByRole('link', { + name: /Connect Wi-Fi Bridge/, + }) + expect(gateway.getAttribute('href')).toBe( + 'https://www.seam.co/gateways/schlage_engage-gateway' + ) + expect(bridge.getAttribute('href')).toBe( + 'https://www.seam.co/bridges/august_august-connect-wifi-bridge' + ) + expect(ctx.ecosystemRequestUrls).toHaveLength(1) + const request = ctx.ecosystemRequestUrls[0] + expect(request?.searchParams.get('slugs')).toBe( + 'august-connect-wifi-bridge,engage-gateway' + ) + expect(request?.searchParams.has('manufacturer')).toBe(false) + expect(request?.searchParams.has('provider')).toBe(false) +}) + +test('missing accessory records stay explicit while known accessories remain linked', async (ctx) => { + renderWithEcosystem( + + + , + ctx + ) + await screen.findByRole('link', { name: /Engage Gateway/ }) + await screen.findByText('Some accessory details couldn’t be loaded.') + expect(screen.queryByRole('link', { name: /missing/ })).toBeNull() +}) + +test('requirements without accessory relations do not fetch an unfiltered catalog', (ctx) => { + renderWithEcosystem( + + + , + ctx + ) + expect(ctx.ecosystemRequests).toEqual([]) + expect(screen.queryByText('Compatible keypads')).toBeNull() +}) diff --git a/examples/supported-devices-prototypes/src/AccessoryCatalog.tsx b/examples/supported-devices-prototypes/src/AccessoryCatalog.tsx new file mode 100644 index 000000000..322c6672f --- /dev/null +++ b/examples/supported-devices-prototypes/src/AccessoryCatalog.tsx @@ -0,0 +1,130 @@ +import { createContext, type PropsWithChildren, useContext } from 'react' + +import { + getDeviceModelImageUrl, + getDeviceModelName, +} from 'lib/seam/components/SupportedDevices/device-models.js' +import type { DeviceModelSummary } from 'lib/seam/ecosystem/types.js' +import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js' + +import { getRequirementKinds } from './model.js' + +const AccessoryContext = createContext<{ + models: DeviceModelSummary[] + isLoading: boolean + isError: boolean +} | null>(null) + +/** One batched lookup per catalog, shared by every expanded provider. */ +export function AccessoryCatalog({ + slugs, + children, +}: PropsWithChildren<{ slugs: string[] }>): JSX.Element { + const { deviceModels, isLoading, isError } = useEcosystemDeviceModelSummaries( + { + slugs, + enabled: slugs.length > 0, + } + ) + return ( + + {children} + + ) +} + +export function AccessoryLinks({ + message, + slugs = [], +}: { + message: string + slugs?: string[] | undefined +}): JSX.Element | null { + const catalog = useContext(AccessoryContext) + if (slugs.length === 0 || catalog == null) return null + const kinds = getRequirementKinds(message) + const label = kinds.includes('bridge') + ? 'Compatible bridges' + : kinds.includes('keypad') + ? 'Compatible keypads' + : 'Compatible accessories' + const models = [...new Set(slugs)].flatMap((slug) => { + const model = catalog.models.find((entry) => entry.slug === slug) + return model == null ? [] : [model] + }) + return ( +
+

{label}

+ {catalog.isLoading ? ( +

+ Loading accessories… +

+ ) : ( + <> + {models.length > 0 && ( +
    + {models.map((model) => { + const image = getDeviceModelImageUrl(model) + const name = getDeviceModelName(model) + const manufacturer = model.manufacturer + const href = + manufacturer == null || model.device_type == null + ? null + : `https://www.seam.co/${encodeURIComponent(model.device_type.slug)}s/${encodeURIComponent(manufacturer.slug)}_${encodeURIComponent(model.slug)}` + const content = ( + <> + {image != null && ( + + )} +
    + {name} + {manufacturer != null && ( + + {manufacturer.display_name ?? manufacturer.slug} + + )} +
    + {href != null && ( + + ↗ + + )} + + ) + return ( +
  • + {href == null ? ( +
    {content}
    + ) : ( + + {content} + + )} +
  • + ) + })} +
+ )} + {(catalog.isError || models.length < new Set(slugs).size) && ( +

+ Some accessory details couldn’t be loaded. +

+ )} + + )} +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/App.tsx b/examples/supported-devices-prototypes/src/App.tsx new file mode 100644 index 000000000..6ae1fc81b --- /dev/null +++ b/examples/supported-devices-prototypes/src/App.tsx @@ -0,0 +1,456 @@ +import { useEffect, useRef, useState } from 'react' + +import { + getProvidersWithMissingRequirements, + resolveProviderRequirements, + scopeDeviceModelsToProvider, +} from 'lib/seam/components/SupportedDevices/device-models.js' +import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js' +import { useEcosystemManufacturers } from 'lib/seam/ecosystem/use-ecosystem-manufacturers.js' +import { useEcosystemProviderRequirements } from 'lib/seam/ecosystem/use-ecosystem-provider-requirements.js' + +import { AccessoryCatalog } from './AccessoryCatalog.js' +import { BrandMark } from './BrandMark.js' +import { CapabilityFilter } from './CapabilityFilter.js' +import { CheckInfo } from './CheckInfo.js' +import { reviewDevice } from './model.js' +import { PhonePreview } from './PhonePreview.js' +import { Picker } from './Picker.js' +import { + postPreviewFilters, + type PreviewFilters, + previewReadyMessage, + readPreviewFilters, +} from './preview-filters.js' +import { RequirementPills } from './RequirementPills.js' +import { Legend } from './shared.js' +import { Stacked } from './Stacked.js' + +const variants = [ + { + name: 'Current', + title: 'Two-line rows · current version', + description: + 'Capability-specific icons, with accessory and subscription requirements shown as pills. Open a row for the specific requirements.', + component: Stacked, + }, + { + name: 'Checks', + title: 'Two-line rows · checks and info', + description: + 'Green checks indicate support. Amber info icons and requirement pills indicate additional setup. Capability names stay visible.', + component: CheckInfo, + }, + { + name: 'Pills', + title: 'Two-line rows · requirement pills', + description: + 'The same check/info treatment, with accessory and subscription pills. Open a row for the specific requirements.', + component: RequirementPills, + }, +] +const brands = [ + { slug: 'nuki', label: 'Nuki', provider: 'nuki-web' }, + { slug: 'august', label: 'August', provider: 'august-access' }, + { slug: 'keyincode', label: 'KeyInCode', provider: 'keyincode' }, + { slug: 'salto', label: 'Salto', provider: 'salto-ks' }, +] +const params = new URLSearchParams(window.location.search) +const isMobilePreview = params.get('view') === 'mobile' +const requestedVariant = Number(params.get('v')) - 1 +const initialVariant = + Number.isInteger(requestedVariant) && + requestedVariant >= 0 && + requestedVariant < variants.length + ? requestedVariant + : 0 + +export function App(): JSX.Element { + const [active, setActive] = useState(initialVariant) + const [replay, setReplay] = useState(0) + const [brandSlug, setBrandSlug] = useState(params.get('brand') ?? 'nuki') + const [dark, setDark] = useState(params.get('theme') === 'dark') + const [compare, setCompare] = useState(params.get('compare') !== '0') + const [requirementView, setRequirementView] = useState<'matrix' | 'list'>( + params.get('requirements') === 'matrix' ? 'matrix' : 'list' + ) + const [search, setSearch] = useState(params.get('q') ?? '') + const [capabilities, setCapabilities] = useState(() => [ + ...new Set(params.getAll('capability').filter((name) => name !== '')), + ]) + const phoneRef = useRef(null) + const brand = brands.find((entry) => entry.slug === brandSlug) ?? brands[0] + const variant = variants[active] ?? variants[0] + + useEffect(() => { + const url = new URL(window.location.href) + url.searchParams.set('v', String(active + 1)) + url.searchParams.set('brand', brandSlug) + url.searchParams.set('theme', dark ? 'dark' : 'light') + url.searchParams.set('requirements', requirementView) + if (!isMobilePreview) url.searchParams.set('compare', compare ? '1' : '0') + if (search === '') url.searchParams.delete('q') + else url.searchParams.set('q', search) + url.searchParams.delete('capability') + for (const name of capabilities) url.searchParams.append('capability', name) + window.history.replaceState(null, '', url) + document.documentElement.style.colorScheme = dark ? 'dark' : 'light' + document.documentElement.classList.toggle( + 'seam-proto-mobile-document', + isMobilePreview + ) + }, [active, brandSlug, dark, compare, search, capabilities, requirementView]) + + useEffect(() => { + const receiveFilters = (event: MessageEvent): void => { + const source = isMobilePreview + ? window.parent + : phoneRef.current?.contentWindow + if (event.origin !== window.location.origin || event.source !== source) + return + if (!isMobilePreview && event.data === previewReadyMessage) { + postPreviewFilters(source, { search, capabilities }) + return + } + const filters = readPreviewFilters(event.data) + if (filters == null) return + setSearch(filters.search) + setCapabilities(filters.capabilities) + } + window.addEventListener('message', receiveFilters) + return () => { + window.removeEventListener('message', receiveFilters) + } + }, [search, capabilities]) + + useEffect(() => { + if (isMobilePreview) + window.parent.postMessage(previewReadyMessage, window.location.origin) + }, []) + + useEffect(() => { + if (!isMobilePreview) + postPreviewFilters(phoneRef.current?.contentWindow, { + search, + capabilities, + }) + }, [search, capabilities]) + + const changeFilters = (filters: PreviewFilters): void => { + setSearch(filters.search) + setCapabilities(filters.capabilities) + if (isMobilePreview) postPreviewFilters(window.parent, filters) + } + + const modelsQuery = useEcosystemDeviceModelSummaries({ + manufacturer: brand?.slug, + provider: brand?.provider, + deviceType: 'smartlock', + }) + const rawModels = modelsQuery.deviceModels ?? [] + const missing = getProvidersWithMissingRequirements(rawModels) + const requirementsQuery = useEcosystemProviderRequirements([ + ...(brand == null ? [] : [brand.provider]), + ...rawModels.flatMap((model) => + model.providers.map((provider) => provider.slug) + ), + ]) + const { manufacturers } = useEcosystemManufacturers({ + slugs: brand == null ? [] : [brand.slug], + }) + const manufacturer = manufacturers?.find( + (entry) => entry.slug === brand?.slug + ) + const resolvedModels = resolveProviderRequirements( + rawModels, + requirementsQuery.providers ?? [] + ) + const devices = scopeDeviceModelsToProvider( + resolvedModels, + brand?.provider + ).map((source) => + reviewDevice( + source, + requirementsQuery.providers ?? [], + resolvedModels.find( + (model) => model.device_model_id === source.device_model_id + ) + ) + ) + const allCapabilities = [ + ...new Set( + devices.flatMap((device) => device.capabilities.map((item) => item.name)) + ), + ] + const filtered = devices.filter((device) => { + const text = [ + device.name, + ...device.source.variants.map((item) => item.manufacturer_model_id), + ] + .join(' ') + .toLowerCase() + return ( + text.includes(search.toLowerCase().trim()) && + capabilities.every((name) => + device.capabilities.some((item) => item.name === name) + ) + ) + }) + const loading = + modelsQuery.isLoading || (missing.length > 0 && requirementsQuery.isLoading) + const error = + modelsQuery.isError || (missing.length > 0 && requirementsQuery.isError) + if (variant == null || brand == null) return

Design review unavailable.

+ const Variant = variant.component + + const catalog = ( +
+
+ Brand guides / {brand.label} + + View live guide ↗ + +
+

Supported devices

+

+ Supported capabilities at a glance. Open a row for setup requirements + and model details. +

+
+ + { + changeFilters({ search, capabilities }) + }} + /> +
+
+
+ + {brand.label} +
+ + {loading ? 'Loading…' : `${filtered.length} devices`} + +
+ + {loading ? ( +

+ Loading capabilities and requirements… +

+ ) : error ? ( +
+

We couldn’t load the complete device information.

+ +
+ ) : filtered.length === 0 ? ( +
+

No devices match these filters.

+ +
+ ) : ( +
+ + device.providers + .filter( + (provider) => provider.integrationStatus !== 'unsupported' + ) + .flatMap((provider) => + provider.requirements.flatMap( + (requirement) => requirement.deviceSlugs ?? [] + ) + ) + )} + > + + +
+ )} + + View {brand.label} in the device catalog ↗ + +
+ ) + const mobileParams = new URLSearchParams({ + view: 'mobile', + v: String(active + 1), + brand: brand.slug, + theme: dark ? 'dark' : 'light', + requirements: requirementView, + }) + + if (isMobilePreview) + return ( +
+
{catalog}
+
+ ) + + return ( +
+ +
+
+ + Option {active + 1} of {variants.length} · {variant.name} + +

{variant.title}

+

{variant.description}

+

+ Switch below or use keys 1–3 / ← →. R resets open details. +

+
+
+ Provider details + + +
+
+
+ {compare && ( +
+ Desktop + + {variant.name} · {brand.label} + +
+ )} + {catalog} +
+ {compare && ( + + )} +
+

+ Review prototype · Live Ecosystem catalog ·{' '} + + Dawn’s design feedback ↗ + +

+
+ item.name)} + active={active} + onChange={setActive} + onReplay={() => { + setReplay((value) => value + 1) + }} + /> +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/BrandMark.tsx b/examples/supported-devices-prototypes/src/BrandMark.tsx new file mode 100644 index 000000000..6481e462d --- /dev/null +++ b/examples/supported-devices-prototypes/src/BrandMark.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' + +import type { DeviceAsset } from 'lib/seam/ecosystem/types.js' + +const logoKinds = [ + 'square_logo', + 'horizontal_logo', + 'square_logo_mono', + 'horizontal_logo_mono', +] as const + +export function BrandMark({ + name, + logos, + className = '', +}: { + name: string + logos?: DeviceAsset[] + className?: string +}): JSX.Element { + const logo = logoKinds + .map((kind) => logos?.find((asset) => asset.kind === kind)) + .find((asset) => asset != null) + const src = logo?.formats.small?.url ?? logo?.url + const initial = name.slice(0, 1) + return ( + + {src == null ? ( + {initial} + ) : ( + + )} + + ) +} + +function LogoImage({ + src, + initial, +}: { + src: string + initial: string +}): JSX.Element { + const [failed, setFailed] = useState(false) + return failed ? ( + {initial} + ) : ( + { + setFailed(true) + }} + /> + ) +} diff --git a/examples/supported-devices-prototypes/src/CapabilityFilter.tsx b/examples/supported-devices-prototypes/src/CapabilityFilter.tsx new file mode 100644 index 000000000..902203f32 --- /dev/null +++ b/examples/supported-devices-prototypes/src/CapabilityFilter.tsx @@ -0,0 +1,133 @@ +import { useEffect, useId, useRef, useState } from 'react' + +import { getCapabilityLabel } from 'lib/seam/components/SupportedDevices/device-models.js' + +export function CapabilityFilter({ + capabilities, + selected, + onChange, +}: { + capabilities: string[] + selected: string[] + onChange: (capabilities: string[]) => void +}): JSX.Element { + const [open, setOpen] = useState(false) + const root = useRef(null) + const trigger = useRef(null) + const id = useId() + const summary = + selected.length === 0 + ? 'All capabilities' + : selected.length === 1 + ? getCapabilityLabel(selected[0] ?? '') + : `${selected.length} selected` + + useEffect(() => { + if (!open) return + root.current?.querySelector('input')?.focus() + const onPointerDown = (event: PointerEvent): void => { + if ( + event.target instanceof Node && + root.current?.contains(event.target) !== true + ) + setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + } + }, [open]) + + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false) + }} + onKeyDown={(event) => { + if (open) event.stopPropagation() + if (event.key === 'Escape' && open) { + event.preventDefault() + setOpen(false) + trigger.current?.focus() + } else if ( + event.key === 'ArrowDown' && + event.target === trigger.current + ) { + event.preventDefault() + event.stopPropagation() + setOpen(true) + root.current?.querySelector('input')?.focus() + } + }} + > + Capabilities + + {open && ( +
+

Show devices supporting every selected capability.

+
+ + Required capabilities + + {capabilities.map((name) => ( + + ))} +
+ +
+ )} +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/CheckInfo.tsx b/examples/supported-devices-prototypes/src/CheckInfo.tsx new file mode 100644 index 000000000..30e0bd02e --- /dev/null +++ b/examples/supported-devices-prototypes/src/CheckInfo.tsx @@ -0,0 +1,28 @@ +import type { VariantProps } from './model.js' +import { CompactRow, DeviceIdentity, Features } from './shared.js' + +export function CheckInfo({ + devices, + requirementView, +}: VariantProps): JSX.Element { + return ( +
+
+ Device & supported capabilities +
+ {devices.map((device) => ( + + + + + + ))} +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/HardwareGallery.tsx b/examples/supported-devices-prototypes/src/HardwareGallery.tsx new file mode 100644 index 000000000..27ddeb3d7 --- /dev/null +++ b/examples/supported-devices-prototypes/src/HardwareGallery.tsx @@ -0,0 +1,264 @@ +import { useEffect, useId, useRef, useState } from 'react' + +import type { + DeviceImage, + DeviceModelSummary, +} from 'lib/seam/ecosystem/types.js' + +interface GalleryImage { + image: DeviceImage + label: string +} + +export function HardwareGallery({ + source, + name, +}: { + source: DeviceModelSummary + name: string +}): JSX.Element { + const seen = new Set() + const photos: GalleryImage[] = [] + for (const variant of source.variants) + for (const image of variant.images) { + if (seen.has(image.url)) continue + seen.add(image.url) + photos.push({ + image, + label: [variant.color_name, variant.style_name] + .filter(Boolean) + .join(' · '), + }) + } + if (photos.length === 0 && source.featured_image != null) + photos.push({ image: source.featured_image, label: name }) + const swatches = source.variants + .filter( + (variant) => + variant.color_name != null || variant.primary_color_hex != null + ) + .map((variant) => ({ + label: variant.color_name ?? 'Color not specified', + color: variant.primary_color_hex, + index: photos.findIndex((photo) => + variant.images.some((image) => image.url === photo.image.url) + ), + })) + .filter( + (swatch, index, all) => + all.findIndex( + (entry) => + entry.label === swatch.label && entry.color === swatch.color + ) === index + ) + + const [selected, setSelected] = useState(null) + const [hovered, setHovered] = useState<{ + index: number + left: number + top: number + } | null>(null) + const dialog = useRef(null) + const titleId = useId() + const current = selected == null ? null : photos[selected] + const hoverPhoto = hovered == null ? null : photos[hovered.index] + + useEffect(() => { + if (selected != null && dialog.current != null && !dialog.current.open) + dialog.current.showModal() + }, [selected]) + + useEffect(() => { + if (hovered == null) return + const dismiss = (): void => { + setHovered(null) + } + window.addEventListener('scroll', dismiss, true) + window.addEventListener('resize', dismiss) + return () => { + window.removeEventListener('scroll', dismiss, true) + window.removeEventListener('resize', dismiss) + } + }, [hovered]) + + const move = (direction: number): void => { + setSelected((index) => + index == null ? null : (index + direction + photos.length) % photos.length + ) + } + + return ( +
+ {swatches.length > 0 ? ( +
+ {swatches.map(({ color, label, index }) => ( + + ))} + {photos.length > 0 && swatches.every(({ index }) => index < 0) && ( + + )} +
+ ) : photos.length > 0 ? ( + + ) : ( + Not listed + )} + {hovered != null && hoverPhoto != null && ( +
+ + {hoverPhoto.label === '' ? name : hoverPhoto.label} +
+ )} + { + setSelected(null) + setHovered(null) + }} + onClick={(event) => { + if (event.target === event.currentTarget) event.currentTarget.close() + }} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + move(event.key === 'ArrowLeft' ? -1 : 1) + } + }} + > +
+

{name}

+ +
+ {current != null && ( + <> + {`${name}${current.label +
+ + + {current.label === '' ? name : current.label} ·{' '} + {(selected ?? 0) + 1} / {photos.length} + + +
+ + )} +
+
+ ) +} + +function largeImage(image: DeviceImage): string { + return image.formats.large?.url ?? image.formats.medium?.url ?? image.url +} diff --git a/examples/supported-devices-prototypes/src/HardwareInformation.tsx b/examples/supported-devices-prototypes/src/HardwareInformation.tsx new file mode 100644 index 000000000..f216e4124 --- /dev/null +++ b/examples/supported-devices-prototypes/src/HardwareInformation.tsx @@ -0,0 +1,98 @@ +import type { DeviceModelSummary } from 'lib/seam/ecosystem/types.js' + +import { HardwareGallery } from './HardwareGallery.js' +import type { ReviewDevice } from './model.js' + +export function HardwareInformation({ + device, +}: { + device: ReviewDevice +}): JSX.Element { + const { source, name } = device + const facts = hardwareFacts(source) + return ( +
+

Hardware information

+
+ {facts.map(({ label, value }) => ( +
+
+ {label} + {label === 'Model numbers' && source.manufacturer != null && ( + + ↗ + + )} +
+
{value}
+
+ ))} +
+
Colors
+
+ +
+
+
+
+ ) +} + +function hardwareFacts( + source: DeviceModelSummary +): Array<{ label: string; value: string }> { + const facts: Array<{ label: string; value: string }> = [] + const numbers = [ + ...new Set( + source.variants + .map((variant) => variant.manufacturer_model_id) + .filter((id): id is string => id != null && id !== '') + ), + ] + facts.push({ + label: 'Model numbers', + value: numbers.length > 0 ? numbers.join(', ') : 'Not listed', + }) + const properties = + typeof source.device_type_properties === 'object' && + source.device_type_properties != null + ? (source.device_type_properties as Record) + : {} + const lockType = properties['lock_type'] + if (typeof lockType === 'string' && lockType !== '') + facts.push({ + label: 'Lock type', + value: + lockType.charAt(0).toUpperCase() + + lockType.slice(1).replaceAll('_', ' '), + }) + const batteryType = properties['battery_type'] + const batteryCount = properties['battery_count'] + if (typeof batteryType === 'string' && batteryType !== '') + facts.push({ + label: 'Batteries', + value: + typeof batteryCount === 'number' && batteryCount > 0 + ? `${batteryCount} × ${batteryType}` + : batteryType, + }) + else if (typeof properties['is_battery_powered'] === 'boolean') + facts.push({ + label: 'Power', + value: properties['is_battery_powered'] + ? 'Battery powered' + : 'Not battery powered', + }) + return facts +} diff --git a/examples/supported-devices-prototypes/src/PhonePreview.tsx b/examples/supported-devices-prototypes/src/PhonePreview.tsx new file mode 100644 index 000000000..3898caa86 --- /dev/null +++ b/examples/supported-devices-prototypes/src/PhonePreview.tsx @@ -0,0 +1,53 @@ +import type { RefObject } from 'react' + +export function PhonePreview({ + src, + title, + frameRef, +}: { + src: string + title: string + frameRef: RefObject +}): JSX.Element { + return ( +