Compare commits
12 Commits
eda3fb4522
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bb3cbf5f1a | |||
| d593230c8a | |||
| 2d5ffcb268 | |||
| 7c9aedb92d | |||
| ee56c650d6 | |||
| 21a83a1db9 | |||
| bbee08bfb7 | |||
| bd96a15650 | |||
| 6aa4284c7b | |||
| d699c2522a | |||
| 90dbc75123 | |||
| d1c6e87225 |
18
.claude/settings.local.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__playwright__browser_navigate",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(npm install)",
|
||||
"mcp__playwright__browser_resize",
|
||||
"mcp__serena__search_for_pattern",
|
||||
"Bash(taskkill:*)",
|
||||
"mcp__zen__analyze",
|
||||
"mcp__playwright__browser_tabs"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
1274
package-lock.json
generated
@@ -15,12 +15,15 @@
|
||||
"@react-hook/media-query": "^1.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"framer-motion": "^12.23.16",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.3",
|
||||
"next": "^15.5.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-phone-input-2": "^2.15.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
BIN
public/marina.jpg
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 354 KiB |
BIN
public/marina_cropped.jpg
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
public/marina_cropped_curved.jpg
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
public/marina_cropped_v2.jpg
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
public/marina_cropped_v3.jpg
Normal file
|
After Width: | Height: | Size: 346 KiB |
BIN
public/marina_cropped_v5.jpg
Normal file
|
After Width: | Height: | Size: 366 KiB |
@@ -1,75 +1,81 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
const { firstName, lastName, email, phone, message } = data;
|
||||
|
||||
if (!firstName || !lastName || !email || !phone) {
|
||||
if (!body.firstName || !body.lastName || !body.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation regex
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
// Get NocoDB configuration
|
||||
const NOCODB_API_TOKEN = process.env.NOCODB_API_TOKEN;
|
||||
const NOCODB_BASE_URL = process.env.NOCODB_BASE_URL || 'https://app.nocodb.com';
|
||||
const NOCODB_TABLE_ID = process.env.NOCODB_TABLE_ID || 'contacts';
|
||||
|
||||
if (!NOCODB_API_TOKEN) {
|
||||
console.error('NOCODB_API_TOKEN not configured');
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
{ error: 'Server configuration error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Log the submission (in production, you would:
|
||||
// 1. Send an email notification using SendGrid, Postmark, or Resend
|
||||
// 2. Store in a database like Supabase or PostgreSQL
|
||||
// 3. Integrate with CRM like HubSpot or Salesforce)
|
||||
// Prepare data for NocoDB with correct field names
|
||||
// Ensure phone number has '+' prefix if it doesn't already
|
||||
const phoneNumber = body.phone ?
|
||||
(body.phone.startsWith('+') ? body.phone : `+${body.phone}`) : '';
|
||||
|
||||
console.log('Contact form submission received:', {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
message: message || '(No message provided)',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
const contactData = {
|
||||
fields: {
|
||||
'First Name': body.firstName,
|
||||
'Last Name': body.lastName,
|
||||
'Full Name': `${body.firstName} ${body.lastName}`,
|
||||
'Email': body.email,
|
||||
'Telephone': phoneNumber,
|
||||
'Message': body.message || '',
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate email sending (in production, replace with actual email service)
|
||||
// Example with Resend:
|
||||
// await resend.emails.send({
|
||||
// from: 'onboarding@portamador.com',
|
||||
// to: 'am@portamador.com',
|
||||
// subject: `New Contact Form Submission from ${firstName} ${lastName}`,
|
||||
// html: `
|
||||
// <h2>New Contact Form Submission</h2>
|
||||
// <p><strong>Name:</strong> ${firstName} ${lastName}</p>
|
||||
// <p><strong>Email:</strong> ${email}</p>
|
||||
// <p><strong>Phone:</strong> ${phone}</p>
|
||||
// <p><strong>Message:</strong> ${message || 'No message provided'}</p>
|
||||
// `
|
||||
// });
|
||||
// Send to NocoDB - using v3 API with correct project ID
|
||||
const nocdbResponse = await fetch(
|
||||
`${NOCODB_BASE_URL}/api/v3/data/p4bq8r1rmsfu77o/${NOCODB_TABLE_ID}/records`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'xc-token': NOCODB_API_TOKEN,
|
||||
},
|
||||
body: JSON.stringify(contactData),
|
||||
}
|
||||
);
|
||||
|
||||
// Return success response
|
||||
if (!nocdbResponse.ok) {
|
||||
const errorText = await nocdbResponse.text();
|
||||
console.error('NocoDB error:', errorText);
|
||||
throw new Error('Failed to save contact');
|
||||
}
|
||||
|
||||
const result = await nocdbResponse.json();
|
||||
|
||||
// Send success response
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Thank you for your submission. We will contact you soon.',
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
email
|
||||
}
|
||||
message: 'Contact form submitted successfully',
|
||||
id: result.Id || result.id,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing contact form:', error);
|
||||
console.error('Contact form error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error. Please try again later.' },
|
||||
{ error: 'Failed to process contact form' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
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';
|
||||
@@ -16,28 +16,32 @@ import {
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { PhoneField } from '@/components/forms/PhoneField';
|
||||
|
||||
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(1, 'Phone is required'),
|
||||
phone: z.string().min(10, 'Please enter a valid phone number'),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function ContactPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [logoPosition, setLogoPosition] = useState('center');
|
||||
const [logoStyle, setLogoStyle] = useState<React.CSSProperties>({});
|
||||
const [buttonOpacity, setButtonOpacity] = useState(1);
|
||||
const [chevronOpacity, setChevronOpacity] = useState(1);
|
||||
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);
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const isDesktop = useMediaQuery("(min-width: 1280px)");
|
||||
@@ -58,9 +62,33 @@ export default function ContactPage() {
|
||||
console.log(values);
|
||||
}
|
||||
|
||||
// Logo dimensions based on screen size
|
||||
const logoWidth = isMobile ? 240 : isDesktop ? 316 : 280;
|
||||
const logoHeight = isMobile ? 115 : isDesktop ? 151 : 134;
|
||||
// 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: 10,
|
||||
startYFactor: 0.30, // Moved up from 0.35 to account for single-line button
|
||||
finalScale: 0.5,
|
||||
fadeThreshold: 5
|
||||
},
|
||||
desktop: {
|
||||
targetTopPosition: 20,
|
||||
logoSpeed: 0.4,
|
||||
maxScroll: 500,
|
||||
scaleReduction: 0.6,
|
||||
fadeThreshold: 10
|
||||
}
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -71,7 +99,128 @@ export default function ContactPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateLogoPosition = () => {
|
||||
// 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;
|
||||
@@ -86,112 +235,15 @@ export default function ContactPage() {
|
||||
setWindowHeight(currentWindowHeight);
|
||||
}
|
||||
|
||||
// Calculate positions - adjusted for scaled logo
|
||||
const targetTopPosition = isMobile ? 10 : 20; // Adjusted for smaller scaled logo
|
||||
// Calculate and apply logo styles based on device type
|
||||
const logoStyles = isMobile
|
||||
? calculateMobileLogoStyles(scrollY, currentWindowHeight, currentContactTop, logoHeight)
|
||||
: calculateDesktopLogoStyles(scrollY, currentWindowHeight, logoHeight);
|
||||
|
||||
if (isMobile) {
|
||||
// For mobile, calculate where the logo should end up
|
||||
const startY = currentWindowHeight * 0.35; // Logo starts higher - 35% from top
|
||||
const endY = targetTopPosition + (logoHeight * 0.5) / 2; // Account for 50% scale
|
||||
const totalDistance = startY - endY;
|
||||
updateElementStyle(logoRef.current, logoStyles);
|
||||
|
||||
// Keep animation ending at the full contact section position
|
||||
const animationEndScroll = currentContactTop;
|
||||
|
||||
if (scrollY >= animationEndScroll) {
|
||||
// Logo has reached destination - keep it fixed but move with scroll
|
||||
setLogoPosition('top');
|
||||
|
||||
// Calculate position to simulate being part of the page
|
||||
const scrollPastEnd = scrollY - animationEndScroll;
|
||||
const fixedY = endY - scrollPastEnd;
|
||||
const mobileScale = 0.5; // Final scale for mobile
|
||||
|
||||
setLogoStyle({
|
||||
position: 'fixed',
|
||||
top: `${fixedY}px`,
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
zIndex: 50
|
||||
});
|
||||
} else if (scrollY > 0) {
|
||||
// Animate logo from center to destination - starts immediately at any scroll
|
||||
const progress = scrollY / animationEndScroll;
|
||||
const currentY = startY - (totalDistance * progress);
|
||||
const mobileScale = 1 - (0.5 * progress); // Scale from 1.0 to 0.5
|
||||
|
||||
setLogoPosition('animating');
|
||||
setLogoStyle({
|
||||
position: 'fixed',
|
||||
top: `${currentY}px`,
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
zIndex: 50
|
||||
});
|
||||
} else {
|
||||
// At the top - logo at starting position
|
||||
setLogoPosition('center');
|
||||
setLogoStyle({
|
||||
position: 'fixed',
|
||||
top: `${startY}px`,
|
||||
left: '50%',
|
||||
transform: 'translate3d(-50%, 0, 0) scale3d(1, 1, 1)',
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
zIndex: 50
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Desktop - standard animation with scaling
|
||||
const logoSpeed = 0.4;
|
||||
const centerY = currentWindowHeight / 2;
|
||||
const targetTopPosition = 20; // Reduced from 100px to account for smaller logo
|
||||
const totalDistance = centerY - targetTopPosition - logoHeight / 2;
|
||||
const logoYPosition = -(scrollY * logoSpeed);
|
||||
const maxUpwardMovement = -totalDistance;
|
||||
const animatedY = Math.max(logoYPosition, maxUpwardMovement);
|
||||
|
||||
// Calculate scale based on scroll progress
|
||||
const maxScroll = 500; // Scroll distance at which scaling completes
|
||||
const scrollProgress = Math.min(scrollY / maxScroll, 1);
|
||||
const scale = 1 - (0.6 * scrollProgress); // Scale from 1.0 to 0.4
|
||||
|
||||
// Update state based on scroll
|
||||
if (scrollY > 10) {
|
||||
setLogoPosition('animating');
|
||||
} else {
|
||||
setLogoPosition('center');
|
||||
}
|
||||
|
||||
// Fixed positioning with animation and scaling
|
||||
setLogoStyle({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, calc(-50% + ${animatedY}px), 0) scale3d(${scale}, ${scale}, 1)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
zIndex: 50
|
||||
});
|
||||
}
|
||||
|
||||
// Hide button and chevron - faster on mobile
|
||||
const fadeThreshold = isMobile ? 5 : 10; // Fade out at just 5px scroll on mobile
|
||||
if (scrollY > fadeThreshold) {
|
||||
setButtonOpacity(0);
|
||||
setChevronOpacity(0);
|
||||
} else {
|
||||
setButtonOpacity(1);
|
||||
setChevronOpacity(1);
|
||||
}
|
||||
// Update controls opacity
|
||||
updateControlsOpacity(scrollY, isMobile);
|
||||
};
|
||||
|
||||
// Animated scroll to form
|
||||
@@ -212,17 +264,15 @@ export default function ContactPage() {
|
||||
|
||||
// Add scroll listener for bidirectional animation with RAF
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
|
||||
const handleScroll = () => {
|
||||
lastScrollY.current = window.scrollY;
|
||||
|
||||
if (!ticking) {
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
updateLogoPosition();
|
||||
ticking = false;
|
||||
if (!ticking.current) {
|
||||
ticking.current = true;
|
||||
animationFrameRef.current = requestAnimationFrame((timestamp) => {
|
||||
updateLogoPosition(timestamp);
|
||||
ticking.current = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -248,6 +298,48 @@ export default function ContactPage() {
|
||||
};
|
||||
}, [isMobile, isDesktop, logoHeight, windowHeight]);
|
||||
|
||||
// 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;
|
||||
@@ -261,7 +353,7 @@ export default function ContactPage() {
|
||||
<div
|
||||
ref={logoRef}
|
||||
className="z-50"
|
||||
style={logoStyle}
|
||||
style={initialLogoStyle}
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
@@ -279,14 +371,13 @@ export default function ContactPage() {
|
||||
|
||||
{/* Button with fade out on scroll */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={scrollToForm}
|
||||
className={`fixed z-30 px-8 py-3 bg-[#C6AE97] text-[#1B233B] font-['bill_corporate_medium'] font-normal text-base uppercase tracking-wider rounded-md hover:bg-[#D4C1AC] transition-all`}
|
||||
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%)',
|
||||
opacity: buttonOpacity,
|
||||
pointerEvents: buttonOpacity > 0 ? 'auto' : 'none',
|
||||
transition: 'opacity 0.3s ease-out'
|
||||
}}
|
||||
>
|
||||
@@ -295,12 +386,12 @@ export default function ContactPage() {
|
||||
|
||||
{/* Chevron Down - with fade out on scroll */}
|
||||
<div
|
||||
ref={chevronRef}
|
||||
className="fixed z-20"
|
||||
style={{
|
||||
bottom: '40px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
opacity: chevronOpacity,
|
||||
transition: 'opacity 0.3s ease-out'
|
||||
}}
|
||||
>
|
||||
@@ -375,13 +466,16 @@ export default function ContactPage() {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Phone number*"
|
||||
{...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]"
|
||||
<PhoneField
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
onBlur={field.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
required
|
||||
placeholder="Phone number"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -415,7 +509,7 @@ export default function ContactPage() {
|
||||
</div>
|
||||
|
||||
{/* Marina Image Section */}
|
||||
<div className="relative w-full h-[300px] mt-8 px-4">
|
||||
<div className="relative w-full h-[300px] mt-2 px-4">
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src="/marina.png"
|
||||
@@ -429,7 +523,7 @@ export default function ContactPage() {
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="px-8 py-8 mt-auto">
|
||||
<div className="px-8 py-8">
|
||||
<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">
|
||||
@@ -527,13 +621,16 @@ export default function ContactPage() {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Phone number*"
|
||||
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"
|
||||
<PhoneField
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
onBlur={field.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
required
|
||||
placeholder="Phone number"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -1,5 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "react-phone-input-2/lib/style.css";
|
||||
@import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
/* Custom fonts */
|
||||
@font-face {
|
||||
|
||||
BIN
src/app/icon.png
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 8.3 KiB |
1
src/app/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0"?><svg version="1.2" baseProfile="tiny-ps" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>Port Amador</title><g><g fill="#c6ae97" transform="matrix(.1 0 0 -.1 0 300)"><path d="M10 1500V0h2990v3000H10V1500z"/></g><g fill="#94857c" transform="matrix(.1 0 0 -.1 0 300)"><path d="M10 1500V0h2990v3000H10V1500zm994 515c-54-58-105-118-113-132l-13-26 5-59 5-58 28-48c15-26 61-83 103-127s91-97 110-118l35-37-159-160c-88-88-163-160-168-160-4 0-80 72-168 160l-161 160 100 104c114 119 153 171 176 239 19 56 19 61 6 109l-11 37-106 111-106 111 134 135 134 134 134-134 134-134-99-107zm1324-3-106-110-12-37-13-38 12-52 12-52 29-46c16-25 76-94 134-154l105-109-165-165-164-164-161 161-162 162 116 126 116 127 22 45 22 46 5 43c2 24 2 55-2 69l-6 25-106 116-107 117 134 134 134 134 134-134 135-135-106-109zm-693 238 129-130-25-27c-14-16-62-67-106-115l-81-88-7-31-7-31 6-41 6-42 29-50 29-51 106-113c58-62 106-116 106-120 0-3-72-78-160-166l-160-160-160 160c-88 88-160 162-160 165s49 58 108 122l108 117 29 53 29 53 1 61v62l-40 46c-22 25-71 79-109 121l-70 76 130 130c71 71 131 129 134 129s64-58 135-130zM958 957l112-112-118-118-117-117-115 115-115 115 115 115c63 63 117 115 120 115s56-51 118-113zm660 0 112-112-115-115-115-115-115 115-115 115 112 112c62 62 115 113 118 113s56-51 118-113zm662-2 115-115-115-115-115-115-117 117-118 118 112 112c62 62 115 113 118 113s57-52 120-115z"/></g><g fill="#605b60" transform="matrix(.1 0 0 -.1 0 300)"><path d="M10 1500V0h2990v3000H10V1500zm1090 618c0-4-47-57-104-118l-104-110-7-31-7-31 6-41 6-42 29-50 29-50 106-112c58-62 106-116 106-120s-73-81-162-170l-163-163 118-118 117-117-118-118-117-117-113 113c-61 62-112 117-112 122s51 60 112 122l113 113-165 165-165 165 66 67c36 38 89 95 119 128l53 60 23 50 24 50v50l-1 50-12 22c-7 12-57 70-111 128l-99 107 134 134 134 134 132-132c73-73 133-136 133-140zm539 138 134-135-106-112-107-112-10-38-10-38 10-48 11-48 34-52c19-29 81-100 137-159l102-105-167-167-167-167-167 167-167 167 102 105c56 59 118 130 137 159l34 52 11 48 10 48-10 38-10 38-107 112-106 112 134 135c73 74 136 134 139 134s66-60 139-134zm661-1 135-135-71-72c-39-40-89-95-112-122l-42-49v-112l21-44c24-54 67-108 178-224l85-88-164-164-165-165 113-113c61-62 112-117 112-122s-51-60-112-122l-113-113-117 117-118 118 117 117 118 118-163 163c-89 89-162 166-162 171s40 50 89 100c48 50 104 112 124 138l35 47 17 51 17 51-7 45-6 44-111 116-111 115 134 135c73 74 136 134 139 134s66-61 140-135zM1620 955l115-115-118-118-117-117-117 117-118 118 115 115c63 63 117 115 120 115s57-52 120-115z"/></g><g fill="#1b233b" transform="matrix(.1 0 0 -.1 0 300)"><path d="M10 1500V0h2990v3000H10V1500zm969 756 134-134-107-113-106-113-10-37-10-36 6-34c4-19 20-59 36-89l29-55 110-115 109-115 104 110 105 110 32 53 32 53 5 49c2 27 1 61-2 75l-7 27-106 115-106 115 136 136 137 137 137-137 136-136-106-116-106-115-7-26c-3-14-4-48-2-75l5-49 32-53 33-53 105-110 104-110 110 115 109 115 30 59 30 59v117l-42 48c-23 26-74 80-112 120l-71 72 138 138 137 137 135-135c74-74 135-137 135-140s-45-53-99-112c-55-58-104-115-110-127l-11-20v-86l29-60 28-61 83-89c46-50 98-105 114-123l30-32-164-165-165-165 118-118 117-117-120-120-120-120-115 115c-63 63-115 119-115 125 0 5 51 60 112 122l113 113-163 163-162 162-162-162-163-163 118-118 117-117-120-120-120-120-120 120-120 120 117 117 118 118-163 162-162 163-165-165-165-165 117-117 118-118-118-117-117-118-120 120-120 120 117 117 118 118-165 165-164 164 24 28c14 15 64 69 111 119l87 92 31 63 31 62v38c0 59-14 83-102 177-46 48-92 97-102 110l-20 23 134 134c74 74 137 135 140 135s66-60 139-134z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -15,7 +15,7 @@ const geistMono = Geist_Mono({
|
||||
export const metadata: Metadata = {
|
||||
title: "Port Amador",
|
||||
description: "Premium marine equipment and services",
|
||||
};;
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
|
||||
BIN
src/app/old_icon.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
983
src/app/page.tsx
@@ -1,18 +1,979 @@
|
||||
import Link from 'next/link';
|
||||
'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);
|
||||
|
||||
// Orientation state management for smooth transitions
|
||||
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>('landscape');
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const transitionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const animationProgressRef = useRef(0);
|
||||
|
||||
// Refs for orientation to avoid stale closures in animation loops
|
||||
const orientationRef = useRef<'portrait' | 'landscape'>('landscape');
|
||||
const isTransitioningRef = useRef(false);
|
||||
|
||||
// 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 isTablet = useMediaQuery("(min-width: 769px) and (max-width: 1024px)");
|
||||
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 : isTablet ? 280 : isDesktop ? 316 : 280,
|
||||
[isMobile, isTablet, isDesktop]
|
||||
);
|
||||
|
||||
const logoHeight = useMemo(() =>
|
||||
isMobile ? 115 : isTablet ? 134 : isDesktop ? 151 : 134,
|
||||
[isMobile, isTablet, isDesktop]
|
||||
);
|
||||
|
||||
// Memoized animation constants
|
||||
const animationConstants = useMemo(() => ({
|
||||
mobile: {
|
||||
targetTopPosition: 10, // Logo settles much higher to avoid text
|
||||
startYFactor: 0.20, // Moved up to position logo higher on mobile
|
||||
finalScale: 0.5,
|
||||
fadeThreshold: 5
|
||||
},
|
||||
tablet: {
|
||||
targetTopPosition: 10, // Same as mobile - position higher up
|
||||
startYFactor: 0.3, // Start lower than center for smoother animation
|
||||
logoSpeed: 0.35, // Slightly slower than desktop
|
||||
finalScale: 0.5, // Same as mobile for consistency
|
||||
fadeThreshold: 8, // Between mobile and desktop
|
||||
maxScroll: 450 // Shorter than desktop
|
||||
},
|
||||
desktop: {
|
||||
targetTopPosition: 20,
|
||||
logoSpeed: 0.4,
|
||||
maxScroll: 500,
|
||||
scaleReduction: 0.6,
|
||||
fadeThreshold: 10
|
||||
}
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Set initial dimensions and orientation on mount
|
||||
setWindowHeight(window.innerHeight);
|
||||
const initialOrientation = window.innerHeight > window.innerWidth ? 'portrait' : 'landscape';
|
||||
setOrientation(initialOrientation);
|
||||
orientationRef.current = initialOrientation;
|
||||
if (contactSectionRef.current) {
|
||||
setContactTop(contactSectionRef.current.offsetTop);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync refs with state to avoid stale closures
|
||||
useEffect(() => {
|
||||
orientationRef.current = orientation;
|
||||
}, [orientation]);
|
||||
|
||||
useEffect(() => {
|
||||
isTransitioningRef.current = isTransitioning;
|
||||
}, [isTransitioning]);
|
||||
|
||||
// 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;
|
||||
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'
|
||||
};
|
||||
};
|
||||
|
||||
const calculateTabletLogoStyles = (
|
||||
scrollY: number,
|
||||
windowHeight: number,
|
||||
logoHeight: number,
|
||||
contactSection: HTMLElement | null
|
||||
): Partial<CSSStyleDeclaration> => {
|
||||
const { targetTopPosition, startYFactor, logoSpeed, finalScale, maxScroll } = animationConstants.tablet;
|
||||
|
||||
// Use ref-based orientation to avoid stale closures
|
||||
const isPortrait = orientationRef.current === 'portrait';
|
||||
|
||||
// Start position - adjust based on orientation for smoother animation
|
||||
// Portrait mode starts lower for a longer, smoother animation path
|
||||
const startY = windowHeight * (isPortrait ? 0.4 : startYFactor);
|
||||
|
||||
// End position - adjust based on orientation to prevent cutoff
|
||||
// Portrait needs more space from top due to taller viewport
|
||||
const endY = isPortrait ? 30 : targetTopPosition; // 30px for portrait (higher up), 10px for landscape
|
||||
|
||||
// Total distance to travel (simpler calculation like mobile)
|
||||
const totalDistance = startY - endY;
|
||||
|
||||
// Calculate Y position during animation
|
||||
let currentY: number;
|
||||
|
||||
if (contactSection) {
|
||||
const contactTop = contactSection.offsetTop;
|
||||
|
||||
// Adjust multiplier based on orientation
|
||||
// Portrait mode - complete animation closer to the contact section
|
||||
// This makes the logo become sticky lower on the page
|
||||
const multiplier = isPortrait ? 1.0 : 0.7; // Use 100% for portrait (like mobile), 70% for landscape
|
||||
|
||||
// Use dynamic contact position with orientation-aware multiplier
|
||||
const animationEndScroll = contactTop * multiplier;
|
||||
|
||||
if (scrollY < animationEndScroll) {
|
||||
// During animation - use progress-based positioning like mobile
|
||||
const progress = scrollY / animationEndScroll;
|
||||
currentY = startY - (totalDistance * progress);
|
||||
|
||||
// Calculate scale based on animation progress
|
||||
const scale = 1 - ((1 - finalScale) * progress);
|
||||
|
||||
// Update logo position state
|
||||
if (scrollY > 10) {
|
||||
logoPositionRef.current = 'animating';
|
||||
} else {
|
||||
logoPositionRef.current = 'center';
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${currentY}px`,
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, 0, 0) scale3d(${scale}, ${scale}, 1) translateZ(0)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'hidden',
|
||||
transition: isTransitioningRef.current ? 'all 0.3s ease-out' : 'none',
|
||||
zIndex: '50'
|
||||
};
|
||||
} else {
|
||||
// After animation - implement scrollPastEnd logic like mobile
|
||||
logoPositionRef.current = 'top';
|
||||
const scrollPastEnd = scrollY - animationEndScroll;
|
||||
currentY = endY - scrollPastEnd;
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${currentY}px`,
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, 0, 0) scale3d(${finalScale}, ${finalScale}, 1) translateZ(0)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: scrollY > 0 ? 'transform' : 'auto',
|
||||
backfaceVisibility: 'hidden',
|
||||
transition: isTransitioningRef.current ? 'all 0.3s ease-out' : 'none',
|
||||
zIndex: '50'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Fallback if contact section not found
|
||||
const progress = Math.min(scrollY / 800, 1); // Use reasonable fallback
|
||||
currentY = startY - (totalDistance * progress);
|
||||
const scale = 1 - ((1 - finalScale) * progress);
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${currentY}px`,
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, 0, 0) scale3d(${scale}, ${scale}, 1) translateZ(0)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: scrollY > 0 ? 'transform' : 'auto',
|
||||
backfaceVisibility: 'hidden',
|
||||
transition: isTransitioning ? 'all 0.3s ease-out' : '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
|
||||
let logoStyles;
|
||||
if (isMobile) {
|
||||
logoStyles = calculateMobileLogoStyles(scrollY, currentWindowHeight, currentContactTop, logoHeight);
|
||||
} else if (isTablet) {
|
||||
logoStyles = calculateTabletLogoStyles(scrollY, currentWindowHeight, logoHeight, contactSectionRef.current);
|
||||
} else {
|
||||
logoStyles = calculateDesktopLogoStyles(scrollY, currentWindowHeight, logoHeight);
|
||||
}
|
||||
|
||||
updateElementStyle(logoRef.current, logoStyles);
|
||||
|
||||
// Update controls opacity
|
||||
updateControlsOpacity(scrollY, isMobile || isTablet);
|
||||
};
|
||||
|
||||
// Animated scroll to form
|
||||
const scrollToForm = () => {
|
||||
if (!contactSectionRef.current) {
|
||||
console.error('Contact section ref not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// For mobile, adjust scroll position to account for the padding
|
||||
const scrollOffset = isMobile ? 0 : 0; // No offset - scroll exactly to where animation ends
|
||||
const targetPosition = contactSectionRef.current.offsetTop + scrollOffset;
|
||||
|
||||
console.log('Starting scroll animation to:', targetPosition);
|
||||
|
||||
// Use native smooth scrolling
|
||||
window.scrollTo({
|
||||
top: targetPosition,
|
||||
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 = () => {
|
||||
const newOrientation = window.innerHeight > window.innerWidth ? 'portrait' : 'landscape';
|
||||
|
||||
// Only trigger transition if orientation actually changed and we're on tablet
|
||||
if (newOrientation !== orientationRef.current && isTablet) {
|
||||
// Store current animation progress
|
||||
const scrollY = window.scrollY;
|
||||
const contactTop = contactSectionRef.current?.offsetTop || 0;
|
||||
animationProgressRef.current = Math.min(scrollY / contactTop, 1);
|
||||
|
||||
// Enable transition mode
|
||||
setIsTransitioning(true);
|
||||
setOrientation(newOrientation);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (transitionTimeoutRef.current) {
|
||||
clearTimeout(transitionTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Disable transition after animation completes
|
||||
transitionTimeoutRef.current = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
} else if (newOrientation !== orientation) {
|
||||
// Update orientation for non-tablet devices without transition
|
||||
setOrientation(newOrientation);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (transitionTimeoutRef.current) {
|
||||
clearTimeout(transitionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isMobile, isTablet, 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="min-h-screen bg-[#1b233b] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-['Palatino',_serif] text-[#C6AE97] mb-4">PORT AMADOR</h1>
|
||||
<p className="text-xl text-white font-['bill_corporate_medium'] font-light mb-8">PANAMA</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-block bg-[#C6AE97] text-[#1B233B] px-8 py-3 rounded-[5px] font-['bill_corporate_medium'] font-medium text-lg hover:bg-[#D4C1AC] transition-colors"
|
||||
<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}
|
||||
>
|
||||
VIEW CONTACT PAGE
|
||||
</Link>
|
||||
</div>
|
||||
<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-[120px]">
|
||||
{/* Form Section */}
|
||||
<div className="px-8 pb-12">
|
||||
<h2 className="font-['Palatino',_serif] text-[#C6AE97] text-[40px] mb-0 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-1">
|
||||
<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-2 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_v5.jpg"
|
||||
alt="Port Amador Marina"
|
||||
fill
|
||||
className="object-cover object-bottom"
|
||||
sizes="(max-width: 768px) 90vw, 100vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="px-8 pt-8 pb-4 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_v5.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>
|
||||
);
|
||||
}
|
||||
248
src/components/forms/PhoneField.module.css
Normal file
@@ -0,0 +1,248 @@
|
||||
.wrapper {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: transparent !important;
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.6);
|
||||
padding: 0 !important;
|
||||
position: relative !important;
|
||||
align-items: center !important;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.country {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
width: 50px !important;
|
||||
min-width: 50px !important;
|
||||
max-width: 50px !important;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
height: 40px !important;
|
||||
position: absolute !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
cursor: pointer !important;
|
||||
z-index: 2 !important;
|
||||
}
|
||||
|
||||
.country:hover {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
/* Target only container divs, not the flag image */
|
||||
.country > div:not(.flag) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.country .selected-flag {
|
||||
background: transparent !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Show + sign when no country is selected */
|
||||
.noCountry .country::after {
|
||||
content: '+' !important;
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
font-size: 18px !important;
|
||||
font-family: 'bill corporate medium', sans-serif !important;
|
||||
font-weight: 300 !important;
|
||||
position: absolute !important;
|
||||
left: 45% !important;
|
||||
top: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 3 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.country .flag-dropdown {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
}
|
||||
|
||||
.country .selected-flag .flag {
|
||||
scale: 1.3;
|
||||
margin: 0 auto;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.country .selected-flag .arrow {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: calc(100% - 66px) !important;
|
||||
font-family: 'bill corporate medium', sans-serif !important;
|
||||
font-weight: 300 !important;
|
||||
font-size: 16px !important;
|
||||
color: #ffffff !important;
|
||||
padding: 8px 0 8px 16px !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 100% !important;
|
||||
position: absolute !important;
|
||||
left: 50px !important;
|
||||
top: 0 !important;
|
||||
margin: 0 !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
font-family: 'bill corporate medium', sans-serif !important;
|
||||
font-weight: 300 !important;
|
||||
font-size: 16px !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.container:focus-within {
|
||||
border-bottom-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Override the library's dropdown positioning */
|
||||
:global(.react-tel-input) .dropdown {
|
||||
position: absolute !important;
|
||||
left: 0 !important;
|
||||
right: auto !important;
|
||||
top: 100% !important;
|
||||
transform: none !important;
|
||||
margin-top: 4px !important;
|
||||
max-height: 300px !important;
|
||||
min-width: 300px !important;
|
||||
z-index: 10000 !important;
|
||||
background: #3B4259 !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.dropdown .search {
|
||||
background: #3B4259 !important;
|
||||
border-bottom: 1px solid rgba(59, 66, 89, 0.3) !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
.dropdown .search input {
|
||||
background: transparent !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
font-size: 14px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.dropdown .search input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
|
||||
.dropdown .country {
|
||||
background: #3B4259 !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
padding: 8px !important;
|
||||
width: 100% !important;
|
||||
min-width: unset !important;
|
||||
max-width: unset !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.dropdown .country:hover {
|
||||
background: rgba(59, 66, 89, 0.95) !important;
|
||||
}
|
||||
|
||||
/* Make the actively selected country (with checkmark/highlight) darker */
|
||||
.dropdown .country.highlight {
|
||||
background: rgba(20, 25, 40, 1) !important;
|
||||
}
|
||||
|
||||
.dropdown .country .flag {
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
|
||||
.dropdown .country .country-name {
|
||||
color: #ffffff !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.dropdown .country .dial-code {
|
||||
color: #ffffff !important;
|
||||
opacity: 0.7 !important;
|
||||
font-size: 14px !important;
|
||||
margin-left: auto !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
font-family: 'bill_corporate_medium', sans-serif;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Ensure consistent width with other form fields */
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.country {
|
||||
width: 45px !important;
|
||||
min-width: 45px !important;
|
||||
max-width: 45px !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 16px !important;
|
||||
left: 45px !important;
|
||||
width: calc(100% - 61px) !important;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
87
src/components/forms/PhoneField.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import PhoneInput, { CountryData } from "react-phone-input-2";
|
||||
import clsx from "clsx";
|
||||
import styles from "./PhoneField.module.css";
|
||||
|
||||
type PhoneFieldProps = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string, data: CountryData) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export function PhoneField({
|
||||
id = "phone",
|
||||
label = "Phone Number",
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Phone number",
|
||||
containerClassName,
|
||||
}: PhoneFieldProps) {
|
||||
const hasValue = useMemo(() => value?.trim().length > 0, [value]);
|
||||
|
||||
// Show + sign only when no country is selected (no value or very short value)
|
||||
const showPlus = useMemo(() => !value || value.length <= 1, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.wrapper,
|
||||
hasValue && styles.hasValue,
|
||||
showPlus && styles.noCountry,
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<label htmlFor={id} className={styles.label}>
|
||||
{label}
|
||||
{required ? " *" : ""}
|
||||
</label>
|
||||
)}
|
||||
<PhoneInput
|
||||
country={""}
|
||||
preferredCountries={["us", "pa", "gb"]}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
inputProps={{
|
||||
id,
|
||||
name: id,
|
||||
required,
|
||||
disabled,
|
||||
autoComplete: "tel",
|
||||
placeholder: placeholder
|
||||
}}
|
||||
containerClass={styles.container}
|
||||
buttonClass={styles.country}
|
||||
inputClass={styles.input}
|
||||
dropdownClass={styles.dropdown}
|
||||
searchClass={styles.search}
|
||||
specialLabel={""}
|
||||
disableDropdown={disabled}
|
||||
enableSearch
|
||||
searchPlaceholder="Search country"
|
||||
disableSearchIcon
|
||||
disableCountryCode={false}
|
||||
enableAreaCodes={false}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className={styles.error}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||