portamador-landing-site/src/app/page.tsx

809 lines
31 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import Image from 'next/image';
import { useMediaQuery } from '@react-hook/media-query';
import { ChevronDown, Phone, Mail } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { PhoneField } from '@/components/forms/PhoneField';
import { toast, Toaster } from 'react-hot-toast';
const formSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email address'),
phone: z.string().min(10, 'Please enter a valid phone number'),
message: z.string().optional(),
});
export default function Home() {
const [mounted, setMounted] = useState(false);
const [contactTop, setContactTop] = useState(0);
const [windowHeight, setWindowHeight] = useState(0);
// Animation values as refs to avoid re-renders
const logoPositionRef = useRef('center');
const buttonRef = useRef<HTMLButtonElement>(null);
const chevronRef = useRef<HTMLDivElement>(null);
const contactSectionRef = useRef<HTMLDivElement>(null);
const logoRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | null>(null);
const lastScrollY = useRef(0);
const lastFrameTime = useRef(0);
const ticking = useRef(false);
// Refs for dynamic image alignment
const headingRef = useRef<HTMLHeadingElement>(null);
const submitButtonRef = useRef<HTMLButtonElement>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery("(max-width: 768px)");
const isDesktop = useMediaQuery("(min-width: 1280px)");
// Manage form state
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
phone: "",
message: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to submit form');
}
toast.success('Thank you! We\'ll be in touch soon.', {
duration: 5000, // 5 seconds
});
form.reset();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Something went wrong. Please try again.', {
duration: 6000, // 6 seconds for errors
});
}
}
// Memoized logo dimensions based on screen size
const logoWidth = useMemo(() =>
isMobile ? 240 : isDesktop ? 316 : 280,
[isMobile, isDesktop]
);
const logoHeight = useMemo(() =>
isMobile ? 115 : isDesktop ? 151 : 134,
[isMobile, isDesktop]
);
// Memoized animation constants
const animationConstants = useMemo(() => ({
mobile: {
targetTopPosition: -50, // Logo ends even higher for better spacing
startYFactor: 0.20, // Moved up to position logo higher on mobile
finalScale: 0.5,
fadeThreshold: 5
},
desktop: {
targetTopPosition: 20,
logoSpeed: 0.4,
maxScroll: 500,
scaleReduction: 0.6,
fadeThreshold: 10
}
}), []);
useEffect(() => {
setMounted(true);
// Set initial dimensions on mount
setWindowHeight(window.innerHeight);
if (contactSectionRef.current) {
setContactTop(contactSectionRef.current.offsetTop);
}
}, []);
// Helper function to update element styles directly
const updateElementStyle = (element: HTMLElement | null, styles: Partial<CSSStyleDeclaration>) => {
if (!element) return;
Object.assign(element.style, styles);
};
// Separated mobile animation logic
const calculateMobileLogoStyles = (
scrollY: number,
windowHeight: number,
contactTop: number,
logoHeight: number
): Partial<CSSStyleDeclaration> => {
const { targetTopPosition, startYFactor, finalScale } = animationConstants.mobile;
const startY = windowHeight * startYFactor;
const endY = targetTopPosition + (logoHeight * finalScale) / 2;
const totalDistance = startY - endY;
const animationEndScroll = contactTop;
if (scrollY >= animationEndScroll) {
logoPositionRef.current = 'top';
const scrollPastEnd = scrollY - animationEndScroll;
const fixedY = endY - scrollPastEnd;
const mobileScale = finalScale;
return {
position: 'fixed',
top: `${fixedY}px`,
left: '50%',
transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1) translateZ(0)`,
transformOrigin: 'center',
willChange: scrollY > 0 ? 'transform' : 'auto',
backfaceVisibility: 'hidden',
transition: 'none',
zIndex: '50'
};
} else if (scrollY > 0) {
logoPositionRef.current = 'animating';
const progress = scrollY / animationEndScroll;
const currentY = startY - (totalDistance * progress);
const mobileScale = 1 - ((1 - finalScale) * progress);
return {
position: 'fixed',
top: `${currentY}px`,
left: '50%',
transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1) translateZ(0)`,
transformOrigin: 'center',
willChange: 'transform',
backfaceVisibility: 'hidden',
transition: 'none',
zIndex: '50'
};
} else {
logoPositionRef.current = 'center';
return {
position: 'fixed',
top: `${startY}px`,
left: '50%',
transform: 'translate3d(-50%, 0, 0) scale3d(1, 1, 1) translateZ(0)',
transformOrigin: 'center',
willChange: 'auto',
backfaceVisibility: 'hidden',
transition: 'none',
zIndex: '50'
};
}
};
// Separated desktop animation logic
const calculateDesktopLogoStyles = (
scrollY: number,
windowHeight: number,
logoHeight: number
): Partial<CSSStyleDeclaration> => {
const { targetTopPosition, logoSpeed, maxScroll, scaleReduction } = animationConstants.desktop;
const centerY = windowHeight / 2;
const totalDistance = centerY - targetTopPosition - logoHeight / 2;
const logoYPosition = -(scrollY * logoSpeed);
const maxUpwardMovement = -totalDistance;
const animatedY = Math.max(logoYPosition, maxUpwardMovement);
const scrollProgress = Math.min(scrollY / maxScroll, 1);
const scale = 1 - (scaleReduction * scrollProgress);
logoPositionRef.current = scrollY > 10 ? 'animating' : 'center';
return {
position: 'fixed',
top: '50%',
left: '50%',
transform: `translate3d(-50%, calc(-50% + ${animatedY}px), 0) scale3d(${scale}, ${scale}, 1) translateZ(0)`,
transformOrigin: 'center',
willChange: scrollY > 0 ? 'transform' : 'auto',
backfaceVisibility: 'hidden',
transition: 'none',
zIndex: '50'
};
};
// Update button and chevron opacity
const updateControlsOpacity = (scrollY: number, isMobile: boolean) => {
const fadeThreshold = isMobile ? animationConstants.mobile.fadeThreshold : animationConstants.desktop.fadeThreshold;
const opacity = scrollY > fadeThreshold ? 0 : 1;
if (buttonRef.current) {
buttonRef.current.style.opacity = String(opacity);
buttonRef.current.style.pointerEvents = opacity > 0 ? 'auto' : 'none';
}
if (chevronRef.current) {
chevronRef.current.style.opacity = String(opacity);
}
};
const updateLogoPosition = (timestamp: number = performance.now()) => {
// Throttle to 60fps (16ms minimum between frames)
if (timestamp - lastFrameTime.current < 16) {
return;
}
lastFrameTime.current = timestamp;
if (!contactSectionRef.current) return;
const scrollY = window.scrollY;
const currentWindowHeight = window.innerHeight;
const currentContactTop = contactSectionRef.current.offsetTop;
// Update cached values if needed
if (currentContactTop !== contactTop) {
setContactTop(currentContactTop);
}
if (currentWindowHeight !== windowHeight) {
setWindowHeight(currentWindowHeight);
}
// Calculate and apply logo styles based on device type
const logoStyles = isMobile
? calculateMobileLogoStyles(scrollY, currentWindowHeight, currentContactTop, logoHeight)
: calculateDesktopLogoStyles(scrollY, currentWindowHeight, logoHeight);
updateElementStyle(logoRef.current, logoStyles);
// Update controls opacity
updateControlsOpacity(scrollY, isMobile);
};
// Animated scroll to form
const scrollToForm = () => {
if (!contactSectionRef.current) {
console.error('Contact section ref not found');
return;
}
console.log('Starting scroll animation to:', contactSectionRef.current.offsetTop);
// Use native smooth scrolling
window.scrollTo({
top: contactSectionRef.current.offsetTop,
behavior: 'smooth'
});
};
// Add scroll listener for bidirectional animation with RAF
useEffect(() => {
const handleScroll = () => {
lastScrollY.current = window.scrollY;
if (!ticking.current) {
ticking.current = true;
animationFrameRef.current = requestAnimationFrame((timestamp) => {
updateLogoPosition(timestamp);
ticking.current = false;
});
}
};
const handleResize = () => {
setWindowHeight(window.innerHeight);
if (contactSectionRef.current) {
setContactTop(contactSectionRef.current.offsetTop);
}
updateLogoPosition();
};
// Initial position update
updateLogoPosition();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isMobile, isDesktop, logoHeight, windowHeight]);
// Dynamic height adjustment for image alignment
useEffect(() => {
const updateImageHeight = () => {
if (!isMobile && headingRef.current && submitButtonRef.current && imageContainerRef.current) {
// Get the bounding rectangles for accurate positioning
const headingRect = headingRef.current.getBoundingClientRect();
const buttonRect = submitButtonRef.current.getBoundingClientRect();
const containerRect = imageContainerRef.current.parentElement?.getBoundingClientRect();
if (containerRect) {
// Calculate relative positions within the container
const headingTop = headingRect.top - containerRect.top;
const buttonBottom = buttonRect.bottom - containerRect.top;
const height = buttonBottom - headingTop - 12; // Reduce height by 12px
// Set a minimum height and apply
const finalHeight = Math.max(height, 400); // Ensure minimum height of 400px
// Apply the calculated height
imageContainerRef.current.style.height = `${finalHeight}px`;
if (headingTop > 0) {
imageContainerRef.current.style.top = `${headingTop + 12}px`; // Start 12px lower to shave off top
imageContainerRef.current.style.position = 'absolute';
} else {
imageContainerRef.current.style.top = '12px';
imageContainerRef.current.style.position = 'relative';
}
}
} else if (!isMobile && imageContainerRef.current) {
// Fallback for desktop when refs aren't ready
imageContainerRef.current.style.height = '500px';
imageContainerRef.current.style.position = 'relative';
}
};
// Update on mount and resize with multiple attempts
updateImageHeight();
const timer1 = setTimeout(updateImageHeight, 100);
const timer2 = setTimeout(updateImageHeight, 500);
const timer3 = setTimeout(updateImageHeight, 1000);
window.addEventListener('resize', updateImageHeight);
window.addEventListener('load', updateImageHeight);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
clearTimeout(timer3);
window.removeEventListener('resize', updateImageHeight);
window.removeEventListener('load', updateImageHeight);
};
}, [isMobile, mounted]);
// Store if initial position has been set
const initialPositionSet = useRef(false);
// Calculate initial logo position to prevent teleport and flicker
const initialLogoStyle = useMemo(() => {
// Only set initial styles once and before JS takes over
if (!mounted || initialPositionSet.current) return {};
// For mobile, position at the calculated start position
if (isMobile && windowHeight > 0) {
const initialTop = windowHeight * animationConstants.mobile.startYFactor;
initialPositionSet.current = true;
return {
position: 'fixed' as const,
top: `${initialTop}px`,
left: '50%',
transform: 'translate3d(-50%, 0, 0) translateZ(0)',
willChange: 'auto',
backfaceVisibility: 'hidden' as const,
zIndex: 50
};
}
// For desktop, center the logo properly
if (!isMobile && mounted) {
initialPositionSet.current = true;
return {
position: 'fixed' as const,
top: '50%',
left: '50%',
transform: 'translate3d(-50%, -50%, 0) translateZ(0)',
willChange: 'auto',
backfaceVisibility: 'hidden' as const,
zIndex: 50
};
}
return {};
}, [isMobile, mounted, windowHeight, animationConstants.mobile.startYFactor]);
// Don't render until mounted to avoid hydration mismatch
if (!mounted) {
return null;
}
return (
<div className="w-full bg-[#1b233b]">
<Toaster
position="top-center"
toastOptions={{
style: {
background: '#1b233b',
color: '#fff',
border: '1px solid #C6AE97',
},
success: {
iconTheme: {
primary: '#C6AE97',
secondary: '#1b233b',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#1b233b',
},
},
}}
/>
{/* Hero Section - Full Viewport Height */}
<section className="relative h-screen flex flex-col items-center justify-center">
{/* Single Port Amador Logo with dynamic positioning - Always rendered */}
<div
ref={logoRef}
className="z-50"
style={initialLogoStyle}
>
<Image
src="/logo.png"
alt="Port Amador"
width={logoWidth}
height={logoHeight}
priority
style={{
width: `${logoWidth}px`,
height: `${logoHeight}px`,
objectFit: 'contain'
}}
/>
</div>
{/* Button with fade out on scroll */}
<button
ref={buttonRef}
onClick={scrollToForm}
className={`fixed z-30 px-6 py-3 bg-[#C6AE97] text-[#1B233B] font-['bill_corporate_medium'] font-normal text-sm md:text-base uppercase tracking-wider rounded-md hover:bg-[#D4C1AC] transition-colors whitespace-nowrap`}
style={{
bottom: '120px',
left: '50%',
transform: 'translateX(-50%)',
transition: 'opacity 0.3s ease-out'
}}
>
CONNECT WITH US
</button>
{/* Chevron Down - with fade out on scroll */}
<div
ref={chevronRef}
className="fixed z-20"
style={{
bottom: '40px',
left: '50%',
transform: 'translateX(-50%)',
transition: 'opacity 0.3s ease-out'
}}
>
<ChevronDown
className="text-[#C6AE97] animate-bounce"
size={32}
/>
</div>
</section>
{/* Contact Section - Desktop Layout with Marina Image */}
<section
ref={contactSectionRef}
className="w-full relative"
>
{isMobile ? (
// Mobile Layout - Stacked with Image
<div className="flex flex-col min-h-[85vh] pt-[60px]">
{/* Form Section */}
<div className="px-8 pb-12">
<h2 className="font-['Palatino',_serif] text-[#C6AE97] text-[40px] mb-8 font-normal text-center">
Connect with us
</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="First Name*"
{...field}
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 py-2 font-['bill_corporate_medium'] font-light text-[16px]"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Last Name*"
{...field}
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 py-2 font-['bill_corporate_medium'] font-light text-[16px]"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Email*"
type="email"
{...field}
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 py-2 font-['bill_corporate_medium'] font-light text-[16px]"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field, fieldState }) => (
<FormItem>
<FormControl>
<PhoneField
value={field.value}
onChange={(value) => field.onChange(value)}
onBlur={field.onBlur}
error={fieldState.error?.message}
required
placeholder="Phone number"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
placeholder="Message"
{...field}
className="min-h-[80px] bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 py-2 font-['bill_corporate_medium'] font-light text-[16px] resize-none"
/>
</FormControl>
</FormItem>
)}
/>
<div className="pt-3">
<Button
type="submit"
className="w-full bg-[#C6AE97] text-[#1B233B] hover:bg-[#D4C1AC] font-['bill_corporate_medium'] font-medium text-[16px] uppercase tracking-wider py-3 rounded-[5px]"
>
SUBMIT
</Button>
</div>
</form>
</Form>
</div>
{/* Marina Image Section */}
<div className="relative w-full h-[192px] -mt-6 px-4">
<div className="relative w-full h-full">
<Image
src="/marina_cropped.jpg"
alt="Port Amador Marina"
fill
className="object-cover object-center"
sizes="(max-width: 768px) 90vw, 100vw"
priority
/>
</div>
</div>
{/* Footer Section */}
<div className="px-8 py-8 mt-auto">
<div className="flex justify-between items-end">
<div className="flex flex-col space-y-0">
<a href="tel:+13109132597" className="font-['bill_corporate_medium'] font-light text-[14px] text-[#C6AE97] hover:text-[#D4C1AC] transition-colors">
+1 310 913 2597
</a>
<a href="mailto:am@portamador.com" className="font-['bill_corporate_medium'] font-light text-[14px] text-[#C6AE97] hover:text-[#D4C1AC] transition-colors">
am@portamador.com
</a>
</div>
<div className="text-[#C6AE97] text-[14px] font-['bill_corporate_medium'] font-light">
© Port Amador 2025
</div>
</div>
</div>
</div>
) : (
// Desktop Layout - Responsive with aligned image
<div className="min-h-screen flex flex-col relative pt-[10vh] lg:pt-[15vh] xl:pt-[200px]">
<div className="w-full max-w-[1600px] mx-auto px-8 lg:px-[80px]">
<div className="flex flex-col lg:grid lg:grid-cols-[45%_55%] gap-8 lg:gap-0">
{/* Left Side - Marina Image Container */}
<div className="order-2 lg:order-1">
<div className="lg:pr-[40px] h-[400px] lg:h-auto lg:relative">
{/* Image positioned to align with heading */}
<div ref={imageContainerRef} className="relative h-full lg:h-auto">
<Image
src="/marina_cropped.jpg"
alt="Port Amador Marina"
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw, 45vw"
priority
style={{ objectPosition: 'center top' }}
/>
</div>
</div>
</div>
{/* Right Side - Form Section */}
<div className="order-1 lg:order-2">
<div className="lg:pl-[40px]">
{/* Form content wrapper */}
<div className="flex flex-col">
{/* Heading */}
<h2 ref={headingRef} className="font-['Palatino',_serif] text-[#C6AE97] text-[48px] md:text-[60px] lg:text-[72px] leading-none mb-8 lg:mb-12 font-normal">
Connect with us
</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 lg:space-y-8">
{/* First Row - First Name and Last Name */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
placeholder="First Name*"
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 pb-1 pt-0 font-['bill_corporate_medium'] font-light text-[16px] focus:outline-none focus:ring-0"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
placeholder="Last Name*"
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 pb-1 pt-0 font-['bill_corporate_medium'] font-light text-[16px] focus:outline-none focus:ring-0"
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Second Row - Email and Phone */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="email"
{...field}
placeholder="Email*"
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 pb-1 pt-0 font-['bill_corporate_medium'] font-light text-[16px] focus:outline-none focus:ring-0"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field, fieldState }) => (
<FormItem>
<FormControl>
<PhoneField
value={field.value}
onChange={(value) => field.onChange(value)}
onBlur={field.onBlur}
error={fieldState.error?.message}
required
placeholder="Phone number"
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Message Field */}
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
placeholder="Message"
className="min-h-[60px] bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 pb-1 pt-0 font-['bill_corporate_medium'] font-light text-[16px] resize-none focus:outline-none focus:ring-0"
/>
</FormControl>
</FormItem>
)}
/>
{/* Submit Button */}
<div className="pt-2 lg:pt-4">
<Button
ref={submitButtonRef}
type="submit"
className="w-full bg-[#C6AE97] text-[#1B233B] hover:bg-[#D4C1AC] font-['bill_corporate_medium'] font-medium text-[16px] lg:text-[18px] uppercase tracking-[0.05em] h-[45px] lg:h-[50px] rounded-[3px]"
>
SUBMIT
</Button>
</div>
</form>
</Form>
</div>
</div>
</div>
</div>
</div>
{/* Footer - at bottom of page */}
<div className="w-full max-w-[1600px] mx-auto px-[80px] pb-8 pt-12">
<div className="flex justify-between items-end">
<div className="flex flex-col space-y-0 text-[#C6AE97]">
<a href="tel:+13109132597" className="font-['bill_corporate_medium'] font-light text-[14px] hover:text-[#D4C1AC] transition-colors">
+1 310 913 2597
</a>
<a href="mailto:am@portamador.com" className="font-['bill_corporate_medium'] font-light text-[14px] hover:text-[#D4C1AC] transition-colors">
am@portamador.com
</a>
</div>
<div className="text-[#C6AE97] text-[14px] font-['bill_corporate_medium'] font-light">
© Port Amador 2025
</div>
</div>
</div>
</div>
)}
</section>
</div>
);
}