Skip to content
Open
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
89 changes: 89 additions & 0 deletions apps/presentation/features/content/teasers/lazy-video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use client'

import { useRef, useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'

interface LazyVideoProps {
src?: string
poster?: string
autoPlay?: boolean
muted?: boolean
controls?: boolean
eager?: boolean
className?: string
aspectRatio?: string
}

export function LazyVideo({
src,
poster,
autoPlay,
muted,
controls,
eager = false,
className,
aspectRatio = '16 / 9',
}: LazyVideoProps) {
const ref = useRef<HTMLVideoElement>(null)
const [inView, setInView] = useState(eager)
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
const t = useTranslations('teaser')

useEffect(() => {
if (inView) {
return
}
const el = ref.current
if (!el) {
return
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setInView(true)
observer.disconnect()
}
},
{ rootMargin: '200px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [inView])

return (
<div
className='relative w-full overflow-hidden rounded-lg'
style={{ aspectRatio }}
>
{!loaded && !error && (
<div className='absolute inset-0 animate-pulse bg-gray-200' />
)}
{error && (
<div className='absolute inset-0 flex items-center justify-center bg-gray-100 text-sm text-gray-500'>
{t('video.unavailable')}
</div>
)}
{!error && (
<video
ref={ref}
src={inView ? src : undefined}
poster={poster}
autoPlay={autoPlay}
muted={muted}
loop={autoPlay}
controls={controls}
preload='none'
playsInline={autoPlay && muted}
onCanPlay={() => setLoaded(true)}
onError={() => setError(true)}
className={cn(
'absolute inset-0 h-full w-full object-cover',
className
)}
/>
)}
</div>
)
}
9 changes: 9 additions & 0 deletions apps/presentation/features/content/teasers/teaser-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { TeaserSectionBlock } from './teaser-section-block'
import { TeaserRegularBlock } from './teaser-regular-block'
import { TeaserAccordionBlock } from './teaser-accordion-block'
import { TeaserBrandBlock } from './teaser-brand-block'
import { TeaserVideoBlock } from './teaser-video-block'

function renderTeaser(
teaser: TeaserResponse,
Expand Down Expand Up @@ -115,6 +116,14 @@ function renderTeaser(
/>
)
}
if (isTeaserOfType(teaser, 'video')) {
return (
<TeaserVideoBlock
teaser={teaser}
imagePreload={imagePreload}
/>
)
}
return null
}

Expand Down
31 changes: 31 additions & 0 deletions apps/presentation/features/content/teasers/teaser-video-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { VideoTeaser } from '@core/contracts/content/teaser-video'
import { LazyVideo } from './lazy-video'
import { CmsLink } from '../cms-link'

interface TeaserVideoProps {
teaser: VideoTeaser
imagePreload?: boolean
}

export function TeaserVideoBlock({ teaser, imagePreload }: TeaserVideoProps) {
const { videoUrl, thumbnailUrl, autoplay, controls, link } = teaser
return (
<div className='relative'>
{link && (
<CmsLink
link={link}
className='absolute inset-0 z-10'
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think now we could have problems for combination of link and controls as it wouldn't be possible to use controls at all due to overlay.

aria-label={link.title}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-label will be ignored by CmsLink, most likely we need to pass rest props in CmsLink.

/>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CmsLink falls back to its label if there is no children passed.

)}
<LazyVideo
src={videoUrl}
poster={thumbnailUrl}
autoPlay={autoplay}
muted={autoplay}
controls={controls}
eager={imagePreload}
/>
</div>
)
}
13 changes: 13 additions & 0 deletions apps/storybook/src/stories/teasers/teaser-mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
AccordionTeaser,
AccordionItem,
} from '@core/contracts/content/teaser-accordion'
import type { VideoTeaser } from '@core/contracts/content/teaser-video'

export const MOCK_IMAGES = {
banner: 'https://placehold.co/1200x400/1e3a5f/94a3b8?text=Hero',
Expand Down Expand Up @@ -637,3 +638,15 @@ export const ACCORDION: AccordionTeaser = {
title: 'FAQ',
items: ACCORDION_ITEMS,
}

export const VIDEO: VideoTeaser = {
type: 'video',
title: 'New in',
videoUrl:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
thumbnailUrl: 'https://placehold.co/1280x720/1e3a5f/94a3b8?text=Video',
autoplay: false,
controls: true,
caption: 'Discover the latest trends.',
link: { label: 'Shop now', url: '/new' },
}
17 changes: 17 additions & 0 deletions apps/storybook/src/stories/teasers/teaser-video.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/react'
import { TeaserVideoBlock } from '@/features/content/teasers/teaser-video-block'
import { VIDEO } from './teaser-mock-data'

const meta: Meta<typeof TeaserVideoBlock> = {
title: 'Teasers/Video',
component: TeaserVideoBlock,
tags: ['autodocs'],
parameters: { layout: 'padded' },
}

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
args: { teaser: VIDEO },
}
14 changes: 14 additions & 0 deletions core/contracts/src/content/teaser-video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z } from 'zod'
import { CmsLinkSchema } from './cms-link'

