Compare commits

..

4 Commits

Author SHA1 Message Date
66495f7b17 cleaning up assets
Some checks are pending
Build and Deploy / build-and-deploy (push) Waiting to run
2026-03-25 10:42:18 -04:00
5be7d43446 0.0.9 2026-03-25 10:42:18 -04:00
mbcube
9d21f06d51 Upload “public/images/stylish-bedroom-interior-with-double-bed-and-emera-2023-11-27-05-08-37-utc.webp” 2026-03-25 10:42:18 -04:00
mbcube
e02e6bae43 Upload “public/images/empty-room-blue-wall-with-moulding-and-parquet-fl-2023-11-27-04-52-31-utc.webp” 2026-03-25 10:42:18 -04:00
44 changed files with 985 additions and 644 deletions

17
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.0",
"shadcn": "^4.0.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
@@ -10170,6 +10171,22 @@
"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": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",

View File

@@ -25,6 +25,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.0",
"shadcn": "^4.0.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",

View File

@@ -258,6 +258,7 @@ collections:
- { name: "navTitle", label: "Titre colonne navigation", 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: "serviceAreasTitle", label: "Titre colonne zones de service", 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: "neq", label: "Ligne NEQ", widget: "string", i18n: true }
@@ -269,6 +270,11 @@ collections:
fields:
- { name: "label", label: "Libellé", 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"
label: "Page 404"
@@ -316,6 +322,27 @@ collections:
- { name: "title", label: "Titre", widget: "string" }
- { 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"
label: "Paramètres du site"
files:
@@ -333,6 +360,7 @@ collections:
- { name: "serviceArea", label: "Zone de service", widget: "string" }
- { name: "facebook", label: "Lien Facebook", widget: "string" }
- { 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: "neq", label: "Numéro NEQ (Registraire)", widget: "string" }

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -16,17 +16,17 @@ const legalNotice = footer.legal.replace('{year}', String(year));
<footer class="bg-brand-600 pt-14 px-8 text-white/85">
<div
class="container-site grid grid-cols-1 gap-7 pb-12 min-[541px]:grid-cols-2 min-[541px]:gap-8 min-[901px]:grid-cols-[1.5fr_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>
<a href="/" aria-label={general.siteName}>
<img
src="/images/logo.png"
src="/images/logo-vignette.svg"
alt={general.siteName}
width="140"
height="52"
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>
<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>
{general.serviceArea}
</p>
{(general.facebook || general.instagram || general.linkedin) && (
<div class="flex flex-wrap gap-2">
{general.facebook && (
<a
href={general.facebook}
@@ -51,6 +53,47 @@ const legalNotice = footer.legal.replace('{year}', String(year));
{footer.facebookLabel}
</a>
)}
{general.instagram && (
<a
href={general.instagram}
class="inline-flex items-center gap-2 rounded border border-white/30 px-3.5 py-1.5 text-sm font-bold text-white/80 transition-colors hover:bg-white/12 hover:text-white"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
)}
{general.linkedin && (
<a
href={general.linkedin}
class="inline-flex items-center gap-2 rounded border border-white/30 px-3.5 py-1.5 text-sm font-bold text-white/80 transition-colors hover:bg-white/12 hover:text-white"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.778v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
LinkedIn
</a>
)}
</div>
)}
</div>
<div>
<h3 class="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
{footer.serviceAreasTitle}
</h3>
<ul class="m-0 list-none columns-2 gap-x-6 p-0 [&>li]:mb-2">
{footer.serviceAreas.map((area) => (
<li class="text-[0.875rem] text-white/70 break-inside-avoid">{area}</li>
))}
</ul>
</div>
<div>
@@ -64,17 +107,6 @@ const legalNotice = footer.legal.replace('{year}', String(year));
</ul>
</div>
<div>
<h3 class="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
{footer.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>
<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}

View File

@@ -1,8 +1,9 @@
import { useState } from "react";
import { Dialog, DialogPanel } from "@headlessui/react";
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({
navigation,
@@ -14,6 +15,7 @@ export default function Header({
ctaHref: string;
}) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const phoneHref = `tel:${general.phone.replace(/\D/g, "")}`;
return (
<header className="absolute inset-x-0 top-0 z-50">
@@ -58,12 +60,29 @@ export default function Header({
))}
</div>
<div className="hidden lg:flex lg:flex-1 lg:justify-end">
<a
{/* <a
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"
>
{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>
</dd>
</dl>
</div>
</nav>
<Dialog
@@ -76,29 +95,21 @@ export default function Header({
<div className="flex items-center justify-between sm:justify-end lg:justify-between">
<a
href="/"
className="flex sm:hidden lg:flex items-center bg-[white] rounded-3xl
ps-0.5 pe-2 pt-1 pb-0.5
md:ps-1 md:pe-3 md:pt-2 md:pb-1
"
className="flex sm:hidden lg:flex items-center bg-[white] rounded-full m-1"
>
<div className="p-1 bg-[white] rounded-full ">
<span className="sr-only">{general.siteName}</span>
<img
alt={general.siteName}
src="/images/logo-vignette.svg"
className="h-10 w-auto"
className="h-10 w-10 object-fill"
/>
</div>
<p className="text-xl sm:text-2xl md:text-3xl font-semibold text-brand-600 uppercase">
{general.brandName}
</p>
</a>
<button
type="button"
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>
<XMarkIcon aria-hidden="true" className="size-6" />
@@ -111,19 +122,36 @@ export default function Header({
<a
key={item.name}
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}
</a>
))}
</div>
<div className="py-6">
<a
{/* <a
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)]"
>
{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>
</dd>
</dl>
</div>
</div>
</div>

View File

@@ -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>

View 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>
);
}

View 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>
)
}

View File

@@ -5,14 +5,10 @@ import {
} from "@/components/ui/carousel";
import Autoplay from "embla-carousel-autoplay";
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";
export type HeroContent = {
pillCta: string;
title: string;
title2: string;
subtitle: string;
@@ -21,11 +17,11 @@ export type HeroContent = {
};
const slides = [
{ src: photo1.src, alt: "Peinture intérieure" },
{ src: photo2.src, alt: "Décoration murale" },
{ src: photo3.src, alt: "Finition de qualité" },
{ src: photo4.src, alt: "Chambre" },
{ src: photo5.src, alt: "Pièce lumineuse" },
{ src: '/images/carrousel-1.webp', alt: "Peinture intérieure" },
{ src: '/images/carrousel-2.webp', alt: "Décoration murale" },
{ src: '/images/carrousel-3.webp', alt: "Finition de qualité" },
{ src: '/images/carrousel-4.webp', alt: "Chambre" },
{ src: '/images/carrousel-5.webp', alt: "Pièce lumineuse" },
];
const autoplayPlugin = Autoplay({
@@ -35,7 +31,7 @@ const autoplayPlugin = Autoplay({
});
export default function Hero({ content }: { content: HeroContent }) {
const { pillCta, title, title2, subtitle, cta, ctaHref } = content;
const { subtitle, cta, ctaHref } = content;
return (
<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="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">
<span>RBQ {general.rbq}</span>
<span className="text-white/30" aria-hidden>
@@ -73,7 +69,7 @@ export default function Hero({ content }: { content: HeroContent }) {
</span>
<span>{pillCta}</span>
</div>
</div>
</div> */}
{/* <h1
className="text-5xl font-semibold tracking-tight text-balance text-white sm:text-7xl"
style={{ fontFamily: "var(--font-display)" }}

View 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>
)
}

View File

@@ -1,46 +1,29 @@
const featuredTestimonial = {
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.',
author: {
name: 'Brenna Goyette',
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(' ')
interface TestimonialItem {
key: string;
src: string;
height: number;
}
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 (
<div className="relative isolate bg-brand-50 pt-24 pb-32 sm:pt-32 dark:bg-brand-50">
<div
@@ -69,52 +52,31 @@ export default function Grid() {
</div>
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-base/7 font-semibold text-brand-400 dark:text-brand-400">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">
Ce que nos clients disent de nous
{title}
</p>
</div>
<div className="mx-auto mt-16 grid max-w-2xl grid-cols-1 grid-rows-1 gap-8 text-sm/6 text-gray-900 sm:mt-20 sm:grid-cols-2 xl:mx-0 xl:max-w-none xl:grid-flow-col xl:grid-cols-4 dark:text-gray-100">
<figure className="rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 sm:col-span-2 xl:col-start-2 xl:row-end-1 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10">
<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>
{testimonials.map((columnGroup, columnGroupIdx) => (
<div key={columnGroupIdx} className="space-y-8 xl:contents xl:space-y-0">
{columnGroup.map((column, columnIdx) => (
{groups.map((group, groupIdx) => (
<div key={groupIdx} className="space-y-8 xl:contents xl:space-y-0">
{group.map((t, colIdx) => {
const isFirstCol = groupIdx === 0 && colIdx === 0
const isLastCol = groupIdx === groups.length - 1 && colIdx === group.length - 1
return (
<div
key={columnIdx}
className={classNames(
(columnGroupIdx === 0 && columnIdx === 0) ||
(columnGroupIdx === testimonials.length - 1 && columnIdx === columnGroup.length - 1)
? 'xl:row-span-2'
: 'xl:row-start-1',
'space-y-8',
)}
key={t.key}
className={(isFirstCol || isLastCol) ? 'xl:row-span-2 space-y-8' : 'xl:row-start-1 space-y-8'}
>
{column.map((testimonial) => (
<figure
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 className="rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10">
<iframe {...iframeProps} src={t.src} height={t.height} />
</figure>
))}
</div>
))}
)
})}
</div>
))}
</div>

View File

@@ -40,7 +40,6 @@ const navigation = defineCollection({
const hero = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/content/hero' }),
schema: z.object({
pillCta: z.string(),
title: z.string(),
title2: z.string(),
subtitle: z.string(),
@@ -120,6 +119,10 @@ const soumission = defineCollection({
}),
message: z.string(),
messagePlaceholder: z.string(),
fichiers: z.string(),
fichiersHint: z.string(),
fichiersAccepted: z.string(),
fichiersRemove: z.string(),
envoyer: z.string(),
sending: z.string(),
successTitle: z.string(),
@@ -132,6 +135,8 @@ const soumission = defineCollection({
telephone: z.string(),
typeService: z.string(),
message: z.string(),
fichiersType: z.string(),
fichiersSize: z.string(),
}),
}),
sidebar: z.object({
@@ -156,6 +161,7 @@ const footer = defineCollection({
navTitle: z.string(),
servicesTitle: z.string(),
contactTitle: z.string(),
serviceAreasTitle: z.string(),
legal: z.string(),
rbq: z.string(),
neq: z.string(),
@@ -166,6 +172,7 @@ const footer = defineCollection({
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 = {
services,
navigation,
@@ -203,4 +225,5 @@ export const collections = {
footer,
notFound,
pourquoi,
testimonials,
};

View File

@@ -3,6 +3,7 @@
"navTitle": "Navigation",
"servicesTitle": "Nos services",
"contactTitle": "Contact",
"serviceAreasTitle": "Zone de service",
"legal": "© {year} 9500-5609 Québec Inc. Tous droits réservés.",
"rbq": "RBQ : 5839 8736 01",
"neq": "NEQ : 9500-5609",
@@ -12,5 +13,25 @@
{ "label": "Peinture extérieure", "href": "/services#peinture-exterieure" },
{ "label": "Peinture commerciale", "href": "/services#peinture-commerciale" },
{ "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"
]
}

View File

@@ -1,8 +1,7 @@
{
"pillCta": "Soumission gratuite",
"title": "CACHET",
"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.",
"cta": "Soumission gratuite",
"ctaHref": "/soumission"
"cta": "Contactez-nous",
"ctaHref": "/contact"
}

View File

@@ -4,12 +4,13 @@
"legalName": "9500-5609 Québec Inc",
"tagline": "Peintres Décorateurs professionnels",
"taglineHero": "Qualité et expertise en peinture résidentielle et commerciale",
"phone": "(450) 555-0199",
"phone": "(514) 552-1049",
"email": "info@cachetdeco.com",
"address": "Laval, Québec",
"address": "Sainte Catherine, Québec",
"serviceArea": "Laval, Rive-Nord, Montréal",
"facebook": "https://www.facebook.com/cachetpeintres",
"instagram": "",
"facebook": "https://www.facebook.com/cachetdecor/",
"instagram": "https://www.instagram.com/cachet.deco/",
"linkedin": "",
"rbq": "5839 8736 01",
"neq": "9500-5609"
}

View File

@@ -23,6 +23,10 @@
},
"message": "Détails du projet",
"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",
"sending": "Envoi en cours...",
"successTitle": "Demande envoyée avec succès!",
@@ -34,7 +38,9 @@
"courriel": "Une adresse courriel valide est requise.",
"telephone": "Le numéro de téléphone est requis.",
"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": {

View 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
}
]
}

View File

@@ -1,6 +1,7 @@
---
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 { getLocale } from '@/lib/locale';
import general from '../content/settings/general.json';
@@ -8,15 +9,9 @@ import general from '../content/settings/general.json';
const locale = getLocale(Astro);
const [contactEntry] = await getCollection('contact', (e) => e.id === `${locale}/main`);
const [ctaEntry] = await getCollection('cta', (e) => e.id === `${locale}/main`);
const [navEntry] = await getCollection('navigation', (e) => e.id === `${locale}/main`);
if (!contactEntry || !ctaEntry || !navEntry) throw new Error(`Missing contact content for locale: ${locale}`);
if (!contactEntry) throw new Error(`Missing contact content for locale: ${locale}`);
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
@@ -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.`}
canonical="/contact"
>
<section class="page-header">
<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
<ShortHero
client:load
title={ctaData.title}
subtitle={ctaData.subtitle}
buttonText={ctaData.button}
buttonHref={ctaData.buttonHref}
imageSrc={ctaImage.src}
imageAlt={ctaData.imageAlt}
content={{
title: contact.title,
subtitle: contact.subtitle,
imageSrc: "/images/short-hero.webp",
imageAlt: "Peintres décorateurs au travail",
}}
/>
<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>

View File

@@ -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 [ctaEntry] = await getCollection('cta', (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}`);
}
@@ -25,31 +26,36 @@ const heroData = heroEntry.data;
const nav = navEntry.data;
const ctaData = ctaEntry.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 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: {
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',
},
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',
},
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',
},
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',
},
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',
},
};
@@ -57,17 +63,15 @@ const serviceImages: Record<string, { src: import('astro').ImageMetadata; alt: s
const features = services.map((service) => {
const icon = service.data.icon ?? 'interior';
const img = serviceImages[icon] ?? serviceImages.interior;
const imageSrc = (img.src as import('astro').ImageMetadata).src;
return {
name: service.data.title,
description: service.data.description,
imageSrc,
imageSrc: img.src ,
imageAlt: img.alt,
};
});
const heroContent = {
pillCta: heroData.pillCta,
title: heroData.title,
title2: heroData.title2,
subtitle: heroData.subtitle,
@@ -88,7 +92,12 @@ const heroContent = {
title={nav.servicesSection.title}
subtitle={nav.servicesSection.subtitle}
/>
<Testimonials client:load />
<Testimonials
client:load
sectionLabel={testimonialsData.sectionLabel}
title={testimonialsData.title}
items={testimonialsData.items}
/>
<WhyChooseUs
client:load

View File

@@ -1,6 +1,7 @@
---
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 { getLocale } from '@/lib/locale';
import general from '../content/settings/general.json';
@@ -8,75 +9,32 @@ import general from '../content/settings/general.json';
const locale = getLocale(Astro);
const [soumissionEntry] = await getCollection('soumission', (e) => e.id === `${locale}/main`);
const [navEntry] = await getCollection('navigation', (e) => e.id === `${locale}/main`);
if (!soumissionEntry || !navEntry) throw new Error(`Missing soumission content for locale: ${locale}`);
if (!soumissionEntry) throw new Error(`Missing soumission content for locale: ${locale}`);
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
title={nav.pageTitles.soumission}
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."
title={soumission.title}
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons Laval, la Rive-Nord et les environs.`}
canonical="/soumission"
>
<section class="page-header">
<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">{soumissionNav?.name ?? soumission.pageTitle}</span>
</nav>
<h1 class="page-title">{soumission.title}</h1>
<p class="page-subtitle">{soumission.subtitle}</p>
</div>
</section>
<ShortHero
client:load
content={{
title: soumission.title,
subtitle: soumission.subtitle,
imageSrc: "/images/short-hero.webp",
imageAlt: "Peintres décorateurs au travail",
}}
/>
<section class="soumission-section">
<div class="container-site soumission-grid">
<div class="soumission-form-col">
<SoumissionForm />
</div>
<aside class="soumission-sidebar">
<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>
<SubmissionForm
client:load
imageSrc="/images/submission.webp"
imageAlt="chambre avec peinture intérieure verte"
title={soumission.title}
subtitle={soumission.subtitle}
form={soumission.form}
/>
</Layout>