Skip to content
Draft
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
3 changes: 3 additions & 0 deletions components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ export function Navbar() {
alt='Vocdoni App'
className='aspect-video w-full object-cover'
loading='lazy'
decoding='async'
width={640}
height={360}
/>
{/* Gradient overlay */}
<span className='absolute inset-0 bg-gradient-to-t from-black/60 to-transparent' />
Expand Down
3 changes: 3 additions & 0 deletions components/TrustedBySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export default function TrustedBySection() {
src={logo.url}
alt={logo.name}
className='h-10 w-auto object-contain opacity-70 grayscale transition-all duration-300 hover:grayscale-0 hover:opacity-100 dark:invert'
width={160}
height={40}
decoding='async'
/>
))}
</div>
Expand Down
50 changes: 14 additions & 36 deletions components/app/AppHeroWithVideo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ArrowRight, PlayCircleIcon, ScaleIcon, ShieldCheckIcon } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

import CleanYoutubePlayer from '@/components/app/CleanYoutubePlayer'
Expand All @@ -13,7 +12,6 @@ const THUMBNAIL_URL = `https://img.youtube.com/vi/${VIDEO_ID}/maxresdefault.jpg`

export default function AppHeroWithVideo() {
const { t } = useTranslation()
const [playing, setPlaying] = useState(false)

return (
<section className='relative w-full pt-6 pb-16 sm:pt-10 sm:pb-20 lg:pt-12 lg:pb-24'>
Expand Down Expand Up @@ -60,9 +58,11 @@ export default function AppHeroWithVideo() {
<ArrowRight className='h-5 w-5 transition-transform duration-200 group-hover:translate-x-0.5' />
</Link>
</Button>
<Button variant='outline' size='lg' className='w-full sm:w-auto' onClick={() => setPlaying(true)}>
<PlayCircleIcon />
{t('vocdoni_app.app_hero.cta_secondary', 'Watch the Demo')}
<Button variant='outline' size='lg' className='w-full sm:w-auto' asChild>
<a href='#app-demo-video'>
<PlayCircleIcon />
{t('vocdoni_app.app_hero.cta_secondary', 'Watch the Demo')}
</a>
</Button>
</MotionPreset>

Expand Down Expand Up @@ -100,37 +100,15 @@ export default function AppHeroWithVideo() {
transition={{ duration: 0.6 }}
className='relative w-full mt-6 lg:mt-0'
>
<div className='aspect-video w-full sm:w-11/12 lg:w-full mx-auto rounded-2xl overflow-hidden shadow-xl hover:shadow-2xl transition-all duration-500 border border-border/50 relative group bg-muted'>
{playing ? (
<div className='absolute inset-0 w-full h-full plyr-clean'>
<CleanYoutubePlayer
videoId={VIDEO_ID}
title={t('vocdoni_app.app_hero.cta_secondary', 'Watch the Demo')}
coverUrl={THUMBNAIL_URL}
coverAlt={t('vocdoni_app.app_hero.cta_secondary', 'Watch the Demo')}
/>
</div>
) : (
<button
onClick={() => setPlaying(true)}
className='absolute inset-0 w-full h-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary'
aria-label={t('vocdoni_app.app_hero.video_play', 'Play demo video')}
>
<img
src={THUMBNAIL_URL}
alt={t('vocdoni_app.app_hero.cta_secondary', 'Watch the Demo')}
className='w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 ease-out'
/>
<div className='absolute inset-0 bg-black/10 group-hover:bg-black/20 transition-colors duration-300' />
<span className='absolute inset-0 flex items-center justify-center'>
<span className='w-20 h-20 rounded-full bg-background/90 text-foreground flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300'>
<svg className='w-8 h-8 ml-1' fill='currentColor' viewBox='0 0 24 24'>
<path d='M8 5v14l11-7z' />
</svg>
</span>
</span>
</button>
)}
<div
id='app-demo-video'
className='aspect-video w-full sm:w-11/12 lg:w-full mx-auto rounded-2xl overflow-hidden shadow-xl hover:shadow-2xl transition-all duration-500 border border-border/50 relative bg-muted'
>
<CleanYoutubePlayer
videoId={VIDEO_ID}
title={t('vocdoni_app.app_hero.cta_secondary', 'Watch the Demo')}
coverUrl={THUMBNAIL_URL}
/>
</div>
</MotionPreset>
</div>
Expand Down
128 changes: 15 additions & 113 deletions components/app/CleanYoutubePlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,125 +1,27 @@
import type { APITypes } from 'plyr-react'
import { lazy, Suspense, useEffect, useRef, useState } from 'react'

// plyr accesses `document` at import time, so it must only load client-side.
const LazyPlyr = lazy(async () => {
await import('plyr/dist/plyr.css')
const mod = await import('plyr-react')
return { default: mod.Plyr }
})
import { YoutubeFacade } from '@/components/ui/youtube-facade'

type Props = {
videoId: string
title?: string
/** Optional cover image shown until playback has ramped up to HD, masking YouTube's low-quality ABR cold-start. */
/** Optional poster image URL. Falls back to the YouTube maxresdefault thumbnail. */
coverUrl?: string
/** Unused — kept for backward-compat; the facade derives its own alt from title */
coverAlt?: string
}

// How long to keep the cover up at most, even if no qualitychange/playing event arrives.
const MAX_COVER_MS = 2500
// Quality (in vertical pixels) considered "good enough" to reveal the player.
const MIN_REVEAL_QUALITY = 720

export default function CleanYoutubePlayer({ videoId, title, coverUrl, coverAlt }: Props) {
const [mounted, setMounted] = useState(false)
const [revealed, setRevealed] = useState(!coverUrl)
const ref = useRef<APITypes>(null)

useEffect(() => {
setMounted(true)
}, [])

useEffect(() => {
if (!mounted || !coverUrl) return

let fallback: ReturnType<typeof setTimeout> | null = null
let cancelled = false

// Poll briefly for the Plyr instance to become available (lazy import + mount).
const attach = () => {
const plyr = ref.current?.plyr
// The instance exists but has no `.on` until ready; keep polling.
if (!plyr || typeof (plyr as unknown as { on?: unknown }).on !== 'function') {
if (!cancelled) setTimeout(attach, 50)
return
}

// Force playback — the user already triggered a gesture by clicking the cover.
plyr.play()

const reveal = () => {
if (cancelled) return
setRevealed(true)
}

// Reveal once quality has ramped up past our threshold…
plyr.on('qualitychange', (event: unknown) => {
const detail = (event as { detail?: { quality?: number } }).detail
const q = detail?.quality ?? 0
if (q >= MIN_REVEAL_QUALITY) reveal()
})

// …or, worst case, once playback has actually started.
plyr.on('playing', reveal)

// Hard fallback so the user is never stuck looking at the cover.
fallback = setTimeout(reveal, MAX_COVER_MS)
}

attach()

return () => {
cancelled = true
if (fallback) clearTimeout(fallback)
}
}, [mounted, coverUrl])

if (!mounted) return null
/**
* A clean YouTube embed that defers iframe creation until user interaction.
* Wraps YoutubeFacade with sensible defaults for poster image derivation.
*/
export default function CleanYoutubePlayer({ videoId, title, coverUrl }: Props) {
const posterUrl = coverUrl || `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`

return (
<div className='absolute inset-0 w-full h-full'>
<Suspense fallback={null}>
<LazyPlyr
ref={ref}
source={{
type: 'video',
title,
sources: [{ src: videoId, provider: 'youtube' }],
}}
options={{
autoplay: true,
controls: ['play-large', 'play', 'progress', 'current-time', 'captions', 'settings', 'fullscreen'],
settings: ['quality', 'captions', 'speed'],
captions: { active: true, update: true },
quality: { default: 1080, options: [1080, 720, 576, 480, 360] },
youtube: {
noCookie: true,
rel: 0,
modestbranding: 1,
iv_load_policy: 3,
playsinline: 1,
autoplay: 1,
vq: 'hd1080',
hd: 1,
},
}}
/>
</Suspense>

{coverUrl && (
<div
aria-hidden='true'
className={`pointer-events-none absolute inset-0 transition-opacity duration-500 ${
revealed ? 'opacity-0' : 'opacity-100'
}`}
>
<img src={coverUrl} alt={coverAlt ?? ''} className='w-full h-full object-cover' loading='lazy' />
<div className='absolute inset-0 flex items-center justify-center bg-black/30'>
<div className='h-10 w-10 rounded-full border-2 border-white/40 border-t-white animate-spin' />
</div>
</div>
)}
</div>
<YoutubeFacade
videoId={videoId}
posterUrl={posterUrl}
title={title || 'Video'}
className='absolute inset-0 rounded-none'
/>
)
}
3 changes: 3 additions & 0 deletions components/app/SocialProof.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export default function SocialProof() {
alt={logo.alt}
className='h-9 w-auto object-contain opacity-85'
loading='lazy'
decoding='async'
width={144}
height={36}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ const AboutUs = ({ aboutUsData }: { aboutUsData: AboutUsData }) => {
src={teamGathering}
alt='The Vocdoni team'
className='w-full h-full object-cover object-center transition-transform duration-1000 hover:scale-105'
width={1400}
height={1205}
loading='lazy'
decoding='async'
/>
{/* Subtle gradient to fade into the page */}
<div className='absolute inset-0 bg-gradient-to-t from-background/30 via-transparent to-transparent pointer-events-none' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const AboutUs = ({
src={aboutVocdoniImage}
alt={t('about_us.image_alt')}
className='h-full w-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110'
width={684}
height={696}
loading='lazy'
decoding='async'
/>
<div className='absolute inset-0 bg-gradient-to-t from-background/40 to-transparent' />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,23 @@ const CTASection = () => {
delay={0.6}
transition={{ duration: 0.7 }}
>
<img src='/images/cta/image-6.png' alt={t('cta.image_alt')} className='max-h-173 w-full dark:hidden' />
<img
src='/images/cta/image-6.png'
alt={t('cta.image_alt')}
className='max-h-173 w-full dark:hidden'
width={906}
height={1159}
loading='lazy'
decoding='async'
/>
<img
src='/images/cta/image-6-dark.png'
alt={t('cta.image_alt')}
className='hidden max-h-173 w-full dark:inline-block'
width={906}
height={1159}
loading='lazy'
decoding='async'
/>
</MotionPreset>
</CardContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const AccordionWithImage = () => {
),
image: memberbaseImg,
imageAlt: 'Upload member base',
imageWidth: 1400,
imageHeight: 924,
},
{
id: 'create',
Expand All @@ -33,6 +35,8 @@ const AccordionWithImage = () => {
),
image: createVoteImg,
imageAlt: 'Create voting process',
imageWidth: 1400,
imageHeight: 893,
},
{
id: 'share',
Expand All @@ -44,6 +48,8 @@ const AccordionWithImage = () => {
),
image: publicVoteImg,
imageAlt: 'Share and vote',
imageWidth: 1400,
imageHeight: 862,
},
]
const [activeAccordion, setActiveAccordion] = useState('upload')
Expand Down Expand Up @@ -102,6 +108,9 @@ const AccordionWithImage = () => {
alt={activeFeature.imageAlt}
className='w-full rounded-t-xl object-cover'
loading='lazy'
decoding='async'
width={activeFeature.imageWidth}
height={activeFeature.imageHeight}
/>
</MotionPreset>
</div>
Expand Down
12 changes: 12 additions & 0 deletions components/shadcn-studio/blocks/portfolio-16/portfolio-16.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type PortfolioItem = {
btnColor?: string
imageClassName?: string
imageWrapperClassName?: string
imageWidth?: number
imageHeight?: number
}

type PortfolioProps = {
Expand Down Expand Up @@ -75,6 +77,11 @@ const Portfolio = ({ portfolioItems }: PortfolioProps) => {
src={project.imageUrl}
alt={project.imageAlt}
className={`w-full object-cover transition-transform duration-300 group-hover:scale-105 ${project.imageClassName || ''}`}
{...(project.imageWidth != null && project.imageHeight != null
? { width: project.imageWidth, height: project.imageHeight }
: {})}
loading='lazy'
decoding='async'
/>
</div>
<Button
Expand Down Expand Up @@ -106,6 +113,11 @@ const Portfolio = ({ portfolioItems }: PortfolioProps) => {
src={project.imageUrl}
alt={project.imageAlt}
className={`w-full object-cover transition-transform duration-300 group-hover:scale-105 ${project.imageClassName || ''}`}
{...(project.imageWidth != null && project.imageHeight != null
? { width: project.imageWidth, height: project.imageHeight }
: {})}
loading='lazy'
decoding='async'
/>
</div>
<Button
Expand Down
Loading
Loading