export const VideoTeaserSchema = z.object({
type: z.literal('video'),
title: z.string().optional(),
videoUrl: z.string().optional(),
thumbnailUrl: z.string().optional(),
autoplay: z.boolean(),
controls: z.boolean(),
caption: z.string().optional(),
link: CmsLinkSchema.optional(),
})
export type VideoTeaser = z.infer<typeof VideoTeaserSchema>
2 changes: 2 additions & 0 deletions core/contracts/src/content/teaser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SectionTeaserSchema } from './teaser-section'
import { RegularTeaserSchema } from './teaser-regular'
import { AccordionTeaserSchema } from './teaser-accordion'
import { BrandTeaserSchema } from './teaser-brand'
import { VideoTeaserSchema } from './teaser-video'

/** Discriminated union of all teaser/component types */
export const TeaserResponseSchema = z.discriminatedUnion('type', [
Expand All @@ -28,6 +29,7 @@ export const TeaserResponseSchema = z.discriminatedUnion('type', [
RegularTeaserSchema,
AccordionTeaserSchema,
BrandTeaserSchema,
VideoTeaserSchema,
])

export type TeaserResponse = z.infer<typeof TeaserResponseSchema>
Expand Down
3 changes: 3 additions & 0 deletions core/i18n/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,9 @@
},
"richText": {
"assetUnresolved": "Asset (nicht aufgelöst)"
},
"video": {
"unavailable": "Video nicht verfügbar"
}
},
"wishlist": {
Expand Down
3 changes: 3 additions & 0 deletions core/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,9 @@
},
"richText": {
"assetUnresolved": "Asset (unresolved)"
},
"video": {
"unavailable": "Video unavailable"
}
},
"wishlist": {
Expand Down
1 change: 1 addition & 0 deletions integrations/contentful-api/src/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './teaser-rich-text.fragment'
export * from './teaser-carousel.fragment'
export * from './teaser-brand-item.fragment'
export * from './teaser-brand.fragment'
export * from './teaser-video.fragment'
export * from './teaser-slider.fragment'
export * from './teaser-product-carousel.fragment'
export * from './teaser-section.fragment'
Expand Down
3 changes: 3 additions & 0 deletions integrations/contentful-api/src/graphql/page-by-slug.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { TeaserSectionFragment } from './teaser-section.fragment'
import { TeaserRegularFragment } from './teaser-regular.fragment'
import { TeaserAccordionFragment } from './teaser-accordion.fragment'
import { TeaserBrandFragment } from './teaser-brand.fragment'
import { TeaserVideoFragment } from './teaser-video.fragment'
import { TeaserBrandItemFragment } from './teaser-brand-item.fragment'
import { TeaserCarouselItemFragment } from './teaser-carousel-item.fragment'
import { AssetFragment } from './asset.fragment'
Expand All @@ -40,6 +41,7 @@ export const PageBySlugQuery = gql`
${TeaserRegularFragment}
${TeaserAccordionFragment}
${TeaserBrandFragment}
${TeaserVideoFragment}
query PageBySlug($slug: String!, $locale: String!, $preview: Boolean!) {
pageCollection(
where: { slug: $slug }
Expand Down Expand Up @@ -99,6 +101,7 @@ export const PageBySlugQuery = gql`
...TeaserRegularFragment
...TeaserAccordionFragment
...TeaserBrandFragment
...TeaserVideoFragment
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions integrations/contentful-api/src/graphql/teaser-video.fragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { gql } from 'graphql-request'

export const TeaserVideoFragment = gql`
fragment TeaserVideoFragment on TeaserVideo {
__typename
title
video {
...AssetFragment
}
thumbnail {
...AssetFragment
}
autoplay
controls
caption
link {
...LinkEntryFragment
}
}
`
3 changes: 3 additions & 0 deletions integrations/contentful-api/src/mappers/teaser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { mapTeaserSection } from './map-teaser-section'
import { mapTeaserRegular } from './map-teaser-regular'
import { mapTeaserAccordion } from './map-teaser-accordion'
import { mapTeaserBrand } from './map-teaser-brand'
import { mapTeaserVideo } from './map-teaser-video'

/**
* Maps a Contentful teaser/component entry to contract TeaserResponse.
Expand Down Expand Up @@ -47,6 +48,8 @@ export function mapTeaserEntryToResponse(
return mapTeaserAccordion(entry)
case 'TeaserBrand':
return mapTeaserBrand(entry)
case 'TeaserVideo':
return mapTeaserVideo(entry)
default:
return null
}
Expand Down
17 changes: 17 additions & 0 deletions integrations/contentful-api/src/mappers/teaser/map-teaser-video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { VideoTeaser } from '@core/contracts/content/teaser-video'
import type { TeaserVideoApiResponse } from '../../schemas/teaser/teaser-video'
import { mapLinkEntryToCmsLink } from '../cms-link'

/** Maps Contentful TeaserVideo entry to contract VideoTeaser. */
export function mapTeaserVideo(entry: TeaserVideoApiResponse): VideoTeaser {
return {
type: 'video',
title: entry.title ?? undefined,
videoUrl: entry.video?.url ?? undefined,
thumbnailUrl: entry.thumbnail?.url ?? undefined,
autoplay: entry.autoplay ?? false,
controls: entry.controls ?? true,
caption: entry.caption ?? undefined,
link: mapLinkEntryToCmsLink(entry.link),
}
}
2 changes: 2 additions & 0 deletions integrations/contentful-api/src/schemas/teaser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TeaserSectionApiResponseSchema } from './teaser-section'
import { TeaserRegularApiResponseSchema } from './teaser-regular'
import { TeaserAccordionApiResponseSchema } from './teaser-accordion'
import { TeaserBrandApiResponseSchema } from './teaser-brand'
import { TeaserVideoApiResponseSchema } from './teaser-video'

