Compare commits
4 Commits
ed565cc5c7
...
66495f7b17
| Author | SHA1 | Date | |
|---|---|---|---|
| 66495f7b17 | |||
| 5be7d43446 | |||
|
|
9d21f06d51 | ||
|
|
e02e6bae43 |
17
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.72.0",
|
||||||
"shadcn": "^4.0.5",
|
"shadcn": "^4.0.5",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
@@ -10170,6 +10171,22 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz",
|
||||||
|
"integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.72.0",
|
||||||
"shadcn": "^4.0.5",
|
"shadcn": "^4.0.5",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ collections:
|
|||||||
- { name: "navTitle", label: "Titre colonne navigation", widget: "string", i18n: true }
|
- { name: "navTitle", label: "Titre colonne navigation", widget: "string", i18n: true }
|
||||||
- { name: "servicesTitle", label: "Titre colonne services", widget: "string", i18n: true }
|
- { name: "servicesTitle", label: "Titre colonne services", widget: "string", i18n: true }
|
||||||
- { name: "contactTitle", label: "Titre colonne contact", widget: "string", i18n: true }
|
- { name: "contactTitle", label: "Titre colonne contact", widget: "string", i18n: true }
|
||||||
|
- { name: "serviceAreasTitle", label: "Titre colonne zones de service", widget: "string", i18n: true }
|
||||||
- { name: "legal", label: "Mention légale (utiliser {year})", widget: "string", i18n: true }
|
- { name: "legal", label: "Mention légale (utiliser {year})", widget: "string", i18n: true }
|
||||||
- { name: "rbq", label: "Ligne RBQ", widget: "string", i18n: true }
|
- { name: "rbq", label: "Ligne RBQ", widget: "string", i18n: true }
|
||||||
- { name: "neq", label: "Ligne NEQ", widget: "string", i18n: true }
|
- { name: "neq", label: "Ligne NEQ", widget: "string", i18n: true }
|
||||||
@@ -269,6 +270,11 @@ collections:
|
|||||||
fields:
|
fields:
|
||||||
- { name: "label", label: "Libellé", widget: "string" }
|
- { name: "label", label: "Libellé", widget: "string" }
|
||||||
- { name: "href", label: "URL", widget: "string" }
|
- { name: "href", label: "URL", widget: "string" }
|
||||||
|
- name: "serviceAreas"
|
||||||
|
label: "Zones de service"
|
||||||
|
widget: "list"
|
||||||
|
i18n: true
|
||||||
|
field: { name: "item", label: "Ville / Secteur", widget: "string" }
|
||||||
|
|
||||||
- name: "not_found"
|
- name: "not_found"
|
||||||
label: "Page 404"
|
label: "Page 404"
|
||||||
@@ -316,6 +322,27 @@ collections:
|
|||||||
- { name: "title", label: "Titre", widget: "string" }
|
- { name: "title", label: "Titre", widget: "string" }
|
||||||
- { name: "description", label: "Description", widget: "text" }
|
- { name: "description", label: "Description", widget: "text" }
|
||||||
|
|
||||||
|
- name: "testimonials"
|
||||||
|
label: "Témoignages"
|
||||||
|
label_singular: "Témoignage"
|
||||||
|
folder: "src/content/testimonials"
|
||||||
|
format: json
|
||||||
|
create: false
|
||||||
|
delete: false
|
||||||
|
i18n: true
|
||||||
|
slug: "{{slug}}"
|
||||||
|
fields:
|
||||||
|
- { name: "sectionLabel", label: "Libellé de section", widget: "string", i18n: true }
|
||||||
|
- { name: "title", label: "Titre de section", widget: "string", i18n: true }
|
||||||
|
- name: "items"
|
||||||
|
label: "Témoignages"
|
||||||
|
widget: "list"
|
||||||
|
i18n: true
|
||||||
|
fields:
|
||||||
|
- { name: "key", label: "Identifiant unique", widget: "string" }
|
||||||
|
- { name: "src", label: "URL du post Facebook (embed)", widget: "string" }
|
||||||
|
- { name: "height", label: "Hauteur de l'iframe (px)", widget: "number" }
|
||||||
|
|
||||||
- name: "settings"
|
- name: "settings"
|
||||||
label: "Paramètres du site"
|
label: "Paramètres du site"
|
||||||
files:
|
files:
|
||||||
@@ -333,6 +360,7 @@ collections:
|
|||||||
- { name: "serviceArea", label: "Zone de service", widget: "string" }
|
- { name: "serviceArea", label: "Zone de service", widget: "string" }
|
||||||
- { name: "facebook", label: "Lien Facebook", widget: "string" }
|
- { name: "facebook", label: "Lien Facebook", widget: "string" }
|
||||||
- { name: "instagram", label: "Lien Instagram", widget: "string", required: false }
|
- { name: "instagram", label: "Lien Instagram", widget: "string", required: false }
|
||||||
|
- { name: "linkedin", label: "Lien LinkedIn (vide = masqué)", widget: "string", required: false }
|
||||||
- { name: "rbq", label: "Numéro RBQ", widget: "string" }
|
- { name: "rbq", label: "Numéro RBQ", widget: "string" }
|
||||||
- { name: "neq", label: "Numéro NEQ (Registraire)", widget: "string" }
|
- { name: "neq", label: "Numéro NEQ (Registraire)", widget: "string" }
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
BIN
public/images/landing-cta.webp
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
public/images/services-1.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/images/services-2.webp
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
public/images/services-3.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
public/images/services-4.webp
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
public/images/services-5.webp
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
public/images/short-hero.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/images/submission.webp
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
src/assets/photos/carrousel-1.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
src/assets/photos/carrousel-2.webp
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
src/assets/photos/carrousel-3.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src/assets/photos/carrousel-4.webp
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
src/assets/photos/carrousel-5.webp
Normal file
|
After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
src/assets/photos/services-1.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
src/assets/photos/services-2.webp
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
src/assets/photos/services-3.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src/assets/photos/services-4.webp
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
src/assets/photos/services-5.webp
Normal file
|
After Width: | Height: | Size: 202 KiB |
@@ -16,17 +16,17 @@ const legalNotice = footer.legal.replace('{year}', String(year));
|
|||||||
|
|
||||||
<footer class="bg-brand-600 pt-14 px-8 text-white/85">
|
<footer class="bg-brand-600 pt-14 px-8 text-white/85">
|
||||||
<div
|
<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_1fr_1fr_1fr] min-[901px]:gap-12"
|
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>
|
<div>
|
||||||
<a href="/" aria-label={general.siteName}>
|
<a href="/" aria-label={general.siteName}>
|
||||||
<img
|
<img
|
||||||
src="/images/logo.png"
|
src="/images/logo-vignette.svg"
|
||||||
alt={general.siteName}
|
alt={general.siteName}
|
||||||
width="140"
|
width="140"
|
||||||
height="52"
|
height="52"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="mb-4 block h-11 w-auto brightness-0 invert"
|
class="mb-4 block h-11 w-auto brightness-75 invert"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<p class="mb-2 text-sm font-normal text-white/75">{footer.tagline}</p>
|
<p class="mb-2 text-sm font-normal text-white/75">{footer.tagline}</p>
|
||||||
@@ -37,6 +37,8 @@ const legalNotice = footer.legal.replace('{year}', String(year));
|
|||||||
</svg>
|
</svg>
|
||||||
{general.serviceArea}
|
{general.serviceArea}
|
||||||
</p>
|
</p>
|
||||||
|
{(general.facebook || general.instagram || general.linkedin) && (
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
{general.facebook && (
|
{general.facebook && (
|
||||||
<a
|
<a
|
||||||
href={general.facebook}
|
href={general.facebook}
|
||||||
@@ -51,6 +53,47 @@ const legalNotice = footer.legal.replace('{year}', String(year));
|
|||||||
{footer.facebookLabel}
|
{footer.facebookLabel}
|
||||||
</a>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -64,17 +107,6 @@ const legalNotice = footer.legal.replace('{year}', String(year));
|
|||||||
</ul>
|
</ul>
|
||||||
</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.servicesTitle}
|
|
||||||
</h3>
|
|
||||||
<ul class="m-0 flex list-none flex-col gap-2.5 p-0">
|
|
||||||
{footer.serviceLinks.map((link) => (
|
|
||||||
<li><a href={link.href} class="text-[0.9375rem] text-white/80 transition-colors hover:text-white">{link.label}</a></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</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">
|
<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}
|
{footer.contactTitle}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Dialog, DialogPanel } from "@headlessui/react";
|
import { Dialog, DialogPanel } from "@headlessui/react";
|
||||||
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import general from "../content/settings/general.json";
|
import { PhoneIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
import general from "../content/settings/general.json";
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
navigation,
|
navigation,
|
||||||
@@ -14,6 +15,7 @@ export default function Header({
|
|||||||
ctaHref: string;
|
ctaHref: string;
|
||||||
}) {
|
}) {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const phoneHref = `tel:${general.phone.replace(/\D/g, "")}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="absolute inset-x-0 top-0 z-50">
|
<header className="absolute inset-x-0 top-0 z-50">
|
||||||
@@ -58,12 +60,29 @@ export default function Header({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:flex lg:flex-1 lg:justify-end">
|
<div className="hidden lg:flex lg:flex-1 lg:justify-end">
|
||||||
<a
|
{/* <a
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
className="rounded-md bg-[var(--color-brand-600)] px-4 py-2 text-sm font-semibold text-white shadow-sm ring-1 ring-inset ring-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)] transition-colors"
|
className="rounded-md bg-[var(--color-brand-600)] px-4 py-2 text-sm font-semibold text-white shadow-sm ring-1 ring-inset ring-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)] transition-colors"
|
||||||
>
|
>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
|
</a> */}
|
||||||
|
<dl className="flex gap-x-2 items-center justify-center text-xl font-semibold bg-white rounded-full p-2">
|
||||||
|
<dt className="flex-none">
|
||||||
|
<span className="sr-only">Telephone</span>
|
||||||
|
<PhoneIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-8 w-8 text-brand-600 "
|
||||||
|
/>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<a
|
||||||
|
href={phoneHref}
|
||||||
|
className="text-brand-600 hover:text-brand-700 "
|
||||||
|
>
|
||||||
|
{general.phone}
|
||||||
</a>
|
</a>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -76,29 +95,21 @@ export default function Header({
|
|||||||
<div className="flex items-center justify-between sm:justify-end lg:justify-between">
|
<div className="flex items-center justify-between sm:justify-end lg:justify-between">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
className="flex sm:hidden lg:flex items-center bg-[white] rounded-3xl
|
className="flex sm:hidden lg:flex items-center bg-[white] rounded-full m-1"
|
||||||
ps-0.5 pe-2 pt-1 pb-0.5
|
|
||||||
md:ps-1 md:pe-3 md:pt-2 md:pb-1
|
|
||||||
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div className="p-1 bg-[white] rounded-full ">
|
<div className="p-1 bg-[white] rounded-full ">
|
||||||
<span className="sr-only">{general.siteName}</span>
|
<span className="sr-only">{general.siteName}</span>
|
||||||
<img
|
<img
|
||||||
alt={general.siteName}
|
alt={general.siteName}
|
||||||
src="/images/logo-vignette.svg"
|
src="/images/logo-vignette.svg"
|
||||||
className="h-10 w-auto"
|
className="h-10 w-10 object-fill"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xl sm:text-2xl md:text-3xl font-semibold text-brand-600 uppercase">
|
|
||||||
{general.brandName}
|
|
||||||
</p>
|
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
className="-m-2.5 rounded-md p-2.5 text-gray-700 dark:text-gray-400"
|
className="my-2 mx-3 rounded-md text-brand-600 hover:text-brand-700"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close menu</span>
|
<span className="sr-only">Close menu</span>
|
||||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||||
@@ -111,19 +122,36 @@ export default function Header({
|
|||||||
<a
|
<a
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="-mx-3 block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 hover:bg-gray-50 dark:text-white dark:hover:bg-white/5"
|
className="-mx-3 block rounded-lg px-3 py-2 text-base/7 font-semibold text-brand-600 hover:bg-brand-50 hover:text-brand-700"
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="py-6">
|
<div className="py-6">
|
||||||
<a
|
{/* <a
|
||||||
href={ctaHref}
|
href={ctaHref}
|
||||||
className="-mx-3 block rounded-lg px-3 py-2.5 text-base/7 font-semibold text-center bg-[var(--color-brand-600)] text-white ring-1 ring-inset ring-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)]"
|
className="-mx-3 block rounded-lg px-3 py-2.5 text-base/7 font-semibold text-center bg-[var(--color-brand-600)] text-white ring-1 ring-inset ring-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)]"
|
||||||
>
|
>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
|
</a> */}
|
||||||
|
<dl className="flex gap-x-2 items-start justify-start text-xl font-semibold">
|
||||||
|
<dt className="flex-none">
|
||||||
|
<span className="sr-only">Telephone</span>
|
||||||
|
<PhoneIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-8 w-8 text-brand-600 "
|
||||||
|
/>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<a
|
||||||
|
href={phoneHref}
|
||||||
|
className="text-brand-600 hover:text-brand-700 "
|
||||||
|
>
|
||||||
|
{general.phone}
|
||||||
</a>
|
</a>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
---
|
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
import { getLocale } from '@/lib/locale';
|
|
||||||
|
|
||||||
const locale = getLocale(Astro);
|
|
||||||
const [entry] = await getCollection('soumission', (e) => e.id === `${locale}/main`);
|
|
||||||
if (!entry) throw new Error(`Missing soumission content for locale: ${locale}`);
|
|
||||||
|
|
||||||
const s = entry.data;
|
|
||||||
const f = s.form;
|
|
||||||
const v = f.validation;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="form-wrapper">
|
|
||||||
<form
|
|
||||||
id="soumission-form"
|
|
||||||
class="soumission-form"
|
|
||||||
novalidate
|
|
||||||
aria-label={f.ariaLabel}
|
|
||||||
>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="nom" class="form-label">
|
|
||||||
{f.nom}
|
|
||||||
<span class="form-required" aria-hidden="true">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="nom"
|
|
||||||
name="nom"
|
|
||||||
class="form-input"
|
|
||||||
placeholder={f.nomPlaceholder}
|
|
||||||
required
|
|
||||||
autocomplete="name"
|
|
||||||
/>
|
|
||||||
<span class="form-error" id="nom-error" role="alert" aria-live="polite"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="courriel" class="form-label">
|
|
||||||
{f.courriel}
|
|
||||||
<span class="form-required" aria-hidden="true">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="courriel"
|
|
||||||
name="courriel"
|
|
||||||
class="form-input"
|
|
||||||
placeholder={f.courrielPlaceholder}
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
/>
|
|
||||||
<span class="form-error" id="courriel-error" role="alert" aria-live="polite"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="telephone" class="form-label">
|
|
||||||
{f.telephone}
|
|
||||||
<span class="form-required" aria-hidden="true">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
id="telephone"
|
|
||||||
name="telephone"
|
|
||||||
class="form-input"
|
|
||||||
placeholder={f.telephonePlaceholder}
|
|
||||||
required
|
|
||||||
autocomplete="tel"
|
|
||||||
/>
|
|
||||||
<span class="form-error" id="telephone-error" role="alert" aria-live="polite"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="type_service" class="form-label">
|
|
||||||
{f.typeService}
|
|
||||||
<span class="form-required" aria-hidden="true">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="type_service"
|
|
||||||
name="type_service"
|
|
||||||
class="form-input form-select"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">{f.typeServicePlaceholder}</option>
|
|
||||||
<option value="interieur">{f.typeServiceOptions.interieur}</option>
|
|
||||||
<option value="exterieur">{f.typeServiceOptions.exterieur}</option>
|
|
||||||
<option value="commercial">{f.typeServiceOptions.commercial}</option>
|
|
||||||
<option value="decoration">{f.typeServiceOptions.decoration}</option>
|
|
||||||
<option value="autre">{f.typeServiceOptions.autre}</option>
|
|
||||||
</select>
|
|
||||||
<span class="form-error" id="type_service-error" role="alert" aria-live="polite"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="adresse" class="form-label">
|
|
||||||
{f.adresse}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="adresse"
|
|
||||||
name="adresse"
|
|
||||||
class="form-input"
|
|
||||||
placeholder={f.adressePlaceholder}
|
|
||||||
autocomplete="street-address"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="message" class="form-label">
|
|
||||||
{f.message}
|
|
||||||
<span class="form-required" aria-hidden="true">*</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="message"
|
|
||||||
name="message"
|
|
||||||
class="form-input form-textarea"
|
|
||||||
placeholder={f.messagePlaceholder}
|
|
||||||
required
|
|
||||||
rows="5"
|
|
||||||
></textarea>
|
|
||||||
<span class="form-error" id="message-error" role="alert" aria-live="polite"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn-primary form-submit-btn" id="submit-btn">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
||||||
<polygon points="22,2 15,22 11,13 2,9"/>
|
|
||||||
</svg>
|
|
||||||
<span id="submit-text">{f.envoyer}</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="form-success" id="form-success" aria-live="polite" aria-hidden="true">
|
|
||||||
<div class="form-success-icon">
|
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
||||||
<polyline points="22,4 12,14.01 9,11.01"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3>{f.successTitle}</h3>
|
|
||||||
<p>{f.successMessage}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-error-msg" id="form-error-msg" aria-live="assertive" aria-hidden="true">
|
|
||||||
<div class="form-error-icon">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
|
||||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{f.errorTitle}</strong>
|
|
||||||
<p>{f.errorMessage}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script define:vars={{ v, sendingLabel: f.sending, submitLabel: f.envoyer }}>
|
|
||||||
const form = document.getElementById('soumission-form') as HTMLFormElement | null;
|
|
||||||
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement | null;
|
|
||||||
const submitText = document.getElementById('submit-text');
|
|
||||||
const successDiv = document.getElementById('form-success');
|
|
||||||
const errorDiv = document.getElementById('form-error-msg');
|
|
||||||
|
|
||||||
function showError(fieldId: string, message: string) {
|
|
||||||
const el = document.getElementById(`${fieldId}-error`);
|
|
||||||
const input = document.getElementById(fieldId);
|
|
||||||
if (el) el.textContent = message;
|
|
||||||
if (input) input.setAttribute('aria-invalid', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearErrors() {
|
|
||||||
['nom', 'courriel', 'telephone', 'type_service', 'message'].forEach((id) => {
|
|
||||||
const el = document.getElementById(`${id}-error`);
|
|
||||||
const input = document.getElementById(id);
|
|
||||||
if (el) el.textContent = '';
|
|
||||||
if (input) input.removeAttribute('aria-invalid');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateForm(data: FormData): boolean {
|
|
||||||
let valid = true;
|
|
||||||
clearErrors();
|
|
||||||
|
|
||||||
const nom = data.get('nom') as string;
|
|
||||||
const courriel = data.get('courriel') as string;
|
|
||||||
const telephone = data.get('telephone') as string;
|
|
||||||
const type_service = data.get('type_service') as string;
|
|
||||||
const message = data.get('message') as string;
|
|
||||||
|
|
||||||
if (!nom?.trim()) {
|
|
||||||
showError('nom', v.nom);
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!courriel?.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(courriel)) {
|
|
||||||
showError('courriel', v.courriel);
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!telephone?.trim()) {
|
|
||||||
showError('telephone', v.telephone);
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!type_service) {
|
|
||||||
showError('type_service', v.typeService);
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message?.trim()) {
|
|
||||||
showError('message', v.message);
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
form?.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!submitBtn || !submitText || !successDiv || !errorDiv) return;
|
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
|
|
||||||
if (!validateForm(formData)) return;
|
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitText.textContent = sendingLabel;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
nom: formData.get('nom'),
|
|
||||||
courriel: formData.get('courriel'),
|
|
||||||
telephone: formData.get('telephone'),
|
|
||||||
type_service: formData.get('type_service'),
|
|
||||||
adresse: formData.get('adresse'),
|
|
||||||
message: formData.get('message'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch('/api/soumission', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Server error');
|
|
||||||
|
|
||||||
form.style.display = 'none';
|
|
||||||
errorDiv.setAttribute('aria-hidden', 'true');
|
|
||||||
errorDiv.style.display = 'none';
|
|
||||||
successDiv.setAttribute('aria-hidden', 'false');
|
|
||||||
successDiv.style.display = 'flex';
|
|
||||||
} catch {
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitText.textContent = submitLabel;
|
|
||||||
errorDiv.setAttribute('aria-hidden', 'false');
|
|
||||||
errorDiv.style.display = 'flex';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
419
src/components/submission.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
ArrowUpTrayIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
DocumentIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
PhotoIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
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;
|
||||||
|
messagePlaceholder: string;
|
||||||
|
fichiers: string;
|
||||||
|
fichiersHint: string;
|
||||||
|
fichiersAccepted: string;
|
||||||
|
fichiersRemove: string;
|
||||||
|
envoyer: string;
|
||||||
|
sending: string;
|
||||||
|
successTitle: string;
|
||||||
|
successMessage: string;
|
||||||
|
errorTitle: string;
|
||||||
|
errorMessage: string;
|
||||||
|
validation: {
|
||||||
|
nom: string;
|
||||||
|
courriel: string;
|
||||||
|
telephone: string;
|
||||||
|
typeService: string;
|
||||||
|
message: string;
|
||||||
|
fichiersType: string;
|
||||||
|
fichiersSize: 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;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||||
|
|
||||||
|
const ACCEPTED_MIME = new Set([
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/svg+xml",
|
||||||
|
"application/pdf",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inputBase =
|
||||||
|
"block w-full rounded-md bg-white px-3.5 py-2 text-base text-brand-dark outline-1 -outline-offset-1 placeholder:text-brand-400 focus:outline-2 focus:-outline-offset-2 dark:bg-white/5 dark:text-white dark:placeholder:text-brand-400";
|
||||||
|
|
||||||
|
const inputClass = `${inputBase} outline-brand-200 focus:outline-brand-600 dark:outline-white/10 dark:focus:outline-brand-500`;
|
||||||
|
const inputErrorClass = `${inputBase} outline-red-400 focus:outline-red-500 dark:outline-red-500/50`;
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileTypeIcon({ mime }: { mime: string }) {
|
||||||
|
if (mime.startsWith("image/")) return <PhotoIcon className="h-5 w-5 shrink-0 text-brand-400" />;
|
||||||
|
return <DocumentIcon className="h-5 w-5 shrink-0 text-brand-400" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubmissionForm({ imageSrc, imageAlt, title, subtitle, form }: Props) {
|
||||||
|
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||||
|
const [fileError, setFileError] = useState("");
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormFields>({ mode: "onTouched" });
|
||||||
|
|
||||||
|
function resetFileInput() {
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(incoming: FileList | null) {
|
||||||
|
if (!incoming) return;
|
||||||
|
const next = [...uploadedFiles];
|
||||||
|
|
||||||
|
for (const file of Array.from(incoming)) {
|
||||||
|
if (!ACCEPTED_MIME.has(file.type)) {
|
||||||
|
setFileError(form.validation.fichiersType);
|
||||||
|
resetFileInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!next.find((f) => f.name === file.name && f.size === file.size)) {
|
||||||
|
next.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = next.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
if (totalSize > MAX_FILE_SIZE) {
|
||||||
|
setFileError(form.validation.fichiersSize);
|
||||||
|
resetFileInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileError("");
|
||||||
|
setUploadedFiles(next);
|
||||||
|
resetFileInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index: number) {
|
||||||
|
const next = uploadedFiles.filter((_, i) => i !== index);
|
||||||
|
setUploadedFiles(next);
|
||||||
|
const newTotal = next.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
if (newTotal <= MAX_FILE_SIZE) setFileError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: React.DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: React.DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
addFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(data: FormFields) {
|
||||||
|
const totalSize = uploadedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
if (totalSize > MAX_FILE_SIZE) {
|
||||||
|
setFileError(form.validation.fichiersSize);
|
||||||
|
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 });
|
||||||
|
setSubmitStatus(res.ok ? "success" : "error");
|
||||||
|
} catch {
|
||||||
|
setSubmitStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = uploadedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
name="soumission"
|
||||||
|
method="POST"
|
||||||
|
data-netlify="true"
|
||||||
|
aria-label={form.ariaLabel}
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
noValidate
|
||||||
|
className="mt-16"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="form-name" value="soumission" />
|
||||||
|
|
||||||
|
{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">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<input
|
||||||
|
id="nom"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
placeholder={form.nomPlaceholder}
|
||||||
|
className={errors.nom ? inputErrorClass : inputClass}
|
||||||
|
{...register("nom", { required: form.validation.nom })}
|
||||||
|
/>
|
||||||
|
{errors.nom && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.nom.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courriel */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label htmlFor="courriel" className={labelClass}>{form.courriel}</label>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<input
|
||||||
|
id="courriel"
|
||||||
|
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 },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.courriel && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.courriel.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Téléphone */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label htmlFor="telephone" className={labelClass}>{form.telephone}</label>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<input
|
||||||
|
id="telephone"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
placeholder={form.telephonePlaceholder}
|
||||||
|
className={errors.telephone ? inputErrorClass : inputClass}
|
||||||
|
{...register("telephone", { required: form.validation.telephone })}
|
||||||
|
/>
|
||||||
|
{errors.telephone && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.telephone.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Adresse */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label htmlFor="adresse" className={labelClass}>{form.adresse}</label>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<input
|
||||||
|
id="adresse"
|
||||||
|
type="text"
|
||||||
|
autoComplete="street-address"
|
||||||
|
placeholder={form.adressePlaceholder}
|
||||||
|
className={inputClass}
|
||||||
|
{...register("adresse")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type de service */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label htmlFor="typeService" className={labelClass}>{form.typeService}</label>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<select
|
||||||
|
id="typeService"
|
||||||
|
defaultValue=""
|
||||||
|
className={`${errors.typeService ? inputErrorClass : inputClass} cursor-pointer`}
|
||||||
|
{...register("typeService", { required: form.validation.typeService })}
|
||||||
|
>
|
||||||
|
<option value="" disabled>{form.typeServicePlaceholder}</option>
|
||||||
|
{Object.entries(form.typeServiceOptions).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>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label htmlFor="message" className={labelClass}>{form.message}</label>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
rows={4}
|
||||||
|
placeholder={form.messagePlaceholder}
|
||||||
|
className={errors.message ? inputErrorClass : inputClass}
|
||||||
|
{...register("message", { required: form.validation.message })}
|
||||||
|
/>
|
||||||
|
{errors.message && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{errors.message.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File upload */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<label className={labelClass}>{form.fichiers}</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"}
|
||||||
|
</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" />
|
||||||
|
{fileError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2.5">
|
||||||
|
{/* Dropzone */}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={form.fichiersHint}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()}
|
||||||
|
className={[
|
||||||
|
"flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors",
|
||||||
|
isDragging
|
||||||
|
? "border-brand-500 bg-brand-50 dark:border-brand-400 dark:bg-brand-900/40"
|
||||||
|
: fileError
|
||||||
|
? "border-red-400 bg-red-50/50 dark:border-red-500/50 dark:bg-red-900/10"
|
||||||
|
: "border-brand-200 hover:border-brand-400 hover:bg-brand-50/50 dark:border-brand-700 dark:hover:border-brand-500 dark:hover:bg-brand-900/20",
|
||||||
|
].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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,.pdf,.doc,.docx"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => addFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{uploadedFiles.map((file, i) => (
|
||||||
|
<li
|
||||||
|
key={`${file.name}-${file.size}`}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md bg-brand-50 px-3 py-2 text-sm dark:bg-brand-900/40"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<FileTypeIcon mime={file.type} />
|
||||||
|
<span className="truncate text-brand-700 dark:text-brand-200">{file.name}</span>
|
||||||
|
<span className="shrink-0 text-xs text-brand-400">{formatBytes(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
aria-label={`${form.fichiersRemove} ${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" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex justify-end border-t border-brand-900/10 pt-8 dark:border-white/10">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative hidden lg:block">
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={imageAlt}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/components/ui/contact.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,14 +5,10 @@ import {
|
|||||||
} from "@/components/ui/carousel";
|
} from "@/components/ui/carousel";
|
||||||
import Autoplay from "embla-carousel-autoplay";
|
import Autoplay from "embla-carousel-autoplay";
|
||||||
import general from "../../content/settings/general.json";
|
import general from "../../content/settings/general.json";
|
||||||
import photo1 from "@/assets/photos/empty-room-blue-wall-with-moulding-and-parquet-fl-2023-11-27-04-52-31-utc.webp";
|
|
||||||
import photo2 from "@/assets/photos/the-attic-of-a-home-with-white-ceilings-and-white-2023-11-27-05-27-44-utc.webp";
|
|
||||||
import photo3 from "@/assets/photos/silver-and-white-apartment-interior-2023-11-27-05-32-03-utc.webp";
|
|
||||||
import photo4 from "@/assets/photos/a-bedroom-with-white-walls-and-a-bed-and-a-2023-11-27-05-25-30-utc.webp";
|
|
||||||
import photo5 from "@/assets/photos/empty-room-with-window-2023-11-27-05-08-12-utc.webp";
|
|
||||||
import logo from "@/assets/cachet-hero-logo.svg";
|
import logo from "@/assets/cachet-hero-logo.svg";
|
||||||
|
|
||||||
export type HeroContent = {
|
export type HeroContent = {
|
||||||
pillCta: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
title2: string;
|
title2: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
@@ -21,11 +17,11 @@ export type HeroContent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const slides = [
|
const slides = [
|
||||||
{ src: photo1.src, alt: "Peinture intérieure" },
|
{ src: '/images/carrousel-1.webp', alt: "Peinture intérieure" },
|
||||||
{ src: photo2.src, alt: "Décoration murale" },
|
{ src: '/images/carrousel-2.webp', alt: "Décoration murale" },
|
||||||
{ src: photo3.src, alt: "Finition de qualité" },
|
{ src: '/images/carrousel-3.webp', alt: "Finition de qualité" },
|
||||||
{ src: photo4.src, alt: "Chambre" },
|
{ src: '/images/carrousel-4.webp', alt: "Chambre" },
|
||||||
{ src: photo5.src, alt: "Pièce lumineuse" },
|
{ src: '/images/carrousel-5.webp', alt: "Pièce lumineuse" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const autoplayPlugin = Autoplay({
|
const autoplayPlugin = Autoplay({
|
||||||
@@ -35,7 +31,7 @@ const autoplayPlugin = Autoplay({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function Hero({ content }: { content: HeroContent }) {
|
export default function Hero({ content }: { content: HeroContent }) {
|
||||||
const { pillCta, title, title2, subtitle, cta, ctaHref } = content;
|
const { subtitle, cta, ctaHref } = content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900">
|
<div className="bg-white dark:bg-gray-900">
|
||||||
@@ -61,7 +57,7 @@ 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="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="mx-auto max-w-4xl text-center">
|
||||||
<div className="hidden sm:mb-8 sm:flex sm:justify-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">
|
<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>RBQ {general.rbq}</span>
|
||||||
<span className="text-white/30" aria-hidden>
|
<span className="text-white/30" aria-hidden>
|
||||||
@@ -73,7 +69,7 @@ export default function Hero({ content }: { content: HeroContent }) {
|
|||||||
</span>
|
</span>
|
||||||
<span>{pillCta}</span>
|
<span>{pillCta}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
{/* <h1
|
{/* <h1
|
||||||
className="text-5xl font-semibold tracking-tight text-balance text-white sm:text-7xl"
|
className="text-5xl font-semibold tracking-tight text-balance text-white sm:text-7xl"
|
||||||
style={{ fontFamily: "var(--font-display)" }}
|
style={{ fontFamily: "var(--font-display)" }}
|
||||||
|
|||||||
44
src/components/ui/short-hero.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
interface ContactHeroContent {
|
||||||
|
imageSrc: string;
|
||||||
|
imageAlt: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function ContactHero({ content }: { content: ContactHeroContent }) {
|
||||||
|
const { imageSrc, imageAlt, title, subtitle } = content;
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center overflow-hidden bg-brand-600 min-h-[50vh]">
|
||||||
|
{/* Decorative background image and gradient */}
|
||||||
|
<div aria-hidden="true" className="absolute inset-0">
|
||||||
|
<div className="absolute inset-0 mx-auto overflow-hidden">
|
||||||
|
<img
|
||||||
|
alt={imageAlt}
|
||||||
|
src={imageSrc}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0" />
|
||||||
|
<div className="absolute inset-0 bg-linear-to-t from-white via-white opacity-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Callout */}
|
||||||
|
<section
|
||||||
|
aria-labelledby="sale-heading"
|
||||||
|
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">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-xl text-xl text-white">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,46 +1,29 @@
|
|||||||
const featuredTestimonial = {
|
interface TestimonialItem {
|
||||||
body: 'Integer id nunc sit semper purus. Bibendum at lacus ut arcu blandit montes vitae auctor libero. Hac condimentum dignissim nibh vulputate ut nunc. Amet nibh orci mi venenatis blandit vel et proin. Non hendrerit in vel ac diam.',
|
key: string;
|
||||||
author: {
|
src: string;
|
||||||
name: 'Brenna Goyette',
|
height: number;
|
||||||
handle: 'brennagoyette',
|
|
||||||
imageUrl:
|
|
||||||
'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=1024&h=1024&q=80',
|
|
||||||
logoUrl: 'https://tailwindcss.com/plus-assets/img/logos/savvycal-logo-gray-900.svg',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const testimonials = [
|
|
||||||
[
|
|
||||||
[
|
|
||||||
{
|
|
||||||
iframe: <iframe className="w-full h-full rounded-2xl " src="https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fjean.christophe.ali.2025%2Fposts%2Fpfbid02qsqq2Rn4vRx1gCG3q5e5M2ByPXH6zMux59fWvotfsQxpuWyUh46TZuwCND9iKpwnl&show_text=true&width=500" width="500" height="194" style={{ border: 'none', overflow: 'hidden' }} scrolling="no" frameBorder={0} allowFullScreen allow="autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"></iframe>,
|
|
||||||
key: 'jean-christophe-ali',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[{
|
|
||||||
iframe: <iframe className="w-full h-full rounded-2xl " src="https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fcharlotte.lafrance.CL%2Fposts%2Fpfbid026Jw7aer2r84q27kgcZ1PKg8q2sqKgZc6nJspP42CTEAU6ac8FF4Ptj8ap8XGh6Wl&show_text=true&width=500" width="500" height="166" style={{ border: 'none', overflow: 'hidden' }} scrolling="no" frameBorder={0} allowFullScreen allow="autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"></iframe>,
|
|
||||||
key: 'charlotte-lafrance',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[{
|
|
||||||
iframe: <iframe className="w-full h-full rounded-2xl " src="https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fcolin.griffin.165%2Fposts%2Fpfbid0iCA6hoF5X32gUJSBcDUiXP2hxhxzMmEiC5Att3CDDwFckrHa7yxyGiLBmXQhQBUFl&show_text=true&width=500" width="500" height="222" style={{ border: 'none', overflow: 'hidden' }} scrolling="no" frameBorder={0} allowFullScreen allow="autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"></iframe> ,
|
|
||||||
key: 'colin-griffin',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[ {
|
|
||||||
iframe: <iframe className="w-full h-full rounded-2xl" src="https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fhollowman.la.rock%2Fposts%2Fpfbid0hPR3RpaSLZtZkgpaEzKsNKAF4hUeKbDzkicAZ9jsbCpXKRRsYD63C32rw78uvRoml&show_text=true&width=500" width="500" height="170" style={{ border: 'none', overflow: 'hidden' }} scrolling="no" frameBorder={0} allowFullScreen allow="autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"></iframe>,
|
|
||||||
key: 'hollowman-la-rock',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
function classNames(...classes: string[]) {
|
|
||||||
return classes.filter(Boolean).join(' ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Grid() {
|
interface Props {
|
||||||
|
sectionLabel: string;
|
||||||
|
title: string;
|
||||||
|
items: TestimonialItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframeProps = {
|
||||||
|
width: 500,
|
||||||
|
scrolling: 'no',
|
||||||
|
frameBorder: 0,
|
||||||
|
allowFullScreen: true,
|
||||||
|
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share',
|
||||||
|
style: { border: 'none', overflow: 'hidden' } as React.CSSProperties,
|
||||||
|
className: 'w-full rounded-2xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Grid({ sectionLabel, title, items }: Props) {
|
||||||
|
const [featured, ...rest] = items
|
||||||
|
const groups = [rest.slice(0, 2), rest.slice(2)]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative isolate bg-brand-50 pt-24 pb-32 sm:pt-32 dark:bg-brand-50">
|
<div className="relative isolate bg-brand-50 pt-24 pb-32 sm:pt-32 dark:bg-brand-50">
|
||||||
<div
|
<div
|
||||||
@@ -69,52 +52,31 @@ export default function Grid() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
<div className="mx-auto max-w-2xl text-center">
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
<h2 className="text-base/7 font-semibold text-brand-400 dark:text-brand-400">Témoignages</h2>
|
<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">
|
<p className="mt-2 text-4xl font-semibold tracking-tight text-balance text-brand-600 sm:text-5xl dark:text-white">
|
||||||
Ce que nos clients disent de nous
|
{title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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">
|
<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">
|
||||||
<iframe className="w-full h-full rounded-2xl " src="https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fsylvie.gauthier.73594%2Fposts%2Fpfbid0DHM1833TLKru4cbkTnZq3KLNHrSR8B8rQAgGosGzAbpGMccvLffoNmCLgEo8GUNPl&show_text=true&width=500" width="500" height="208" style={{ border: 'none', overflow: 'hidden' }} scrolling="no" frameBorder={0} allowFullScreen allow="autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"></iframe>
|
<iframe {...iframeProps} src={featured.src} height={featured.height} />
|
||||||
</figure>
|
</figure>
|
||||||
{testimonials.map((columnGroup, columnGroupIdx) => (
|
{groups.map((group, groupIdx) => (
|
||||||
<div key={columnGroupIdx} className="space-y-8 xl:contents xl:space-y-0">
|
<div key={groupIdx} className="space-y-8 xl:contents xl:space-y-0">
|
||||||
{columnGroup.map((column, columnIdx) => (
|
{group.map((t, colIdx) => {
|
||||||
|
const isFirstCol = groupIdx === 0 && colIdx === 0
|
||||||
|
const isLastCol = groupIdx === groups.length - 1 && colIdx === group.length - 1
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={columnIdx}
|
key={t.key}
|
||||||
className={classNames(
|
className={(isFirstCol || isLastCol) ? 'xl:row-span-2 space-y-8' : 'xl:row-start-1 space-y-8'}
|
||||||
(columnGroupIdx === 0 && columnIdx === 0) ||
|
|
||||||
(columnGroupIdx === testimonials.length - 1 && columnIdx === columnGroup.length - 1)
|
|
||||||
? 'xl:row-span-2'
|
|
||||||
: 'xl:row-start-1',
|
|
||||||
'space-y-8',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{column.map((testimonial) => (
|
<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">
|
||||||
<figure
|
<iframe {...iframeProps} src={t.src} height={t.height} />
|
||||||
key={testimonial.key}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{testimonial.iframe}
|
|
||||||
{/* <blockquote className="text-gray-900 dark:text-white">
|
|
||||||
<p>{`“${testimonial.body}”`}</p>
|
|
||||||
</blockquote>
|
|
||||||
<figcaption className="mt-6 flex items-center gap-x-4">
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
src={testimonial.author.imageUrl}
|
|
||||||
className="size-10 rounded-full bg-gray-50 dark:bg-gray-700"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-gray-900 dark:text-white">{testimonial.author.name}</div>
|
|
||||||
<div className="text-gray-600 dark:text-gray-400">{`@${testimonial.author.handle}`}</div>
|
|
||||||
</div>
|
|
||||||
</figcaption> */}
|
|
||||||
</figure>
|
</figure>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const navigation = defineCollection({
|
|||||||
const hero = defineCollection({
|
const hero = defineCollection({
|
||||||
loader: glob({ pattern: '**/*.json', base: './src/content/hero' }),
|
loader: glob({ pattern: '**/*.json', base: './src/content/hero' }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
pillCta: z.string(),
|
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
title2: z.string(),
|
title2: z.string(),
|
||||||
subtitle: z.string(),
|
subtitle: z.string(),
|
||||||
@@ -120,6 +119,10 @@ const soumission = defineCollection({
|
|||||||
}),
|
}),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
messagePlaceholder: z.string(),
|
messagePlaceholder: z.string(),
|
||||||
|
fichiers: z.string(),
|
||||||
|
fichiersHint: z.string(),
|
||||||
|
fichiersAccepted: z.string(),
|
||||||
|
fichiersRemove: z.string(),
|
||||||
envoyer: z.string(),
|
envoyer: z.string(),
|
||||||
sending: z.string(),
|
sending: z.string(),
|
||||||
successTitle: z.string(),
|
successTitle: z.string(),
|
||||||
@@ -132,6 +135,8 @@ const soumission = defineCollection({
|
|||||||
telephone: z.string(),
|
telephone: z.string(),
|
||||||
typeService: z.string(),
|
typeService: z.string(),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
|
fichiersType: z.string(),
|
||||||
|
fichiersSize: z.string(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
sidebar: z.object({
|
sidebar: z.object({
|
||||||
@@ -156,6 +161,7 @@ const footer = defineCollection({
|
|||||||
navTitle: z.string(),
|
navTitle: z.string(),
|
||||||
servicesTitle: z.string(),
|
servicesTitle: z.string(),
|
||||||
contactTitle: z.string(),
|
contactTitle: z.string(),
|
||||||
|
serviceAreasTitle: z.string(),
|
||||||
legal: z.string(),
|
legal: z.string(),
|
||||||
rbq: z.string(),
|
rbq: z.string(),
|
||||||
neq: z.string(),
|
neq: z.string(),
|
||||||
@@ -166,6 +172,7 @@ const footer = defineCollection({
|
|||||||
href: z.string(),
|
href: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
serviceAreas: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,6 +200,21 @@ const pourquoi = defineCollection({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const testimonials = defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.json', base: './src/content/testimonials' }),
|
||||||
|
schema: z.object({
|
||||||
|
sectionLabel: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
items: z.array(
|
||||||
|
z.object({
|
||||||
|
key: z.string(),
|
||||||
|
src: z.string().url(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const collections = {
|
export const collections = {
|
||||||
services,
|
services,
|
||||||
navigation,
|
navigation,
|
||||||
@@ -203,4 +225,5 @@ export const collections = {
|
|||||||
footer,
|
footer,
|
||||||
notFound,
|
notFound,
|
||||||
pourquoi,
|
pourquoi,
|
||||||
|
testimonials,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"navTitle": "Navigation",
|
"navTitle": "Navigation",
|
||||||
"servicesTitle": "Nos services",
|
"servicesTitle": "Nos services",
|
||||||
"contactTitle": "Contact",
|
"contactTitle": "Contact",
|
||||||
|
"serviceAreasTitle": "Zone de service",
|
||||||
"legal": "© {year} 9500-5609 Québec Inc. Tous droits réservés.",
|
"legal": "© {year} 9500-5609 Québec Inc. Tous droits réservés.",
|
||||||
"rbq": "RBQ : 5839 8736 01",
|
"rbq": "RBQ : 5839 8736 01",
|
||||||
"neq": "NEQ : 9500-5609",
|
"neq": "NEQ : 9500-5609",
|
||||||
@@ -12,5 +13,25 @@
|
|||||||
{ "label": "Peinture extérieure", "href": "/services#peinture-exterieure" },
|
{ "label": "Peinture extérieure", "href": "/services#peinture-exterieure" },
|
||||||
{ "label": "Peinture commerciale", "href": "/services#peinture-commerciale" },
|
{ "label": "Peinture commerciale", "href": "/services#peinture-commerciale" },
|
||||||
{ "label": "Décoration intérieure", "href": "/services#decoration-interieure" }
|
{ "label": "Décoration intérieure", "href": "/services#decoration-interieure" }
|
||||||
|
],
|
||||||
|
"serviceAreas": [
|
||||||
|
"Saint-Basile-le-Grand",
|
||||||
|
"Longueuil",
|
||||||
|
"Brossard",
|
||||||
|
"Saint-Hubert",
|
||||||
|
"Saint-Lambert",
|
||||||
|
"Saint-Bruno-de-Montarville",
|
||||||
|
"Beloeil",
|
||||||
|
"Mont-Saint-Hilaire",
|
||||||
|
"Sainte-Julie",
|
||||||
|
"Boucherville",
|
||||||
|
"La Prairie",
|
||||||
|
"Carignan",
|
||||||
|
"Candiac",
|
||||||
|
"McMasterville",
|
||||||
|
"Otterburn Park",
|
||||||
|
"Chambly",
|
||||||
|
"Partout sur la Rive-Sud",
|
||||||
|
"Montréal"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"pillCta": "Soumission gratuite",
|
|
||||||
"title": "CACHET",
|
"title": "CACHET",
|
||||||
"title2": "PEINTRES DÉCORATEURS",
|
"title2": "PEINTRES DÉCORATEURS",
|
||||||
"subtitle": "Travaux de peinture résidentielle et commerciale, décoration intérieure et extérieure. Qualité professionnelle sur la Rive-Nord et Laval.",
|
"subtitle": "Travaux de peinture résidentielle et commerciale, décoration intérieure et extérieure. Qualité professionnelle sur la Rive-Nord et Laval.",
|
||||||
"cta": "Soumission gratuite",
|
"cta": "Contactez-nous",
|
||||||
"ctaHref": "/soumission"
|
"ctaHref": "/contact"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
"legalName": "9500-5609 Québec Inc",
|
"legalName": "9500-5609 Québec Inc",
|
||||||
"tagline": "Peintres Décorateurs professionnels",
|
"tagline": "Peintres Décorateurs professionnels",
|
||||||
"taglineHero": "Qualité et expertise en peinture résidentielle et commerciale",
|
"taglineHero": "Qualité et expertise en peinture résidentielle et commerciale",
|
||||||
"phone": "(450) 555-0199",
|
"phone": "(514) 552-1049",
|
||||||
"email": "info@cachetdeco.com",
|
"email": "info@cachetdeco.com",
|
||||||
"address": "Laval, Québec",
|
"address": "Sainte Catherine, Québec",
|
||||||
"serviceArea": "Laval, Rive-Nord, Montréal",
|
"serviceArea": "Laval, Rive-Nord, Montréal",
|
||||||
"facebook": "https://www.facebook.com/cachetpeintres",
|
"facebook": "https://www.facebook.com/cachetdecor/",
|
||||||
"instagram": "",
|
"instagram": "https://www.instagram.com/cachet.deco/",
|
||||||
|
"linkedin": "",
|
||||||
"rbq": "5839 8736 01",
|
"rbq": "5839 8736 01",
|
||||||
"neq": "9500-5609"
|
"neq": "9500-5609"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
},
|
},
|
||||||
"message": "Détails du projet",
|
"message": "Détails du projet",
|
||||||
"messagePlaceholder": "Décrivez votre projet (pièces à peindre, superficie approximative, délai souhaité...)",
|
"messagePlaceholder": "Décrivez votre projet (pièces à peindre, superficie approximative, délai souhaité...)",
|
||||||
|
"fichiers": "Photos ou documents",
|
||||||
|
"fichiersHint": "Glissez vos fichiers ici ou cliquez pour parcourir",
|
||||||
|
"fichiersAccepted": "Images, PDF, Word · Max 20 Mo au total",
|
||||||
|
"fichiersRemove": "Retirer",
|
||||||
"envoyer": "Envoyer ma demande",
|
"envoyer": "Envoyer ma demande",
|
||||||
"sending": "Envoi en cours...",
|
"sending": "Envoi en cours...",
|
||||||
"successTitle": "Demande envoyée avec succès!",
|
"successTitle": "Demande envoyée avec succès!",
|
||||||
@@ -34,7 +38,9 @@
|
|||||||
"courriel": "Une adresse courriel valide est requise.",
|
"courriel": "Une adresse courriel valide est requise.",
|
||||||
"telephone": "Le numéro de téléphone est requis.",
|
"telephone": "Le numéro de téléphone est requis.",
|
||||||
"typeService": "Veuillez sélectionner un type de service.",
|
"typeService": "Veuillez sélectionner un type de service.",
|
||||||
"message": "Un message est requis."
|
"message": "Un message est requis.",
|
||||||
|
"fichiersType": "Seules les images, PDF et documents Word sont acceptés.",
|
||||||
|
"fichiersSize": "La taille totale des fichiers ne doit pas dépasser 20 Mo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
|||||||
31
src/content/testimonials/fr/main.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"sectionLabel": "Témoignages",
|
||||||
|
"title": "Ce que nos clients disent de nous",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"key": "sylvie-gauthier",
|
||||||
|
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fsylvie.gauthier.73594%2Fposts%2Fpfbid0DHM1833TLKru4cbkTnZq3KLNHrSR8B8rQAgGosGzAbpGMccvLffoNmCLgEo8GUNPl&show_text=true&width=500",
|
||||||
|
"height": 208
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "jean-christophe-ali",
|
||||||
|
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fjean.christophe.ali.2025%2Fposts%2Fpfbid02qsqq2Rn4vRx1gCG3q5e5M2ByPXH6zMux59fWvotfsQxpuWyUh46TZuwCND9iKpwnl&show_text=true&width=500",
|
||||||
|
"height": 194
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "charlotte-lafrance",
|
||||||
|
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fcharlotte.lafrance.CL%2Fposts%2Fpfbid026Jw7aer2r84q27kgcZ1PKg8q2sqKgZc6nJspP42CTEAU6ac8FF4Ptj8ap8XGh6Wl&show_text=true&width=500",
|
||||||
|
"height": 166
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "colin-griffin",
|
||||||
|
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fcolin.griffin.165%2Fposts%2Fpfbid0iCA6hoF5X32gUJSBcDUiXP2hxhxzMmEiC5Att3CDDwFckrHa7yxyGiLBmXQhQBUFl&show_text=true&width=500",
|
||||||
|
"height": 222
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hollowman-la-rock",
|
||||||
|
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fhollowman.la.rock%2Fposts%2Fpfbid0hPR3RpaSLZtZkgpaEzKsNKAF4hUeKbDzkicAZ9jsbCpXKRRsYD63C32rw78uvRoml&show_text=true&width=500",
|
||||||
|
"height": 170
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from '@/layouts/base.astro';
|
import Layout from '@/layouts/base.astro';
|
||||||
import CTABanner from '@/components/ui/cta-banner';
|
import ContactForm from '@/components/ui/contact';
|
||||||
|
import ShortHero from '@/components/ui/short-hero';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import { getLocale } from '@/lib/locale';
|
import { getLocale } from '@/lib/locale';
|
||||||
import general from '../content/settings/general.json';
|
import general from '../content/settings/general.json';
|
||||||
@@ -8,15 +9,9 @@ import general from '../content/settings/general.json';
|
|||||||
const locale = getLocale(Astro);
|
const locale = getLocale(Astro);
|
||||||
|
|
||||||
const [contactEntry] = await getCollection('contact', (e) => e.id === `${locale}/main`);
|
const [contactEntry] = await getCollection('contact', (e) => e.id === `${locale}/main`);
|
||||||
const [ctaEntry] = await getCollection('cta', (e) => e.id === `${locale}/main`);
|
if (!contactEntry) throw new Error(`Missing contact content for locale: ${locale}`);
|
||||||
const [navEntry] = await getCollection('navigation', (e) => e.id === `${locale}/main`);
|
|
||||||
if (!contactEntry || !ctaEntry || !navEntry) throw new Error(`Missing contact content for locale: ${locale}`);
|
|
||||||
|
|
||||||
const contact = contactEntry.data;
|
const contact = contactEntry.data;
|
||||||
const ctaData = ctaEntry.data;
|
|
||||||
const homeLink = navEntry.data.links.find((l) => l.href === '/')?.name ?? contact.breadcrumbHome;
|
|
||||||
|
|
||||||
const ctaImage = (await import('@/assets/Website Content/Photos/cozy-modern-kitchen-room-interior-design-with-dark-2024-04-01-23-13-59-utc.webp')).default;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
@@ -24,154 +19,25 @@ const ctaImage = (await import('@/assets/Website Content/Photos/cozy-modern-kitc
|
|||||||
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons Laval, la Rive-Nord et les environs.`}
|
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons Laval, la Rive-Nord et les environs.`}
|
||||||
canonical="/contact"
|
canonical="/contact"
|
||||||
>
|
>
|
||||||
<section class="page-header">
|
<ShortHero
|
||||||
<div class="container-site">
|
|
||||||
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
|
||||||
<a href="/">{homeLink}</a>
|
|
||||||
<span aria-hidden="true">›</span>
|
|
||||||
<span aria-current="page">{contact.title}</span>
|
|
||||||
</nav>
|
|
||||||
<h1 class="page-title">{contact.title}</h1>
|
|
||||||
<p class="page-subtitle">{contact.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="contact-section">
|
|
||||||
<div class="container-site contact-grid">
|
|
||||||
<div class="contact-info">
|
|
||||||
<div class="contact-card">
|
|
||||||
<div class="contact-card-icon">
|
|
||||||
<svg width="22" height="22" 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>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>{contact.phone}</h3>
|
|
||||||
<a href={`tel:${general.phone.replace(/\D/g, '')}`} class="contact-value contact-link">
|
|
||||||
{general.phone}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contact-card">
|
|
||||||
<div class="contact-card-icon">
|
|
||||||
<svg width="22" height="22" 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>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>{contact.email}</h3>
|
|
||||||
<a href={`mailto:${general.email}`} class="contact-value contact-link">
|
|
||||||
{general.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contact-card">
|
|
||||||
<div class="contact-card-icon">
|
|
||||||
<svg width="22" height="22" 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>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>{contact.address}</h3>
|
|
||||||
<p class="contact-value">{general.address}</p>
|
|
||||||
<p class="contact-service-area">{contact.serviceAreaLabel} {general.serviceArea}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contact-hours">
|
|
||||||
<h3 class="contact-hours-title">{contact.hours.title}</h3>
|
|
||||||
<ul class="hours-list">
|
|
||||||
<li>
|
|
||||||
<span class="hours-day">{contact.hours.weekdays.day}</span>
|
|
||||||
<span class="hours-time">{contact.hours.weekdays.time}</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="hours-day">{contact.hours.saturday.day}</span>
|
|
||||||
<span class="hours-time">{contact.hours.saturday.time}</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="hours-day">{contact.hours.sunday.day}</span>
|
|
||||||
<span class="hours-time closed">{contact.hours.sunday.time}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{general.facebook && (
|
|
||||||
<div class="contact-social">
|
|
||||||
<h3 class="contact-social-title">{contact.follow}</h3>
|
|
||||||
<a
|
|
||||||
href={general.facebook}
|
|
||||||
class="social-link"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
{contact.facebookCta}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div class="contact-legal">
|
|
||||||
<p>{contact.rbq} {general.rbq}</p>
|
|
||||||
<p>{contact.neq} {general.neq} ({general.legalName})</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contact-action-panel">
|
|
||||||
<div class="contact-cta-card">
|
|
||||||
<div class="contact-cta-icon">
|
|
||||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2>{contact.ctaCard.title}</h2>
|
|
||||||
<p>{contact.ctaCard.description}</p>
|
|
||||||
<a href="/soumission" class="btn-primary contact-soumission-btn">
|
|
||||||
{contact.ctaCard.button}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contact-map">
|
|
||||||
<h3 class="contact-map-title">{contact.map.title}</h3>
|
|
||||||
<div class="map-placeholder">
|
|
||||||
<div class="map-placeholder-inner">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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>
|
|
||||||
<p>{contact.map.region}</p>
|
|
||||||
<span class="map-service-area">{general.serviceArea}</span>
|
|
||||||
<a
|
|
||||||
href={`https://maps.google.com/?q=Laval+Quebec`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="btn-outline map-btn"
|
|
||||||
>
|
|
||||||
{contact.map.mapsButton}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<CTABanner
|
|
||||||
client:load
|
client:load
|
||||||
title={ctaData.title}
|
content={{
|
||||||
subtitle={ctaData.subtitle}
|
title: contact.title,
|
||||||
buttonText={ctaData.button}
|
subtitle: contact.subtitle,
|
||||||
buttonHref={ctaData.buttonHref}
|
imageSrc: "/images/short-hero.webp",
|
||||||
imageSrc={ctaImage.src}
|
imageAlt: "Peintres décorateurs au travail",
|
||||||
imageAlt={ctaData.imageAlt}
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContactForm
|
||||||
|
client:load
|
||||||
|
phoneLabel={contact.phone}
|
||||||
|
phoneValue={general.phone}
|
||||||
|
emailLabel={contact.email}
|
||||||
|
emailValue={general.email}
|
||||||
|
addressLabel={contact.address}
|
||||||
|
addressValue={general.address}
|
||||||
|
serviceAreaLabel={contact.serviceAreaLabel}
|
||||||
|
serviceArea={general.serviceArea}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ const [heroEntry] = await getCollection('hero', (e) => e.id === `${locale}/main`
|
|||||||
const [navEntry] = await getCollection('navigation', (e) => e.id === `${locale}/main`);
|
const [navEntry] = await getCollection('navigation', (e) => e.id === `${locale}/main`);
|
||||||
const [ctaEntry] = await getCollection('cta', (e) => e.id === `${locale}/main`);
|
const [ctaEntry] = await getCollection('cta', (e) => e.id === `${locale}/main`);
|
||||||
const [pourquoiEntry] = await getCollection('pourquoi', (e) => e.id === `${locale}/main`);
|
const [pourquoiEntry] = await getCollection('pourquoi', (e) => e.id === `${locale}/main`);
|
||||||
|
const [testimonialsEntry] = await getCollection('testimonials', (e) => e.id === `${locale}/main`);
|
||||||
|
|
||||||
if (!heroEntry || !navEntry || !ctaEntry || !pourquoiEntry) {
|
if (!heroEntry || !navEntry || !ctaEntry || !pourquoiEntry || !testimonialsEntry) {
|
||||||
throw new Error(`Missing content collections for locale: ${locale}`);
|
throw new Error(`Missing content collections for locale: ${locale}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,31 +26,36 @@ const heroData = heroEntry.data;
|
|||||||
const nav = navEntry.data;
|
const nav = navEntry.data;
|
||||||
const ctaData = ctaEntry.data;
|
const ctaData = ctaEntry.data;
|
||||||
const pourquoiData = pourquoiEntry.data;
|
const pourquoiData = pourquoiEntry.data;
|
||||||
|
const testimonialsData = testimonialsEntry.data;
|
||||||
|
|
||||||
const ctaImage = (await import('@/assets/Website Content/Photos/cozy-modern-kitchen-room-interior-design-with-dark-2024-04-01-23-13-59-utc.webp')).default;
|
const ctaImage =
|
||||||
|
{
|
||||||
|
src: '/images/landing-cta.webp',
|
||||||
|
alt: 'Peintres décorateurs au travail',
|
||||||
|
};
|
||||||
|
|
||||||
const allServices = await getCollection('services', (s) => s.id.startsWith(`${locale}/`));
|
const allServices = await getCollection('services', (s) => s.id.startsWith(`${locale}/`));
|
||||||
const services = allServices.sort((a, b) => a.data.order - b.data.order);
|
const services = allServices.sort((a, b) => a.data.order - b.data.order);
|
||||||
|
|
||||||
const serviceImages: Record<string, { src: import('astro').ImageMetadata; alt: string }> = {
|
const serviceImages: Record<string, { src: string; alt: string }> = {
|
||||||
interior: {
|
interior: {
|
||||||
src: (await import('@/assets/photos/empty-room-blue-wall-with-moulding-and-parquet-fl-2023-11-27-04-52-31-utc.webp')).default,
|
src: '/images/services-1.webp',
|
||||||
alt: 'Salon avec peinture intérieure soignée',
|
alt: 'Salon avec peinture intérieure soignée',
|
||||||
},
|
},
|
||||||
exterior: {
|
exterior: {
|
||||||
src: (await import('@/assets/Website Content/Photos/wooden-deck-with-sky-2023-11-27-05-29-44-utc.webp')).default,
|
src: '/images/services-2.webp',
|
||||||
alt: 'Maison avec peinture extérieure',
|
alt: 'Maison avec peinture extérieure',
|
||||||
},
|
},
|
||||||
commercial: {
|
commercial: {
|
||||||
src: (await import('@/assets/Website Content/Photos/minimalist-bright-office-room-interior-design-mee-2023-11-27-05-17-04-utc.webp')).default,
|
src: '/images/services-3.webp',
|
||||||
alt: 'Espace commercial peint',
|
alt: 'Espace commercial peint',
|
||||||
},
|
},
|
||||||
decoration: {
|
decoration: {
|
||||||
src: (await import('@/assets/Website Content/Photos/Mural_Jerkspot_02.jpg')).default,
|
src: '/images/services-4.webp',
|
||||||
alt: 'Décoration intérieure et murale',
|
alt: 'Décoration intérieure et murale',
|
||||||
},
|
},
|
||||||
renovation: {
|
renovation: {
|
||||||
src: (await import('@/assets/Website Content/Photos/amazing-design-of-a-room-with-doors-and-parquet-fl-2023-11-27-05-33-04-utc.webp')).default,
|
src: '/images/services-5.webp',
|
||||||
alt: 'Rénovation et finitions',
|
alt: 'Rénovation et finitions',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -57,17 +63,15 @@ const serviceImages: Record<string, { src: import('astro').ImageMetadata; alt: s
|
|||||||
const features = services.map((service) => {
|
const features = services.map((service) => {
|
||||||
const icon = service.data.icon ?? 'interior';
|
const icon = service.data.icon ?? 'interior';
|
||||||
const img = serviceImages[icon] ?? serviceImages.interior;
|
const img = serviceImages[icon] ?? serviceImages.interior;
|
||||||
const imageSrc = (img.src as import('astro').ImageMetadata).src;
|
|
||||||
return {
|
return {
|
||||||
name: service.data.title,
|
name: service.data.title,
|
||||||
description: service.data.description,
|
description: service.data.description,
|
||||||
imageSrc,
|
imageSrc: img.src ,
|
||||||
imageAlt: img.alt,
|
imageAlt: img.alt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const heroContent = {
|
const heroContent = {
|
||||||
pillCta: heroData.pillCta,
|
|
||||||
title: heroData.title,
|
title: heroData.title,
|
||||||
title2: heroData.title2,
|
title2: heroData.title2,
|
||||||
subtitle: heroData.subtitle,
|
subtitle: heroData.subtitle,
|
||||||
@@ -88,7 +92,12 @@ const heroContent = {
|
|||||||
title={nav.servicesSection.title}
|
title={nav.servicesSection.title}
|
||||||
subtitle={nav.servicesSection.subtitle}
|
subtitle={nav.servicesSection.subtitle}
|
||||||
/>
|
/>
|
||||||
<Testimonials client:load />
|
<Testimonials
|
||||||
|
client:load
|
||||||
|
sectionLabel={testimonialsData.sectionLabel}
|
||||||
|
title={testimonialsData.title}
|
||||||
|
items={testimonialsData.items}
|
||||||
|
/>
|
||||||
|
|
||||||
<WhyChooseUs
|
<WhyChooseUs
|
||||||
client:load
|
client:load
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from '@/layouts/base.astro';
|
import Layout from '@/layouts/base.astro';
|
||||||
import SoumissionForm from '@/components/submission-form.astro';
|
import SubmissionForm from '@/components/submission';
|
||||||
|
import ShortHero from '@/components/ui/short-hero';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import { getLocale } from '@/lib/locale';
|
import { getLocale } from '@/lib/locale';
|
||||||
import general from '../content/settings/general.json';
|
import general from '../content/settings/general.json';
|
||||||
@@ -8,75 +9,32 @@ import general from '../content/settings/general.json';
|
|||||||
const locale = getLocale(Astro);
|
const locale = getLocale(Astro);
|
||||||
|
|
||||||
const [soumissionEntry] = await getCollection('soumission', (e) => e.id === `${locale}/main`);
|
const [soumissionEntry] = await getCollection('soumission', (e) => e.id === `${locale}/main`);
|
||||||
const [navEntry] = await getCollection('navigation', (e) => e.id === `${locale}/main`);
|
if (!soumissionEntry) throw new Error(`Missing soumission content for locale: ${locale}`);
|
||||||
if (!soumissionEntry || !navEntry) throw new Error(`Missing soumission content for locale: ${locale}`);
|
|
||||||
|
|
||||||
const soumission = soumissionEntry.data;
|
const soumission = soumissionEntry.data;
|
||||||
const nav = navEntry.data;
|
|
||||||
const homeLink = nav.links.find((l) => l.href === '/')?.name ?? soumission.breadcrumbHome;
|
|
||||||
const soumissionNav = nav.links.find((l) => l.href === '/soumission');
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
title={nav.pageTitles.soumission}
|
title={soumission.title}
|
||||||
description="Demandez votre soumission gratuite pour vos travaux de peinture ou décoration à Laval et sur la Rive-Nord. Cachet Peintres Décorateurs vous répond rapidement."
|
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons Laval, la Rive-Nord et les environs.`}
|
||||||
canonical="/soumission"
|
canonical="/soumission"
|
||||||
>
|
>
|
||||||
<section class="page-header">
|
<ShortHero
|
||||||
<div class="container-site">
|
client:load
|
||||||
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
content={{
|
||||||
<a href="/">{homeLink}</a>
|
title: soumission.title,
|
||||||
<span aria-hidden="true">›</span>
|
subtitle: soumission.subtitle,
|
||||||
<span aria-current="page">{soumissionNav?.name ?? soumission.pageTitle}</span>
|
imageSrc: "/images/short-hero.webp",
|
||||||
</nav>
|
imageAlt: "Peintres décorateurs au travail",
|
||||||
<h1 class="page-title">{soumission.title}</h1>
|
}}
|
||||||
<p class="page-subtitle">{soumission.subtitle}</p>
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="soumission-section">
|
<SubmissionForm
|
||||||
<div class="container-site soumission-grid">
|
client:load
|
||||||
<div class="soumission-form-col">
|
imageSrc="/images/submission.webp"
|
||||||
<SoumissionForm />
|
imageAlt="chambre avec peinture intérieure verte"
|
||||||
</div>
|
title={soumission.title}
|
||||||
|
subtitle={soumission.subtitle}
|
||||||
<aside class="soumission-sidebar">
|
form={soumission.form}
|
||||||
<div class="sidebar-card">
|
/>
|
||||||
<h2 class="sidebar-title">{soumission.sidebar.processTitle}</h2>
|
|
||||||
<ol class="process-list">
|
|
||||||
{soumission.sidebar.steps.map((step, i) => (
|
|
||||||
<li class="process-step">
|
|
||||||
<div class="process-num">{i + 1}</div>
|
|
||||||
<div>
|
|
||||||
<h3>{step.title}</h3>
|
|
||||||
<p>{step.description}</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-contact">
|
|
||||||
<h3>{soumission.sidebar.directContactTitle}</h3>
|
|
||||||
<a href={`tel:${general.phone.replace(/\D/g, '')}`} class="sidebar-phone">
|
|
||||||
<svg width="18" height="18" 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>
|
|
||||||
<a href={`mailto:${general.email}`} class="sidebar-email">
|
|
||||||
<svg width="18" height="18" 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>
|
|
||||||
<p class="sidebar-legal">
|
|
||||||
{soumission.sidebar.rbqLabel} {general.rbq}<br />
|
|
||||||
{general.legalName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||