Major UI/UX improvements and NocoDB integration
Build And Push Image / docker (push) Successful in 2m18s Details

- 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>
This commit is contained in:
Matt 2025-09-23 19:55:48 +02:00
parent 6aa4284c7b
commit bd96a15650
11 changed files with 644 additions and 99 deletions

89
package-lock.json generated
View File

@ -14,12 +14,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",
"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"
},
@ -2431,6 +2434,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -2492,7 +2501,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -3396,6 +3404,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@ -3614,6 +3628,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -4234,7 +4257,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@ -4599,6 +4621,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4606,11 +4640,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.reduce": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz",
"integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==",
"license": "MIT"
},
"node_modules/lodash.startswith": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/lodash.startswith/-/lodash.startswith-4.2.1.tgz",
"integrity": "sha512-XClYR1h4/fJ7H+mmCKppbiBmljN/nGs73iq2SjCT9SF4CBPoUHzLvWmH1GtZMhMBZSiRkHXfeA2RY1eIlJ75ww==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@ -4881,7 +4926,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -5180,7 +5224,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@ -5256,13 +5299,47 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-phone-input-2": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/react-phone-input-2/-/react-phone-input-2-2.15.1.tgz",
"integrity": "sha512-W03abwhXcwUoq+vUFvC6ch2+LJYMN8qSOiO889UH6S7SyMCQvox/LF3QWt+cZagZrRdi5z2ON3omnjoCUmlaYw==",
"license": "MIT",
"dependencies": {
"classnames": "^2.2.6",
"lodash.debounce": "^4.0.8",
"lodash.memoize": "^4.1.2",
"lodash.reduce": "^4.6.0",
"lodash.startswith": "^4.2.1",
"prop-types": "^15.7.2"
},
"peerDependencies": {
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0",
"react-dom": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",

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

BIN
public/marina_cropped.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 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

@ -16,12 +16,13 @@ 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(),
});
@ -465,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>
@ -505,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"
@ -519,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">
@ -617,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>

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 {

View File

@ -15,7 +15,12 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Port Amador",
description: "Premium marine equipment and services",
};;
icons: {
icon: '/favicon.ico',
shortcut: '/favicon.ico',
apple: '/favicon.ico',
},
};
export default function RootLayout({
children,

View File

@ -16,12 +16,14 @@ import {
} 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(1, 'Phone is required'),
phone: z.string().min(10, 'Please enter a valid phone number'),
message: z.string().optional(),
});
@ -42,6 +44,11 @@ export default function Home() {
const lastFrameTime = useRef(0);
const ticking = useRef(false);
// Refs for dynamic image alignment
const headingRef = useRef<HTMLHeadingElement>(null);
const submitButtonRef = useRef<HTMLButtonElement>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery("(max-width: 768px)");
const isDesktop = useMediaQuery("(min-width: 1280px)");
@ -58,7 +65,30 @@ export default function Home() {
});
async function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values);
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
@ -75,8 +105,8 @@ export default function Home() {
// Memoized animation constants
const animationConstants = useMemo(() => ({
mobile: {
targetTopPosition: 10,
startYFactor: 0.30, // Moved up from 0.35 to account for single-line button
targetTopPosition: -50, // Logo ends even higher for better spacing
startYFactor: 0.20, // Moved up to position logo higher on mobile
finalScale: 0.5,
fadeThreshold: 5
},
@ -297,6 +327,59 @@ export default function Home() {
};
}, [isMobile, isDesktop, logoHeight, windowHeight]);
// Dynamic height adjustment for image alignment
useEffect(() => {
const updateImageHeight = () => {
if (!isMobile && headingRef.current && submitButtonRef.current && imageContainerRef.current) {
// Get the bounding rectangles for accurate positioning
const headingRect = headingRef.current.getBoundingClientRect();
const buttonRect = submitButtonRef.current.getBoundingClientRect();
const containerRect = imageContainerRef.current.parentElement?.getBoundingClientRect();
if (containerRect) {
// Calculate relative positions within the container
const headingTop = headingRect.top - containerRect.top;
const buttonBottom = buttonRect.bottom - containerRect.top;
const height = buttonBottom - headingTop - 12; // Reduce height by 12px
// Set a minimum height and apply
const finalHeight = Math.max(height, 400); // Ensure minimum height of 400px
// Apply the calculated height
imageContainerRef.current.style.height = `${finalHeight}px`;
if (headingTop > 0) {
imageContainerRef.current.style.top = `${headingTop + 12}px`; // Start 12px lower to shave off top
imageContainerRef.current.style.position = 'absolute';
} else {
imageContainerRef.current.style.top = '12px';
imageContainerRef.current.style.position = 'relative';
}
}
} else if (!isMobile && imageContainerRef.current) {
// Fallback for desktop when refs aren't ready
imageContainerRef.current.style.height = '500px';
imageContainerRef.current.style.position = 'relative';
}
};
// Update on mount and resize with multiple attempts
updateImageHeight();
const timer1 = setTimeout(updateImageHeight, 100);
const timer2 = setTimeout(updateImageHeight, 500);
const timer3 = setTimeout(updateImageHeight, 1000);
window.addEventListener('resize', updateImageHeight);
window.addEventListener('load', updateImageHeight);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
clearTimeout(timer3);
window.removeEventListener('resize', updateImageHeight);
window.removeEventListener('load', updateImageHeight);
};
}, [isMobile, mounted]);
// Store if initial position has been set
const initialPositionSet = useRef(false);
@ -346,6 +429,28 @@ export default function Home() {
return (
<div className="w-full bg-[#1b233b]">
<Toaster
position="top-center"
toastOptions={{
style: {
background: '#1b233b',
color: '#fff',
border: '1px solid #C6AE97',
},
success: {
iconTheme: {
primary: '#C6AE97',
secondary: '#1b233b',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#1b233b',
},
},
}}
/>
{/* Hero Section - Full Viewport Height */}
<section className="relative h-screen flex flex-col items-center justify-center">
{/* Single Port Amador Logo with dynamic positioning - Always rendered */}
@ -408,7 +513,7 @@ export default function Home() {
>
{isMobile ? (
// Mobile Layout - Stacked with Image
<div className="flex flex-col min-h-screen pt-[150px]">
<div className="flex flex-col min-h-[85vh] pt-[60px]">
{/* Form Section */}
<div className="px-8 pb-12">
<h2 className="font-['Palatino',_serif] text-[#C6AE97] text-[40px] mb-8 font-normal text-center">
@ -465,13 +570,16 @@ export default function Home() {
<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>
@ -492,7 +600,7 @@ export default function Home() {
</FormItem>
)}
/>
<div className="pt-6">
<div className="pt-3">
<Button
type="submit"
className="w-full bg-[#C6AE97] text-[#1B233B] hover:bg-[#D4C1AC] font-['bill_corporate_medium'] font-medium text-[16px] uppercase tracking-wider py-3 rounded-[5px]"
@ -505,10 +613,10 @@ export default function Home() {
</div>
{/* Marina Image Section */}
<div className="relative w-full h-[300px] mt-8 px-4">
<div className="relative w-full h-[300px] -mt-6 px-4">
<div className="relative w-full h-full">
<Image
src="/marina.jpg"
src="/marina_cropped.jpg"
alt="Port Amador Marina"
fill
className="object-cover object-center"
@ -542,12 +650,11 @@ export default function Home() {
<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">
<div className="lg:pr-[40px] h-[400px] lg:h-auto lg:relative">
{/* Image positioned to align with heading */}
<div className="relative h-full lg:sticky lg:top-0">
<div className="absolute inset-0 lg:relative lg:h-[calc(72px+3rem+400px)]">
<div ref={imageContainerRef} className="relative h-full lg:h-auto">
<Image
src="/marina.jpg"
src="/marina_cropped.jpg"
alt="Port Amador Marina"
fill
className="object-cover"
@ -558,7 +665,6 @@ export default function Home() {
</div>
</div>
</div>
</div>
{/* Right Side - Form Section */}
<div className="order-1 lg:order-2">
@ -566,7 +672,7 @@ export default function Home() {
{/* Form content wrapper */}
<div className="flex flex-col">
{/* Heading */}
<h2 className="font-['Palatino',_serif] text-[#C6AE97] text-[48px] md:text-[60px] lg:text-[72px] leading-none mb-8 lg:mb-12 font-normal">
<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>
@ -627,13 +733,16 @@ export default function Home() {
<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>
@ -661,6 +770,7 @@ export default function Home() {
{/* 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]"
>

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