/** Union of all teaser/component content types from Contentful. */
export const TeaserEntryApiResponseSchema = z.union([
Expand All @@ -28,6 +29,7 @@ export const TeaserEntryApiResponseSchema = z.union([
TeaserRegularApiResponseSchema,
TeaserAccordionApiResponseSchema,
TeaserBrandApiResponseSchema,
TeaserVideoApiResponseSchema,
])

export type TeaserEntryApiResponse = z.infer<
Expand Down
18 changes: 18 additions & 0 deletions integrations/contentful-api/src/schemas/teaser/teaser-video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from 'zod'
import { ContentfulImageApiResponseSchema } from '../image'
import { LinkEntryApiResponseSchema } from '../link'

export const TeaserVideoApiResponseSchema = z.object({
__typename: z.literal('TeaserVideo'),
title: z.string().optional().nullable(),
video: ContentfulImageApiResponseSchema,
thumbnail: ContentfulImageApiResponseSchema.optional().nullable(),
autoplay: z.boolean().optional().nullable(),
controls: z.boolean().optional().nullable(),
caption: z.string().optional().nullable(),
link: LinkEntryApiResponseSchema.optional().nullable(),
})

export type TeaserVideoApiResponse = z.infer<
typeof TeaserVideoApiResponseSchema
>

This file was deleted.

Loading
Loading