Files
website/apps/website/components/HeroSection.vue
Matt ac7651811c
All checks were successful
build-website / build (push) Successful in 1m42s
fix: iOS Safari safe area - remove HTML height constraint and add body position fix
- Remove height: 100% from HTML element that was preventing safe area extension
- Add min-height with -webkit-fill-available instead
- Detect iOS devices and set body position:fixed for proper safe area handling
- iOS Safari requires body to be position:fixed for content to extend into safe areas

This addresses the root cause: parent element constraints preventing video extension
2025-09-25 17:00:42 +02:00

371 lines
10 KiB
Vue

<template>
<section class="hero-voyage" id="heroSection">
<!-- Video is now injected directly into DOM to bypass Vue/Nuxt issues -->
<div class="hero-content" :class="{ 'is-ready': videoLoaded }">
<div class="hero-main">
<div class="hero-logo animate-fade-in">
<img src="/HARBOR-SMITH-white.png" alt="Harbor Smith" width="400" height="250">
</div>
<p class="hero-subtext animate-fade-up-delay">
<span style="font-size: 1.5rem; font-weight: 500; text-transform: none; letter-spacing: normal; margin-bottom: 10px; display: block;">
Personalized Service Maintenance for Your Boat
</span>
Reliable Care Above and Below the Waterline. Servicing the Bay Area and Beyond!
</p>
<div class="hero-actions animate-fade-up-delay-2">
<button class="btn-primary-warm" @click="handlePhoneClick">
<Icon name="lucide:phone" class="btn-icon" />
Call (510) 701-2535
</button>
<button class="btn-secondary-warm" @click="handleServicesClick">
<Icon name="lucide:wrench" class="btn-icon" />
View Our Services
</button>
</div>
</div>
<!-- Scroll to explore indicator -->
<div class="scroll-to-explore" @click="handleScrollToExplore">
<span>Scroll to explore</span>
<Icon name="lucide:chevron-down" class="chevron-icon" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useParallax } from '~/composables/useParallax'
import { useIntersectionAnimations } from '~/composables/useIntersectionAnimations'
import { useRipple } from '~/composables/useRipple'
import { useSmoothScroll } from '~/composables/useSmoothScroll'
const { scrollToElement } = useSmoothScroll()
const videoLoaded = ref(false)
const videoContainer = ref<HTMLElement | null>(null)
const videoElement = ref<HTMLVideoElement | null>(null)
const animatedCount = ref(0)
// Parallax will be disabled for injected video
// useParallax(videoContainer, 0.5)
useIntersectionAnimations()
useRipple()
const handleVideoLoaded = () => {
videoLoaded.value = true
}
const handlePhoneClick = () => {
window.location.href = 'tel:510-701-2535'
}
const handleServicesClick = () => {
scrollToElement('#services', 800) // Constant-speed smooth scrolling
}
const handleScrollToExplore = () => {
scrollToElement('#services', 800) // Constant-speed scroll to services
}
// Video visibility handling removed - video is now injected directly into DOM
const initSmoothScroll = () => {
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (event) => {
event.preventDefault()
const href = anchor.getAttribute('href')
if (!href) {
return
}
scrollToElement(href, 800) // Constant-speed smooth scrolling
})
})
}
const animateCount = () => {
const duration = 1500 // 1.5 seconds
const targetCount = 100
const startTime = Date.now()
const updateCount = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Easing function for smooth animation
const easeOutQuart = 1 - Math.pow(1 - progress, 4)
animatedCount.value = Math.floor(easeOutQuart * targetCount)
if (progress < 1) {
requestAnimationFrame(updateCount)
} else {
animatedCount.value = targetCount
}
}
// Start animation after a small delay for better effect
setTimeout(() => {
updateCount()
}, 500)
}
onMounted(() => {
// FINAL FIX: iOS detection and body positioning for safe area extension
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
if (isIOS) {
// iOS Safari requires body to be position:fixed for safe area extension
document.body.style.position = 'fixed';
document.body.style.width = '100%';
document.body.style.top = '0';
document.body.style.left = '0';
}
// Inject video with proper positioning
const videoHTML = `
<style>
#ios-video-bg {
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
height: -webkit-fill-available !important;
z-index: -1 !important;
background: #000 !important;
}
@supports (-webkit-touch-callout: none) {
#ios-video-bg {
min-height: -webkit-fill-available !important;
}
}
#bg-video-element {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
object-position: center !important;
}
</style>
<div id="ios-video-bg">
<video
id="bg-video-element"
autoplay
muted
playsinline
loop>
<source src="https://videos.pexels.com/video-files/3571264/3571264-uhd_2560_1440_30fps.mp4" type="video/mp4">
</video>
<div style="
position: absolute;
inset: 0;
background: linear-gradient(to bottom,
rgba(0, 31, 63, 0.3) 0%,
rgba(0, 31, 63, 0.5) 50%,
rgba(0, 31, 63, 0.7) 100%);
"></div>
</div>
`;
// Insert at the very beginning of body, outside #__nuxt
document.body.insertAdjacentHTML('afterbegin', videoHTML);
// Get reference to injected video
const injectedVideo = document.getElementById('bg-video-element') as HTMLVideoElement;
// Handle video load state
if (injectedVideo) {
injectedVideo.addEventListener('loadeddata', () => {
videoLoaded.value = true;
});
// iOS autoplay fallback
const tryPlay = () => {
if (injectedVideo.paused) {
injectedVideo.play().catch(() => {});
}
};
window.addEventListener('pointerdown', tryPlay, { once: true });
window.addEventListener('touchstart', tryPlay, { once: true });
}
initSmoothScroll()
animateCount()
// Fallback: ensure video becomes visible after a delay
setTimeout(() => {
if (!videoLoaded.value) {
videoLoaded.value = true
}
}, 1500)
})
onUnmounted(() => {
// Clean up injected video on component unmount
document.getElementById('ios-video-bg')?.remove();
})
</script>
<style scoped>
/* Hero video container - COMPLETE safe area formula (Apple/Netflix pattern) */
.hero-video-container {
position: fixed; /* Fixed with complete formula works better */
/* Extend into ALL safe areas */
top: calc(0px - env(safe-area-inset-top, 0px));
left: calc(0px - env(safe-area-inset-left, 0px));
/* CRITICAL: Include ALL safe area insets in width and height */
width: calc(100vw + env(safe-area-inset-left, 0px) + env(safe-area-inset-right, 0px));
height: calc(100vh + env(safe-area-inset-top, 0px) + env(safe-area-inset-bottom, 0px));
z-index: 0; /* Base layer for video */
pointer-events: none;
overflow: hidden; /* Keep video content clipped */
/* Apply parallax transform using CSS variable - delayed to prevent autoplay issues */
transform: translateY(var(--parallax-offset, 0px));
will-change: transform; /* Optimization hint */
/* Initial state for autoplay compatibility */
transition: transform 0.1s ease-out;
}
/* iOS Safari safe area extension - removed to prevent interference with complete formula */
/* The main .hero-video-container rule now handles all safe areas properly */
/* Support for dynamic viewport height - simplified */
@supports (height: 100dvh) {
.hero-video-container {
/* Update height calc to include ALL safe areas for dvh */
height: calc(100dvh + env(safe-area-inset-top, 0px) + env(safe-area-inset-bottom, 0px));
}
}
/* Video fade-in transition to prevent flash */
.hero-video {
opacity: 0;
transition: opacity 1s ease-in-out;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
/* Force GPU acceleration */
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
.hero-video.video-loaded {
opacity: 1;
}
/* White overlay for smooth video loading transition */
.video-white-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%);
z-index: 2;
opacity: 1;
transition: opacity 800ms ease-out;
will-change: opacity;
}
.video-white-overlay.fade-out {
opacity: 0;
pointer-events: none;
}
/* Container for hero section */
.hero-voyage {
position: static; /* Remove containing block to allow video to escape */
min-height: 100vh;
/* Allow video to extend into safe area */
overflow: visible;
isolation: isolate; /* Create new stacking context */
}
.hero-content {
position: fixed; /* Use fixed to match video container positioning */
top: 0; /* Start at viewport top */
left: 0;
right: 0;
width: 100%;
height: 100vh;
z-index: 10; /* Ensure content is above video */
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top, 0px); /* Safe area padding */
}
.hero-main {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
width: min(720px, 100%);
margin: 0 auto;
}
/* Scroll to explore indicator */
.scroll-to-explore {
margin-top: auto;
margin-bottom: calc(var(--safe-area-cover-bottom, 0px) + clamp(0.75rem, 2vh, 1.75rem));
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: white;
opacity: 0.8;
transition: all 0.3s ease;
animation: bounce 2s infinite;
justify-self: center;
}
.scroll-to-explore:hover {
opacity: 1;
transform: translateY(-3px);
}
.scroll-to-explore span {
font-size: 0.875rem;
font-weight: 300;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.chevron-icon {
width: 24px;
height: 24px;
animation: chevronBounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
@keyframes chevronBounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(5px);
}
}
/* Hide on mobile for better UX */
@media (max-width: 768px) {
.scroll-to-explore {
display: none;
}
}
</style>