Major UI/UX improvements and NocoDB integration
Build And Push Image / docker (push) Successful in 2m18s
Details
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:
parent
6aa4284c7b
commit
bd96a15650
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
|
|
@ -1,75 +1,81 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
const { firstName, lastName, email, phone, message } = data;
|
||||
|
||||
if (!firstName || !lastName || !email || !phone) {
|
||||
if (!body.firstName || !body.lastName || !body.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation regex
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
// Get NocoDB configuration
|
||||
const NOCODB_API_TOKEN = process.env.NOCODB_API_TOKEN;
|
||||
const NOCODB_BASE_URL = process.env.NOCODB_BASE_URL || 'https://app.nocodb.com';
|
||||
const NOCODB_TABLE_ID = process.env.NOCODB_TABLE_ID || 'contacts';
|
||||
|
||||
if (!NOCODB_API_TOKEN) {
|
||||
console.error('NOCODB_API_TOKEN not configured');
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
{ error: 'Server configuration error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Log the submission (in production, you would:
|
||||
// 1. Send an email notification using SendGrid, Postmark, or Resend
|
||||
// 2. Store in a database like Supabase or PostgreSQL
|
||||
// 3. Integrate with CRM like HubSpot or Salesforce)
|
||||
// Prepare data for NocoDB with correct field names
|
||||
// Ensure phone number has '+' prefix if it doesn't already
|
||||
const phoneNumber = body.phone ?
|
||||
(body.phone.startsWith('+') ? body.phone : `+${body.phone}`) : '';
|
||||
|
||||
console.log('Contact form submission received:', {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
message: message || '(No message provided)',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
const contactData = {
|
||||
fields: {
|
||||
'First Name': body.firstName,
|
||||
'Last Name': body.lastName,
|
||||
'Full Name': `${body.firstName} ${body.lastName}`,
|
||||
'Email': body.email,
|
||||
'Telephone': phoneNumber,
|
||||
'Message': body.message || '',
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate email sending (in production, replace with actual email service)
|
||||
// Example with Resend:
|
||||
// await resend.emails.send({
|
||||
// from: 'onboarding@portamador.com',
|
||||
// to: 'am@portamador.com',
|
||||
// subject: `New Contact Form Submission from ${firstName} ${lastName}`,
|
||||
// html: `
|
||||
// <h2>New Contact Form Submission</h2>
|
||||
// <p><strong>Name:</strong> ${firstName} ${lastName}</p>
|
||||
// <p><strong>Email:</strong> ${email}</p>
|
||||
// <p><strong>Phone:</strong> ${phone}</p>
|
||||
// <p><strong>Message:</strong> ${message || 'No message provided'}</p>
|
||||
// `
|
||||
// });
|
||||
// Send to NocoDB - using v3 API with correct project ID
|
||||
const nocdbResponse = await fetch(
|
||||
`${NOCODB_BASE_URL}/api/v3/data/p4bq8r1rmsfu77o/${NOCODB_TABLE_ID}/records`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'xc-token': NOCODB_API_TOKEN,
|
||||
},
|
||||
body: JSON.stringify(contactData),
|
||||
}
|
||||
);
|
||||
|
||||
// Return success response
|
||||
if (!nocdbResponse.ok) {
|
||||
const errorText = await nocdbResponse.text();
|
||||
console.error('NocoDB error:', errorText);
|
||||
throw new Error('Failed to save contact');
|
||||
}
|
||||
|
||||
const result = await nocdbResponse.json();
|
||||
|
||||
// Send success response
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Thank you for your submission. We will contact you soon.',
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
email
|
||||
}
|
||||
message: 'Contact form submitted successfully',
|
||||
id: result.Id || result.id,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing contact form:', error);
|
||||
console.error('Contact form error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error. Please try again later.' },
|
||||
{ error: 'Failed to process contact form' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
174
src/app/page.tsx
174
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<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,20 +650,18 @@ 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)]">
|
||||
<Image
|
||||
src="/marina.jpg"
|
||||
alt="Port Amador Marina"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 45vw"
|
||||
priority
|
||||
style={{ objectPosition: 'center top' }}
|
||||
/>
|
||||
</div>
|
||||
<div ref={imageContainerRef} className="relative h-full lg:h-auto">
|
||||
<Image
|
||||
src="/marina_cropped.jpg"
|
||||
alt="Port Amador Marina"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 45vw"
|
||||
priority
|
||||
style={{ objectPosition: 'center top' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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]"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue