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() { )} /> -
+