Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
88 changes: 88 additions & 0 deletions apps/presentation/features/content/teasers/lazy-video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'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'
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
33 changes: 33 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,33 @@
import type { VideoTeaser } from '@core/contracts/content/teaser-video'
import { CmsLink } from '@/features/content/cms-link'
import { LazyVideo } from './lazy-video'

interface TeaserVideoProps {
teaser: VideoTeaser
imagePreload?: boolean
}

export function TeaserVideoBlock({ teaser, imagePreload }: TeaserVideoProps) {
const { videoUrl, thumbnailUrl, autoplay, controls, link } = teaser
const video = (
<LazyVideo
src={videoUrl}
poster={thumbnailUrl}
autoPlay={autoplay}
muted={autoplay}
controls={controls}
eager={imagePreload}
/>
)

return link ? (
<CmsLink
link={link}
className='block'
>
{video}
</CmsLink>
) : (
video
)
}
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
>
Loading
Loading