Initial commit: Port Amador landing site with Docker deployment setup
This commit is contained in:
76
src/app/api/contact/route.ts
Normal file
76
src/app/api/contact/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
const { firstName, lastName, email, phone, message } = data;
|
||||
|
||||
if (!firstName || !lastName || !email || !phone) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Email validation regex
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email address' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
console.log('Contact form submission received:', {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone,
|
||||
message: message || '(No message provided)',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 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>
|
||||
// `
|
||||
// });
|
||||
|
||||
// Return success response
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Thank you for your submission. We will contact you soon.',
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
email
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing contact form:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error. Please try again later.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
596
src/app/contact/page.tsx
Normal file
596
src/app/contact/page.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useMediaQuery } from '@react-hook/media-query';
|
||||
import { ChevronDown, Phone, Mail } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
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'),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function ContactPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [logoPosition, setLogoPosition] = useState('center');
|
||||
const [logoStyle, setLogoStyle] = useState<React.CSSProperties>({});
|
||||
const [buttonOpacity, setButtonOpacity] = useState(1);
|
||||
const [chevronOpacity, setChevronOpacity] = useState(1);
|
||||
const [contactTop, setContactTop] = useState(0);
|
||||
const [windowHeight, setWindowHeight] = useState(0);
|
||||
|
||||
const contactSectionRef = useRef<HTMLDivElement>(null);
|
||||
const logoRef = useRef<HTMLDivElement>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const lastScrollY = useRef(0);
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const isDesktop = useMediaQuery("(min-width: 1280px)");
|
||||
|
||||
// Manage form state
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
console.log(values);
|
||||
}
|
||||
|
||||
// Logo dimensions based on screen size
|
||||
const logoWidth = isMobile ? 240 : isDesktop ? 316 : 280;
|
||||
const logoHeight = isMobile ? 115 : isDesktop ? 151 : 134;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Set initial dimensions on mount
|
||||
setWindowHeight(window.innerHeight);
|
||||
if (contactSectionRef.current) {
|
||||
setContactTop(contactSectionRef.current.offsetTop);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateLogoPosition = () => {
|
||||
if (!contactSectionRef.current) return;
|
||||
|
||||
const scrollY = window.scrollY;
|
||||
const currentWindowHeight = window.innerHeight;
|
||||
const currentContactTop = contactSectionRef.current.offsetTop;
|
||||
|
||||
// Update cached values if needed
|
||||
if (currentContactTop !== contactTop) {
|
||||
setContactTop(currentContactTop);
|
||||
}
|
||||
if (currentWindowHeight !== windowHeight) {
|
||||
setWindowHeight(currentWindowHeight);
|
||||
}
|
||||
|
||||
// Calculate positions - adjusted for scaled logo
|
||||
const targetTopPosition = isMobile ? 10 : 20; // Adjusted for smaller scaled logo
|
||||
|
||||
if (isMobile) {
|
||||
// For mobile, calculate where the logo should end up
|
||||
const startY = currentWindowHeight * 0.35; // Logo starts higher - 35% from top
|
||||
const endY = targetTopPosition + (logoHeight * 0.5) / 2; // Account for 50% scale
|
||||
const totalDistance = startY - endY;
|
||||
|
||||
// Keep animation ending at the full contact section position
|
||||
const animationEndScroll = currentContactTop;
|
||||
|
||||
if (scrollY >= animationEndScroll) {
|
||||
// Logo has reached destination - keep it fixed but move with scroll
|
||||
setLogoPosition('top');
|
||||
|
||||
// Calculate position to simulate being part of the page
|
||||
const scrollPastEnd = scrollY - animationEndScroll;
|
||||
const fixedY = endY - scrollPastEnd;
|
||||
const mobileScale = 0.5; // Final scale for mobile
|
||||
|
||||
setLogoStyle({
|
||||
position: 'fixed',
|
||||
top: `${fixedY}px`,
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
zIndex: 50
|
||||
});
|
||||
} else if (scrollY > 0) {
|
||||
// Animate logo from center to destination - starts immediately at any scroll
|
||||
const progress = scrollY / animationEndScroll;
|
||||
const currentY = startY - (totalDistance * progress);
|
||||
const mobileScale = 1 - (0.5 * progress); // Scale from 1.0 to 0.5
|
||||
|
||||
setLogoPosition('animating');
|
||||
setLogoStyle({
|
||||
position: 'fixed',
|
||||
top: `${currentY}px`,
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
zIndex: 50
|
||||
});
|
||||
} else {
|
||||
// At the top - logo at starting position
|
||||
setLogoPosition('center');
|
||||
setLogoStyle({
|
||||
position: 'fixed',
|
||||
top: `${startY}px`,
|
||||
left: '50%',
|
||||
transform: 'translate3d(-50%, 0, 0) scale3d(1, 1, 1)',
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
zIndex: 50
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Desktop - standard animation with scaling
|
||||
const logoSpeed = 0.4;
|
||||
const centerY = currentWindowHeight / 2;
|
||||
const targetTopPosition = 20; // Reduced from 100px to account for smaller logo
|
||||
const totalDistance = centerY - targetTopPosition - logoHeight / 2;
|
||||
const logoYPosition = -(scrollY * logoSpeed);
|
||||
const maxUpwardMovement = -totalDistance;
|
||||
const animatedY = Math.max(logoYPosition, maxUpwardMovement);
|
||||
|
||||
// Calculate scale based on scroll progress
|
||||
const maxScroll = 500; // Scroll distance at which scaling completes
|
||||
const scrollProgress = Math.min(scrollY / maxScroll, 1);
|
||||
const scale = 1 - (0.6 * scrollProgress); // Scale from 1.0 to 0.4
|
||||
|
||||
// Update state based on scroll
|
||||
if (scrollY > 10) {
|
||||
setLogoPosition('animating');
|
||||
} else {
|
||||
setLogoPosition('center');
|
||||
}
|
||||
|
||||
// Fixed positioning with animation and scaling
|
||||
setLogoStyle({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: `translate3d(-50%, calc(-50% + ${animatedY}px), 0) scale3d(${scale}, ${scale}, 1)`,
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
zIndex: 50
|
||||
});
|
||||
}
|
||||
|
||||
// Hide button and chevron - faster on mobile
|
||||
const fadeThreshold = isMobile ? 5 : 10; // Fade out at just 5px scroll on mobile
|
||||
if (scrollY > fadeThreshold) {
|
||||
setButtonOpacity(0);
|
||||
setChevronOpacity(0);
|
||||
} else {
|
||||
setButtonOpacity(1);
|
||||
setChevronOpacity(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Animated scroll to form
|
||||
const scrollToForm = () => {
|
||||
if (!contactSectionRef.current) {
|
||||
console.error('Contact section ref not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting scroll animation to:', contactSectionRef.current.offsetTop);
|
||||
|
||||
// Use native smooth scrolling
|
||||
window.scrollTo({
|
||||
top: contactSectionRef.current.offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
// Add scroll listener for bidirectional animation with RAF
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
|
||||
const handleScroll = () => {
|
||||
lastScrollY.current = window.scrollY;
|
||||
|
||||
if (!ticking) {
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
updateLogoPosition();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
setWindowHeight(window.innerHeight);
|
||||
if (contactSectionRef.current) {
|
||||
setContactTop(contactSectionRef.current.offsetTop);
|
||||
}
|
||||
updateLogoPosition();
|
||||
};
|
||||
|
||||
// Initial position update
|
||||
updateLogoPosition();
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [isMobile, isDesktop, logoHeight, windowHeight]);
|
||||
|
||||
// Don't render until mounted to avoid hydration mismatch
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-[#1b233b]">
|
||||
{/* Hero Section - Full Viewport Height */}
|
||||
<section className="relative h-screen flex flex-col items-center justify-center">
|
||||
{/* Single Port Amador Logo with dynamic positioning - Always rendered */}
|
||||
<div
|
||||
ref={logoRef}
|
||||
className="z-50"
|
||||
style={logoStyle}
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Port Amador"
|
||||
width={logoWidth}
|
||||
height={logoHeight}
|
||||
priority
|
||||
style={{
|
||||
width: `${logoWidth}px`,
|
||||
height: `${logoHeight}px`,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Button with fade out on scroll */}
|
||||
<button
|
||||
onClick={scrollToForm}
|
||||
className={`fixed z-30 px-8 py-3 bg-[#C6AE97] text-[#1B233B] font-['bill_corporate_medium'] font-normal text-base uppercase tracking-wider rounded-md hover:bg-[#D4C1AC] transition-all`}
|
||||
style={{
|
||||
bottom: '120px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
opacity: buttonOpacity,
|
||||
pointerEvents: buttonOpacity > 0 ? 'auto' : 'none',
|
||||
transition: 'opacity 0.3s ease-out'
|
||||
}}
|
||||
>
|
||||
CONNECT WITH US
|
||||
</button>
|
||||
|
||||
{/* Chevron Down - with fade out on scroll */}
|
||||
<div
|
||||
className="fixed z-20"
|
||||
style={{
|
||||
bottom: '40px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
opacity: chevronOpacity,
|
||||
transition: 'opacity 0.3s ease-out'
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
className="text-[#C6AE97] animate-bounce"
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Section - Desktop Layout with Marina Image */}
|
||||
<section
|
||||
ref={contactSectionRef}
|
||||
className="w-full relative"
|
||||
>
|
||||
{isMobile ? (
|
||||
// Mobile Layout - Stacked with Image
|
||||
<div className="flex flex-col min-h-screen pt-[150px]">
|
||||
{/* Form Section */}
|
||||
<div className="px-8 pb-12">
|
||||
<h2 className="font-['Palatino',_serif] text-[#C6AE97] text-[40px] mb-8 font-normal text-center">
|
||||
Connect with us
|
||||
</h2>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="First Name*"
|
||||
{...field}
|
||||
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 py-2 font-['bill_corporate_medium'] font-light text-[16px]"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Last Name*"
|
||||
{...field}
|
||||
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 py-2 font-['bill_corporate_medium'] font-light text-[16px]"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Email*"
|
||||
type="email"
|
||||
{...field}
|
||||
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 py-2 font-['bill_corporate_medium'] font-light text-[16px]"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<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]"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Message"
|
||||
{...field}
|
||||
className="min-h-[80px] bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 py-2 font-['bill_corporate_medium'] font-light text-[16px] resize-none"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<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]"
|
||||
>
|
||||
SUBMIT
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Marina Image Section */}
|
||||
<div className="relative w-full h-[300px] mt-8 px-4">
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src="/marina.png"
|
||||
alt="Port Amador Marina"
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
sizes="(max-width: 768px) 90vw, 100vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="px-8 py-8 mt-auto">
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex flex-col space-y-0">
|
||||
<a href="tel:+13109132597" className="font-['bill_corporate_medium'] font-light text-[14px] text-[#C6AE97] hover:text-[#D4C1AC] transition-colors">
|
||||
+1 310 913 2597
|
||||
</a>
|
||||
<a href="mailto:am@portamador.com" className="font-['bill_corporate_medium'] font-light text-[14px] text-[#C6AE97] hover:text-[#D4C1AC] transition-colors">
|
||||
am@portamador.com
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-[#C6AE97] text-[14px] font-['bill_corporate_medium'] font-light">
|
||||
© Port Amador 2025
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Desktop Layout - Matching Figma exactly
|
||||
<div className="min-h-screen flex flex-col relative pt-[200px]">
|
||||
<div className="w-full max-w-[1600px] mx-auto flex items-stretch flex-1">
|
||||
{/* Left Side - Marina Image - Aligned with form content */}
|
||||
<div className="w-[45%] relative">
|
||||
<div className="absolute top-0 bottom-0 left-[80px] right-[40px]">
|
||||
<Image
|
||||
src="/marina.png"
|
||||
alt="Port Amador Marina"
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
sizes="45vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Form Section */}
|
||||
<div className="w-[55%] flex flex-col justify-center py-[40px] pl-[40px] pr-[80px]">
|
||||
{/* Heading */}
|
||||
<h2 className="font-['Palatino',_serif] text-[#C6AE97] text-[72px] leading-none mb-12 font-normal">
|
||||
Connect with us
|
||||
</h2>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* First Row - First Name and Last Name */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="First Name*"
|
||||
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 pb-1 pt-0 font-['bill_corporate_medium'] font-light text-[16px] focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Last Name*"
|
||||
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 pb-1 pt-0 font-['bill_corporate_medium'] font-light text-[16px] focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Second Row - Email and Phone */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
{...field}
|
||||
placeholder="Email*"
|
||||
className="bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 pb-1 pt-0 font-['bill_corporate_medium'] font-light text-[16px] focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<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"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Message"
|
||||
className="min-h-[60px] bg-transparent border-b border-t-0 border-l-0 border-r-0 border-white/60 text-white placeholder:text-white/70 focus:border-white rounded-none px-0 pb-1 pt-0 font-['bill_corporate_medium'] font-light text-[16px] resize-none focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#C6AE97] text-[#1B233B] hover:bg-[#D4C1AC] font-['bill_corporate_medium'] font-medium text-[18px] uppercase tracking-[0.05em] h-[50px] rounded-[3px]"
|
||||
>
|
||||
SUBMIT
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer - at bottom of page */}
|
||||
<div className="w-full max-w-[1600px] mx-auto px-[80px] pb-8 pt-12">
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex flex-col space-y-0 text-[#C6AE97]">
|
||||
<a href="tel:+13109132597" className="font-['bill_corporate_medium'] font-light text-[14px] hover:text-[#D4C1AC] transition-colors">
|
||||
+1 310 913 2597
|
||||
</a>
|
||||
<a href="mailto:am@portamador.com" className="font-['bill_corporate_medium'] font-light text-[14px] hover:text-[#D4C1AC] transition-colors">
|
||||
am@portamador.com
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-[#C6AE97] text-[14px] font-['bill_corporate_medium'] font-light">
|
||||
© Port Amador 2025
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,171 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* Custom fonts */
|
||||
@font-face {
|
||||
font-family: 'Palatino';
|
||||
src: local('Palatino'), local('Palatino Linotype'), local('Book Antiqua');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Bill Corporate Medium - Book weight */
|
||||
@font-face {
|
||||
font-family: 'bill corporate medium';
|
||||
src: url('/fonts/Bill corporate medium book.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Bill Corporate Medium - Roman/Regular weight */
|
||||
@font-face {
|
||||
font-family: 'bill corporate medium';
|
||||
src: url('/fonts/Bill corporate medium roman.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Bill Corporate Medium - Medium weight (using roman as fallback) */
|
||||
@font-face {
|
||||
font-family: 'bill corporate medium';
|
||||
src: url('/fonts/Bill corporate medium roman.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-serif: 'Palatino', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
/* Port Amador Brand Colors */
|
||||
--color-navy: #1B233B;
|
||||
--color-gold: #C6AE97;
|
||||
--color-navy-light: #2A3550;
|
||||
--color-gold-light: #D4C1AC;
|
||||
--color-gold-dark: #B89D84;
|
||||
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--radius: 0.313rem; /* 5px from Figma design */
|
||||
--background: #ffffff;
|
||||
--foreground: #1B233B; /* Navy */
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1B233B;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1B233B;
|
||||
--primary: #C6AE97; /* Gold */
|
||||
--primary-foreground: #1B233B;
|
||||
--secondary: #1B233B; /* Navy */
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #f4f4f4;
|
||||
--muted-foreground: #6c6c6c;
|
||||
--accent: #C6AE97; /* Gold */
|
||||
--accent-foreground: #1B233B;
|
||||
--destructive: #ef4444;
|
||||
--border: #e5e5e5;
|
||||
--input: #e5e5e5;
|
||||
--ring: #C6AE97; /* Gold focus ring */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #1B233B; /* Navy background */
|
||||
--foreground: #ffffff;
|
||||
--card: #2A3550; /* Lighter navy */
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #2A3550;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #C6AE97; /* Gold remains primary */
|
||||
--primary-foreground: #1B233B;
|
||||
--secondary: #D4C1AC; /* Light gold */
|
||||
--secondary-foreground: #1B233B;
|
||||
--muted: #2A3550;
|
||||
--muted-foreground: #a0a0a0;
|
||||
--accent: #C6AE97; /* Gold accent */
|
||||
--accent-foreground: #1B233B;
|
||||
--destructive: #ef4444;
|
||||
--border: rgba(198, 174, 151, 0.2); /* Gold border with transparency */
|
||||
--input: rgba(198, 174, 151, 0.15);
|
||||
--ring: #C6AE97; /* Gold focus ring */
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Enable smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
BIN
src/app/icon.png
Normal file
BIN
src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -13,9 +13,9 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
title: "Port Amador",
|
||||
description: "Premium marine equipment and services",
|
||||
};;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -25,7 +25,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[#1b233b]`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
107
src/app/page.tsx
107
src/app/page.tsx
@@ -1,103 +1,18 @@
|
||||
import Image from "next/image";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<div className="min-h-screen bg-[#1b233b] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-['Palatino',_serif] text-[#C6AE97] mb-4">PORT AMADOR</h1>
|
||||
<p className="text-xl text-white font-['bill_corporate_medium'] font-light mb-8">PANAMA</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-block bg-[#C6AE97] text-[#1B233B] px-8 py-3 rounded-[5px] font-['bill_corporate_medium'] font-medium text-lg hover:bg-[#D4C1AC] transition-colors"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
VIEW CONTACT PAGE
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
167
src/components/ui/form.tsx
Normal file
167
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user