Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/hero-banner-asset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@graphcommerce/next-ui': minor
---

`HeroBanner` gains an `asset?: React.ReactNode` prop, and `videoSrc` is deprecated.

The banner rendered a `<video>` itself, from a raw URL — so it could only ever hold a video, and left nowhere to hang a poster, the one place a poster matters most (a full-bleed autoplaying video above the fold). It now takes a node and renders it, the way its sibling `SpecialBanner` already does; positioning stays with the banner, stretching whatever is passed to fill via `& img, & video`.

```diff
- <HeroBanner videoSrc={asset.filename} … />
+ <HeroBanner asset={<Asset asset={asset} poster={poster} />} … />
```

Taking a node rather than a source also keeps `next-ui` free of any CMS: the Storyblok and Hygraph examples each pass their own `<Asset>`, both of which already render images and video.

`videoSrc` still works but is deprecated: when set (and `asset` is not) it renders a bare autoplaying video, keeping the `HeroBanner-video` class. The only behavioural change on that path is that the scroll parallax is gone, along with the `framer-motion`, `useScrollY` and `clientSize` machinery it needed. Migrate to `asset` to regain images, posters, and control over how the media is rendered.
23 changes: 23 additions & 0 deletions .changeset/storyblok-asset-poster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@graphcommerce/storyblok-ui': minor
'@graphcommerce/image': minor
---

Let a Storyblok video asset show a poster.

A `<video>` paints nothing until it has buffered enough for its first frame, and nothing at all when autoplay is blocked (iOS Low Power Mode) — so an autoplaying video banner starts out black, and can stay black. `Asset` now takes a `poster` prop, rendered as `<video poster>`.

Storyblok's own `type: asset` has no room for a poster, so `assetWithPoster()` is added to read the convention of an asset value carrying an extra `poster` key:

```jsonc
{ "fieldtype": "asset", "id": 1, "filename": "…", "poster": { "filename": "…" } }
```

Keeping the poster beside the asset rather than nesting both under a wrapper means a custom field type storing that shape is a drop-in for a plain asset field: existing content stays valid and `value.filename` keeps working for consumers that ignore the poster. The narrowing is unavoidable — Storyblok has no JSONSchema for custom field types, so its type generator emits `unknown` for them.

```tsx
const { asset, poster } = assetWithPoster(blok.asset)
return asset && <Asset asset={asset} poster={poster} />
```

`@graphcommerce/image` gains `imageUrl(src, { width, quality })`, which builds an optimized URL outside of a React tree — for the places that need a bare URL string rather than an `<Image>`, such as `<video poster>`, a CSS `background-image` or an `og:image`. It routes through the configured loader exactly like `<Image>` does, so the bytes are served and cached by your own deployment rather than fetched from the origin host by every visitor, which matters when the origin meters bandwidth. `width` is snapped up to the nearest configured size, since the optimizer rejects any width outside `imageSizes`/`deviceSizes`.
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { RichText } from '@graphcommerce/hygraph-ui'
import type { AssetProps } from '@graphcommerce/hygraph-ui'
import { Asset, RichText } from '@graphcommerce/hygraph-ui'
import { breakpointVal, HeroBanner } from '@graphcommerce/next-ui'
import { Button } from '@mui/material'
import { RowHeroBannerFragment } from './RowHeroBanner.gql'

