feat: FDVideoHeroBlock

This commit is contained in:
Jeffrey 2026-03-04 21:42:26 +01:00
parent f9327f8d98
commit d7f3a52838
8 changed files with 39028 additions and 1 deletions

View File

@ -0,0 +1,158 @@
'use client'
import React, { useRef, useEffect, useState } from 'react'
import type { FDVideoHeroBlock as FDVideoHeroBlockProps, Media } from '@/payload-types'
import { FDButton } from '@/components/FDButton'
export const FDVideoHeroBlockComponent: React.FC<FDVideoHeroBlockProps> = (props) => {
const {
videoMp4,
videoWebm,
posterImage,
heading,
body,
ctaText,
ctaLink,
secondaryCtaText,
secondaryCtaLink,
overlayColor = 'none',
overlayStrength = 'medium',
textAlign = 'left',
minHeight = 'screen',
disableVideoOnMobile = false,
anchorId,
} = props
const videoRef = useRef<HTMLVideoElement>(null)
const [isMobile, setIsMobile] = useState(false)
// Detect mobile for conditional video loading
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// Resolve media objects
const mp4Media = videoMp4 as Media | undefined
const webmMedia = videoWebm as Media | undefined
const poster = posterImage as Media | undefined
const mp4Url = mp4Media?.url || ''
const webmUrl = webmMedia?.url || ''
const posterUrl = poster?.url || ''
const showVideo = mp4Url && !(disableVideoOnMobile && isMobile)
// Overlay classes
const hasOverlay = overlayColor !== 'none'
const overlayOpacityMap: Record<string, string> = {
light: 'opacity-30',
medium: 'opacity-50',
heavy: 'opacity-70',
}
const overlayBgMap: Record<string, string> = {
black: 'bg-black',
navy: 'bg-fd-navy',
}
const overlayClass = hasOverlay
? `${overlayBgMap[overlayColor] || ''} ${overlayOpacityMap[overlayStrength] || 'opacity-50'}`
: ''
// Text colors — white when overlay is present, navy otherwise
const hasText = heading || body
const textColorHeading = hasOverlay ? 'text-white' : 'text-fd-navy'
const textColorBody = hasOverlay ? 'text-white/90' : 'text-fd-navy/80'
// Min height
const heightMap: Record<string, string> = {
screen: 'min-h-screen',
large: 'min-h-[80vh]',
medium: 'min-h-[60vh]',
}
const heightClass = heightMap[minHeight] || 'min-h-screen'
// Text alignment
const alignClass = textAlign === 'center' ? 'text-center items-center' : 'text-left items-start'
return (
<section
id={anchorId || undefined}
className={`relative w-full ${heightClass} flex items-end overflow-hidden`}
>
{/* Video / Poster background */}
{showVideo ? (
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
poster={posterUrl || undefined}
className="absolute inset-0 w-full h-full object-cover"
aria-hidden="true"
>
{webmUrl && <source src={webmUrl} type="video/webm" />}
<source src={mp4Url} type="video/mp4" />
</video>
) : posterUrl ? (
<img
src={posterUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover"
aria-hidden="true"
/>
) : null}
{/* Overlay */}
{hasOverlay && (
<div
className={`absolute inset-0 ${overlayClass}`}
aria-hidden="true"
/>
)}
{/* Content */}
{(hasText || ctaText || secondaryCtaText) && (
<div className="relative z-10 w-full pb-16 md:pb-20 lg:pb-[99px] pt-32 md:pt-40">
<div className={`max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col ${alignClass}`}>
{/* Text container — constrain width */}
<div className={`max-w-[640px] ${textAlign === 'center' ? 'mx-auto' : ''}`}>
{heading && (
<h1
className={`fd-h1 font-joey-heavy ${textColorHeading} mb-4 md:mb-6`}
>
{heading}
</h1>
)}
{body && (
<p
className={`fd-body-lg font-joey ${textColorBody} mb-8 md:mb-10 whitespace-pre-line`}
>
{body}
</p>
)}
</div>
{/* CTAs */}
{(ctaText || secondaryCtaText) && (
<div className={`flex flex-wrap gap-4 ${textAlign === 'center' ? 'justify-center' : ''}`}>
{ctaText && ctaLink && (
<FDButton href={ctaLink} onDark={hasOverlay}>
{ctaText}
</FDButton>
)}
{secondaryCtaText && secondaryCtaLink && (
<FDButton href={secondaryCtaLink} variant="outline" onDark={hasOverlay}>
{secondaryCtaText}
</FDButton>
)}
</div>
)}
</div>
</div>
)}
</section>
)
}

View File

@ -0,0 +1,157 @@
import type { Block } from 'payload'
import { anchorField } from '@/fields/anchorField'
export const FDVideoHeroBlock: Block = {
slug: 'fdVideoHero',
interfaceName: 'FDVideoHeroBlock',
imageURL: '/block-thumbnails/fd-video-hero.svg',
imageAltText: 'FD Video Hero',
labels: {
singular: 'FD Video Hero',
plural: 'FD Video Heroes',
},
fields: [
// --- Video sources ---
{
name: 'videoMp4',
type: 'upload',
relationTo: 'media',
required: true,
label: 'Video (MP4)',
admin: {
description: 'Huvudvideo — MP4 (H.264). Fungerar i alla webbläsare. Håll filstorleken under 5 MB för bästa prestanda.',
},
},
{
name: 'videoWebm',
type: 'upload',
relationTo: 'media',
label: 'Video (WebM) — valfri',
admin: {
description: 'Progressiv förbättring — WebM är mindre och bättre kvalitet. Webbläsare som stödjer WebM väljer denna automatiskt.',
},
},
{
name: 'posterImage',
type: 'upload',
relationTo: 'media',
label: 'Poster / fallback-bild',
admin: {
description: 'Visas medan videon laddas och som fallback på mobil om video hoppas över.',
},
},
// --- Text content (all optional) ---
{
name: 'heading',
type: 'text',
localized: true,
label: 'Rubrik (valfri)',
admin: {
description: 'H1-rubrik som visas ovanpå videon.',
},
},
{
name: 'body',
type: 'textarea',
localized: true,
label: 'Brödtext (valfri)',
admin: {
description: 'Kort text under rubriken.',
},
},
// --- CTA buttons (optional) ---
{
name: 'ctaText',
type: 'text',
localized: true,
label: 'Primär CTA-text',
admin: {
description: 'T.ex. "Kom igång"',
},
},
{
name: 'ctaLink',
type: 'text',
label: 'Primär CTA-länk',
},
{
name: 'secondaryCtaText',
type: 'text',
localized: true,
label: 'Sekundär CTA-text',
},
{
name: 'secondaryCtaLink',
type: 'text',
label: 'Sekundär CTA-länk',
},
// --- Overlay ---
{
name: 'overlayColor',
type: 'select',
label: 'Overlay-färg',
defaultValue: 'none',
options: [
{ label: 'Ingen', value: 'none' },
{ label: 'Svart', value: 'black' },
{ label: 'Navy', value: 'navy' },
],
},
{
name: 'overlayStrength',
type: 'select',
label: 'Overlay-styrka',
defaultValue: 'medium',
admin: {
condition: (_, siblingData) => siblingData?.overlayColor !== 'none',
description: 'Hur stark overlay ovanpå videon (för läsbarhet).',
},
options: [
{ label: 'Lätt (30%)', value: 'light' },
{ label: 'Medium (50%)', value: 'medium' },
{ label: 'Stark (70%)', value: 'heavy' },
],
},
// --- Layout options ---
{
name: 'textAlign',
type: 'select',
label: 'Textposition',
defaultValue: 'left',
options: [
{ label: 'Vänster', value: 'left' },
{ label: 'Center', value: 'center' },
],
},
{
name: 'minHeight',
type: 'select',
label: 'Minimihöjd',
defaultValue: 'screen',
admin: {
description: 'Hur hög video-sektionen ska vara.',
},
options: [
{ label: 'Helskärm (100vh)', value: 'screen' },
{ label: 'Stor (80vh)', value: 'large' },
{ label: 'Medium (60vh)', value: 'medium' },
],
},
{
name: 'disableVideoOnMobile',
type: 'checkbox',
label: 'Visa bara poster-bild på mobil',
defaultValue: false,
admin: {
description: 'Hoppa över video på små skärmar för att spara bandbredd. Poster-bilden visas istället.',
},
},
// --- Always last ---
anchorField,
],
}

View File

@ -38,6 +38,8 @@ import { FDServiceCalculatorBlockComponent } from '@/blocks/FDServiceCalculatorB
import { FDLinkCardsBlockComponent } from '@/blocks/FDLinkCardsBlock/Component'
import { FDSpecCardsBlockComponent } from '@/blocks/FDSpecCardsBlock/Component'
import { FDQuizBlockComponent } from '@/blocks/FDQuizBlock/Component'
import { FDVideoHeroBlockComponent } from '@/blocks/FDVideoHeroBlock/Component'
const blockComponents: Record<string, React.FC<any>> = {
formBlock: FormBlock,
@ -76,6 +78,7 @@ const blockComponents: Record<string, React.FC<any>> = {
fdLinkCards: FDLinkCardsBlockComponent,
fdSpecCards: FDSpecCardsBlockComponent,
fdQuiz: FDQuizBlockComponent,
fdVideoHero: FDVideoHeroBlockComponent,
}
/**

View File

@ -41,6 +41,8 @@ import { FDServiceCalculatorBlock } from '../../blocks/FDServiceCalculatorBlock/
import { FDLinkCardsBlock } from '../../blocks/FDLinkCardsBlock/config'
import { FDSpecCardsBlock } from '../../blocks/FDSpecCardsBlock/config'
import { FDQuizBlock } from '../../blocks/FDQuizBlock/config'
import { FDVideoHeroBlock } from '../../blocks/FDVideoHeroBlock/config'
import {
MetaDescriptionField,
@ -140,6 +142,7 @@ export const Pages: CollectionConfig<'pages'> = {
FDLinkCardsBlock,
FDSpecCardsBlock,
FDQuizBlock,
FDVideoHeroBlock,
],
required: true,
admin: {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_pages_blocks_fd_video_hero_overlay_color" AS ENUM('none', 'black', 'navy');
CREATE TYPE "public"."enum_pages_blocks_fd_video_hero_overlay_strength" AS ENUM('light', 'medium', 'heavy');
CREATE TYPE "public"."enum_pages_blocks_fd_video_hero_text_align" AS ENUM('left', 'center');
CREATE TYPE "public"."enum_pages_blocks_fd_video_hero_min_height" AS ENUM('screen', 'large', 'medium');
CREATE TYPE "public"."enum__pages_v_blocks_fd_video_hero_overlay_color" AS ENUM('none', 'black', 'navy');
CREATE TYPE "public"."enum__pages_v_blocks_fd_video_hero_overlay_strength" AS ENUM('light', 'medium', 'heavy');
CREATE TYPE "public"."enum__pages_v_blocks_fd_video_hero_text_align" AS ENUM('left', 'center');
CREATE TYPE "public"."enum__pages_v_blocks_fd_video_hero_min_height" AS ENUM('screen', 'large', 'medium');
CREATE TABLE "pages_blocks_fd_video_hero" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"video_mp4_id" integer,
"video_webm_id" integer,
"poster_image_id" integer,
"cta_link" varchar,
"secondary_cta_link" varchar,
"overlay_color" "enum_pages_blocks_fd_video_hero_overlay_color" DEFAULT 'none',
"overlay_strength" "enum_pages_blocks_fd_video_hero_overlay_strength" DEFAULT 'medium',
"text_align" "enum_pages_blocks_fd_video_hero_text_align" DEFAULT 'left',
"min_height" "enum_pages_blocks_fd_video_hero_min_height" DEFAULT 'screen',
"disable_video_on_mobile" boolean DEFAULT false,
"anchor_id" varchar,
"block_name" varchar
);
CREATE TABLE "pages_blocks_fd_video_hero_locales" (
"heading" varchar,
"body" varchar,
"cta_text" varchar,
"secondary_cta_text" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "_pages_v_blocks_fd_video_hero" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"video_mp4_id" integer,
"video_webm_id" integer,
"poster_image_id" integer,
"cta_link" varchar,
"secondary_cta_link" varchar,
"overlay_color" "enum__pages_v_blocks_fd_video_hero_overlay_color" DEFAULT 'none',
"overlay_strength" "enum__pages_v_blocks_fd_video_hero_overlay_strength" DEFAULT 'medium',
"text_align" "enum__pages_v_blocks_fd_video_hero_text_align" DEFAULT 'left',
"min_height" "enum__pages_v_blocks_fd_video_hero_min_height" DEFAULT 'screen',
"disable_video_on_mobile" boolean DEFAULT false,
"anchor_id" varchar,
"_uuid" varchar,
"block_name" varchar
);
CREATE TABLE "_pages_v_blocks_fd_video_hero_locales" (
"heading" varchar,
"body" varchar,
"cta_text" varchar,
"secondary_cta_text" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
ALTER TABLE "pages_blocks_fd_video_hero" ADD CONSTRAINT "pages_blocks_fd_video_hero_video_mp4_id_media_id_fk" FOREIGN KEY ("video_mp4_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_video_hero" ADD CONSTRAINT "pages_blocks_fd_video_hero_video_webm_id_media_id_fk" FOREIGN KEY ("video_webm_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_video_hero" ADD CONSTRAINT "pages_blocks_fd_video_hero_poster_image_id_media_id_fk" FOREIGN KEY ("poster_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_video_hero" ADD CONSTRAINT "pages_blocks_fd_video_hero_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pages_blocks_fd_video_hero_locales" ADD CONSTRAINT "pages_blocks_fd_video_hero_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."pages_blocks_fd_video_hero"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_video_mp4_id_media_id_fk" FOREIGN KEY ("video_mp4_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_video_webm_id_media_id_fk" FOREIGN KEY ("video_webm_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_poster_image_id_media_id_fk" FOREIGN KEY ("poster_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_pages_v_blocks_fd_video_hero_locales" ADD CONSTRAINT "_pages_v_blocks_fd_video_hero_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v_blocks_fd_video_hero"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "pages_blocks_fd_video_hero_order_idx" ON "pages_blocks_fd_video_hero" USING btree ("_order");
CREATE INDEX "pages_blocks_fd_video_hero_parent_id_idx" ON "pages_blocks_fd_video_hero" USING btree ("_parent_id");
CREATE INDEX "pages_blocks_fd_video_hero_path_idx" ON "pages_blocks_fd_video_hero" USING btree ("_path");
CREATE INDEX "pages_blocks_fd_video_hero_video_mp4_idx" ON "pages_blocks_fd_video_hero" USING btree ("video_mp4_id");
CREATE INDEX "pages_blocks_fd_video_hero_video_webm_idx" ON "pages_blocks_fd_video_hero" USING btree ("video_webm_id");
CREATE INDEX "pages_blocks_fd_video_hero_poster_image_idx" ON "pages_blocks_fd_video_hero" USING btree ("poster_image_id");
CREATE UNIQUE INDEX "pages_blocks_fd_video_hero_locales_locale_parent_id_unique" ON "pages_blocks_fd_video_hero_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "_pages_v_blocks_fd_video_hero_order_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("_order");
CREATE INDEX "_pages_v_blocks_fd_video_hero_parent_id_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("_parent_id");
CREATE INDEX "_pages_v_blocks_fd_video_hero_path_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("_path");
CREATE INDEX "_pages_v_blocks_fd_video_hero_video_mp4_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("video_mp4_id");
CREATE INDEX "_pages_v_blocks_fd_video_hero_video_webm_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("video_webm_id");
CREATE INDEX "_pages_v_blocks_fd_video_hero_poster_image_idx" ON "_pages_v_blocks_fd_video_hero" USING btree ("poster_image_id");
CREATE UNIQUE INDEX "_pages_v_blocks_fd_video_hero_locales_locale_parent_id_uniqu" ON "_pages_v_blocks_fd_video_hero_locales" USING btree ("_locale","_parent_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "pages_blocks_fd_video_hero" CASCADE;
DROP TABLE "pages_blocks_fd_video_hero_locales" CASCADE;
DROP TABLE "_pages_v_blocks_fd_video_hero" CASCADE;
DROP TABLE "_pages_v_blocks_fd_video_hero_locales" CASCADE;
DROP TYPE "public"."enum_pages_blocks_fd_video_hero_overlay_color";
DROP TYPE "public"."enum_pages_blocks_fd_video_hero_overlay_strength";
DROP TYPE "public"."enum_pages_blocks_fd_video_hero_text_align";
DROP TYPE "public"."enum_pages_blocks_fd_video_hero_min_height";
DROP TYPE "public"."enum__pages_v_blocks_fd_video_hero_overlay_color";
DROP TYPE "public"."enum__pages_v_blocks_fd_video_hero_overlay_strength";
DROP TYPE "public"."enum__pages_v_blocks_fd_video_hero_text_align";
DROP TYPE "public"."enum__pages_v_blocks_fd_video_hero_min_height";`)
}

View File

@ -3,6 +3,7 @@ import * as migration_20260224_133833 from './20260224_133833';
import * as migration_20260226_095439 from './20260226_095439';
import * as migration_20260302_145030 from './20260302_145030';
import * as migration_20260304_194657 from './20260304_194657';
import * as migration_20260304_203759 from './20260304_203759';
export const migrations = [
{
@ -28,6 +29,11 @@ export const migrations = [
{
up: migration_20260304_194657.up,
down: migration_20260304_194657.down,
name: '20260304_194657'
name: '20260304_194657',
},
{
up: migration_20260304_203759.up,
down: migration_20260304_203759.down,
name: '20260304_203759'
},
];

View File

@ -191,6 +191,7 @@ export interface Page {
| FDLinkCardsBlock
| FDSpecCardsBlock
| FDQuizBlock
| FDVideoHeroBlock
)[];
meta?: {
title?: string | null;
@ -1619,6 +1620,60 @@ export interface FDQuizBlock {
blockName?: string | null;
blockType: 'fdQuiz';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDVideoHeroBlock".
*/
export interface FDVideoHeroBlock {
/**
* Huvudvideo MP4 (H.264). Fungerar i alla webbläsare. Håll filstorleken under 5 MB för bästa prestanda.
*/
videoMp4: number | Media;
/**
* Progressiv förbättring WebM är mindre och bättre kvalitet. Webbläsare som stödjer WebM väljer denna automatiskt.
*/
videoWebm?: (number | null) | Media;
/**
* Visas medan videon laddas och som fallback mobil om video hoppas över.
*/
posterImage?: (number | null) | Media;
/**
* H1-rubrik som visas ovanpå videon.
*/
heading?: string | null;
/**
* Kort text under rubriken.
*/
body?: string | null;
/**
* T.ex. "Kom igång"
*/
ctaText?: string | null;
ctaLink?: string | null;
secondaryCtaText?: string | null;
secondaryCtaLink?: string | null;
overlayColor?: ('none' | 'black' | 'navy') | null;
/**
* Hur stark overlay ovanpå videon (för läsbarhet).
*/
overlayStrength?: ('light' | 'medium' | 'heavy') | null;
textAlign?: ('left' | 'center') | null;
/**
* Hur hög video-sektionen ska vara.
*/
minHeight?: ('screen' | 'large' | 'medium') | null;
/**
* Hoppa över video små skärmar för att spara bandbredd. Poster-bilden visas istället.
*/
disableVideoOnMobile?: boolean | null;
/**
* Valfritt. Används för att länka direkt till denna sektion, t.ex. "priser" ger /sida#priser. Använd bara små bokstäver, siffror och bindestreck.
*/
anchorId?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'fdVideoHero';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
@ -2002,6 +2057,7 @@ export interface PagesSelect<T extends boolean = true> {
fdLinkCards?: T | FDLinkCardsBlockSelect<T>;
fdSpecCards?: T | FDSpecCardsBlockSelect<T>;
fdQuiz?: T | FDQuizBlockSelect<T>;
fdVideoHero?: T | FDVideoHeroBlockSelect<T>;
};
meta?:
| T
@ -2820,6 +2876,29 @@ export interface FDQuizBlockSelect<T extends boolean = true> {
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FDVideoHeroBlock_select".
*/
export interface FDVideoHeroBlockSelect<T extends boolean = true> {
videoMp4?: T;
videoWebm?: T;
posterImage?: T;
heading?: T;
body?: T;
ctaText?: T;
ctaLink?: T;
secondaryCtaText?: T;
secondaryCtaLink?: T;
overlayColor?: T;
overlayStrength?: T;
textAlign?: T;
minHeight?: T;
disableVideoOnMobile?: T;
anchorId?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".