diff --git a/package-lock.json b/package-lock.json
index 0d44e13..00ad1cf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 005cc37..df3c20c 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/public/marina_cropped.jpg b/public/marina_cropped.jpg
new file mode 100644
index 0000000..a311ad6
Binary files /dev/null and b/public/marina_cropped.jpg differ
diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts
index 09dc3e5..719e15b 100644
--- a/src/app/api/contact/route.ts
+++ b/src/app/api/contact/route.ts
@@ -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: `
- //
New Contact Form Submission
- // Name: ${firstName} ${lastName}
- // Email: ${email}
- // Phone: ${phone}
- // Message: ${message || 'No message provided'}
- // `
- // });
+ // 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 }
);
}
diff --git a/src/app/contact/page.tsx b/src/app/contact.disabled/page.tsx
similarity index 95%
rename from src/app/contact/page.tsx
rename to src/app/contact.disabled/page.tsx
index 6931132..0fc1f48 100644
--- a/src/app/contact/page.tsx
+++ b/src/app/contact.disabled/page.tsx
@@ -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() {
(
+ render={({ field, fieldState }) => (
- field.onChange(value)}
+ onBlur={field.onBlur}
+ error={fieldState.error?.message}
+ required
+ placeholder="Phone number"
/>
@@ -505,7 +509,7 @@ export default function ContactPage() {
{/* Marina Image Section */}
-
+
{/* Footer Section */}
-
+
@@ -617,13 +621,16 @@ export default function ContactPage() {
(
+ render={({ field, fieldState }) => (
- field.onChange(value)}
+ onBlur={field.onBlur}
+ error={fieldState.error?.message}
+ required
+ placeholder="Phone number"
/>
diff --git a/src/app/globals.css b/src/app/globals.css
index 3f8817b..1bccfc3 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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 {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 50df4ee..f19568f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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,
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 893d9b2..e51af20 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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(null);
+ const submitButtonRef = useRef(null);
+ const imageContainerRef = useRef(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) {
- 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 (
+
{/* Hero Section - Full Viewport Height */}
{/* Single Port Amador Logo with dynamic positioning - Always rendered */}
@@ -408,7 +513,7 @@ export default function Home() {
>
{isMobile ? (
// Mobile Layout - Stacked with Image
-
+
{/* Form Section */}
@@ -465,13 +570,16 @@ export default function Home() {
(
+ render={({ field, fieldState }) => (
- field.onChange(value)}
+ onBlur={field.onBlur}
+ error={fieldState.error?.message}
+ required
+ placeholder="Phone number"
/>
@@ -492,7 +600,7 @@ export default function Home() {
)}
/>
-
+