diff --git a/apps/presentation/features/content/cms-link.tsx b/apps/presentation/features/content/cms-link.tsx index a576607..383800e 100644 --- a/apps/presentation/features/content/cms-link.tsx +++ b/apps/presentation/features/content/cms-link.tsx @@ -17,10 +17,14 @@ export interface CmsLinkProps { className?: string /** When provided, used as link content instead of link.label (e.g. for teaser cards). */ children?: React.ReactNode + useLabelAsFallbackContent?: boolean } export const CmsLink = React.forwardRef( - function CmsLink({ link, className, children }, ref) { + function CmsLink( + { link, className, children, useLabelAsFallbackContent }, + ref + ) { return ( ( title={link.title ?? undefined} className={className} > - {children ?? link.label} + {children ?? (useLabelAsFallbackContent ? link.label : undefined)} ) } diff --git a/apps/presentation/features/content/teasers/lazy-video.tsx b/apps/presentation/features/content/teasers/lazy-video.tsx new file mode 100644 index 0000000..c19275c --- /dev/null +++ b/apps/presentation/features/content/teasers/lazy-video.tsx @@ -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 = false, + eager = false, + className, + aspectRatio = '16 / 9', +}: LazyVideoProps) { + const ref = useRef(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 ( +
+ {!loaded && !error && ( +
+ )} + {error && ( +
+ {t('video.unavailable')} +
+ )} + {!error && ( +
+ ) +} diff --git a/apps/presentation/features/content/teasers/teaser-block.tsx b/apps/presentation/features/content/teasers/teaser-block.tsx index a253d9c..0ed3dc2 100644 --- a/apps/presentation/features/content/teasers/teaser-block.tsx +++ b/apps/presentation/features/content/teasers/teaser-block.tsx @@ -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, @@ -115,6 +116,14 @@ function renderTeaser( /> ) } + if (isTeaserOfType(teaser, 'video')) { + return ( + + ) + } return null } diff --git a/apps/presentation/features/content/teasers/teaser-video-block.tsx b/apps/presentation/features/content/teasers/teaser-video-block.tsx new file mode 100644 index 0000000..e48da5c --- /dev/null +++ b/apps/presentation/features/content/teasers/teaser-video-block.tsx @@ -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 ( +
+ {link && !controls && ( + + )} + +
+ ) +} diff --git a/apps/storybook/src/stories/teasers/teaser-mock-data.ts b/apps/storybook/src/stories/teasers/teaser-mock-data.ts index 689c2a0..42ea86f 100644 --- a/apps/storybook/src/stories/teasers/teaser-mock-data.ts +++ b/apps/storybook/src/stories/teasers/teaser-mock-data.ts @@ -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', @@ -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' }, +} diff --git a/apps/storybook/src/stories/teasers/teaser-video.stories.tsx b/apps/storybook/src/stories/teasers/teaser-video.stories.tsx new file mode 100644 index 0000000..03f703b --- /dev/null +++ b/apps/storybook/src/stories/teasers/teaser-video.stories.tsx @@ -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 = { + title: 'Teasers/Video', + component: TeaserVideoBlock, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { teaser: VIDEO }, +} diff --git a/core/contracts/src/content/teaser-video.ts b/core/contracts/src/content/teaser-video.ts new file mode 100644 index 0000000..36cffa3 --- /dev/null +++ b/core/contracts/src/content/teaser-video.ts @@ -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 diff --git a/core/contracts/src/content/teaser.ts b/core/contracts/src/content/teaser.ts index 8d39bed..f7bd03a 100644 --- a/core/contracts/src/content/teaser.ts +++ b/core/contracts/src/content/teaser.ts @@ -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', [ @@ -28,6 +29,7 @@ export const TeaserResponseSchema = z.discriminatedUnion('type', [ RegularTeaserSchema, AccordionTeaserSchema, BrandTeaserSchema, + VideoTeaserSchema, ]) export type TeaserResponse = z.infer diff --git a/core/i18n/de-DE.json b/core/i18n/de-DE.json index feaab6e..706db69 100644 --- a/core/i18n/de-DE.json +++ b/core/i18n/de-DE.json @@ -694,6 +694,9 @@ }, "richText": { "assetUnresolved": "Asset (nicht aufgelöst)" + }, + "video": { + "unavailable": "Video nicht verfügbar" } }, "wishlist": { diff --git a/core/i18n/en-US.json b/core/i18n/en-US.json index 2a08c68..10ec9f0 100644 --- a/core/i18n/en-US.json +++ b/core/i18n/en-US.json @@ -695,6 +695,9 @@ }, "richText": { "assetUnresolved": "Asset (unresolved)" + }, + "video": { + "unavailable": "Video unavailable" } }, "wishlist": { diff --git a/integrations/contentful-api/src/graphql/index.ts b/integrations/contentful-api/src/graphql/index.ts index df98037..5665640 100644 --- a/integrations/contentful-api/src/graphql/index.ts +++ b/integrations/contentful-api/src/graphql/index.ts @@ -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' diff --git a/integrations/contentful-api/src/graphql/page-by-slug.query.ts b/integrations/contentful-api/src/graphql/page-by-slug.query.ts index 864b489..6fdb4a9 100644 --- a/integrations/contentful-api/src/graphql/page-by-slug.query.ts +++ b/integrations/contentful-api/src/graphql/page-by-slug.query.ts @@ -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' @@ -40,6 +41,7 @@ export const PageBySlugQuery = gql` ${TeaserRegularFragment} ${TeaserAccordionFragment} ${TeaserBrandFragment} + ${TeaserVideoFragment} query PageBySlug($slug: String!, $locale: String!, $preview: Boolean!) { pageCollection( where: { slug: $slug } @@ -99,6 +101,7 @@ export const PageBySlugQuery = gql` ...TeaserRegularFragment ...TeaserAccordionFragment ...TeaserBrandFragment + ...TeaserVideoFragment } } } diff --git a/integrations/contentful-api/src/graphql/teaser-video.fragment.ts b/integrations/contentful-api/src/graphql/teaser-video.fragment.ts new file mode 100644 index 0000000..25a843e --- /dev/null +++ b/integrations/contentful-api/src/graphql/teaser-video.fragment.ts @@ -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 + } + } +` diff --git a/integrations/contentful-api/src/mappers/teaser/index.ts b/integrations/contentful-api/src/mappers/teaser/index.ts index 23120f2..bd48449 100644 --- a/integrations/contentful-api/src/mappers/teaser/index.ts +++ b/integrations/contentful-api/src/mappers/teaser/index.ts @@ -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. @@ -47,6 +48,8 @@ export function mapTeaserEntryToResponse( return mapTeaserAccordion(entry) case 'TeaserBrand': return mapTeaserBrand(entry) + case 'TeaserVideo': + return mapTeaserVideo(entry) default: return null } diff --git a/integrations/contentful-api/src/mappers/teaser/map-teaser-video.ts b/integrations/contentful-api/src/mappers/teaser/map-teaser-video.ts new file mode 100644 index 0000000..6d188a2 --- /dev/null +++ b/integrations/contentful-api/src/mappers/teaser/map-teaser-video.ts @@ -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), + } +} diff --git a/integrations/contentful-api/src/schemas/teaser/index.ts b/integrations/contentful-api/src/schemas/teaser/index.ts index 06ade1a..8547a3d 100644 --- a/integrations/contentful-api/src/schemas/teaser/index.ts +++ b/integrations/contentful-api/src/schemas/teaser/index.ts @@ -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([ @@ -28,6 +29,7 @@ export const TeaserEntryApiResponseSchema = z.union([ TeaserRegularApiResponseSchema, TeaserAccordionApiResponseSchema, TeaserBrandApiResponseSchema, + TeaserVideoApiResponseSchema, ]) export type TeaserEntryApiResponse = z.infer< diff --git a/integrations/contentful-api/src/schemas/teaser/teaser-video.ts b/integrations/contentful-api/src/schemas/teaser/teaser-video.ts new file mode 100644 index 0000000..bcd9879 --- /dev/null +++ b/integrations/contentful-api/src/schemas/teaser/teaser-video.ts @@ -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 +> diff --git a/integrations/contentful-migration/migrations/01-02-teasers/01-02-16-page-add-brand-component.ts b/integrations/contentful-migration/migrations/01-02-teasers/01-02-16-page-add-brand-component.ts deleted file mode 100644 index d4d961a..0000000 --- a/integrations/contentful-migration/migrations/01-02-teasers/01-02-16-page-add-brand-component.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 16 Update page.components validation to include teaserBrand. - * Must run after 01-02-15-brand.ts (teaserBrand content type must exist first). - */ -import type Migration from 'contentful-migration' -import { PAGE_COMPONENT_IDS } from '../lib/content-types/ids' - -const run = async (migration: Migration) => { - const page = migration.editContentType('page') - page.editField('components').items({ - type: 'Link', - linkType: 'Entry', - validations: [{ linkContentType: [...PAGE_COMPONENT_IDS] }], - }) -} - -export = run diff --git a/integrations/contentful-migration/migrations/01-02-teasers/01-02-16-video.ts b/integrations/contentful-migration/migrations/01-02-teasers/01-02-16-video.ts new file mode 100644 index 0000000..087773f --- /dev/null +++ b/integrations/contentful-migration/migrations/01-02-teasers/01-02-16-video.ts @@ -0,0 +1,65 @@ +/** + * 05 Teaser Video. Fails if content type already exists. + */ +import type Migration from 'contentful-migration' +import type { + ContentTypeDefinition, + MigrationFunctionWithDefinition, +} from '../lib/content-type' +import { applyContentTypeFromDefinition } from '../lib/content-type' + +const definition: ContentTypeDefinition = { + id: 'teaserVideo', + name: 'Teaser Video', + description: 'Single video with optional caption and link', + displayField: 'title', + fields: [ + { id: 'title', spec: { type: 'Symbol', name: 'Title', localized: true } }, + { + id: 'video', + spec: { + type: 'Link', + name: 'Video', + linkType: 'Asset', + localized: false, + required: true, + }, + }, + { + id: 'thumbnail', + spec: { + type: 'Link', + name: 'Thumbnail', + linkType: 'Asset', + localized: false, + }, + }, + { + id: 'autoplay', + spec: { type: 'Boolean', name: 'Autoplay', localized: false }, + }, + { + id: 'controls', + spec: { type: 'Boolean', name: 'Controls', localized: false }, + }, + { + id: 'caption', + spec: { type: 'Symbol', name: 'Caption', localized: true }, + }, + { + id: 'link', + spec: { + type: 'Link', + name: 'Link', + linkType: 'Entry', + validations: [{ linkContentType: ['link'] }], + localized: false, + }, + }, + ], +} + +const run: MigrationFunctionWithDefinition = async (migration: Migration) => { + applyContentTypeFromDefinition(migration, definition) +} +export = run diff --git a/integrations/contentful-migration/migrations/02-01-demo-content/homepage/create-content.ts b/integrations/contentful-migration/migrations/02-01-demo-content/homepage/create-content.ts index 3fdae8c..0803fe5 100644 --- a/integrations/contentful-migration/migrations/02-01-demo-content/homepage/create-content.ts +++ b/integrations/contentful-migration/migrations/02-01-demo-content/homepage/create-content.ts @@ -29,6 +29,8 @@ import { BRAND_TITLE, BRAND, BRAND_IMAGES, + VIDEO_TEASER, + VIDEO_URLS, } from './data' export async function createLinks(client: PlainClientAPI) { @@ -317,5 +319,34 @@ export async function createTeasers( }, }) ) + + const videoAssetId = await createImageAsset( + client, + { + title: 'Video teaser', + url: VIDEO_URLS[0] ?? '', + fileName: 'video-teaser.mp4', + contentType: 'video/mp4', + }, + [...LOCALES] + ) + const videoAssetLink = { + sys: { + type: 'Link' as const, + linkType: 'Asset' as const, + id: videoAssetId, + }, + } + const videoTeaser = VIDEO_TEASER(toEntryRef(links.linkNewId)) + componentIds.push( + await createEntryWithLocales(client, 'teaserVideo', { + 'en-US': { + ...videoTeaser['en-US'], + video: videoAssetLink, + }, + 'de-DE': { ...videoTeaser['de-DE'], video: videoAssetLink }, + }) + ) + return componentIds } diff --git a/integrations/contentful-migration/migrations/02-01-demo-content/homepage/data.ts b/integrations/contentful-migration/migrations/02-01-demo-content/homepage/data.ts index c8e088d..0aaf380 100644 --- a/integrations/contentful-migration/migrations/02-01-demo-content/homepage/data.ts +++ b/integrations/contentful-migration/migrations/02-01-demo-content/homepage/data.ts @@ -248,6 +248,26 @@ export const IMAGE_TEASER = (linkRef: EntryLinkRef) => ({ link: linkRef, }, }) +export const VIDEO_TEASER = (linkRef: EntryLinkRef) => ({ + 'en-US': { + title: 'New in', + caption: 'Discover the latest trends.', + link: linkRef, + autoplay: true, + controls: false, + }, + 'de-DE': { + title: 'Neu eingetroffen', + caption: 'Entdecke die neuesten Trends.', + link: linkRef, + }, +}) + +export const VIDEO_URLS = [ + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4', + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', +] export const CAROUSEL_ITEMS = ( linkNew: EntryLinkRef, linkC: EntryLinkRef, diff --git a/integrations/contentful-migration/migrations/lib/content-types/ids.ts b/integrations/contentful-migration/migrations/lib/content-types/ids.ts index ab44965..59b7f3e 100644 --- a/integrations/contentful-migration/migrations/lib/content-types/ids.ts +++ b/integrations/contentful-migration/migrations/lib/content-types/ids.ts @@ -16,6 +16,7 @@ const TEASERS = [ 'teaserRegular', 'teaserAccordion', 'teaserBrand', + 'teaserVideo', ] /** All content types in dependency-safe order. Reset deletes entries then content types in this order. */