All checks were successful
build-website / build (push) Successful in 1m38s
- Add viewport-fit=cover meta tag in nuxt.config.ts for safe area extension - Remove CSS containment property that was blocking safe area rendering - Replace hardcoded fallback values with proper max(env(), fallback) syntax - Fix z-index stacking order (video: 0, footer: 2) to prevent overlays - Enhance HTML/body background color configuration for safe area coverage - Add ipx dependency for image optimization Fixes white background in iOS Safari safe areas by using proper env() functions with max() for fallbacks instead of hardcoded values that override dynamic sizing.
353 lines
8.9 KiB
Vue
353 lines
8.9 KiB
Vue
<template>
|
|
<section class="hero-voyage" id="heroSection">
|
|
<!-- Hero video background using safe-area-max-inset-top for iOS Safari -->
|
|
<div ref="videoContainer" class="hero-video-container">
|
|
<!-- White overlay that fades out when video is ready -->
|
|
<div
|
|
class="video-white-overlay"
|
|
:class="{ 'fade-out': videoLoaded }"
|
|
/>
|
|
|
|
<video
|
|
ref="videoElement"
|
|
autoplay
|
|
loop
|
|
muted
|
|
playsinline
|
|
preload="auto"
|
|
class="hero-video"
|
|
:class="{ 'video-loaded': videoLoaded }"
|
|
@loadeddata="handleVideoLoaded"
|
|
@canplaythrough="handleVideoLoaded"
|
|
@play="handleVideoLoaded"
|
|
>
|
|
<source
|
|
src="https://videos.pexels.com/video-files/3571264/3571264-uhd_2560_1440_30fps.mp4"
|
|
type="video/mp4"
|
|
>
|
|
</video>
|
|
|
|
<div class="hero-overlay gradient-warm" />
|
|
<div class="hero-overlay gradient-depth" />
|
|
</div>
|
|
|
|
<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 } 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)
|
|
|
|
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
|
|
}
|
|
|
|
const handleVideoVisibility = () => {
|
|
if (!videoElement.value) {
|
|
return
|
|
}
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
videoElement.value?.play()
|
|
} else {
|
|
videoElement.value?.pause()
|
|
}
|
|
})
|
|
}, { threshold: 0.25 })
|
|
|
|
observer.observe(videoElement.value)
|
|
|
|
return () => observer.disconnect()
|
|
}
|
|
|
|
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(() => {
|
|
handleVideoVisibility()
|
|
initSmoothScroll()
|
|
animateCount()
|
|
|
|
// Fallback: ensure video becomes visible after a delay
|
|
setTimeout(() => {
|
|
if (!videoLoaded.value) {
|
|
videoLoaded.value = true
|
|
}
|
|
}, 1500)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Hero video container using standard safe-area-inset-top for iOS Safari */
|
|
.hero-video-container {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
width: 100vw;
|
|
/* Use standard safe-area-inset-top */
|
|
/* Pull up by the safe area to extend under notch */
|
|
top: calc(-1 * env(safe-area-inset-top, 0px));
|
|
/* Extend height to compensate */
|
|
height: calc(100vh + env(safe-area-inset-top, 0px));
|
|
z-index: 0; /* Base layer for video */
|
|
pointer-events: none;
|
|
/* iOS Safari rendering optimizations */
|
|
-webkit-transform: translate3d(0, 0, 0); /* Force GPU layer */
|
|
transform: translateZ(0); /* Hardware acceleration */
|
|
will-change: transform; /* Optimization hint */
|
|
/* Removed contain property to allow extension into safe area */
|
|
}
|
|
|
|
/* iOS Safari safe area extension with proper env() usage */
|
|
@supports (-webkit-touch-callout: none) {
|
|
.hero-video-container {
|
|
/* Use max() to provide fallback when env() returns 0 */
|
|
top: calc(-1 * max(env(safe-area-inset-top), 59px));
|
|
height: calc(100vh + max(env(safe-area-inset-top), 59px));
|
|
}
|
|
|
|
/* Older iPhones with smaller notch */
|
|
@media screen and (max-height: 812px) {
|
|
.hero-video-container {
|
|
top: calc(-1 * max(env(safe-area-inset-top), 44px));
|
|
height: calc(100vh + max(env(safe-area-inset-top), 44px));
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Support for dynamic viewport height */
|
|
@supports (height: 100dvh) {
|
|
.hero-video-container {
|
|
height: calc(100dvh + env(safe-area-inset-top, 0px));
|
|
}
|
|
|
|
/* iOS specific with dvh and proper env() usage */
|
|
@supports (-webkit-touch-callout: none) {
|
|
.hero-video-container {
|
|
height: calc(100dvh + max(env(safe-area-inset-top), 59px)); /* Dynamic Island with env() */
|
|
}
|
|
|
|
@media screen and (max-height: 812px) {
|
|
.hero-video-container {
|
|
height: calc(100dvh + max(env(safe-area-inset-top), 44px)); /* Notch with env() */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 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 center;
|
|
/* 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: relative;
|
|
min-height: 100vh;
|
|
/* Allow video to extend into safe area */
|
|
overflow: visible;
|
|
isolation: isolate; /* Create new stacking context */
|
|
}
|
|
|
|
.hero-content {
|
|
position: relative;
|
|
z-index: 10; /* Ensure content is above video */
|
|
}
|
|
|
|
.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> |