export function RowHeroBanner(props: RowHeroBannerFragment) {
const { copy, heroAsset, pageLinks } = props
export type RowHeroBannerProps = RowHeroBannerFragment & {
/**
* Only applies to an image asset — a video is never lazy-loaded. Pass
* `'eager'` where the banner is the LCP element.
*/
loading?: AssetProps['loading']
}

export function RowHeroBanner(props: RowHeroBannerProps) {
const { copy, heroAsset, pageLinks, loading } = props

return (
<HeroBanner
Expand All @@ -13,7 +22,7 @@ export function RowHeroBanner(props: RowHeroBannerFragment) {
{title}
</Button>
))}
videoSrc={heroAsset.url}
asset={heroAsset && <Asset asset={heroAsset} loading={loading} sizes='100vw' />}
sx={(theme) => ({
'& .HeroBanner-copy': {
minHeight: { xs: 'min(70vh,600px)', md: 'min(70vh,1080px)' },
Expand Down
9 changes: 5 additions & 4 deletions examples/magento-storyblok/components/Blog/BlogItem.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { BlogListItem } from '@graphcommerce/next-ui'
import { Asset } from '@graphcommerce/storyblok-ui'
import { Asset, assetWithPoster } from '@graphcommerce/storyblok-ui'
import type { StoryblokStory } from '@graphcommerce/storyblok-ui'
import { Trans } from '@lingui/react/macro'
import { Typography, useTheme } from '@mui/material'
import type { StoryblokStory } from '@graphcommerce/storyblok-ui'
import type { StoryblokPage } from '../Storyblok/types'

export type BlogItemProps = { story: StoryblokStory }

export function BlogItem({ story }: BlogItemProps) {
const theme = useTheme()
const content = story.content as StoryblokPage
const asset = content?.asset
const { asset, poster } = assetWithPoster(content?.asset)

return (
<BlogListItem
asset={
asset?.filename ? (
asset ? (
<Asset
asset={asset}
poster={poster}
sizes={{
0: '48vw',
[theme.breakpoints.values.md]: '30vw',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import { breakpointVal, HeroBanner } from '@graphcommerce/next-ui'
import { multilinkHref, RichText, storyblokEditable } from '@graphcommerce/storyblok-ui'
import type { AssetProps } from '@graphcommerce/storyblok-ui'
import {
Asset,
assetWithPoster,
multilinkHref,
RichText,
storyblokEditable,
} from '@graphcommerce/storyblok-ui'
import { Button } from '@mui/material'
import type { StoryblokRowHeroBanner as RowHeroBannerBlok } from '../types'

export function RowHeroBanner({ blok }: { blok: RowHeroBannerBlok }) {
export type RowHeroBannerProps = {
blok: RowHeroBannerBlok
/**
* Only applies to an image asset — a video is never lazy-loaded. Pass
* `'eager'` where the banner is the LCP element, via `RowRenderer`'s
* `renderer`.
*/
loading?: AssetProps['loading']
}

export function RowHeroBanner({ blok, loading }: RowHeroBannerProps) {
const { asset, poster } = assetWithPoster(blok.asset)

return (
<HeroBanner
{...storyblokEditable(blok)}
Expand All @@ -18,7 +37,7 @@ export function RowHeroBanner({ blok }: { blok: RowHeroBannerBlok }) {
{link.title}
</Button>
))}
videoSrc={blok.asset?.filename ?? ''}
asset={asset && <Asset asset={asset} poster={poster} loading={loading} />}
sx={(theme) => ({
'& .HeroBanner-copy': {
minHeight: { xs: 'min(70vh,600px)', md: 'min(70vh,1080px)' },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { responsiveVal, VariantImageLabelSwiper } from '@graphcommerce/next-ui'
import { Asset, multilinkHref, RichText, storyblokEditable } from '@graphcommerce/storyblok-ui'
import {
Asset,
assetWithPoster,
multilinkHref,
RichText,
storyblokEditable,
} from '@graphcommerce/storyblok-ui'
import { Box, ButtonBase, Typography } from '@mui/material'
import type { RowLinksVariantProps } from '../RowLinks'

Expand All @@ -12,40 +18,45 @@ export function ImageLabelSwiper(props: RowLinksVariantProps) {
copy={copy ? <RichText content={copy} /> : undefined}
sx={{ '& .Scroller-root': { alignItems: 'start' } }}
>
{page_links?.map((pageLink) => (
<ButtonBase
{...storyblokEditable(pageLink)}
href={multilinkHref(pageLink.url)}
key={pageLink._uid}
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
textAlign: 'center',
rowGap: theme.spacings.xs,
'& img, & video': { display: 'block' },
})}
>
<>
{pageLink.asset && (
<Asset
asset={pageLink.asset}
sx={{
width: responsiveVal(260, 400),
maxWidth: responsiveVal(260, 400),
borderRadius: 3,
}}
sizes={responsiveVal(260, 400)}
/>
)}
<Box sx={{ maxWidth: responsiveVal(260, 400) }}>
<Typography variant='h6' component='h3'>
{pageLink.title}
</Typography>
{pageLink.description && <RichText content={pageLink.description} />}
</Box>
</>
</ButtonBase>
))}
{page_links?.map((pageLink) => {
const { asset, poster } = assetWithPoster(pageLink.asset)

return (
<ButtonBase
{...storyblokEditable(pageLink)}
href={multilinkHref(pageLink.url)}
key={pageLink._uid}
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
textAlign: 'center',
rowGap: theme.spacings.xs,
'& img, & video': { display: 'block' },
})}
>
<>
{asset && (
<Asset
asset={asset}
poster={poster}
sx={{
width: responsiveVal(260, 400),
maxWidth: responsiveVal(260, 400),
borderRadius: 3,
}}
sizes={responsiveVal(260, 400)}
/>
)}
<Box sx={{ maxWidth: responsiveVal(260, 400) }}>
<Typography variant='h6' component='h3'>
{pageLink.title}
</Typography>
{pageLink.description && <RichText content={pageLink.description} />}
</Box>
</>
</ButtonBase>
)
})}
</VariantImageLabelSwiper>
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { VariantLogoSwiper } from '@graphcommerce/next-ui'
import {
Asset,
assetWithPoster,
multilinkHref,
parseDimensions,
storyblokEditable,
Expand All @@ -17,42 +18,45 @@ export function LogoSwiper(props: RowLinksVariantProps) {
maxWidth={false}
sx={(theme) => ({ my: `calc(${theme.spacings.xxl} + ${theme.spacings.md})` })}
>
{page_links?.map((pageLink) => (
<Link
{...storyblokEditable(pageLink)}
href={multilinkHref(pageLink.url)}
key={pageLink._uid}
color='inherit'
underline='hover'
sx={{ '& img, & video': { display: 'block' } }}
>
{pageLink.asset && (
<Asset
asset={pageLink.asset}
sizes={{ 0: '120px', 960: '240px' }}
sx={(theme) => {
const dimensions = pageLink.asset?.filename
? parseDimensions(pageLink.asset.filename)
: null
{page_links?.map((pageLink) => {
const { asset, poster } = assetWithPoster(pageLink.asset)

return {
...(dimensions && {
width: () => {
const widthBase = 60
const scaleFactor = 0.525
const imageRatio = dimensions.width / dimensions.height
const w = imageRatio ** scaleFactor * widthBase
return { xs: w * 0.65, sm: w * 0.8, md: w * 0.9, lg: w }
},
}),
filter: 'none',
...theme.applyStyles('dark', { filter: 'invert(100%)' }),
}
}}
/>
)}
</Link>
))}
return (
<Link
{...storyblokEditable(pageLink)}
href={multilinkHref(pageLink.url)}
key={pageLink._uid}
color='inherit'
underline='hover'
sx={{ '& img, & video': { display: 'block' } }}
>
{asset && (
<Asset
asset={asset}
poster={poster}
sizes={{ 0: '120px', 960: '240px' }}
sx={(theme) => {
const dimensions = parseDimensions(asset.filename ?? '')

return {
...(dimensions && {
width: () => {
const widthBase = 60
const scaleFactor = 0.525
const imageRatio = dimensions.width / dimensions.height
const w = imageRatio ** scaleFactor * widthBase
return { xs: w * 0.65, sm: w * 0.8, md: w * 0.9, lg: w }
},
}),
filter: 'none',
...theme.applyStyles('dark', { filter: 'invert(100%)' }),
}
}}
/>
)}
</Link>
)
})}
</VariantLogoSwiper>
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Image } from '@graphcommerce/image'
import { ImageText, responsiveVal, useContainerSpacing } from '@graphcommerce/next-ui'
import { Asset, RichText } from '@graphcommerce/storyblok-ui'
import { Asset, assetWithPoster, RichText } from '@graphcommerce/storyblok-ui'
import { Typography, useTheme } from '@mui/material'
import type { RowPdpVariantProps } from '../../RowPdp'

Expand All @@ -9,12 +9,13 @@ export function Feature(props: RowPdpVariantProps) {
const theme = useTheme()
const item = media_gallery?.[2] ?? media_gallery?.[0]
const { size, breakpoint } = useContainerSpacing({ sizing: 'content' })
const { asset, poster } = assetWithPoster(blok.asset)

return (
<ImageText
item={
blok.asset?.filename ? (
<Asset asset={blok.asset} sizes={responsiveVal(100, 600)} />
asset ? (
<Asset asset={asset} poster={poster} sizes={responsiveVal(100, 600)} />
) : item?.__typename === 'ProductImage' && item.url ? (
<Image
alt={item.label ?? 'Product Image'}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { Image } from '@graphcommerce/image'
import { ImageTextBoxed, responsiveVal } from '@graphcommerce/next-ui'
import { Asset, RichText } from '@graphcommerce/storyblok-ui'
import { Asset, assetWithPoster, RichText } from '@graphcommerce/storyblok-ui'
import { Typography, useTheme } from '@mui/material'
import type { RowPdpVariantProps } from '../../RowPdp'

export function FeatureBoxed(props: RowPdpVariantProps) {
const { blok, media_gallery } = props
const theme = useTheme()
const item = media_gallery?.[1] ?? media_gallery?.[0]
const { asset, poster } = assetWithPoster(blok.asset)

return (
<ImageTextBoxed
item={
blok.asset?.filename ? (
<Asset asset={blok.asset} sizes='50vw' />
asset ? (
<Asset asset={asset} poster={poster} sizes='50vw' />
) : item?.__typename === 'ProductImage' && item.url ? (
<Image
alt={item.label ?? 'Product Image'}
Expand Down
Loading
Loading