This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
import { getLocale } from '@/lib/locale';
|
||||
import general from '../content/settings/general.json';
|
||||
import FooterAnimated from '@/components/ui/footer-animated';
|
||||
|
||||
const locale = getLocale(Astro);
|
||||
const [footerEntry] = await getCollection('footer', (e) => e.id === `${locale}/main`);
|
||||
@@ -14,142 +15,17 @@ const year = new Date().getFullYear();
|
||||
const legalNotice = footer.legal.replace('{year}', String(year));
|
||||
---
|
||||
|
||||
<footer class="bg-brand-600 pt-14 px-8 text-white/85">
|
||||
<div
|
||||
class="container-site grid grid-cols-1 gap-7 pb-12 min-[541px]:grid-cols-2 min-[541px]:gap-8 min-[901px]:grid-cols-[1.5fr_2fr_1fr_1fr] min-[901px]:gap-10"
|
||||
>
|
||||
<div>
|
||||
<a href="/" aria-label={general.siteName}>
|
||||
<img
|
||||
src="/images/logo-vignette.svg"
|
||||
alt={general.siteName}
|
||||
width="140"
|
||||
height="52"
|
||||
loading="lazy"
|
||||
class="mb-4 block h-11 w-auto brightness-75 invert"
|
||||
/>
|
||||
</a>
|
||||
<p class="mb-2 text-sm font-normal text-white/75">{footer.tagline}</p>
|
||||
<p class="mb-4 flex items-center gap-1.5 text-sm text-white/65">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
{general.serviceArea}
|
||||
</p>
|
||||
{(general.facebook || general.instagram || general.linkedin) && (
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{general.facebook && (
|
||||
<a
|
||||
href={general.facebook}
|
||||
class="inline-flex items-center gap-2 rounded border border-white/30 px-3.5 py-1.5 text-sm font-bold text-white/80 transition-colors hover:bg-white/12 hover:text-white"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={footer.facebookLabel}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>
|
||||
</svg>
|
||||
{footer.facebookLabel}
|
||||
</a>
|
||||
)}
|
||||
{general.instagram && (
|
||||
<a
|
||||
href={general.instagram}
|
||||
class="inline-flex items-center gap-2 rounded border border-white/30 px-3.5 py-1.5 text-sm font-bold text-white/80 transition-colors hover:bg-white/12 hover:text-white"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||
</svg>
|
||||
Instagram
|
||||
</a>
|
||||
)}
|
||||
{general.linkedin && (
|
||||
<a
|
||||
href={general.linkedin}
|
||||
class="inline-flex items-center gap-2 rounded border border-white/30 px-3.5 py-1.5 text-sm font-bold text-white/80 transition-colors hover:bg-white/12 hover:text-white"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.778v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
LinkedIn
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
||||
{footer.serviceAreasTitle}
|
||||
</h3>
|
||||
<ul class="m-0 list-none columns-2 gap-x-6 p-0 [&>li]:mb-2">
|
||||
{footer.serviceAreas.map((area) => (
|
||||
<li class="text-[0.875rem] text-white/70 break-inside-avoid">{area}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
||||
{footer.navTitle}
|
||||
</h3>
|
||||
<ul class="m-0 flex list-none flex-col gap-2.5 p-0">
|
||||
{navLinks.map((link) => (
|
||||
<li><a href={link.href} class="text-[0.9375rem] text-white/80 transition-colors hover:text-white">{link.name}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
||||
{footer.contactTitle}
|
||||
</h3>
|
||||
<ul class="m-0 flex list-none flex-col gap-3 p-0">
|
||||
<li>
|
||||
<a href={`tel:${general.phone.replace(/\D/g, '')}`} class="flex items-start gap-2 text-[0.9375rem] text-white/80 transition-colors hover:text-white">
|
||||
<svg class="mt-[3px] shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2.18h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 9.91a16 16 0 0 0 6.18 6.18l.94-.94a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
|
||||
</svg>
|
||||
{general.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`mailto:${general.email}`} class="flex items-start gap-2 text-[0.9375rem] text-white/80 transition-colors hover:text-white">
|
||||
<svg class="mt-[3px] shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
{general.email}
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex items-start gap-2 text-[0.9375rem] text-white/70">
|
||||
<svg class="mt-[3px] shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
{general.address}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-white/15 py-[1.125rem]">
|
||||
<div
|
||||
class="container-site flex flex-wrap items-center justify-between gap-4 max-[540px]:flex-col max-[540px]:items-start max-[540px]:gap-2"
|
||||
>
|
||||
<p class="text-[0.8125rem] text-white/50">{legalNotice}</p>
|
||||
<p class="flex items-center gap-2.5 text-xs tracking-[0.02em] text-white/40">
|
||||
<span>{footer.rbq}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{footer.neq}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<FooterAnimated
|
||||
client:visible
|
||||
siteName={general.siteName}
|
||||
serviceArea={general.serviceArea}
|
||||
phone={general.phone}
|
||||
email={general.email}
|
||||
address={general.address}
|
||||
facebook={general.facebook}
|
||||
instagram={general.instagram}
|
||||
linkedin={general.linkedin}
|
||||
footer={footer}
|
||||
navLinks={navLinks}
|
||||
legalNotice={legalNotice}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Dialog, DialogPanel } from "@headlessui/react";
|
||||
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { PhoneIcon } from "@heroicons/react/24/solid";
|
||||
@@ -50,13 +51,15 @@ export default function Header({
|
||||
</div>
|
||||
<div className="hidden lg:flex lg:gap-x-12">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
<motion.a
|
||||
whileHover={{ scale: 1.1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="text-sm/6 font-semibold text-white hover:underline underline-offset-4 decoration-2 drop-shadow"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden lg:flex lg:flex-1 lg:justify-end">
|
||||
@@ -66,7 +69,11 @@ export default function Header({
|
||||
>
|
||||
{ctaLabel}
|
||||
</a> */}
|
||||
<dl className="flex gap-x-2 items-center justify-center text-xl font-semibold bg-white rounded-full p-2">
|
||||
<motion.dl
|
||||
className="flex gap-x-2 items-center justify-center text-xl font-semibold bg-white rounded-full p-2"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<dt className="flex-none">
|
||||
<span className="sr-only">Telephone</span>
|
||||
<PhoneIcon
|
||||
@@ -82,7 +89,7 @@ export default function Header({
|
||||
{general.phone}
|
||||
</a>
|
||||
</dd>
|
||||
</dl>
|
||||
</motion.dl>
|
||||
</div>
|
||||
</nav>
|
||||
<Dialog
|
||||
|
||||
288
src/components/sections/contact.tsx
Normal file
288
src/components/sections/contact.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { BuildingOffice2Icon, CheckCircleIcon, EnvelopeIcon, ExclamationTriangleIcon, PhoneIcon } from '@heroicons/react/24/outline'
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface Props {
|
||||
phoneLabel: string
|
||||
phoneValue: string
|
||||
emailLabel: string
|
||||
emailValue: string
|
||||
addressLabel: string
|
||||
addressValue: string
|
||||
serviceAreaLabel: string
|
||||
serviceArea: string
|
||||
firstNameLabel?: string
|
||||
lastNameLabel?: string
|
||||
emailFieldLabel?: string
|
||||
phoneFieldLabel?: string
|
||||
messageLabel?: string
|
||||
firstNamePlaceholder?: string
|
||||
lastNamePlaceholder?: string
|
||||
emailPlaceholder?: string
|
||||
phonePlaceholder?: string
|
||||
messagePlaceholder?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
interface ContactFields {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export default function Contact({
|
||||
phoneLabel,
|
||||
phoneValue,
|
||||
emailLabel,
|
||||
emailValue,
|
||||
addressLabel,
|
||||
addressValue,
|
||||
serviceAreaLabel,
|
||||
serviceArea,
|
||||
firstNameLabel = 'Prénom',
|
||||
lastNameLabel = 'Nom',
|
||||
emailFieldLabel = 'Courriel',
|
||||
phoneFieldLabel = 'Téléphone',
|
||||
messageLabel = 'Message',
|
||||
firstNamePlaceholder = 'Jean',
|
||||
lastNamePlaceholder = 'Tremblay',
|
||||
emailPlaceholder = 'nom@exemple.com',
|
||||
phonePlaceholder = '(514) 555-1234',
|
||||
messagePlaceholder = 'Décrivez votre projet...',
|
||||
submitLabel = 'Envoyer',
|
||||
}: Props) {
|
||||
const inputClass =
|
||||
'block w-full rounded-md bg-white px-3.5 py-2 text-base text-brand-dark outline-1 -outline-offset-1 outline-brand-200 placeholder:text-brand-400 focus:outline-2 focus:-outline-offset-2 focus:outline-brand-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-brand-400 dark:focus:outline-brand-500'
|
||||
const inputErrorClass =
|
||||
'block w-full rounded-md bg-white px-3.5 py-2 text-base text-brand-dark outline-1 -outline-offset-1 outline-red-400 placeholder:text-brand-400 focus:outline-2 focus:-outline-offset-2 focus:outline-red-500 dark:bg-white/5 dark:text-white dark:outline-red-500/50 dark:placeholder:text-brand-400'
|
||||
const labelClass = 'block text-sm/6 font-semibold text-brand-dark dark:text-white'
|
||||
|
||||
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle")
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ContactFields>({ mode: "onTouched" })
|
||||
|
||||
async function onSubmit(data: ContactFields) {
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
setSubmitStatus(res.ok ? "success" : "error")
|
||||
} catch {
|
||||
setSubmitStatus("error")
|
||||
}
|
||||
}
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 18 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { amount: 0.35, once: false },
|
||||
transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] as const, delay },
|
||||
})
|
||||
|
||||
const slideIn = (direction: "left" | "right", delay: number) => ({
|
||||
initial: { opacity: 0, x: direction === "left" ? -44 : 44 },
|
||||
whileInView: { opacity: 1, x: 0 },
|
||||
viewport: { amount: 0.25, once: false },
|
||||
transition: { duration: 0.65, ease: [0.22, 1, 0.36, 1] as const, delay },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative isolate bg-brand-50/50 dark:bg-brand-900">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-2">
|
||||
<motion.div {...slideIn("left", 0.05)} className="relative px-6 pt-12 pb-20 lg:static">
|
||||
<div className="mx-auto max-w-xl lg:mx-0 lg:max-w-lg">
|
||||
<div className="absolute inset-y-0 left-0 -z-10 w-full overflow-hidden bg-brand-50 ring-1 ring-brand-900/10 lg:w-1/2 dark:bg-brand-900 dark:ring-white/10">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 size-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-brand-200 dark:stroke-white/10"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
x="100%"
|
||||
y={-1}
|
||||
id="83fd4e5a-9d52-42fc-97b6-718e5d7ee527"
|
||||
width={200}
|
||||
height={200}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M130 200V.5M.5 .5H200" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} className="fill-white dark:fill-brand-900" />
|
||||
<svg x="100%" y={-1} className="overflow-visible fill-brand-50 dark:fill-brand-800/20">
|
||||
<path d="M-470.5 0h201v201h-201Z" strokeWidth={0} />
|
||||
</svg>
|
||||
<rect fill="url(#83fd4e5a-9d52-42fc-97b6-718e5d7ee527)" width="100%" height="100%" strokeWidth={0} />
|
||||
</svg>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-[calc(100%-13rem)] -left-56 hidden transform-gpu blur-3xl lg:top-[calc(50%-7rem)] lg:left-[max(-14rem,calc(100%-59rem))] dark:block"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(74.1% 56.1%, 100% 38.6%, 97.5% 73.3%, 85.5% 100%, 80.7% 98.2%, 72.5% 67.7%, 60.2% 37.8%, 52.4% 32.2%, 47.5% 41.9%, 45.2% 65.8%, 27.5% 23.5%, 0.1% 35.4%, 17.9% 0.1%, 27.6% 23.5%, 76.1% 2.6%, 74.1% 56.1%)',
|
||||
}}
|
||||
className="aspect-1155/678 w-288.75 bg-linear-to-br from-brand-300 to-brand-600 opacity-10 dark:opacity-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<motion.dl {...fadeUp(0.1)} className="mt-10 space-y-4 text-base/7 text-brand-700 dark:text-brand-100">
|
||||
<div className="flex gap-x-4">
|
||||
<dt className="flex-none">
|
||||
<span className="sr-only">{addressLabel}</span>
|
||||
<BuildingOffice2Icon aria-hidden="true" className="h-7 w-6 text-brand-400" />
|
||||
</dt>
|
||||
<dd>
|
||||
{addressValue}
|
||||
<br />
|
||||
<span className="text-sm text-brand-500">{serviceAreaLabel} {serviceArea}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<dt className="flex-none">
|
||||
<span className="sr-only">{phoneLabel}</span>
|
||||
<PhoneIcon aria-hidden="true" className="h-7 w-6 text-brand-400" />
|
||||
</dt>
|
||||
<dd>
|
||||
<a href={`tel:${phoneValue.replace(/\D/g, '')}`} className="hover:text-brand-dark dark:hover:text-white">
|
||||
{phoneValue}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<dt className="flex-none">
|
||||
<span className="sr-only">{emailLabel}</span>
|
||||
<EnvelopeIcon aria-hidden="true" className="h-7 w-6 text-brand-400" />
|
||||
</dt>
|
||||
<dd>
|
||||
<a href={`mailto:${emailValue}`} className="hover:text-brand-dark dark:hover:text-white">
|
||||
{emailValue}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</motion.dl>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div {...slideIn("right", 0.1)} className="px-6 pt-12 pb-20">
|
||||
<div className="mx-auto max-w-xl lg:mr-0 lg:max-w-lg">
|
||||
{submitStatus === "success" ? (
|
||||
<motion.div {...fadeUp(0.05)} className="mt-8 flex flex-col items-center gap-4 rounded-2xl bg-green-50 p-10 text-center ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-500/30">
|
||||
<CheckCircleIcon className="h-12 w-12 text-green-500" />
|
||||
<h3 className="text-xl font-semibold text-green-800 dark:text-green-300">Message envoyé !</h3>
|
||||
<p className="text-green-700 dark:text-green-400">Merci pour votre message. Nous vous répondrons dans les plus brefs délais.</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="mt-0">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="first-name" className={labelClass}>{firstNameLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
id="first-name"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder={firstNamePlaceholder}
|
||||
className={errors.firstName ? inputErrorClass : inputClass}
|
||||
{...register("firstName", { required: "Veuillez entrer votre prénom." })}
|
||||
/>
|
||||
{errors.firstName && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.firstName.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="last-name" className={labelClass}>{lastNameLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
id="last-name"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder={lastNamePlaceholder}
|
||||
className={errors.lastName ? inputErrorClass : inputClass}
|
||||
{...register("lastName", { required: "Veuillez entrer votre nom." })}
|
||||
/>
|
||||
{errors.lastName && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.lastName.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="email" className={labelClass}>{emailFieldLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={emailPlaceholder}
|
||||
className={errors.email ? inputErrorClass : inputClass}
|
||||
{...register("email", {
|
||||
required: "Veuillez entrer votre courriel.",
|
||||
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "Veuillez entrer un courriel valide." },
|
||||
})}
|
||||
/>
|
||||
{errors.email && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.email.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="phone-number" className={labelClass}>{phoneFieldLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
id="phone-number"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder={phonePlaceholder}
|
||||
className={errors.phone ? inputErrorClass : inputClass}
|
||||
{...register("phone", { required: "Veuillez entrer votre numéro de téléphone." })}
|
||||
/>
|
||||
{errors.phone && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.phone.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="message" className={labelClass}>{messageLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<textarea
|
||||
id="message"
|
||||
rows={4}
|
||||
placeholder={messagePlaceholder}
|
||||
className={errors.message ? inputErrorClass : inputClass}
|
||||
defaultValue={''}
|
||||
{...register("message", { required: "Veuillez écrire votre message." })}
|
||||
/>
|
||||
{errors.message && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.message.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{submitStatus === "error" && (
|
||||
<motion.div {...fadeUp(0)} className="mb-6 flex gap-x-3 rounded-lg bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-500/30 mt-4">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="font-semibold text-red-800 dark:text-red-300">Une erreur est survenue</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-400">Veuillez réessayer ou nous contacter directement par téléphone.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="mt-8 flex justify-end">
|
||||
<motion.button
|
||||
type="submit"
|
||||
whileHover={{ scale: 1.05, transition: { duration: 0.1 } }}
|
||||
transition={{ type: "spring" as const, stiffness: 400, damping: 25 }}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-brand-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-xs hover:bg-brand-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 disabled:opacity-60 dark:bg-brand-500 dark:hover:bg-brand-400 dark:focus-visible:outline-brand-500"
|
||||
>
|
||||
{isSubmitting ? 'Envoi en cours...' : submitLabel}
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
src/components/sections/services.tsx
Normal file
100
src/components/sections/services.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
export interface Feature {
|
||||
name: string
|
||||
description: string
|
||||
imageSrc: string
|
||||
imageAlt: string
|
||||
}
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface WithAlternatingSectionsProps {
|
||||
id: string
|
||||
features: Feature[]
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: (string | boolean | undefined)[]) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const fadeUp = {
|
||||
initial: { opacity: 0, y: 18 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { amount: 0.35, once: false },
|
||||
transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] as const },
|
||||
};
|
||||
|
||||
const slideIn = (direction: "left" | "right") => ({
|
||||
initial: { opacity: 0, x: direction === "left" ? -40 : 40 },
|
||||
whileInView: { opacity: 1, x: 0 },
|
||||
viewport: { amount: 0.35, once: false },
|
||||
transition: { duration: 0.95, ease: [0.22, 1, 0.36, 1] as const },
|
||||
});
|
||||
|
||||
export default function WithAlternatingSections({
|
||||
id,
|
||||
features,
|
||||
title = 'Nos services',
|
||||
subtitle = 'Des solutions complètes en peinture et décoration pour transformer vos espaces.',
|
||||
}: WithAlternatingSectionsProps) {
|
||||
return (
|
||||
<div id={id} className="bg-brand-light text-brand-600">
|
||||
<div className="mx-auto max-w-2xl px-4 py-24 sm:px-6 sm:py-32 lg:max-w-7xl lg:px-8">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<motion.h2
|
||||
{...fadeUp}
|
||||
className="text-4xl font-bold tracking-tight text-brand-600 sm:text-5xl"
|
||||
>
|
||||
{title}
|
||||
</motion.h2>
|
||||
<motion.p {...fadeUp} className="mt-4 text-lg text-brand-500">
|
||||
{subtitle}
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{features.map((feature, featureIdx) => (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="flex flex-col-reverse lg:grid lg:grid-cols-12 lg:items-center lg:gap-x-8"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
featureIdx % 2 === 0 ? 'lg:col-start-1' : 'lg:col-start-8 xl:col-start-9',
|
||||
'mt-6 lg:col-span-5 lg:row-start-1 lg:mt-0 xl:col-span-4',
|
||||
)}
|
||||
>
|
||||
<motion.h3
|
||||
{...fadeUp}
|
||||
className="text-4xl font-semibold tracking-tight text-brand-600"
|
||||
>
|
||||
{feature.name}
|
||||
</motion.h3>
|
||||
<motion.p {...fadeUp} className="mt-2 text-base text-brand-800">
|
||||
{feature.description}
|
||||
</motion.p>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
featureIdx % 2 === 0 ? 'lg:col-start-6 xl:col-start-5' : 'lg:col-start-1',
|
||||
'flex-auto lg:col-span-7 lg:row-start-1 xl:col-span-8',
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
{...slideIn(featureIdx % 2 === 0 ? "right" : "left")}
|
||||
>
|
||||
<img
|
||||
alt={feature.imageAlt}
|
||||
src={feature.imageSrc}
|
||||
className="aspect-3/2 w-full rounded-lg bg-gray-100 object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
ArrowUpTrayIcon,
|
||||
CheckCircleIcon,
|
||||
@@ -11,54 +12,52 @@ import {
|
||||
|
||||
interface FormContent {
|
||||
ariaLabel: string;
|
||||
nom: string;
|
||||
nomPlaceholder: string;
|
||||
courriel: string;
|
||||
courrielPlaceholder: string;
|
||||
telephone: string;
|
||||
telephonePlaceholder: string;
|
||||
adresse: string;
|
||||
adressePlaceholder: string;
|
||||
typeService: string;
|
||||
typeServicePlaceholder: string;
|
||||
typeServiceOptions: Record<string, string>;
|
||||
message: string;
|
||||
fullNameLabel: string;
|
||||
fullNamePlaceholder: string;
|
||||
emailLabel: string;
|
||||
emailPlaceholder: string;
|
||||
phoneLabel: string;
|
||||
phonePlaceholder: string;
|
||||
workAddressLabel: string;
|
||||
workAddressPlaceholder: string;
|
||||
serviceTypeLabel: string;
|
||||
serviceTypePlaceholder: string;
|
||||
serviceTypeOptions: Record<string, string>;
|
||||
messageLabel: string;
|
||||
messagePlaceholder: string;
|
||||
fichiers: string;
|
||||
fichiersHint: string;
|
||||
fichiersAccepted: string;
|
||||
fichiersRemove: string;
|
||||
envoyer: string;
|
||||
sending: string;
|
||||
attachmentsLabel: string;
|
||||
attachmentsHint: string;
|
||||
attachmentsAccepted: string;
|
||||
attachmentsRemove: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
successTitle: string;
|
||||
successMessage: string;
|
||||
errorTitle: string;
|
||||
errorMessage: string;
|
||||
validation: {
|
||||
nom: string;
|
||||
courriel: string;
|
||||
telephone: string;
|
||||
typeService: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
serviceType: string;
|
||||
message: string;
|
||||
fichiersType: string;
|
||||
fichiersSize: string;
|
||||
attachmentsType: string;
|
||||
attachmentsSize: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
imageSrc: string;
|
||||
imageAlt: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
form: FormContent;
|
||||
}
|
||||
|
||||
interface FormFields {
|
||||
nom: string;
|
||||
courriel: string;
|
||||
telephone: string;
|
||||
adresse: string;
|
||||
typeService: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
workAddress: string;
|
||||
serviceType: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -83,8 +82,8 @@ const inputErrorClass = `${inputBase} outline-red-400 focus:outline-red-500 dark
|
||||
const labelClass = "block text-sm/6 font-semibold text-brand-dark dark:text-white";
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FileTypeIcon({ mime }: { mime: string }) {
|
||||
@@ -92,7 +91,7 @@ function FileTypeIcon({ mime }: { mime: string }) {
|
||||
return <DocumentIcon className="h-5 w-5 shrink-0 text-brand-400" />;
|
||||
}
|
||||
|
||||
export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, form }: Props) {
|
||||
export default function SubmissionForm({ imageSrc, imageAlt, form }: Props) {
|
||||
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||
const [fileError, setFileError] = useState("");
|
||||
@@ -115,7 +114,7 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
|
||||
for (const file of Array.from(incoming)) {
|
||||
if (!ACCEPTED_MIME.has(file.type)) {
|
||||
setFileError(form.validation.fichiersType);
|
||||
setFileError(form.validation.attachmentsType);
|
||||
resetFileInput();
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +125,7 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
|
||||
const totalSize = next.reduce((sum, f) => sum + f.size, 0);
|
||||
if (totalSize > MAX_FILE_SIZE) {
|
||||
setFileError(form.validation.fichiersSize);
|
||||
setFileError(form.validation.attachmentsSize);
|
||||
resetFileInput();
|
||||
return;
|
||||
}
|
||||
@@ -161,15 +160,23 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
async function onSubmit(data: FormFields) {
|
||||
const totalSize = uploadedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||
if (totalSize > MAX_FILE_SIZE) {
|
||||
setFileError(form.validation.fichiersSize);
|
||||
setFileError(form.validation.attachmentsSize);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = new FormData();
|
||||
payload.append("form-name", "soumission");
|
||||
(Object.entries(data) as [string, string][]).forEach(([k, v]) => payload.append(k, v));
|
||||
uploadedFiles.forEach((f) => payload.append("fichiers", f));
|
||||
const res = await fetch("/", { method: "POST", body: payload });
|
||||
const formData = new FormData();
|
||||
formData.append("fullName", data.fullName);
|
||||
formData.append("email", data.email);
|
||||
formData.append("phone", data.phone);
|
||||
formData.append("workAddress", data.workAddress ?? "");
|
||||
formData.append("serviceType", data.serviceType);
|
||||
formData.append("message", data.message);
|
||||
uploadedFiles.forEach((f) => formData.append("files", f));
|
||||
|
||||
const res = await fetch("/api/submission", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
setSubmitStatus(res.ok ? "success" : "error");
|
||||
} catch {
|
||||
setSubmitStatus("error");
|
||||
@@ -178,19 +185,33 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
|
||||
const totalSize = uploadedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 18 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { amount: 0.35, once: false },
|
||||
transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] as const, delay },
|
||||
});
|
||||
|
||||
const slideIn = (direction: "left" | "right", delay: number) => ({
|
||||
initial: { opacity: 0, x: direction === "left" ? -44 : 44 },
|
||||
whileInView: { opacity: 1, x: 0 },
|
||||
viewport: { amount: 0.25, once: false },
|
||||
transition: { duration: 0.7, ease: [0.22, 1, 0.36, 1] as const, delay },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-brand-50 dark:bg-brand-900 lg:grid lg:grid-cols-2">
|
||||
<div className="px-6 pt-8 pb-16 lg:px-6 lg:pt-8 lg:pb-16">
|
||||
<motion.div {...slideIn("left", 0.05)} className="px-6 pt-8 pb-16 lg:px-6 lg:pt-8 lg:pb-16">
|
||||
<div className="mx-auto max-w-lg">
|
||||
{submitStatus === "success" ? (
|
||||
<div className="mt-16 flex flex-col items-center gap-4 rounded-2xl bg-green-50 p-10 text-center ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-500/30">
|
||||
<motion.div {...fadeUp(0.05)} className="mt-16 flex flex-col items-center gap-4 rounded-2xl bg-green-50 p-10 text-center ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-500/30">
|
||||
<CheckCircleIcon className="h-12 w-12 text-green-500" />
|
||||
<h3 className="text-xl font-semibold text-green-800 dark:text-green-300">{form.successTitle}</h3>
|
||||
<p className="text-green-700 dark:text-green-400">{form.successMessage}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form
|
||||
name="soumission"
|
||||
name="submission"
|
||||
method="POST"
|
||||
data-netlify="true"
|
||||
aria-label={form.ariaLabel}
|
||||
@@ -198,108 +219,102 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
noValidate
|
||||
className="mt-16"
|
||||
>
|
||||
<input type="hidden" name="form-name" value="soumission" />
|
||||
<input type="hidden" name="form-name" value="submission" />
|
||||
|
||||
{submitStatus === "error" && (
|
||||
<div className="mb-6 flex gap-x-3 rounded-lg bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-500/30">
|
||||
<motion.div {...fadeUp(0)} className="mb-6 flex gap-x-3 rounded-lg bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-500/30">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="font-semibold text-red-800 dark:text-red-300">{form.errorTitle}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{form.errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
|
||||
{/* Nom */}
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="nom" className={labelClass}>{form.nom}</label>
|
||||
<label htmlFor="full-name" className={labelClass}>{form.fullNameLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
id="nom"
|
||||
id="full-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder={form.nomPlaceholder}
|
||||
className={errors.nom ? inputErrorClass : inputClass}
|
||||
{...register("nom", { required: form.validation.nom })}
|
||||
placeholder={form.fullNamePlaceholder}
|
||||
className={errors.fullName ? inputErrorClass : inputClass}
|
||||
{...register("fullName", { required: form.validation.fullName })}
|
||||
/>
|
||||
{errors.nom && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.nom.message}</p>}
|
||||
{errors.fullName && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.fullName.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Courriel */}
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="courriel" className={labelClass}>{form.courriel}</label>
|
||||
<label htmlFor="email" className={labelClass}>{form.emailLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
id="courriel"
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={form.courrielPlaceholder}
|
||||
className={errors.courriel ? inputErrorClass : inputClass}
|
||||
{...register("courriel", {
|
||||
required: form.validation.courriel,
|
||||
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: form.validation.courriel },
|
||||
placeholder={form.emailPlaceholder}
|
||||
className={errors.email ? inputErrorClass : inputClass}
|
||||
{...register("email", {
|
||||
required: form.validation.email,
|
||||
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: form.validation.email },
|
||||
})}
|
||||
/>
|
||||
{errors.courriel && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.courriel.message}</p>}
|
||||
{errors.email && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.email.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Téléphone */}
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="telephone" className={labelClass}>{form.telephone}</label>
|
||||
<label htmlFor="phone" className={labelClass}>{form.phoneLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
id="telephone"
|
||||
id="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder={form.telephonePlaceholder}
|
||||
className={errors.telephone ? inputErrorClass : inputClass}
|
||||
{...register("telephone", { required: form.validation.telephone })}
|
||||
placeholder={form.phonePlaceholder}
|
||||
className={errors.phone ? inputErrorClass : inputClass}
|
||||
{...register("phone", { required: form.validation.phone })}
|
||||
/>
|
||||
{errors.telephone && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.telephone.message}</p>}
|
||||
{errors.phone && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.phone.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Adresse */}
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="adresse" className={labelClass}>{form.adresse}</label>
|
||||
<label htmlFor="work-address" className={labelClass}>{form.workAddressLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
id="adresse"
|
||||
id="work-address"
|
||||
type="text"
|
||||
autoComplete="street-address"
|
||||
placeholder={form.adressePlaceholder}
|
||||
placeholder={form.workAddressPlaceholder}
|
||||
className={inputClass}
|
||||
{...register("adresse")}
|
||||
{...register("workAddress")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type de service */}
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="typeService" className={labelClass}>{form.typeService}</label>
|
||||
<label htmlFor="service-type" className={labelClass}>{form.serviceTypeLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<select
|
||||
id="typeService"
|
||||
id="service-type"
|
||||
defaultValue=""
|
||||
className={`${errors.typeService ? inputErrorClass : inputClass} cursor-pointer`}
|
||||
{...register("typeService", { required: form.validation.typeService })}
|
||||
className={`${errors.serviceType ? inputErrorClass : inputClass} cursor-pointer`}
|
||||
{...register("serviceType", { required: form.validation.serviceType })}
|
||||
>
|
||||
<option value="" disabled>{form.typeServicePlaceholder}</option>
|
||||
{Object.entries(form.typeServiceOptions).map(([key, label]) => (
|
||||
<option value="" disabled>{form.serviceTypePlaceholder}</option>
|
||||
{Object.entries(form.serviceTypeOptions).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.typeService && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.typeService.message}</p>}
|
||||
{errors.serviceType && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.serviceType.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="message" className={labelClass}>{form.message}</label>
|
||||
<label htmlFor="message" className={labelClass}>{form.messageLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<textarea
|
||||
id="message"
|
||||
@@ -312,16 +327,14 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="sm:col-span-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label className={labelClass}>{form.fichiers}</label>
|
||||
<label className={labelClass}>{form.attachmentsLabel}</label>
|
||||
<span className={`text-xs ${totalSize > MAX_FILE_SIZE ? "text-red-500" : "text-brand-400 dark:text-brand-500"}`}>
|
||||
{uploadedFiles.length > 0 ? `${formatBytes(totalSize)} / 20 Mo` : "Max 20 Mo"}
|
||||
{uploadedFiles.length > 0 ? `${formatBytes(totalSize)} / 20 MB` : "Max 20 MB"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error banner — always above the dropzone so it's never buried */}
|
||||
{fileError && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 ring-1 ring-red-200 dark:bg-red-900/20 dark:text-red-400 dark:ring-red-500/30">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 shrink-0" />
|
||||
@@ -330,11 +343,10 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
)}
|
||||
|
||||
<div className="mt-2.5">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={form.fichiersHint}
|
||||
aria-label={form.attachmentsHint}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -350,8 +362,8 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
].join(" ")}
|
||||
>
|
||||
<ArrowUpTrayIcon className="h-8 w-8 text-brand-300 dark:text-brand-600" />
|
||||
<p className="text-sm font-medium text-brand-700 dark:text-brand-200">{form.fichiersHint}</p>
|
||||
<p className="text-xs text-brand-400 dark:text-brand-500">{form.fichiersAccepted}</p>
|
||||
<p className="text-sm font-medium text-brand-700 dark:text-brand-200">{form.attachmentsHint}</p>
|
||||
<p className="text-xs text-brand-400 dark:text-brand-500">{form.attachmentsAccepted}</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -363,7 +375,6 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
onChange={(e) => addFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
{/* File list */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{uploadedFiles.map((file, i) => (
|
||||
@@ -379,7 +390,7 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(i)}
|
||||
aria-label={`${form.fichiersRemove} ${file.name}`}
|
||||
aria-label={`${form.attachmentsRemove} ${file.name}`}
|
||||
className="shrink-0 rounded p-0.5 text-brand-300 hover:text-red-500 dark:text-brand-500 dark:hover:text-red-400"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
@@ -394,26 +405,28 @@ export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, fo
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex justify-end border-t border-brand-900/10 pt-8 dark:border-white/10">
|
||||
<button
|
||||
<motion.button
|
||||
type="submit"
|
||||
whileHover={{ scale: 1.05, transition: { duration: 0.1 } }}
|
||||
transition={{ type: "spring" as const, stiffness: 400, damping: 25 }}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-md bg-brand-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-xs hover:bg-brand-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 disabled:opacity-60 dark:bg-brand-500 dark:hover:bg-brand-400 dark:focus-visible:outline-brand-500"
|
||||
>
|
||||
{isSubmitting ? form.sending : form.envoyer}
|
||||
</button>
|
||||
{isSubmitting ? form.submittingLabel : form.submitLabel}
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative hidden lg:block">
|
||||
<motion.div {...slideIn("right", 0.12)} className="relative hidden lg:block">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={imageAlt}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ interface TestimonialItem {
|
||||
height: number;
|
||||
}
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface Props {
|
||||
sectionLabel: string;
|
||||
title: string;
|
||||
@@ -20,6 +22,28 @@ const iframeProps = {
|
||||
className: 'w-full rounded-2xl',
|
||||
}
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 18 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { amount: 0.35, once: false },
|
||||
transition: {
|
||||
duration: 0.55,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
delay,
|
||||
},
|
||||
})
|
||||
|
||||
const slideUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 26 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { amount: 0.25, once: false },
|
||||
transition: {
|
||||
duration: 0.65,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
delay,
|
||||
},
|
||||
})
|
||||
|
||||
export default function Grid({ sectionLabel, title, items }: Props) {
|
||||
const [featured, ...rest] = items
|
||||
const groups = [rest.slice(0, 2), rest.slice(2)]
|
||||
@@ -52,28 +76,43 @@ export default function Grid({ sectionLabel, title, items }: Props) {
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-base/7 font-semibold text-brand-400 dark:text-brand-400">{sectionLabel}</h2>
|
||||
<p className="mt-2 text-4xl font-semibold tracking-tight text-balance text-brand-600 sm:text-5xl dark:text-white">
|
||||
<motion.h2
|
||||
{...fadeUp(0)}
|
||||
className="text-base/7 font-semibold text-brand-400 dark:text-brand-400"
|
||||
>
|
||||
{sectionLabel}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
{...fadeUp(0.1)}
|
||||
className="mt-2 text-4xl font-semibold tracking-tight text-balance text-brand-600 sm:text-5xl dark:text-white"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</motion.p>
|
||||
</div>
|
||||
<div className="mx-auto mt-16 grid max-w-2xl grid-cols-1 grid-rows-1 gap-8 text-sm/6 text-gray-900 sm:mt-20 sm:grid-cols-2 xl:mx-0 xl:max-w-none xl:grid-flow-col xl:grid-cols-4 dark:text-gray-100">
|
||||
<figure className="rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 sm:col-span-2 xl:col-start-2 xl:row-end-1 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10">
|
||||
<motion.figure
|
||||
{...slideUp(0.05)}
|
||||
className="rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 sm:col-span-2 xl:col-start-2 xl:row-end-1 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10"
|
||||
>
|
||||
<iframe {...iframeProps} src={featured.src} height={featured.height} />
|
||||
</figure>
|
||||
</motion.figure>
|
||||
{groups.map((group, groupIdx) => (
|
||||
<div key={groupIdx} className="space-y-8 xl:contents xl:space-y-0">
|
||||
{group.map((t, colIdx) => {
|
||||
const isFirstCol = groupIdx === 0 && colIdx === 0
|
||||
const isLastCol = groupIdx === groups.length - 1 && colIdx === group.length - 1
|
||||
const delay = 0.15 + (groupIdx * 2 + colIdx) * 0.08
|
||||
return (
|
||||
<div
|
||||
key={t.key}
|
||||
className={(isFirstCol || isLastCol) ? 'xl:row-span-2 space-y-8' : 'xl:row-start-1 space-y-8'}
|
||||
>
|
||||
<figure className="rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10">
|
||||
<motion.figure
|
||||
{...slideUp(delay)}
|
||||
className="rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10"
|
||||
>
|
||||
<iframe {...iframeProps} src={t.src} height={t.height} />
|
||||
</figure>
|
||||
</motion.figure>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -8,6 +8,8 @@ const iconPaths: Record<string, string> = {
|
||||
location: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z M15 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0z',
|
||||
}
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
function getIconPath(icon: string): string {
|
||||
return iconPaths[icon] ?? iconPaths.check
|
||||
}
|
||||
@@ -34,6 +36,28 @@ interface WithTieredImagesProps {
|
||||
images: PourquoiImages
|
||||
}
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 18 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { amount: 0.35, once: false },
|
||||
transition: {
|
||||
duration: 0.55,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
delay,
|
||||
},
|
||||
})
|
||||
|
||||
const slideIn = (direction: "left" | "right", delay: number) => ({
|
||||
initial: { opacity: 0, x: direction === "left" ? -44 : 44 },
|
||||
whileInView: { opacity: 1, x: 0 },
|
||||
viewport: { amount: 0.3, once: false },
|
||||
transition: {
|
||||
duration: 0.7,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
delay,
|
||||
},
|
||||
})
|
||||
|
||||
export default function WithTieredImages({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -46,15 +70,24 @@ export default function WithTieredImages({
|
||||
<div className="grid grid-cols-1 items-center gap-x-8 gap-y-16 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="border-b border-white/30 pb-10">
|
||||
<h2 className="text-lg font-medium text-white">{title}</h2>
|
||||
<p className="mt-2 text-4xl font-bold tracking-tight text-white sm:text-5xl">
|
||||
<motion.h2 {...fadeUp(0)} className="text-lg font-medium text-white">
|
||||
{title}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
{...fadeUp(0.1)}
|
||||
className="mt-2 text-4xl font-bold tracking-tight text-white sm:text-5xl"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-10 space-y-10">
|
||||
{cards.map((card) => (
|
||||
<div key={card.title} className="flex gap-4">
|
||||
{cards.map((card, idx) => (
|
||||
<motion.div
|
||||
key={card.title}
|
||||
{...fadeUp(0.12 + idx * 0.06)}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-white text-[var(--color-brand-600)]">
|
||||
<svg
|
||||
width="24"
|
||||
@@ -91,24 +124,27 @@ export default function WithTieredImages({
|
||||
<dt className="text-base font-medium text-white">{card.title}</dt>
|
||||
<dd className="mt-3 text-base text-white/90">{card.description}</dd>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<img
|
||||
<motion.img
|
||||
{...slideIn("right", 0.05)}
|
||||
alt={images.mainAlt}
|
||||
src={images.main}
|
||||
className="aspect-square w-full rounded-lg bg-[var(--color-brand-100)] object-cover"
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 sm:mt-6 sm:gap-6 lg:mt-8 lg:gap-8">
|
||||
<img
|
||||
<motion.img
|
||||
{...slideIn("right", 0.14)}
|
||||
alt={images.detail1Alt}
|
||||
src={images.detail1}
|
||||
className="aspect-square w-full rounded-lg bg-[var(--color-brand-100)] object-cover"
|
||||
/>
|
||||
<img
|
||||
<motion.img
|
||||
{...slideIn("right", 0.22)}
|
||||
alt={images.detail2Alt}
|
||||
src={images.detail2}
|
||||
className="aspect-square w-full rounded-lg bg-[var(--color-brand-100)] object-cover"
|
||||
@@ -1,165 +0,0 @@
|
||||
import { BuildingOffice2Icon, EnvelopeIcon, PhoneIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
interface Props {
|
||||
phoneLabel: string
|
||||
phoneValue: string
|
||||
emailLabel: string
|
||||
emailValue: string
|
||||
addressLabel: string
|
||||
addressValue: string
|
||||
serviceAreaLabel: string
|
||||
serviceArea: string
|
||||
firstNameLabel?: string
|
||||
lastNameLabel?: string
|
||||
emailFieldLabel?: string
|
||||
phoneFieldLabel?: string
|
||||
messageLabel?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
export default function Contact({
|
||||
phoneLabel,
|
||||
phoneValue,
|
||||
emailLabel,
|
||||
emailValue,
|
||||
addressLabel,
|
||||
addressValue,
|
||||
serviceAreaLabel,
|
||||
serviceArea,
|
||||
firstNameLabel = 'Prénom',
|
||||
lastNameLabel = 'Nom',
|
||||
emailFieldLabel = 'Courriel',
|
||||
phoneFieldLabel = 'Téléphone',
|
||||
messageLabel = 'Message',
|
||||
submitLabel = 'Envoyer',
|
||||
}: Props) {
|
||||
const inputClass =
|
||||
'block w-full rounded-md bg-white px-3.5 py-2 text-base text-brand-dark outline-1 -outline-offset-1 outline-brand-200 placeholder:text-brand-400 focus:outline-2 focus:-outline-offset-2 focus:outline-brand-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-brand-400 dark:focus:outline-brand-500'
|
||||
const labelClass = 'block text-sm/6 font-semibold text-brand-dark dark:text-white'
|
||||
|
||||
return (
|
||||
<div className="relative isolate bg-brand-50/50 dark:bg-brand-900">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-2">
|
||||
<div className="relative px-6 pt-12 pb-20 lg:static">
|
||||
<div className="mx-auto max-w-xl lg:mx-0 lg:max-w-lg">
|
||||
<div className="absolute inset-y-0 left-0 -z-10 w-full overflow-hidden bg-brand-50 ring-1 ring-brand-900/10 lg:w-1/2 dark:bg-brand-900 dark:ring-white/10">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 size-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-brand-200 dark:stroke-white/10"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
x="100%"
|
||||
y={-1}
|
||||
id="83fd4e5a-9d52-42fc-97b6-718e5d7ee527"
|
||||
width={200}
|
||||
height={200}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M130 200V.5M.5 .5H200" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} className="fill-white dark:fill-brand-900" />
|
||||
<svg x="100%" y={-1} className="overflow-visible fill-brand-50 dark:fill-brand-800/20">
|
||||
<path d="M-470.5 0h201v201h-201Z" strokeWidth={0} />
|
||||
</svg>
|
||||
<rect fill="url(#83fd4e5a-9d52-42fc-97b6-718e5d7ee527)" width="100%" height="100%" strokeWidth={0} />
|
||||
</svg>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-[calc(100%-13rem)] -left-56 hidden transform-gpu blur-3xl lg:top-[calc(50%-7rem)] lg:left-[max(-14rem,calc(100%-59rem))] dark:block"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(74.1% 56.1%, 100% 38.6%, 97.5% 73.3%, 85.5% 100%, 80.7% 98.2%, 72.5% 67.7%, 60.2% 37.8%, 52.4% 32.2%, 47.5% 41.9%, 45.2% 65.8%, 27.5% 23.5%, 0.1% 35.4%, 17.9% 0.1%, 27.6% 23.5%, 76.1% 2.6%, 74.1% 56.1%)',
|
||||
}}
|
||||
className="aspect-1155/678 w-288.75 bg-linear-to-br from-brand-300 to-brand-600 opacity-10 dark:opacity-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-10 space-y-4 text-base/7 text-brand-700 dark:text-brand-100">
|
||||
<div className="flex gap-x-4">
|
||||
<dt className="flex-none">
|
||||
<span className="sr-only">{addressLabel}</span>
|
||||
<BuildingOffice2Icon aria-hidden="true" className="h-7 w-6 text-brand-400" />
|
||||
</dt>
|
||||
<dd>
|
||||
{addressValue}
|
||||
<br />
|
||||
<span className="text-sm text-brand-500">{serviceAreaLabel} {serviceArea}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<dt className="flex-none">
|
||||
<span className="sr-only">{phoneLabel}</span>
|
||||
<PhoneIcon aria-hidden="true" className="h-7 w-6 text-brand-400" />
|
||||
</dt>
|
||||
<dd>
|
||||
<a href={`tel:${phoneValue.replace(/\D/g, '')}`} className="hover:text-brand-dark dark:hover:text-white">
|
||||
{phoneValue}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<dt className="flex-none">
|
||||
<span className="sr-only">{emailLabel}</span>
|
||||
<EnvelopeIcon aria-hidden="true" className="h-7 w-6 text-brand-400" />
|
||||
</dt>
|
||||
<dd>
|
||||
<a href={`mailto:${emailValue}`} className="hover:text-brand-dark dark:hover:text-white">
|
||||
{emailValue}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<form action="#" method="POST" className="px-6 pt-12 pb-20">
|
||||
<div className="mx-auto max-w-xl lg:mr-0 lg:max-w-lg">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="first-name" className={labelClass}>{firstNameLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input id="first-name" name="first-name" type="text" autoComplete="given-name" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="last-name" className={labelClass}>{lastNameLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input id="last-name" name="last-name" type="text" autoComplete="family-name" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="email" className={labelClass}>{emailFieldLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input id="email" name="email" type="email" autoComplete="email" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="phone-number" className={labelClass}>{phoneFieldLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<input id="phone-number" name="phone-number" type="tel" autoComplete="tel" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="message" className={labelClass}>{messageLabel}</label>
|
||||
<div className="mt-2.5">
|
||||
<textarea id="message" name="message" rows={4} className={inputClass} defaultValue={''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-brand-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-xs hover:bg-brand-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 dark:bg-brand-500 dark:hover:bg-brand-400 dark:focus-visible:outline-brand-500"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,26 @@ interface FullWidthWithBackgroundImageAndLargeContentProps {
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 18 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { amount: 0.35, once: false },
|
||||
transition: {
|
||||
duration: 0.55,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
delay,
|
||||
},
|
||||
})
|
||||
|
||||
const bgReveal = {
|
||||
initial: { opacity: 0.6, scale: 1.06 },
|
||||
whileInView: { opacity: 1, scale: 1 },
|
||||
viewport: { amount: 0.25, once: false },
|
||||
transition: { duration: 0.9, ease: [0.22, 1, 0.36, 1] as const },
|
||||
}
|
||||
|
||||
export default function FullWidthWithBackgroundImageAndLargeContent({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -20,7 +40,8 @@ export default function FullWidthWithBackgroundImageAndLargeContent({
|
||||
<div className="relative bg-gray-900 min-h-[40rem] flex items-center justify-center">
|
||||
{/* Decorative image and overlay */}
|
||||
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
||||
<img
|
||||
<motion.img
|
||||
{...bgReveal}
|
||||
alt={imageAlt}
|
||||
src={imageSrc}
|
||||
className="size-full object-cover"
|
||||
@@ -29,18 +50,25 @@ export default function FullWidthWithBackgroundImageAndLargeContent({
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-gray-900 opacity-50" />
|
||||
|
||||
<div className="relative mx-auto flex max-w-3xl flex-col items-center px-6 py-24 text-center sm:py-32 lg:px-0">
|
||||
<h2 id="cta-heading" className="text-3xl font-bold tracking-tight text-brand-50 sm:text-4xl lg:text-5xl">
|
||||
<motion.h2
|
||||
{...fadeUp(0)}
|
||||
id="cta-heading"
|
||||
className="text-3xl font-bold tracking-tight text-brand-50 sm:text-4xl lg:text-5xl"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-brand-50/95 sm:text-xl">
|
||||
</motion.h2>
|
||||
<motion.p {...fadeUp(0.1)} className="mt-4 text-lg text-brand-50/95 sm:text-xl">
|
||||
{subtitle}
|
||||
</p>
|
||||
<a
|
||||
</motion.p>
|
||||
<motion.a
|
||||
{...fadeUp(0.2)}
|
||||
whileHover={{ scale: 1.05, transition: { duration: 0.1 } }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
href={buttonHref}
|
||||
className="mt-8 inline-block rounded-md border border-transparent bg-[var(--color-brand-600)] px-8 py-3 text-base font-medium text-white shadow-sm transition-colors hover:bg-[var(--color-brand-700)]"
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
292
src/components/ui/footer-animated.tsx
Normal file
292
src/components/ui/footer-animated.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
type NavLink = { name: string; href: string };
|
||||
|
||||
type Props = {
|
||||
siteName: string;
|
||||
serviceArea: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
facebook?: string;
|
||||
instagram?: string;
|
||||
linkedin?: string;
|
||||
footer: {
|
||||
tagline: string;
|
||||
facebookLabel: string;
|
||||
serviceAreasTitle: string;
|
||||
serviceAreas: string[];
|
||||
navTitle: string;
|
||||
contactTitle: string;
|
||||
rbq: string;
|
||||
neq: string;
|
||||
};
|
||||
navLinks: NavLink[];
|
||||
legalNotice: string;
|
||||
};
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 18 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { amount: 0.3, once: false },
|
||||
transition: {
|
||||
duration: 0.55,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
delay,
|
||||
},
|
||||
});
|
||||
|
||||
const hoverPop = {
|
||||
whileHover: { scale: 1.05, transition: { duration: 0.2 } },
|
||||
transition: { type: "spring" as const, stiffness: 500, damping: 30 },
|
||||
};
|
||||
|
||||
export default function FooterAnimated({
|
||||
siteName,
|
||||
serviceArea,
|
||||
phone,
|
||||
email,
|
||||
address,
|
||||
facebook,
|
||||
instagram,
|
||||
linkedin,
|
||||
footer,
|
||||
navLinks,
|
||||
legalNotice,
|
||||
}: Props) {
|
||||
return (
|
||||
<motion.footer
|
||||
className="bg-brand-600 pt-14 px-8 text-white/85"
|
||||
initial="initial"
|
||||
whileInView="whileInView"
|
||||
viewport={{ amount: 0.2, once: false }}
|
||||
>
|
||||
<div className="container-site grid grid-cols-1 gap-7 pb-12 min-[541px]:grid-cols-2 min-[541px]:gap-8 min-[901px]:grid-cols-[1.5fr_2fr_1fr_1fr] min-[901px]:gap-10">
|
||||
<motion.div {...fadeUp(0)}>
|
||||
<motion.a {...hoverPop} href="/" aria-label={siteName}>
|
||||
<img
|
||||
src="/images/logo-vignette.svg"
|
||||
alt={siteName}
|
||||
width={140}
|
||||
height={52}
|
||||
loading="lazy"
|
||||
className="mb-4 block h-11 w-auto brightness-75 invert"
|
||||
/>
|
||||
</motion.a>
|
||||
<p className="mb-2 text-sm font-normal text-white/75">
|
||||
{footer.tagline}
|
||||
</p>
|
||||
<p className="mb-4 flex items-center gap-1.5 text-sm text-white/65">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
{serviceArea}
|
||||
</p>
|
||||
|
||||
{(facebook || instagram || linkedin) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{facebook && (
|
||||
<motion.a
|
||||
{...hoverPop}
|
||||
href={facebook}
|
||||
className="inline-flex items-center gap-2 rounded border border-white/30 px-3.5 py-1.5 text-sm font-bold text-white/80 transition-colors hover:bg-white/12 hover:text-white"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={footer.facebookLabel}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
|
||||
</svg>
|
||||
{footer.facebookLabel}
|
||||
</motion.a>
|
||||
)}
|
||||
{instagram && (
|
||||
<motion.a
|
||||
{...hoverPop}
|
||||
href={instagram}
|
||||
className="inline-flex items-center gap-2 rounded border border-white/30 px-3.5 py-1.5 text-sm font-bold text-white/80 transition-colors hover:bg-white/12 hover:text-white"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
|
||||
</svg>
|
||||
Instagram
|
||||
</motion.a>
|
||||
)}
|
||||
{linkedin && (
|
||||
<motion.a
|
||||
{...hoverPop}
|
||||
href={linkedin}
|
||||
className="inline-flex items-center gap-2 rounded border border-white/30 px-3.5 py-1.5 text-sm font-bold text-white/80 transition-colors hover:bg-white/12 hover:text-white"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.778v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
LinkedIn
|
||||
</motion.a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div {...fadeUp(0.08)}>
|
||||
<h3 className="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
||||
{footer.serviceAreasTitle}
|
||||
</h3>
|
||||
<ul className="m-0 list-none columns-2 gap-x-6 p-0 [&>li]:mb-2">
|
||||
{footer.serviceAreas.map((area) => (
|
||||
<li
|
||||
key={area}
|
||||
className="text-[0.875rem] text-white/70 break-inside-avoid"
|
||||
>
|
||||
{area}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<motion.div {...fadeUp(0.16)}>
|
||||
<h3 className="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
||||
{footer.navTitle}
|
||||
</h3>
|
||||
<ul className="m-0 flex list-none flex-col gap-2.5 p-0">
|
||||
{navLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<motion.a
|
||||
{...hoverPop}
|
||||
href={link.href}
|
||||
className="inline-flex text-[0.9375rem] text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
{link.name}
|
||||
</motion.a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<motion.div {...fadeUp(0.24)}>
|
||||
<h3 className="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
||||
{footer.contactTitle}
|
||||
</h3>
|
||||
<ul className="m-0 flex list-none flex-col gap-3 p-0">
|
||||
<li>
|
||||
<motion.a
|
||||
{...hoverPop}
|
||||
href={`tel:${phone.replace(/\D/g, "")}`}
|
||||
className="flex items-start gap-2 text-[0.9375rem] text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
<svg
|
||||
className="mt-[3px] shrink-0"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2.18h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 9.91a16 16 0 0 0 6.18 6.18l.94-.94a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
||||
</svg>
|
||||
{phone}
|
||||
</motion.a>
|
||||
</li>
|
||||
<li>
|
||||
<motion.a
|
||||
{...hoverPop}
|
||||
href={`mailto:${email}`}
|
||||
className="flex items-start gap-2 text-[0.9375rem] text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
<svg
|
||||
className="mt-[3px] shrink-0"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</svg>
|
||||
{email}
|
||||
</motion.a>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-[0.9375rem] text-white/70">
|
||||
<svg
|
||||
className="mt-[3px] shrink-0"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
{address}
|
||||
</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div {...fadeUp(0.1)} className="border-t border-white/15 py-[1.125rem]">
|
||||
<div className="container-site flex flex-wrap items-center justify-between gap-4 max-[540px]:flex-col max-[540px]:items-start max-[540px]:gap-2">
|
||||
<p className="text-[0.8125rem] text-white/50">{legalNotice}</p>
|
||||
<motion.p {...fadeUp(0.32)} className="text-[0.8125rem] text-white/50">
|
||||
Réalisation par{" "}
|
||||
<motion.a
|
||||
{...hoverPop}
|
||||
href="https://www.linkedin.com/in/mouadbouras/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex text-white/70 hover:text-white"
|
||||
>
|
||||
MBCube Consulting
|
||||
</motion.a>
|
||||
</motion.p>
|
||||
<p className="flex items-center gap-2.5 text-xs tracking-[0.02em] text-white/40">
|
||||
<span>{footer.rbq}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{footer.neq}</span>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ import {
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import { motion } from "motion/react";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import general from "../../content/settings/general.json";
|
||||
|
||||
import logo from "@/assets/cachet-hero-logo.svg";
|
||||
import general from "@/content/settings/general.json";
|
||||
|
||||
export type HeroContent = {
|
||||
title: string;
|
||||
@@ -16,6 +15,11 @@ export type HeroContent = {
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
const logo = {
|
||||
src: '/images/logo-text.svg',
|
||||
alt: 'Cachet Peintres Décorateurs',
|
||||
}
|
||||
|
||||
const slides = [
|
||||
{ src: '/images/carrousel-1.webp', alt: "Peinture intérieure" },
|
||||
{ src: '/images/carrousel-2.webp', alt: "Décoration murale" },
|
||||
@@ -30,6 +34,16 @@ const autoplayPlugin = Autoplay({
|
||||
stopOnMouseEnter: false,
|
||||
});
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 24 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
delay,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Hero({ content }: { content: HeroContent }) {
|
||||
const { subtitle, cta, ctaHref } = content;
|
||||
|
||||
@@ -57,42 +71,33 @@ export default function Hero({ content }: { content: HeroContent }) {
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-6 lg:px-8 h-full flex items-center justify-center pt-14">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
{/* <div className="hidden sm:mb-8 sm:flex sm:justify-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 gap-x-3.5 rounded-full px-4 py-2 text-sm/6 text-gray-300 ring-1 ring-white/20">
|
||||
<span>RBQ {general.rbq}</span>
|
||||
<span className="text-white/30" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span>{general.serviceArea}</span>
|
||||
<span className="text-white/30" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span>{pillCta}</span>
|
||||
</div>
|
||||
</div> */}
|
||||
{/* <h1
|
||||
className="text-5xl font-semibold tracking-tight text-balance text-white sm:text-7xl"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
<motion.div {...fadeUp(0.2)}>
|
||||
<img
|
||||
src={logo.src}
|
||||
alt={general.siteName}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
{...fadeUp(0.45)}
|
||||
className="p-4 mt-8 text-lg font-medium text-pretty text-white sm:text-xl/8 lg:text-2xl/8"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<h2
|
||||
className="text-5xl font-semibold tracking-tight text-balance text-white sm:text-6xl"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
{title2}
|
||||
</h2> */}
|
||||
<img src={logo.src} alt={general.siteName} className="w-full h-auto" />
|
||||
<p className="mt-8 text-lg font-medium text-pretty text-gray-300 sm:text-xl/8">
|
||||
{subtitle}
|
||||
</p>
|
||||
<div className="mt-10 flex items-center justify-center">
|
||||
<a
|
||||
</motion.p>
|
||||
|
||||
<div
|
||||
{...fadeUp(0.65)}
|
||||
|
||||
className="mt-10 flex items-center justify-center"
|
||||
>
|
||||
<motion.a
|
||||
whileHover={{ scale: 1.075, transition: { duration: 0.1 } }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25, }}
|
||||
href={ctaHref}
|
||||
className="rounded-md bg-[var(--color-brand-600)] px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-[var(--color-brand-700)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-brand-600)]"
|
||||
className="rounded-md bg-[var(--color-brand-600)] px-3.5 py-2.5 text-lg font-light text-white shadow-xs hover:bg-[var(--color-brand-700)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-brand-600)]"
|
||||
>
|
||||
{cta}
|
||||
</a>
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
export interface Feature {
|
||||
name: string
|
||||
description: string
|
||||
imageSrc: string
|
||||
imageAlt: string
|
||||
}
|
||||
|
||||
interface WithAlternatingSectionsProps {
|
||||
features: Feature[]
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: (string | boolean | undefined)[]) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function WithAlternatingSections({
|
||||
features,
|
||||
title = 'Nos services',
|
||||
subtitle = 'Des solutions complètes en peinture et décoration pour transformer vos espaces.',
|
||||
}: WithAlternatingSectionsProps) {
|
||||
return (
|
||||
<div className="bg-brand-light text-brand-600">
|
||||
<div className="mx-auto max-w-2xl px-4 py-24 sm:px-6 sm:py-32 lg:max-w-7xl lg:px-8">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 className="text-4xl font-bold tracking-tight text-brand-600 sm:text-5xl">{title}</h2>
|
||||
<p className="mt-4 text-lg text-brand-500">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{features.map((feature, featureIdx) => (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="flex flex-col-reverse lg:grid lg:grid-cols-12 lg:items-center lg:gap-x-8"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
featureIdx % 2 === 0 ? 'lg:col-start-1' : 'lg:col-start-8 xl:col-start-9',
|
||||
'mt-6 lg:col-span-5 lg:row-start-1 lg:mt-0 xl:col-span-4',
|
||||
)}
|
||||
>
|
||||
<h3 className="text-4xl font-semibold tracking-tight text-brand-600">{feature.name}</h3>
|
||||
<p className="mt-2 text-base text-brand-800">{feature.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
featureIdx % 2 === 0 ? 'lg:col-start-6 xl:col-start-5' : 'lg:col-start-1',
|
||||
'flex-auto lg:col-span-7 lg:row-start-1 xl:col-span-8',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
alt={feature.imageAlt}
|
||||
src={feature.imageSrc}
|
||||
className="aspect-3/2 w-full rounded-lg bg-gray-100 object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,18 @@ interface ContactHeroContent {
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
const fadeUp = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 18 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
delay,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export default function ContactHero({ content }: { content: ContactHeroContent }) {
|
||||
const { imageSrc, imageAlt, title, subtitle } = content;
|
||||
@@ -29,12 +41,19 @@ interface ContactHeroContent {
|
||||
className="relative mx-auto flex max-w-7xl flex-col items-center justify-center text-center sm:px-6 lg:px-8"
|
||||
>
|
||||
<div className="mx-auto max-w-2xl lg:max-w-none">
|
||||
<h2 id="sale-heading" className="text-4xl font-bold tracking-tight text-white sm:text-5xl lg:text-6xl">
|
||||
<motion.h2
|
||||
{...fadeUp(0.15)}
|
||||
id="sale-heading"
|
||||
className="text-4xl font-bold tracking-tight text-white sm:text-5xl lg:text-6xl"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-xl text-xl text-white">
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
{...fadeUp(0.3)}
|
||||
className="mx-auto mt-4 max-w-xl text-xl text-white"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user