Compare commits

...

14 Commits

Author SHA1 Message Date
bb3cbf5f1a Update Next.js and dependencies to latest versions
All checks were successful
Build And Push Image / docker (push) Successful in 2m23s
2026-01-11 20:31:31 +01:00
d593230c8a Update marina image to v5 and improve favicon support across devices
All checks were successful
Build And Push Image / docker (push) Successful in 2m1s
- Updated marina image from v3 to v5 for both mobile and desktop layouts
- Moved favicon files to src/app/ for automatic icon handling
- Added icon.svg for modern browsers and updated icon.png for Apple devices
- Removed manual icon metadata configuration
- Icons now automatically serve the correct format for each device
2025-10-20 14:29:59 +02:00
2d5ffcb268 Update marina image to use v3 across all devices
All checks were successful
Build And Push Image / docker (push) Successful in 2m6s
Switched to marina_cropped_v3.jpg for both mobile and desktop layouts, showcasing the premium evening marina view with luxury yachts and resort amenities.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 21:21:28 +02:00
7c9aedb92d Update favicon to use SVG format
All checks were successful
Build And Push Image / docker (push) Successful in 2m15s
Switch from favicon.ico to favicon.svg for better quality scaling across all device sizes. Added apple icon support for improved iOS compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 20:23:47 +02:00
ee56c650d6 Fix mobile logo animation and layout positioning
All checks were successful
Build And Push Image / docker (push) Successful in 2m27s
- Remove bounce effect from logo animation by fixing endY calculation
- Adjust logo final position to 10px from top to avoid text collision
- Synchronize scroll anchor with animation endpoint
- Reduce form padding and tighten spacing between elements
- Make submit button more compact
- Ensure footer sits closer to bottom of page
- Update marina image to v3 with object-bottom positioning

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 19:58:20 +02:00
21a83a1db9 Reduce mobile marina image frame height by 36% (from 300px to 192px)
All checks were successful
Build And Push Image / docker (push) Successful in 2m56s
2025-09-24 02:28:19 +02:00
bbee08bfb7 Fix iOS Safari favicon by adding PNG icon and updating metadata
All checks were successful
Build And Push Image / docker (push) Successful in 2m17s
2025-09-23 20:08:44 +02:00
bd96a15650 Major UI/UX improvements and NocoDB integration
All checks were successful
Build And Push Image / docker (push) Successful in 2m18s
- Fixed phone field country dropdown positioning and styling
- Added + sign placeholder when no country selected
- Improved dropdown colors for better contrast
- Adjusted mobile marina image spacing (closer to submit button)
- Fine-tuned desktop image frame positioning
- Integrated NocoDB database for form submissions
- Added phone number formatting with country codes
- Extended toast notification duration for better readability
- Set favicon.ico in metadata
- Removed unnecessary phone validation
- Disabled /contact route (using root page only)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-23 19:55:48 +02:00
6aa4284c7b Fix marina image top alignment to match heading
All checks were successful
Build And Push Image / docker (push) Successful in 2m8s
- Adjusted image container to align precisely with Connect with us heading
- Top of image now starts at the same level as the C in the heading
- Used CSS grid and proper height calculation for accurate alignment
- Changed object-position to center top for better visual alignment
2025-09-23 00:00:21 +02:00
d699c2522a Align marina image with form boundaries
All checks were successful
Build And Push Image / docker (push) Successful in 1m55s
- Image now aligns with top of 'Connect with us' heading
- Bottom aligns with Submit button
- Improved responsive layout for all screen sizes
- Changed marina.png to marina.jpg
- Fixed layout structure using flexbox for better alignment
2025-09-22 23:53:09 +02:00
90dbc75123 Replace main page with contact page
All checks were successful
Build And Push Image / docker (push) Successful in 2m2s
- Contact page is now the homepage
- Removed simple landing page
- All animations and optimizations included
2025-09-22 23:33:19 +02:00
d1c6e87225 Optimize contact page animations for smooth performance
All checks were successful
Build And Push Image / docker (push) Successful in 1m56s
- Replace React state with refs to eliminate re-renders
- Add RAF throttling for scroll events
- Implement GPU acceleration with transform3d
- Fix logo positioning and flickering issues
- Optimize mobile button text display
- Add memoization for expensive calculations
- Improve performance from 60+ re-renders/sec to 60fps smooth animation
2025-09-22 23:27:29 +02:00
eda3fb4522 Replace favicon with Port Amador logo ICO
All checks were successful
Build And Push Image / docker (push) Successful in 2m10s
2025-09-22 16:40:42 +02:00
3f06edee2f Fix favicon - use Port Amador logo 2025-09-22 16:37:47 +02:00
23 changed files with 2271 additions and 830 deletions

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 40 KiB

BIN
public/marina.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

BIN
public/marina_cropped.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@@ -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 }
);
}

View File

@@ -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'
}}
>
@@ -294,13 +385,13 @@ export default function ContactPage() {
</button>
{/* Chevron Down - with fade out on scroll */}
<div
<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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -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 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

1
src/app/icon.svg Normal file
View 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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -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>
);
}
}

View 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;
}
}

View 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>
);
}

0
srcappapicontactroute.ts Normal file
View File