This commit is contained in:
2026-03-12 20:16:40 -04:00
commit 184cdcad78
71 changed files with 10018 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Resend API key — set in Cloudflare Pages > Settings > Environment variables
RESEND_API_KEY=re_xxxxxxxxxxxx
# Destination email for soumission form submissions
CONTACT_EMAIL=info@cachetdeco.com

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Build output
dist/
.astro/
# Dependencies
node_modules/
# Environment variables
.env
.env.local
.env.production
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/settings.json
.idea/
*.swp
*.swo

194
README.md Normal file
View File

@@ -0,0 +1,194 @@
# Cachet Peintres Décorateurs — Site Web
Site vitrine statique pour **Cachet Peintres Décorateurs** (9500-5609 Québec Inc).
Construit avec **Astro v5 + Tailwind v4 + Decap CMS**, déployé sur **Cloudflare Pages**.
## Stack
| Technologie | Rôle |
|---|---|
| Astro v5 | Framework SSG (zéro JS par défaut) |
| Tailwind v4 | CSS utility-first (config par `@theme` dans global.css) |
| Decap CMS | Interface d'édition de contenu (via `/admin/`) |
| Cloudflare Pages | Hébergement statique + CDN global |
| Cloudflare Functions | Traitement du formulaire de soumission |
| Resend | Envoi de courriel via API |
## Prérequis
- Node.js ≥ 18
- npm ≥ 9
## Installation locale
```bash
cd cachetdeco
npm install
npm run dev
```
Ouvre [http://localhost:4321](http://localhost:4321) dans le navigateur.
## Structure du projet
```
cachetdeco/
├── public/
│ ├── admin/ # Decap CMS (index.html + config.yml)
│ ├── fonts/ # Polices Cocogoose & Geoform
│ ├── images/ # Images statiques (logo, uploads CMS)
│ ├── favicon.svg
│ └── robots.txt
├── functions/
│ └── api/
│ └── soumission.ts # Cloudflare Pages Function → Resend API
├── src/
│ ├── i18n/ # Infrastructure i18n
│ │ ├── config.ts # Locales et routes
│ │ ├── ui.ts # Traductions FR
│ │ └── utils.ts # Fonctions getLangFromUrl() et t()
│ ├── components/ # Composants Astro réutilisables
│ ├── content/ # Contenu géré par CMS
│ │ ├── services/fr/ # Services en markdown
│ │ └── settings/ # general.json et seo.json
│ ├── content.config.ts # Définitions des collections (Astro v5)
│ ├── layouts/
│ │ └── BaseLayout.astro
│ ├── pages/ # Pages Astro (routes)
│ └── styles/
│ └── global.css # Tailwind + @theme brand tokens
├── astro.config.mjs
├── tsconfig.json
├── .env.example
└── package.json
```
## Variables d'environnement
Copiez `.env.example` en `.env` pour le développement local :
```bash
cp .env.example .env
```
| Variable | Description | Où la configurer |
|---|---|---|
| `RESEND_API_KEY` | Clé API Resend (envoi de courriels) | Cloudflare Pages > Settings > Environment variables |
| `CONTACT_EMAIL` | Adresse de destination des soumissions | Cloudflare Pages > Settings > Environment variables |
> **Note :** Ces variables NE doivent PAS être dans le code source. Elles sont injectées au runtime par Cloudflare Pages Functions.
## Déploiement sur Cloudflare Pages
1. Créez un dépôt GitHub et poussez le code
2. Dans Cloudflare Pages, créez un nouveau projet connecté à ce dépôt
3. Configurez :
- **Build command :** `npm run build`
- **Build output directory :** `dist`
- **Node.js version :** 18
4. Ajoutez les variables d'environnement (`RESEND_API_KEY`, `CONTACT_EMAIL`)
5. Déclenchez un déploiement
Les fonctions dans `functions/` sont automatiquement déployées comme Cloudflare Pages Functions.
## Gestion du contenu (Decap CMS)
### Configuration initiale
1. Créez un **GitHub OAuth App** :
- GitHub > Settings > Developer settings > OAuth Apps > New OAuth App
- Homepage URL : `https://cachetdeco.com`
- Authorization callback URL : `https://cachetdeco.com/admin/`
- Notez le **Client ID**
2. Modifiez `public/admin/config.yml` :
```yaml
backend:
name: github
repo: your-org/cachetdeco # Remplacez par votre dépôt
branch: main
auth_type: pkce
app_id: YOUR_GITHUB_OAUTH_APP_CLIENT_ID # Remplacez par votre Client ID
```
3. Accédez à `https://cachetdeco.com/admin/` et connectez-vous avec GitHub
### Flux de publication
```
Éditeur → Decap CMS (/admin/) → Commit GitHub → Webhook → Cloudflare Pages Build → Site mis à jour
```
Le délai de publication est d'environ **12 minutes** après une modification.
### Collections disponibles
| Collection | Chemin | Description |
|---|---|---|
| Services | `src/content/services/fr/*.md` | Pages de services (markdown) |
| Paramètres généraux | `src/content/settings/general.json` | Nom, téléphone, courriel, adresse |
| Paramètres SEO | `src/content/settings/seo.json` | Titre, description, mots-clés par défaut |
## Branding
### Palette de couleurs
| Token | Valeur | Usage |
|---|---|---|
| `--color-brand-600` | `#314732` | CTA, boutons principaux, titres |
| `--color-brand-700` | `#263a27` | Hover state |
| `--color-brand-400` | `#7a8876` | Accents, bordures |
| `--color-brand-50` | `#f0f4ef` | Fonds de section |
| `--color-gray-dark` | `#444445` | Texte courant |
| `--color-gray-light` | `#b7b6b6` | Texte secondaire, placeholders |
### Typographie
- **Cocogoose Medium** — titres et en-têtes (`font-family: var(--font-display)`)
- **Geoform Bold** — corps de texte et sous-titres (`font-family: var(--font-body)`)
## i18n — Ajouter l'anglais plus tard
Quand le client souhaite une version anglaise, voici les étapes :
1. Ajoutez `'en'` dans `src/i18n/config.ts` :
```ts
export const locales = ['fr', 'en'] as const;
export const routes = {
services: { fr: 'services', en: 'services' },
contact: { fr: 'contact', en: 'contact' },
soumission: { fr: 'soumission', en: 'quote' },
};
```
2. Ajoutez `'en'` dans `astro.config.mjs` :
```js
i18n: { locales: ['fr', 'en'], ... }
```
3. Traduisez toutes les clés dans `src/i18n/ui.ts` (section `en: { ... }`)
4. Créez `src/pages/en/` avec les 4 fichiers de page (ils importent les mêmes composants)
5. Ajoutez `en` dans `public/admin/config.yml` sous `i18n.locales`
6. Décommentez le composant `LanguagePicker` dans `Header.astro`
Aucune refactorisation structurelle n'est nécessaire — l'infrastructure est déjà en place.
## Mentions légales obligatoires
Conformément aux exigences légales québécoises, les informations suivantes doivent apparaître visiblement sur le site :
- **Raison sociale :** 9500-5609 Québec Inc
- **Numéro RBQ :** 5839 8736 01 (obligatoire pour tout service nécessitant une modification d'un bâtiment)
Ces informations sont affichées dans le footer de chaque page.
## Commandes disponibles
```bash
npm run dev # Serveur de développement local (http://localhost:4321)
npm run build # Build de production (dans dist/)
npm run preview # Prévisualisation du build de production
```

26
astro.config.mjs Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
output: 'static',
site: 'https://cachetdeco.com',
i18n: {
defaultLocale: 'fr',
locales: ['fr'],
routing: {
prefixDefaultLocale: false,
},
},
integrations: [
sitemap({
i18n: {
defaultLocale: 'fr',
locales: { fr: 'fr-CA' },
},
}),
],
vite: {
plugins: [tailwindcss()],
},
});

30
branding logo.md Normal file

File diff suppressed because one or more lines are too long

17
branding notes.md Normal file

File diff suppressed because one or more lines are too long

8
branding.md Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
fonts/geoform/Geoform.otf Normal file

Binary file not shown.

Binary file not shown.

175
functions/api/soumission.ts Normal file
View File

@@ -0,0 +1,175 @@
interface SoumissionBody {
nom: string;
courriel: string;
telephone: string;
type_service: string;
adresse?: string;
message: string;
}
interface Env {
RESEND_API_KEY: string;
CONTACT_EMAIL: string;
}
const serviceLabels: Record<string, string> = {
interieur: 'Peinture intérieure',
exterieur: 'Peinture extérieure',
commercial: 'Peinture commerciale',
decoration: 'Décoration intérieure',
autre: 'Autre',
};
function buildEmailHtml(data: SoumissionBody): string {
const serviceLabel = serviceLabels[data.type_service] ?? data.type_service;
const adresseLine = data.adresse
? `<tr><td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Adresse des travaux</td><td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee">${data.adresse}</td></tr>`
: '';
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:system-ui,sans-serif">
<div style="max-width:600px;margin:0 auto;padding:40px 20px">
<div style="background:#314732;padding:32px;border-radius:8px 8px 0 0;text-align:center">
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">Nouvelle demande de soumission</h1>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.7);font-size:14px">Cachet Peintres Décorateurs</p>
</div>
<div style="background:#ffffff;border-radius:0 0 8px 8px;overflow:hidden">
<div style="padding:24px;background:#f0f4ef;border-bottom:2px solid #d6e2d4">
<p style="margin:0;font-size:15px;color:#314732;font-weight:600">
Vous avez reçu une nouvelle demande de soumission de la part de <strong>${data.nom}</strong>.
</p>
</div>
<table style="width:100%;border-collapse:collapse">
<tbody>
<tr>
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee;width:40%">Nom</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee;font-weight:600">${data.nom}</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Courriel</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee"><a href="mailto:${data.courriel}" style="color:#314732;font-weight:600">${data.courriel}</a></td>
</tr>
<tr>
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Téléphone</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee"><a href="tel:${data.telephone.replace(/\D/g, '')}" style="color:#314732;font-weight:600">${data.telephone}</a></td>
</tr>
<tr>
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Type de service</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee">${serviceLabel}</td>
</tr>
${adresseLine}
</tbody>
</table>
<div style="padding:20px">
<p style="margin:0 0 8px;font-size:13px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:0.05em">Message</p>
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:16px;font-size:14px;color:#374151;line-height:1.6;white-space:pre-wrap">${data.message}</div>
</div>
<div style="padding:16px 20px 24px">
<a href="mailto:${data.courriel}?subject=Re: Soumission — Cachet Peintres Décorateurs" style="display:inline-block;padding:10px 24px;background:#314732;color:#fff;text-decoration:none;border-radius:4px;font-size:14px;font-weight:700">Répondre à ${data.nom}</a>
</div>
</div>
<p style="margin:24px 0 0;text-align:center;font-size:11px;color:#999">
Cachet Peintres Décorateurs · 9500-5609 Québec Inc · RBQ 5839 8736 01
</p>
</div>
</body>
</html>`;
}
function validateBody(body: unknown): body is SoumissionBody {
if (typeof body !== 'object' || body === null) return false;
const b = body as Record<string, unknown>;
return (
typeof b.nom === 'string' && b.nom.trim().length > 0 &&
typeof b.courriel === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(b.courriel) &&
typeof b.telephone === 'string' && b.telephone.trim().length > 0 &&
typeof b.type_service === 'string' && b.type_service.length > 0 &&
typeof b.message === 'string' && b.message.trim().length > 0
);
}
export const onRequestPost: PagesFunction<Env> = async (context) => {
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://cachetdeco.com',
'Content-Type': 'application/json',
};
// Rate limiting via CF headers (basic check)
const ip = context.request.headers.get('CF-Connecting-IP') ?? 'unknown';
let body: unknown;
try {
body = await context.request.json();
} catch {
return new Response(
JSON.stringify({ success: false, error: 'Corps de requête invalide.' }),
{ status: 400, headers: corsHeaders }
);
}
if (!validateBody(body)) {
return new Response(
JSON.stringify({ success: false, error: 'Données manquantes ou invalides.' }),
{ status: 422, headers: corsHeaders }
);
}
const apiKey = context.env.RESEND_API_KEY;
const contactEmail = context.env.CONTACT_EMAIL;
if (!apiKey || !contactEmail) {
console.error('Missing env vars: RESEND_API_KEY or CONTACT_EMAIL');
return new Response(
JSON.stringify({ success: false, error: 'Configuration serveur manquante.' }),
{ status: 500, headers: corsHeaders }
);
}
const emailPayload = {
from: 'Cachet Peintres Décorateurs <soumission@cachetdeco.com>',
to: [contactEmail],
reply_to: body.courriel,
subject: `Soumission — ${serviceLabels[body.type_service] ?? body.type_service}${body.nom}`,
html: buildEmailHtml(body),
};
const resendRes = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(emailPayload),
});
if (!resendRes.ok) {
const resendError = await resendRes.text().catch(() => 'Unknown error');
console.error('Resend error:', resendError, 'IP:', ip);
return new Response(
JSON.stringify({ success: false, error: 'Erreur lors de l\'envoi du courriel.' }),
{ status: 502, headers: corsHeaders }
);
}
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: corsHeaders }
);
};
// Handle preflight OPTIONS
export const onRequestOptions: PagesFunction = async () => {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': 'https://cachetdeco.com',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
};

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

6202
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "cachetdeco",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/sitemap": "^3.2.1",
"@tailwindcss/vite": "^4.0.0",
"astro": "^5.4.0",
"tailwindcss": "^4.0.0"
}
}

70
public/admin/config.yml Normal file
View File

@@ -0,0 +1,70 @@
backend:
name: github
repo: cachetdeco # REPLACE with your GitHub org/repo
branch: main
# For Cloudflare Pages (no Netlify Identity), use PKCE flow:
auth_type: pkce
app_id: YOUR_GITHUB_OAUTH_APP_CLIENT_ID # REPLACE with your GitHub OAuth App client ID
locale: fr
media_folder: "public/images"
public_folder: "/images"
i18n:
structure: multiple_folders
locales: [fr]
default_locale: fr
collections:
- name: "services"
label: "Services"
label_singular: "Service"
folder: "src/content/services"
create: true
delete: true
i18n: true
slug: "{{slug}}"
fields:
- { name: "title", label: "Titre", widget: "string", i18n: true }
- { name: "description", label: "Description courte", widget: "text", i18n: true }
- name: "icon"
label: "Icône (identifiant)"
widget: "select"
options: ["interior", "exterior", "commercial", "decoration", "renovation"]
required: false
i18n: duplicate
hint: "Identifiant interne pour l'icône SVG"
- { name: "image", label: "Image de couverture", widget: "image", required: false, i18n: duplicate }
- { name: "order", label: "Ordre d'affichage", widget: "number", default: 0, i18n: duplicate }
- { name: "featured", label: "Mis en vedette", widget: "boolean", default: false, i18n: duplicate }
- { name: "body", label: "Contenu détaillé", widget: "markdown", i18n: true }
- name: "settings"
label: "Paramètres du site"
files:
- label: "Général"
name: "general"
file: "src/content/settings/general.json"
fields:
- { name: "siteName", label: "Nom du site", widget: "string" }
- { name: "legalName", label: "Nom légal (NEQ)", widget: "string" }
- { name: "tagline", label: "Slogan court", widget: "string" }
- { name: "taglineHero", label: "Slogan hero", widget: "string" }
- { name: "phone", label: "Téléphone", widget: "string" }
- { name: "email", label: "Courriel", widget: "string" }
- { name: "address", label: "Adresse", widget: "string" }
- { 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: "rbq", label: "Numéro RBQ", widget: "string" }
- { name: "neq", label: "Numéro NEQ (Registraire)", widget: "string" }
- label: "SEO"
name: "seo"
file: "src/content/settings/seo.json"
fields:
- { name: "defaultTitle", label: "Titre par défaut", widget: "string" }
- { name: "titleTemplate", label: "Gabarit de titre (%s = titre de page)", widget: "string" }
- { name: "defaultDescription", label: "Description par défaut", widget: "text" }
- { name: "keywords", label: "Mots-clés (séparés par virgule)", widget: "string" }
- { name: "ogImage", label: "Image OG par défaut", widget: "image", required: false }

13
public/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<link href="config.yml" type="text/yaml" rel="cms-config-url" />
<title>Gestionnaire de contenu — Cachet Peintres Décorateurs</title>
</head>
<body>
<script src="https://unpkg.com/decap-cms@^3.1.2/dist/decap-cms.js"></script>
</body>
</html>

5
public/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<!-- Brand C shape -->
<rect width="48" height="48" rx="10" fill="#314732"/>
<text x="24" y="34" font-size="28" font-family="system-ui, sans-serif" font-weight="700" text-anchor="middle" fill="white">C</text>
</svg>

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Binary file not shown.

BIN
public/fonts/Geoform.otf Normal file

Binary file not shown.

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

7
public/robots.txt Normal file
View File

@@ -0,0 +1,7 @@
User-agent: *
Allow: /
# Block CMS admin from indexing
Disallow: /admin/
Sitemap: https://cachetdeco.com/sitemap-index.xml

View File

@@ -0,0 +1,92 @@
---
import { getLangFromUrl, t } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
---
<section class="cta-banner-section">
<div class="cta-banner-bg" aria-hidden="true"></div>
<div class="container-site cta-banner-inner">
<div class="cta-banner-text">
<h2 class="cta-banner-title">{t(lang, 'cta_banner.title')}</h2>
<p class="cta-banner-subtitle">{t(lang, 'cta_banner.subtitle')}</p>
</div>
<div class="cta-banner-action">
<a href="/soumission" class="btn-primary cta-banner-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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>
{t(lang, 'cta_banner.button')}
</a>
</div>
</div>
</section>
<style>
.cta-banner-section {
position: relative;
background-color: var(--color-brand-600);
color: #ffffff;
padding: 4rem 0;
overflow: hidden;
}
.cta-banner-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--color-brand-700) 0%, var(--color-brand-600) 50%, var(--color-brand-500) 100%);
opacity: 0.6;
pointer-events: none;
}
.cta-banner-inner {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
flex-wrap: wrap;
}
.cta-banner-title {
font-family: var(--font-display);
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 500;
color: #ffffff;
margin-bottom: 0.5rem;
}
.cta-banner-subtitle {
font-size: 1.0625rem;
color: rgba(255, 255, 255, 0.8);
max-width: 520px;
}
.cta-banner-btn {
background-color: #ffffff;
color: var(--color-brand-600);
font-size: 1rem;
padding: 0.875rem 2rem;
white-space: nowrap;
flex-shrink: 0;
}
.cta-banner-btn:hover {
background-color: var(--color-brand-50);
}
@media (max-width: 640px) {
.cta-banner-inner {
flex-direction: column;
align-items: flex-start;
}
.cta-banner-btn {
width: 100%;
justify-content: center;
}
}
</style>

279
src/components/Footer.astro Normal file
View File

@@ -0,0 +1,279 @@
---
import { getLangFromUrl, t } from '../i18n/utils';
import general from '../content/settings/general.json';
const lang = getLangFromUrl(Astro.url);
const year = new Date().getFullYear();
const navLinks = [
{ href: '/', label: t(lang, 'nav.accueil') },
{ href: '/services', label: t(lang, 'nav.services') },
{ href: '/contact', label: t(lang, 'nav.contact') },
{ href: '/soumission', label: t(lang, 'nav.soumission') },
];
const serviceLinks = [
{ label: 'Peinture intérieure', href: '/services#peinture-interieure' },
{ label: 'Peinture extérieure', href: '/services#peinture-exterieure' },
{ label: 'Peinture commerciale', href: '/services#peinture-commerciale' },
{ label: 'Décoration intérieure', href: '/services#decoration-interieure' },
];
const legalNotice = t(lang, 'footer.legal').replace('{year}', String(year));
---
<footer class="site-footer">
<div class="container-site footer-grid">
<!-- Brand column -->
<div class="footer-brand">
<a href="/" aria-label="Cachet Peintres Décorateurs">
<img
src="/images/logo.png"
alt="Cachet Peintres Décorateurs"
width="140"
height="52"
loading="lazy"
/>
</a>
<p class="footer-tagline">{t(lang, 'footer.tagline')}</p>
<p class="footer-service-area">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
{general.serviceArea}
</p>
{general.facebook && (
<a
href={general.facebook}
class="footer-social"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
>
<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>
Facebook
</a>
)}
</div>
<!-- Navigation column -->
<div class="footer-col">
<h3 class="footer-col-title">{t(lang, 'footer.nav.title')}</h3>
<ul>
{navLinks.map(link => (
<li><a href={link.href} class="footer-link">{link.label}</a></li>
))}
</ul>
</div>
<!-- Services column -->
<div class="footer-col">
<h3 class="footer-col-title">{t(lang, 'footer.services.title')}</h3>
<ul>
{serviceLinks.map(link => (
<li><a href={link.href} class="footer-link">{link.label}</a></li>
))}
</ul>
</div>
<!-- Contact column -->
<div class="footer-col">
<h3 class="footer-col-title">{t(lang, 'footer.contact.title')}</h3>
<ul class="footer-contact-list">
<li>
<a href={`tel:${general.phone.replace(/\D/g, '')}`} class="footer-link footer-contact-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2.18h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 9.91a16 16 0 0 0 6.18 6.18l.94-.94a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
{general.phone}
</a>
</li>
<li>
<a href={`mailto:${general.email}`} class="footer-link footer-contact-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
{general.email}
</a>
</li>
<li class="footer-contact-item footer-address">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
{general.address}
</li>
</ul>
</div>
</div>
<!-- Bottom bar -->
<div class="footer-bottom">
<div class="container-site footer-bottom-inner">
<p class="footer-copyright">{legalNotice}</p>
<p class="footer-legal-nums">
<span>{t(lang, 'footer.rbq')}</span>
<span aria-hidden="true">·</span>
<span>{t(lang, 'footer.neq')}</span>
</p>
</div>
</div>
</footer>
<style>
.site-footer {
background-color: var(--color-brand-600);
color: rgba(255, 255, 255, 0.85);
padding-top: 3.5rem;
}
.footer-grid {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr 1fr;
gap: 3rem;
padding-bottom: 3rem;
}
.footer-brand img {
height: 44px;
width: auto;
display: block;
filter: brightness(0) invert(1);
margin-bottom: 1rem;
}
.footer-tagline {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 0.5rem;
font-weight: 400;
}
.footer-service-area {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.65);
margin-bottom: 1rem;
}
.footer-social {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 0.4rem 0.875rem;
border-radius: 4px;
transition: background-color 0.2s ease, color 0.2s ease;
}
.footer-social:hover {
background-color: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.footer-col-title {
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 1.125rem;
}
.footer-col ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.footer-link {
font-size: 0.9375rem;
color: rgba(255, 255, 255, 0.8);
transition: color 0.2s ease;
}
.footer-link:hover {
color: #ffffff;
}
.footer-contact-list {
gap: 0.75rem !important;
}
.footer-contact-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.9375rem;
color: rgba(255, 255, 255, 0.8);
}
.footer-contact-item svg {
flex-shrink: 0;
margin-top: 3px;
}
.footer-address {
color: rgba(255, 255, 255, 0.7);
}
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.15);
padding: 1.125rem 0;
}
.footer-bottom-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.footer-copyright {
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.5);
}
.footer-legal-nums {
display: flex;
align-items: center;
gap: 0.625rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.02em;
}
@media (max-width: 900px) {
.footer-grid {
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
}
@media (max-width: 540px) {
.footer-grid {
grid-template-columns: 1fr;
gap: 1.75rem;
}
.footer-bottom-inner {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>

242
src/components/Header.astro Normal file
View File

@@ -0,0 +1,242 @@
---
import { getLangFromUrl, t } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const currentPath = Astro.url.pathname;
const navLinks = [
{ href: '/', label: t(lang, 'nav.accueil') },
{ href: '/services', label: t(lang, 'nav.services') },
{ href: '/contact', label: t(lang, 'nav.contact') },
];
function isActive(href: string): boolean {
if (href === '/') return currentPath === '/';
return currentPath.startsWith(href);
}
---
<header class="site-header">
<div class="container-site header-inner">
<!-- Logo -->
<a href="/" class="header-logo" aria-label="Cachet Peintres Décorateurs — Accueil">
<img
src="/images/logo.png"
alt="Cachet Peintres Décorateurs"
width="160"
height="60"
loading="eager"
/>
</a>
<!-- Desktop nav -->
<nav class="header-nav" aria-label="Navigation principale">
<ul>
{navLinks.map(link => (
<li>
<a
href={link.href}
class:list={['nav-link', { active: isActive(link.href) }]}
>
{link.label}
</a>
</li>
))}
</ul>
</nav>
<!-- CTA -->
<div class="header-cta">
<a href="/soumission" class="btn-primary">
{t(lang, 'cta.soumission.short')}
</a>
</div>
<!-- Mobile menu button -->
<button
class="mobile-menu-btn"
aria-label="Menu"
aria-expanded="false"
aria-controls="mobile-menu"
id="mobile-menu-btn"
>
<span class="sr-only">Ouvrir le menu</span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
</div>
<!-- Mobile menu -->
<div class="mobile-menu" id="mobile-menu" aria-hidden="true">
<nav aria-label="Navigation mobile">
<ul>
{navLinks.map(link => (
<li>
<a
href={link.href}
class:list={['mobile-nav-link', { active: isActive(link.href) }]}
>
{link.label}
</a>
</li>
))}
<li>
<a href="/soumission" class="mobile-cta-btn">
{t(lang, 'cta.soumission.short')}
</a>
</li>
</ul>
</nav>
</div>
</header>
<style>
.site-header {
position: sticky;
top: 0;
z-index: 50;
background-color: #ffffff;
border-bottom: 2px solid var(--color-brand-100);
box-shadow: 0 1px 8px rgba(49, 71, 50, 0.08);
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 72px;
gap: 1rem;
}
.header-logo img {
height: 48px;
width: auto;
display: block;
object-fit: contain;
}
.header-nav ul {
display: flex;
align-items: center;
gap: 2rem;
list-style: none;
margin: 0;
padding: 0;
}
.nav-link {
font-family: var(--font-body);
font-weight: 700;
font-size: 0.9375rem;
color: var(--color-gray-dark);
letter-spacing: 0.02em;
padding: 0.25rem 0;
border-bottom: 2px solid transparent;
transition: color 0.2s ease, border-color 0.2s ease;
}
.nav-link:hover,
.nav-link.active {
color: var(--color-brand-600);
border-bottom-color: var(--color-brand-600);
}
.mobile-menu-btn {
display: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: none;
border: none;
color: var(--color-brand-600);
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.mobile-menu-btn:hover {
background-color: var(--color-brand-50);
}
.mobile-menu {
display: none;
background-color: #ffffff;
border-top: 1px solid var(--color-brand-100);
padding: 1rem 0 1.5rem;
}
.mobile-menu ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.mobile-nav-link {
display: block;
padding: 0.875rem 1.5rem;
font-family: var(--font-body);
font-weight: 700;
font-size: 1rem;
color: var(--color-gray-dark);
border-left: 3px solid transparent;
transition: color 0.2s ease, background-color 0.2s ease;
}
.mobile-nav-link:hover,
.mobile-nav-link.active {
color: var(--color-brand-600);
background-color: var(--color-brand-50);
border-left-color: var(--color-brand-600);
}
.mobile-cta-btn {
display: block;
margin: 1rem 1.5rem 0;
padding: 0.875rem 1.5rem;
background-color: var(--color-brand-600);
color: #ffffff;
font-family: var(--font-body);
font-weight: 700;
font-size: 1rem;
text-align: center;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.mobile-cta-btn:hover {
background-color: var(--color-brand-700);
}
@media (max-width: 768px) {
.header-nav,
.header-cta {
display: none;
}
.mobile-menu-btn {
display: flex;
}
.mobile-menu.open {
display: block;
}
}
</style>
<script>
const btn = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');
btn?.addEventListener('click', () => {
const isOpen = menu?.classList.toggle('open');
btn.setAttribute('aria-expanded', String(isOpen));
menu?.setAttribute('aria-hidden', String(!isOpen));
});
</script>

192
src/components/Hero.astro Normal file
View File

@@ -0,0 +1,192 @@
---
import { getLangFromUrl, t } from '../i18n/utils';
import general from '../content/settings/general.json';
const lang = getLangFromUrl(Astro.url);
---
<section class="hero-section">
<div class="hero-bg" aria-hidden="true"></div>
<div class="container-site hero-content">
<div class="hero-text">
<p class="hero-eyebrow">{general.tagline}</p>
<h1 class="hero-title">{t(lang, 'hero.title')}</h1>
<p class="hero-subtitle">{t(lang, 'hero.subtitle')}</p>
<div class="hero-actions">
<a href="/soumission" class="btn-primary hero-cta-primary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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"/>
<polyline points="10,9 9,9 8,9"/>
</svg>
{t(lang, 'hero.cta')}
</a>
<a href="/services" class="btn-outline-white">
{t(lang, 'cta.learn_more')}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12,5 19,12 12,19"/>
</svg>
</a>
</div>
<div class="hero-trust">
<div class="trust-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>RBQ 5839 8736 01</span>
</div>
<div class="trust-separator" aria-hidden="true">·</div>
<div class="trust-item">
<svg width="16" height="16" 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>
<span>{general.serviceArea}</span>
</div>
<div class="trust-separator" aria-hidden="true">·</div>
<div class="trust-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>Soumission gratuite</span>
</div>
</div>
</div>
<div class="hero-logo-wrap" aria-hidden="true">
<img
src="/images/logo.png"
alt=""
width="320"
height="320"
loading="eager"
class="hero-logo"
/>
</div>
</div>
</section>
<style>
.hero-section {
position: relative;
background-color: var(--color-brand-600);
color: #ffffff;
padding: 5rem 0 4rem;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 70% 50%, rgba(122, 136, 118, 0.25) 0%, transparent 60%),
linear-gradient(135deg, var(--color-brand-700) 0%, var(--color-brand-600) 100%);
pointer-events: none;
}
.hero-content {
position: relative;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 3rem;
}
.hero-eyebrow {
display: inline-block;
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.65);
margin-bottom: 1rem;
}
.hero-title {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 3.25rem);
font-weight: 500;
color: #ffffff;
line-height: 1.1;
margin-bottom: 1.25rem;
}
.hero-subtitle {
font-size: 1.125rem;
color: rgba(255, 255, 255, 0.82);
line-height: 1.65;
max-width: 540px;
margin-bottom: 2.25rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 2rem;
}
.hero-cta-primary {
background-color: #ffffff;
color: var(--color-brand-600);
font-size: 1rem;
padding: 0.875rem 2rem;
}
.hero-cta-primary:hover {
background-color: var(--color-brand-50);
}
.hero-trust {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.875rem;
}
.trust-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.65);
}
.trust-separator {
color: rgba(255, 255, 255, 0.3);
}
.hero-logo-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.hero-logo {
width: 280px;
height: 280px;
object-fit: contain;
opacity: 0.18;
filter: brightness(0) invert(1);
}
@media (max-width: 768px) {
.hero-section {
padding: 3.5rem 0 3rem;
}
.hero-content {
grid-template-columns: 1fr;
}
.hero-logo-wrap {
display: none;
}
.hero-title {
font-size: clamp(1.75rem, 6vw, 2.5rem);
}
}
</style>

View File

@@ -0,0 +1,16 @@
---
// Language picker — currently hidden (French only)
// Uncomment and use when English is added:
// import { locales, defaultLocale } from '../i18n/config';
// import { getLangFromUrl } from '../i18n/utils';
// const lang = getLangFromUrl(Astro.url);
---
<!-- LanguagePicker hidden until English is enabled -->
<!-- When adding English:
1. Add 'en' to locales in src/i18n/config.ts and astro.config.mjs
2. Create src/pages/en/ directory with translated pages
3. Remove the 'hidden' attribute below and uncomment the picker -->
<div hidden aria-hidden="true">
<!-- Language picker placeholder -->
</div>

View File

@@ -0,0 +1,99 @@
---
import seoData from '../content/settings/seo.json';
import general from '../content/settings/general.json';
import { locales, defaultLocale } from '../i18n/config';
interface Props {
title?: string;
description?: string;
keywords?: string;
ogImage?: string;
canonical?: string;
noindex?: boolean;
pageType?: 'website' | 'article';
}
const {
title,
description = seoData.defaultDescription,
keywords = seoData.keywords,
ogImage = seoData.ogImage,
canonical,
noindex = false,
pageType = 'website',
} = Astro.props;
const siteUrl = 'https://cachetdeco.com';
const fullTitle = title
? seoData.titleTemplate.replace('%s', title)
: seoData.defaultTitle;
const canonicalUrl = canonical
? `${siteUrl}${canonical}`
: `${siteUrl}${Astro.url.pathname}`;
const ogImageUrl = ogImage
? ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage}`
: `${siteUrl}/images/logo.png`;
// JSON-LD LocalBusiness structured data
const structuredData = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
'@id': siteUrl,
name: general.siteName,
legalName: general.legalName,
url: siteUrl,
telephone: general.phone,
email: general.email,
address: {
'@type': 'PostalAddress',
addressLocality: 'Laval',
addressRegion: 'QC',
addressCountry: 'CA',
},
areaServed: general.serviceArea,
description: seoData.defaultDescription,
sameAs: [
general.facebook,
].filter(Boolean),
image: ogImageUrl,
priceRange: '$$',
};
---
<!-- Primary meta -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content={Astro.generator} />
<title>{fullTitle}</title>
<meta name="description" content={description} />
{keywords && <meta name="keywords" content={keywords} />}
{noindex && <meta name="robots" content="noindex, nofollow" />}
<link rel="canonical" href={canonicalUrl} />
<!-- Open Graph -->
<meta property="og:type" content={pageType} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:site_name" content={general.siteName} />
<meta property="og:locale" content="fr_CA" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />
<!-- hreflang — French only for now; add en here when English pages exist -->
<link rel="alternate" hreflang="fr" href={canonicalUrl} />
<link rel="alternate" hreflang="x-default" href={canonicalUrl} />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/images/logo.png" />
<!-- JSON-LD -->
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />

View File

@@ -0,0 +1,112 @@
---
interface Props {
title: string;
description: string;
icon?: string;
slug: string;
featured?: boolean;
}
const { title, description, icon, slug, featured = false } = Astro.props;
const icons: Record<string, string> = {
interior: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>`,
exterior: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 22V12l10-10 10 10v10"/><path d="M15 22v-4a3 3 0 0 0-6 0v4"/><path d="M22 22H2"/></svg>`,
commercial: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
decoration: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`,
renovation: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
};
const iconSvg = icon ? (icons[icon] ?? icons['interior']) : icons['interior'];
const serviceId = slug.replace('fr/', '');
---
<article class:list={['service-card', { featured }]} id={serviceId}>
<div class="service-card-icon" aria-hidden="true" set:html={iconSvg} />
<div class="service-card-body">
<h3 class="service-card-title">{title}</h3>
<p class="service-card-desc">{description}</p>
<a href={`/services#${serviceId}`} class="service-card-link">
En savoir plus
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12,5 19,12 12,19"/>
</svg>
</a>
</div>
</article>
<style>
.service-card {
background-color: #ffffff;
border: 1.5px solid var(--color-brand-100);
border-radius: 8px;
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
.service-card:hover {
box-shadow: 0 8px 24px rgba(49, 71, 50, 0.1);
transform: translateY(-2px);
border-color: var(--color-brand-400);
}
.service-card.featured {
border-color: var(--color-brand-300);
background-color: var(--color-brand-50);
}
.service-card-icon {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-brand-600);
color: #ffffff;
border-radius: 8px;
flex-shrink: 0;
}
.service-card-body {
display: flex;
flex-direction: column;
gap: 0.625rem;
flex: 1;
}
.service-card-title {
font-family: var(--font-display);
font-size: 1.125rem;
font-weight: 500;
color: var(--color-brand-600);
line-height: 1.25;
}
.service-card-desc {
font-size: 0.9375rem;
color: var(--color-gray-dark);
line-height: 1.6;
opacity: 0.85;
flex: 1;
}
.service-card-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-brand-600);
transition: gap 0.2s ease;
margin-top: auto;
padding-top: 0.25rem;
}
.service-card-link:hover {
gap: 0.625rem;
}
</style>

View File

@@ -0,0 +1,90 @@
---
import { getCollection } from 'astro:content';
import { getLangFromUrl, t } from '../i18n/utils';
import ServiceCard from './ServiceCard.astro';
const lang = getLangFromUrl(Astro.url);
const allServices = await getCollection('services');
const frServices = allServices
.filter(s => s.id.startsWith('fr/'))
.sort((a, b) => a.data.order - b.data.order)
.slice(0, 3);
---
<section class="services-preview-section">
<div class="container-site">
<div class="section-header">
<h2 class="section-title">{t(lang, 'services.title')}</h2>
<p class="section-subtitle">{t(lang, 'services.subtitle')}</p>
</div>
{frServices.length > 0 ? (
<div class="services-grid">
{frServices.map(service => (
<ServiceCard
title={service.data.title}
description={service.data.description}
icon={service.data.icon}
slug={service.id}
featured={service.data.featured}
/>
))}
</div>
) : (
<p class="services-empty">{t(lang, 'services.empty')}</p>
)}
<div class="services-cta">
<a href="/services" class="btn-outline">
{t(lang, 'services.view_all')}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12,5 19,12 12,19"/>
</svg>
</a>
</div>
</div>
</section>
<style>
.services-preview-section {
padding: 5rem 0;
background-color: #ffffff;
}
.section-header {
text-align: center;
margin-bottom: 3rem;
}
.services-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.services-empty {
text-align: center;
color: var(--color-gray-dark);
opacity: 0.6;
padding: 3rem 0;
}
.services-cta {
margin-top: 2.5rem;
text-align: center;
}
@media (max-width: 900px) {
.services-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 540px) {
.services-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,390 @@
---
import { getLangFromUrl, t } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
---
<div class="form-wrapper">
<form
id="soumission-form"
class="soumission-form"
novalidate
aria-label={t(lang, 'soumission.title')}
>
<div class="form-row">
<div class="form-group">
<label for="nom" class="form-label">
{t(lang, 'form.nom')}
<span class="form-required" aria-hidden="true">*</span>
</label>
<input
type="text"
id="nom"
name="nom"
class="form-input"
placeholder={t(lang, 'form.nom.placeholder')}
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">
{t(lang, 'form.courriel')}
<span class="form-required" aria-hidden="true">*</span>
</label>
<input
type="email"
id="courriel"
name="courriel"
class="form-input"
placeholder={t(lang, 'form.courriel.placeholder')}
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">
{t(lang, 'form.telephone')}
<span class="form-required" aria-hidden="true">*</span>
</label>
<input
type="tel"
id="telephone"
name="telephone"
class="form-input"
placeholder={t(lang, 'form.telephone.placeholder')}
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">
{t(lang, 'form.type_service')}
<span class="form-required" aria-hidden="true">*</span>
</label>
<select
id="type_service"
name="type_service"
class="form-input form-select"
required
>
<option value="">{t(lang, 'form.type_service.placeholder')}</option>
<option value="interieur">{t(lang, 'form.type_service.interieur')}</option>
<option value="exterieur">{t(lang, 'form.type_service.exterieur')}</option>
<option value="commercial">{t(lang, 'form.type_service.commercial')}</option>
<option value="decoration">{t(lang, 'form.type_service.decoration')}</option>
<option value="autre">{t(lang, 'form.type_service.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">
{t(lang, 'form.adresse')}
</label>
<input
type="text"
id="adresse"
name="adresse"
class="form-input"
placeholder={t(lang, 'form.adresse.placeholder')}
autocomplete="street-address"
/>
</div>
<div class="form-group">
<label for="message" class="form-label">
{t(lang, 'form.message')}
<span class="form-required" aria-hidden="true">*</span>
</label>
<textarea
id="message"
name="message"
class="form-input form-textarea"
placeholder={t(lang, 'form.message.placeholder')}
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">{t(lang, 'form.envoyer')}</span>
</button>
</form>
<!-- Success message -->
<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>{t(lang, 'form.success.title')}</h3>
<p>{t(lang, 'form.success.message')}</p>
</div>
<!-- Error message -->
<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>{t(lang, 'form.error.title')}</strong>
<p>{t(lang, 'form.error.message')}</p>
</div>
</div>
</div>
<style>
.form-wrapper {
width: 100%;
}
.soumission-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0;
}
.form-required {
color: #c0392b;
margin-left: 2px;
}
.form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23444445' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.875rem center;
padding-right: 2.5rem;
cursor: pointer;
}
.form-textarea {
resize: vertical;
min-height: 120px;
}
.form-error {
display: block;
font-size: 0.8125rem;
color: #c0392b;
margin-top: 0.25rem;
min-height: 1.125rem;
}
.form-submit-btn {
width: 100%;
justify-content: center;
padding: 1rem;
font-size: 1.0625rem;
margin-top: 0.5rem;
}
.form-success,
.form-error-msg {
display: none;
border-radius: 8px;
padding: 2rem;
text-align: center;
}
.form-success[aria-hidden="false"],
.form-error-msg[aria-hidden="false"] {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.form-success {
background-color: var(--color-brand-50);
border: 1.5px solid var(--color-brand-300);
color: var(--color-brand-600);
}
.form-success-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background-color: var(--color-brand-600);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
}
.form-success h3 {
font-size: 1.25rem;
color: var(--color-brand-600);
}
.form-success p {
color: var(--color-gray-dark);
opacity: 0.8;
}
.form-error-msg {
background-color: #fef2f2;
border: 1.5px solid #fca5a5;
color: #b91c1c;
flex-direction: row;
text-align: left;
gap: 1rem;
}
.form-error-icon {
flex-shrink: 0;
color: #b91c1c;
}
.form-error-msg p {
margin-top: 0.25rem;
font-size: 0.9rem;
opacity: 0.85;
}
@media (max-width: 540px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>
<script>
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', 'Le nom est requis.');
valid = false;
}
if (!courriel?.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(courriel)) {
showError('courriel', 'Une adresse courriel valide est requise.');
valid = false;
}
if (!telephone?.trim()) {
showError('telephone', 'Le numéro de téléphone est requis.');
valid = false;
}
if (!type_service) {
showError('type_service', 'Veuillez sélectionner un type de service.');
valid = false;
}
if (!message?.trim()) {
showError('message', 'Un message est requis.');
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;
// Disable form during submission
submitBtn.disabled = true;
submitText.textContent = 'Envoi en cours...';
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 = 'Envoyer ma demande';
errorDiv.setAttribute('aria-hidden', 'false');
errorDiv.style.display = 'flex';
}
});
</script>

17
src/content.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const services = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/services' }),
schema: z.object({
title: z.string(),
description: z.string(),
icon: z.string().optional(),
image: z.string().optional(),
order: z.number().default(0),
featured: z.boolean().default(false),
}),
});
export const collections = { services };

View File

@@ -0,0 +1,25 @@
---
title: Décoration intérieure
description: Conseils personnalisés en couleur et décoration. Nos experts vous aident à créer l'ambiance parfaite pour chaque pièce de votre maison.
icon: decoration
order: 4
featured: false
---
Au-delà de la peinture, nos peintres décorateurs apportent leur expertise en design pour vous aider à créer des espaces qui vous ressemblent.
## Nos services de décoration
- Consultation couleur et tendances
- Effets décoratifs et textures
- Trompe-l'œil et murales
- Application de papier peint
- Conseil en éclairage et mise en valeur des espaces
## Techniques spécialisées
- Badigeon et enduit à la chaux
- Effet béton ciré
- Patines et vieillissements
- Peintures texturées
- Finitions métalliques

View File

@@ -0,0 +1,26 @@
---
title: Peinture commerciale
description: Solutions de peinture pour bureaux, commerces et espaces institutionnels. Travaux réalisés en dehors des heures d'ouverture pour minimiser les perturbations.
icon: commercial
order: 3
featured: true
---
Nos équipes sont habituées à travailler dans des environnements commerciaux et institutionnels, avec flexibilité pour s'adapter à vos contraintes opérationnelles.
## Ce qui est inclus
- Planification et coordination selon votre calendrier
- Travaux en dehors des heures d'ouverture si requis
- Produits à faible émission de COV
- Respect des normes de santé et sécurité
- Délais garantis pour éviter les interruptions d'activité
## Types d'établissements
- Bureaux et espaces de travail
- Commerces de détail
- Restaurants et hôtels
- Cliniques et établissements de santé
- Écoles et institutions
- Entrepôts et espaces industriels

View File

@@ -0,0 +1,25 @@
---
title: Peinture extérieure
description: Protection et embellissement de la façade de votre maison. Travaux résistants aux conditions climatiques du Québec pour une durabilité maximale.
icon: exterior
order: 2
featured: true
---
La peinture extérieure protège votre investissement tout en rehaussant l'apparence de votre propriété. Nous utilisons des produits spécialement formulés pour résister aux hivers québécois.
## Ce qui est inclus
- Nettoyage haute pression des surfaces
- Réparation des fissures et dommages
- Application d'apprêt spécialisé
- 2 couches de peinture extérieure haute performance
- Protection et calfeutrage aux joints
## Nos spécialités
- Revêtements en bois, aluminium et vinyle
- Briques et stucco
- Balcons, vérandas et galeries
- Clôtures et cabanons
- Portes et fenêtres (cadres et contours)

View File

@@ -0,0 +1,25 @@
---
title: Peinture intérieure
description: Transformation complète de vos espaces intérieurs. Préparation des surfaces, application soignée et finitions impeccables pour tous types de pièces.
icon: interior
order: 1
featured: true
---
Nos peintres décorateurs réalisent tous vos projets de peinture intérieure avec professionnalisme et souci du détail. De la préparation minutieuse des surfaces à la finition parfaite, chaque étape est effectuée avec rigueur.
## Ce qui est inclus
- Évaluation et préparation des surfaces (rebouchage, sablage, apprêt)
- Application de 2 couches de peinture de qualité professionnelle
- Protection des planchers, meubles et garnitures
- Nettoyage complet après les travaux
- Garantie sur la main-d'œuvre
## Nos spécialités
- Salons et salles à manger
- Chambres à coucher
- Cuisines et salles de bain
- Sous-sols et espaces récréatifs
- Couloirs et entrées

View File

@@ -0,0 +1,24 @@
---
title: Rénovation complète
description: Coordination de projets de rénovation résidentielle. De la peinture aux finitions, un seul entrepreneur pour tous vos travaux de décoration.
icon: renovation
order: 5
featured: false
---
Pour les projets de rénovation importants, Cachet Peintres Décorateurs coordonne l'ensemble des travaux de finition, vous offrant un interlocuteur unique pour simplifier votre projet.
## Ce qui est inclus
- Évaluation complète du projet
- Coordination des corps de métier
- Peinture intérieure et extérieure
- Travaux de finition et moulures
- Supervision et contrôle qualité
## Projets réalisés
- Rénovations complètes de condos
- Mises à jour de maisons avant vente
- Rénovations post-construction
- Remises en état locatifs

View File

@@ -0,0 +1,14 @@
{
"siteName": "Cachet Peintres Décorateurs",
"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",
"email": "info@cachetdeco.com",
"address": "Laval, Québec",
"serviceArea": "Laval, Rive-Nord, Montréal",
"facebook": "https://www.facebook.com/cachetpeintres",
"instagram": "",
"rbq": "5839 8736 01",
"neq": "9500-5609"
}

View File

@@ -0,0 +1,8 @@
{
"defaultTitle": "Cachet Peintres Décorateurs | Laval & Rive-Nord",
"titleTemplate": "%s | Cachet Peintres Décorateurs",
"defaultDescription": "Peintres décorateurs professionnels à Laval et sur la Rive-Nord. Peinture résidentielle et commerciale, décoration intérieure et extérieure. RBQ 5839 8736 01. Soumission gratuite.",
"keywords": "peintre Laval, peinture résidentielle Laval, peinture commerciale Rive-Nord, décoration intérieure Laval, peintres décorateurs Québec, peinture extérieure Laval, rénovation Rive-Nord, soumission peinture gratuite",
"ogImage": "/images/og-default.jpg",
"twitterCard": "summary_large_image"
}

9
src/i18n/config.ts Normal file
View File

@@ -0,0 +1,9 @@
export const defaultLocale = 'fr' as const;
export const locales = ['fr'] as const;
export type Locale = (typeof locales)[number];
export const routes: Record<string, Record<Locale, string>> = {
services: { fr: 'services' },
contact: { fr: 'contact' },
soumission: { fr: 'soumission' },
};

92
src/i18n/ui.ts Normal file
View File

@@ -0,0 +1,92 @@
export const ui = {
fr: {
// Navigation
'nav.accueil': 'Accueil',
'nav.services': 'Services',
'nav.contact': 'Contact',
'nav.soumission': 'Soumission',
// CTA
'cta.soumission': 'Demandez votre soumission',
'cta.soumission.short': 'Soumission gratuite',
'cta.contact': 'Nous contacter',
'cta.learn_more': 'En savoir plus',
// Hero
'hero.title': 'Peintres Décorateurs à votre service',
'hero.subtitle': 'Travaux de peinture résidentielle et commerciale, décoration intérieure et extérieure. Qualité professionnelle sur la Rive-Nord et Laval.',
'hero.cta': 'Demandez votre soumission gratuite',
// Services section
'services.title': 'Nos Services',
'services.subtitle': 'Des solutions complètes en peinture et décoration',
'services.view_all': 'Voir tous nos services',
'services.empty': 'Aucun service disponible pour le moment.',
// CTA Banner
'cta_banner.title': 'Prêt à transformer votre espace?',
'cta_banner.subtitle': 'Obtenez une soumission gratuite et sans engagement dès aujourd\'hui.',
'cta_banner.button': 'Demandez votre soumission',
// Contact page
'contact.title': 'Contactez-nous',
'contact.subtitle': 'Nous sommes à votre disposition pour répondre à vos questions.',
'contact.phone': 'Téléphone',
'contact.email': 'Courriel',
'contact.address': 'Adresse',
'contact.hours': 'Heures d\'ouverture',
'contact.hours.weekdays': 'Lundi Vendredi : 7h00 18h00',
'contact.hours.saturday': 'Samedi : 8h00 16h00',
'contact.hours.sunday': 'Dimanche : Fermé',
'contact.follow': 'Suivez-nous',
'contact.map.title': 'Notre territoire de service',
// Soumission form
'soumission.title': 'Demande de soumission',
'soumission.subtitle': 'Remplissez le formulaire ci-dessous et nous vous contacterons dans les plus brefs délais.',
'form.nom': 'Nom complet',
'form.nom.placeholder': 'Jean Tremblay',
'form.courriel': 'Courriel',
'form.courriel.placeholder': 'jean@exemple.com',
'form.telephone': 'Téléphone',
'form.telephone.placeholder': '(450) 555-1234',
'form.adresse': 'Adresse des travaux',
'form.adresse.placeholder': '123 rue Principale, Laval, QC',
'form.type_service': 'Type de service',
'form.type_service.placeholder': 'Sélectionnez un service',
'form.type_service.interieur': 'Peinture intérieure',
'form.type_service.exterieur': 'Peinture extérieure',
'form.type_service.commercial': 'Peinture commerciale',
'form.type_service.decoration': 'Décoration intérieure',
'form.type_service.autre': 'Autre',
'form.message': 'Détails du projet',
'form.message.placeholder': 'Décrivez votre projet (pièces à peindre, superficie approximative, délai souhaité...)',
'form.envoyer': 'Envoyer ma demande',
'form.sending': 'Envoi en cours...',
'form.success.title': 'Demande envoyée avec succès!',
'form.success.message': 'Merci pour votre demande. Nous vous contacterons dans les 24 à 48 heures.',
'form.error.title': 'Erreur lors de l\'envoi',
'form.error.message': 'Une erreur s\'est produite. Veuillez réessayer ou nous contacter directement.',
'form.required': 'Champ requis',
// Footer
'footer.tagline': 'Peintres Décorateurs professionnels',
'footer.nav.title': 'Navigation',
'footer.services.title': 'Nos services',
'footer.contact.title': 'Contact',
'footer.legal': '© {year} 9500-5609 Québec Inc. Tous droits réservés.',
'footer.rbq': 'RBQ : 5839 8736 01',
'footer.neq': 'NEQ : 9500-5609',
'footer.droits': 'Tous droits réservés.',
// 404
'404.title': 'Page introuvable',
'404.message': 'La page que vous cherchez n\'existe pas ou a été déplacée.',
'404.home': 'Retour à l\'accueil',
// SEO / meta
'meta.site_name': 'Cachet Peintres Décorateurs',
'meta.default_title': 'Cachet Peintres Décorateurs | Laval & Rive-Nord',
'meta.default_description': 'Peintres décorateurs professionnels à Laval et sur la Rive-Nord. Peinture résidentielle et commerciale, décoration intérieure et extérieure. Soumission gratuite.',
},
} as const;

17
src/i18n/utils.ts Normal file
View File

@@ -0,0 +1,17 @@
import { ui } from './ui';
import { defaultLocale, type Locale } from './config';
export function getLangFromUrl(url: URL): Locale {
const [, lang] = url.pathname.split('/');
if (lang in ui) return lang as Locale;
return defaultLocale;
}
export function t(lang: Locale, key: keyof typeof ui[typeof defaultLocale]): string {
return (ui[lang] as Record<string, string>)?.[key] ?? ui[defaultLocale][key];
}
export function getLocalizedUrl(lang: Locale, path: string): string {
if (lang === defaultLocale) return path;
return `/${lang}${path}`;
}

View File

@@ -0,0 +1,54 @@
---
import '../styles/global.css';
import SEOHead from '../components/SEOHead.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { getLangFromUrl } from '../i18n/utils';
interface Props {
title?: string;
description?: string;
keywords?: string;
ogImage?: string;
canonical?: string;
noindex?: boolean;
pageType?: 'website' | 'article';
}
const lang = getLangFromUrl(Astro.url);
const props = Astro.props;
---
<!doctype html>
<html lang={lang}>
<head>
<SEOHead {...props} />
</head>
<body>
<a href="#main-content" class="skip-link">Aller au contenu principal</a>
<Header />
<main id="main-content">
<slot />
</main>
<Footer />
</body>
</html>
<style is:global>
.skip-link {
position: absolute;
top: -100%;
left: 1rem;
z-index: 9999;
padding: 0.5rem 1rem;
background-color: var(--color-brand-600);
color: #ffffff;
font-weight: 700;
border-radius: 0 0 4px 4px;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 0;
}
</style>

64
src/pages/404.astro Normal file
View File

@@ -0,0 +1,64 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getLangFromUrl, t } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
---
<BaseLayout
title={t(lang, '404.title')}
noindex={true}
>
<section class="not-found-section">
<div class="container-site not-found-inner">
<div class="not-found-number" aria-hidden="true">404</div>
<h1 class="not-found-title">{t(lang, '404.title')}</h1>
<p class="not-found-message">{t(lang, '404.message')}</p>
<a href="/" class="btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12,19 5,12 12,5"/>
</svg>
{t(lang, '404.home')}
</a>
</div>
</section>
</BaseLayout>
<style>
.not-found-section {
padding: 6rem 0;
min-height: 60vh;
display: flex;
align-items: center;
}
.not-found-inner {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
}
.not-found-number {
font-family: var(--font-display);
font-size: clamp(5rem, 15vw, 10rem);
font-weight: 500;
color: var(--color-brand-100);
line-height: 1;
letter-spacing: -0.02em;
}
.not-found-title {
font-size: clamp(1.5rem, 3vw, 2rem);
color: var(--color-brand-600);
}
.not-found-message {
font-size: 1.0625rem;
color: var(--color-gray-dark);
opacity: 0.7;
max-width: 420px;
}
</style>

454
src/pages/contact.astro Normal file
View File

@@ -0,0 +1,454 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import CTABanner from '../components/CTABanner.astro';
import { getLangFromUrl, t } from '../i18n/utils';
import general from '../content/settings/general.json';
const lang = getLangFromUrl(Astro.url);
---
<BaseLayout
title={t(lang, 'contact.title')}
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons Laval, la Rive-Nord et les environs.`}
canonical="/contact"
>
<!-- Page header -->
<section class="page-header">
<div class="container-site">
<nav class="breadcrumb" aria-label="Fil d'Ariane">
<a href="/">{t(lang, 'nav.accueil')}</a>
<span aria-hidden="true"></span>
<span aria-current="page">{t(lang, 'contact.title')}</span>
</nav>
<h1 class="page-title">{t(lang, 'contact.title')}</h1>
<p class="page-subtitle">{t(lang, 'contact.subtitle')}</p>
</div>
</section>
<section class="contact-section">
<div class="container-site contact-grid">
<!-- Contact info -->
<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>{t(lang, '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>{t(lang, '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>{t(lang, 'contact.address')}</h3>
<p class="contact-value">{general.address}</p>
<p class="contact-service-area">Zone desservie : {general.serviceArea}</p>
</div>
</div>
<!-- Hours -->
<div class="contact-hours">
<h3 class="contact-hours-title">{t(lang, 'contact.hours')}</h3>
<ul class="hours-list">
<li>
<span class="hours-day">Lundi Vendredi</span>
<span class="hours-time">7h00 18h00</span>
</li>
<li>
<span class="hours-day">Samedi</span>
<span class="hours-time">8h00 16h00</span>
</li>
<li>
<span class="hours-day">Dimanche</span>
<span class="hours-time closed">Fermé</span>
</li>
</ul>
</div>
<!-- Social -->
{general.facebook && (
<div class="contact-social">
<h3 class="contact-social-title">{t(lang, '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>
Suivez-nous sur Facebook
</a>
</div>
)}
<!-- Legal -->
<div class="contact-legal">
<p>RBQ : {general.rbq}</p>
<p>NEQ : {general.neq} ({general.legalName})</p>
</div>
</div>
<!-- CTA -->
<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>Demandez votre soumission</h2>
<p>Obtenez une estimation personnalisée et gratuite pour votre projet de peinture ou décoration.</p>
<a href="/soumission" class="btn-primary contact-soumission-btn">
{t(lang, 'cta.soumission')}
</a>
</div>
<!-- Map placeholder -->
<div class="contact-map">
<h3 class="contact-map-title">{t(lang, '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>Laval & Rive-Nord, Québec</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"
>
Voir sur Google Maps
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<CTABanner />
</BaseLayout>
<style>
.page-header {
background-color: var(--color-brand-50);
border-bottom: 1.5px solid var(--color-brand-100);
padding: 3rem 0 2.5rem;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-brand-400);
margin-bottom: 1rem;
}
.breadcrumb a {
color: var(--color-brand-600);
transition: opacity 0.2s ease;
}
.breadcrumb a:hover { opacity: 0.7; }
.breadcrumb span[aria-current] {
color: var(--color-gray-dark);
opacity: 0.7;
}
.page-title {
font-size: clamp(1.75rem, 4vw, 2.75rem);
color: var(--color-brand-600);
margin-bottom: 0.75rem;
}
.page-subtitle {
font-size: 1.125rem;
color: var(--color-gray-dark);
opacity: 0.75;
}
.contact-section {
padding: 4rem 0 5rem;
}
.contact-grid {
display: grid;
grid-template-columns: 1fr 1.1fr;
gap: 4rem;
align-items: start;
}
.contact-info {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.contact-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.375rem;
background-color: var(--color-brand-50);
border: 1.5px solid var(--color-brand-100);
border-radius: 8px;
}
.contact-card-icon {
width: 44px;
height: 44px;
background-color: var(--color-brand-600);
color: #ffffff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.contact-card h3 {
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-brand-400);
margin-bottom: 0.375rem;
}
.contact-value {
font-size: 1.0625rem;
color: var(--color-gray-dark);
font-weight: 400;
}
.contact-link {
color: var(--color-brand-600);
font-weight: 700;
transition: opacity 0.2s ease;
}
.contact-link:hover { opacity: 0.75; }
.contact-service-area {
font-size: 0.875rem;
color: var(--color-brand-400);
margin-top: 0.25rem;
}
.contact-hours {
padding: 1.375rem;
background-color: var(--color-brand-50);
border: 1.5px solid var(--color-brand-100);
border-radius: 8px;
}
.contact-hours-title {
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-brand-400);
margin-bottom: 0.875rem;
}
.hours-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hours-list li {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9375rem;
}
.hours-day {
color: var(--color-gray-dark);
opacity: 0.8;
}
.hours-time {
font-weight: 700;
color: var(--color-brand-600);
}
.hours-time.closed {
color: var(--color-gray-light);
font-weight: 400;
}
.contact-social { margin-top: 0.25rem; }
.contact-social-title {
font-size: 0.875rem;
font-weight: 700;
color: var(--color-brand-600);
margin-bottom: 0.625rem;
}
.social-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
color: #1877f2;
font-weight: 700;
transition: opacity 0.2s ease;
}
.social-link:hover { opacity: 0.8; }
.contact-legal {
font-size: 0.75rem;
color: var(--color-gray-dark);
opacity: 0.45;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Action panel */
.contact-action-panel {
display: flex;
flex-direction: column;
gap: 2rem;
}
.contact-cta-card {
background-color: var(--color-brand-600);
color: #ffffff;
padding: 2.5rem;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.contact-cta-icon {
width: 64px;
height: 64px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.contact-cta-card h2 {
font-size: clamp(1.25rem, 2.5vw, 1.625rem);
color: #ffffff;
font-family: var(--font-display);
}
.contact-cta-card p {
font-size: 1rem;
color: rgba(255, 255, 255, 0.8);
line-height: 1.6;
}
.contact-soumission-btn {
background-color: #ffffff;
color: var(--color-brand-600);
margin-top: 0.5rem;
}
.contact-soumission-btn:hover {
background-color: var(--color-brand-50);
}
.map-placeholder {
background-color: var(--color-brand-50);
border: 1.5px solid var(--color-brand-100);
border-radius: 10px;
overflow: hidden;
}
.map-placeholder-inner {
padding: 3rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
color: var(--color-brand-400);
}
.map-placeholder-inner p {
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-brand-600);
}
.map-service-area {
font-size: 0.875rem;
color: var(--color-gray-dark);
opacity: 0.65;
}
.map-btn {
margin-top: 0.5rem;
font-size: 0.875rem;
padding: 0.5rem 1.25rem;
}
.contact-map-title {
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-brand-400);
margin-bottom: 0.875rem;
}
@media (max-width: 900px) {
.contact-grid {
grid-template-columns: 1fr;
gap: 2.5rem;
}
}
</style>

139
src/pages/index.astro Normal file
View File

@@ -0,0 +1,139 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Hero from '../components/Hero.astro';
import ServicesPreview from '../components/ServicesPreview.astro';
import CTABanner from '../components/CTABanner.astro';
import { getLangFromUrl, t } from '../i18n/utils';
import general from '../content/settings/general.json';
const lang = getLangFromUrl(Astro.url);
---
<BaseLayout
title="Peintres Décorateurs | Laval & Rive-Nord"
description={`${general.siteName} — Peintres décorateurs professionnels à Laval et sur la Rive-Nord. Peinture résidentielle et commerciale, décoration intérieure et extérieure. Soumission gratuite.`}
canonical="/"
>
<Hero />
<ServicesPreview />
<!-- Why choose us section -->
<section class="why-section">
<div class="container-site">
<div class="section-header">
<h2 class="section-title">Pourquoi choisir Cachet?</h2>
<p class="section-subtitle">Une expertise reconnue au service de votre satisfaction</p>
</div>
<div class="why-grid">
<div class="why-card">
<div class="why-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<h3>Certifié RBQ</h3>
<p>Entrepreneur certifié par la Régie du Bâtiment du Québec (RBQ : 5839 8736 01). Travaux assurés et conformes aux normes.</p>
</div>
<div class="why-card">
<div class="why-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<polyline points="12,6 12,12 16,14"/>
</svg>
</div>
<h3>Ponctualité et fiabilité</h3>
<p>Nous respectons nos engagements. Les délais sont tenus et votre projet est livré à temps, selon les standards convenus.</p>
</div>
<div class="why-card">
<div class="why-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="20,6 9,17 4,12"/>
</svg>
</div>
<h3>Matériaux de qualité</h3>
<p>Nous utilisons exclusivement des peintures et produits de première qualité pour un résultat durable et esthétique.</p>
</div>
<div class="why-card">
<div class="why-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h3>Équipe professionnelle</h3>
<p>Nos peintres décorateurs sont formés et expérimentés. Chaque détail compte pour nous, de la préparation à la finition.</p>
</div>
</div>
</div>
</section>
<CTABanner />
</BaseLayout>
<style>
.why-section {
padding: 5rem 0;
background-color: var(--color-brand-50);
}
.section-header {
text-align: center;
margin-bottom: 3rem;
}
.why-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
}
.why-card {
background-color: #ffffff;
border: 1.5px solid var(--color-brand-100);
border-radius: 8px;
padding: 1.75rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.why-icon {
width: 48px;
height: 48px;
background-color: var(--color-brand-600);
color: #ffffff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.why-card h3 {
font-family: var(--font-body);
font-size: 1rem;
font-weight: 700;
color: var(--color-brand-600);
}
.why-card p {
font-size: 0.9rem;
color: var(--color-gray-dark);
opacity: 0.8;
line-height: 1.6;
}
@media (max-width: 900px) {
.why-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 540px) {
.why-grid {
grid-template-columns: 1fr;
}
}
</style>

233
src/pages/services.astro Normal file
View File

@@ -0,0 +1,233 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import CTABanner from '../components/CTABanner.astro';
import { getCollection, render } from 'astro:content';
import { getLangFromUrl, t } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const allServices = await getCollection('services');
const services = allServices
.filter(s => s.id.startsWith('fr/'))
.sort((a, b) => a.data.order - b.data.order);
const icons: Record<string, string> = {
interior: `<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>`,
exterior: `<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 22V12l10-10 10 10v10"/><path d="M15 22v-4a3 3 0 0 0-6 0v4"/><path d="M22 22H2"/></svg>`,
commercial: `<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
decoration: `<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`,
renovation: `<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
};
---
<BaseLayout
title={t(lang, 'nav.services')}
description="Découvrez tous nos services de peinture et décoration à Laval et sur la Rive-Nord : peinture intérieure et extérieure, commerciale, décoration d'intérieur et rénovation."
canonical="/services"
>
<!-- Page header -->
<section class="page-header">
<div class="container-site">
<nav class="breadcrumb" aria-label="Fil d'Ariane">
<a href="/">{t(lang, 'nav.accueil')}</a>
<span aria-hidden="true"></span>
<span aria-current="page">{t(lang, 'nav.services')}</span>
</nav>
<h1 class="page-title">{t(lang, 'services.title')}</h1>
<p class="page-subtitle">{t(lang, 'services.subtitle')}</p>
</div>
</section>
<!-- Services listing -->
<section class="services-listing">
<div class="container-site">
{services.length > 0 ? (
services.map(async (service) => {
const { Content } = await render(service);
const iconSvg = service.data.icon ? (icons[service.data.icon] ?? icons['interior']) : icons['interior'];
const serviceId = service.id.replace('fr/', '');
return (
<article class="service-detail" id={serviceId}>
<div class="service-detail-header">
<div class="service-detail-icon" set:html={iconSvg} />
<div>
<h2 class="service-detail-title">{service.data.title}</h2>
<p class="service-detail-lead">{service.data.description}</p>
</div>
</div>
<div class="service-detail-content prose">
<Content />
</div>
<div class="service-detail-cta">
<a href="/soumission" class="btn-primary">
{t(lang, 'cta.soumission.short')}
</a>
</div>
</article>
);
})
) : (
<p class="services-empty">{t(lang, 'services.empty')}</p>
)}
</div>
</section>
<CTABanner />
</BaseLayout>
<style>
.page-header {
background-color: var(--color-brand-50);
border-bottom: 1.5px solid var(--color-brand-100);
padding: 3rem 0 2.5rem;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-brand-400);
margin-bottom: 1rem;
}
.breadcrumb a {
color: var(--color-brand-600);
transition: opacity 0.2s ease;
}
.breadcrumb a:hover {
opacity: 0.7;
}
.breadcrumb span[aria-current] {
color: var(--color-gray-dark);
opacity: 0.7;
}
.page-title {
font-size: clamp(1.75rem, 4vw, 2.75rem);
color: var(--color-brand-600);
margin-bottom: 0.75rem;
}
.page-subtitle {
font-size: 1.125rem;
color: var(--color-gray-dark);
opacity: 0.75;
max-width: 560px;
}
.services-listing {
padding: 4rem 0 5rem;
}
.service-detail {
padding: 3rem 0;
border-bottom: 1.5px solid var(--color-brand-100);
}
.service-detail:last-of-type {
border-bottom: none;
}
.service-detail-header {
display: flex;
align-items: flex-start;
gap: 1.5rem;
margin-bottom: 2rem;
}
.service-detail-icon {
width: 64px;
height: 64px;
background-color: var(--color-brand-600);
color: #ffffff;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.service-detail-title {
font-size: clamp(1.375rem, 3vw, 1.75rem);
color: var(--color-brand-600);
margin-bottom: 0.5rem;
}
.service-detail-lead {
font-size: 1.0625rem;
color: var(--color-gray-dark);
opacity: 0.8;
line-height: 1.6;
max-width: 640px;
}
.service-detail-content {
max-width: 720px;
margin-bottom: 2rem;
}
/* Prose styles */
.prose :global(h2) {
font-size: 1.25rem;
color: var(--color-brand-600);
font-family: var(--font-display);
margin: 1.5rem 0 0.75rem;
}
.prose :global(ul) {
list-style: none;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.prose :global(li) {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.9375rem;
color: var(--color-gray-dark);
opacity: 0.85;
}
.prose :global(li)::before {
content: '—';
color: var(--color-brand-400);
flex-shrink: 0;
font-weight: 700;
}
.prose :global(p) {
font-size: 1rem;
color: var(--color-gray-dark);
opacity: 0.8;
line-height: 1.7;
margin-bottom: 1rem;
}
.service-detail-cta {
margin-top: 1rem;
}
.services-empty {
text-align: center;
padding: 5rem 0;
color: var(--color-gray-dark);
opacity: 0.6;
}
@media (max-width: 640px) {
.service-detail-header {
flex-direction: column;
gap: 1rem;
}
.prose :global(ul) {
grid-template-columns: 1fr;
}
}
</style>

258
src/pages/soumission.astro Normal file
View File

@@ -0,0 +1,258 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import SoumissionForm from '../components/SoumissionForm.astro';
import { getLangFromUrl, t } from '../i18n/utils';
import general from '../content/settings/general.json';
const lang = getLangFromUrl(Astro.url);
---
<BaseLayout
title={t(lang, 'nav.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."
canonical="/soumission"
>
<!-- Page header -->
<section class="page-header">
<div class="container-site">
<nav class="breadcrumb" aria-label="Fil d'Ariane">
<a href="/">{t(lang, 'nav.accueil')}</a>
<span aria-hidden="true"></span>
<span aria-current="page">{t(lang, 'nav.soumission')}</span>
</nav>
<h1 class="page-title">{t(lang, 'soumission.title')}</h1>
<p class="page-subtitle">{t(lang, 'soumission.subtitle')}</p>
</div>
</section>
<section class="soumission-section">
<div class="container-site soumission-grid">
<!-- Form -->
<div class="soumission-form-col">
<SoumissionForm />
</div>
<!-- Side info -->
<aside class="soumission-sidebar">
<div class="sidebar-card">
<h2 class="sidebar-title">Comment ça fonctionne</h2>
<ol class="process-list">
<li class="process-step">
<div class="process-num">1</div>
<div>
<h3>Remplissez le formulaire</h3>
<p>Décrivez votre projet en quelques mots — type de travaux, superficie et délai souhaité.</p>
</div>
</li>
<li class="process-step">
<div class="process-num">2</div>
<div>
<h3>Nous vous contactons</h3>
<p>Un de nos experts vous rappelle dans les 2448 heures pour planifier une visite gratuite.</p>
</div>
</li>
<li class="process-step">
<div class="process-num">3</div>
<div>
<h3>Soumission détaillée</h3>
<p>Vous recevez une soumission claire et sans engagement, avec tous les détails du projet.</p>
</div>
</li>
</ol>
</div>
<div class="sidebar-contact">
<h3>Ou contactez-nous directement</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">
Numéro RBQ : {general.rbq}<br />
{general.legalName}
</p>
</div>
</aside>
</div>
</section>
</BaseLayout>
<style>
.page-header {
background-color: var(--color-brand-50);
border-bottom: 1.5px solid var(--color-brand-100);
padding: 3rem 0 2.5rem;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-brand-400);
margin-bottom: 1rem;
}
.breadcrumb a {
color: var(--color-brand-600);
transition: opacity 0.2s ease;
}
.breadcrumb a:hover { opacity: 0.7; }
.breadcrumb span[aria-current] {
color: var(--color-gray-dark);
opacity: 0.7;
}
.page-title {
font-size: clamp(1.75rem, 4vw, 2.75rem);
color: var(--color-brand-600);
margin-bottom: 0.75rem;
}
.page-subtitle {
font-size: 1.125rem;
color: var(--color-gray-dark);
opacity: 0.75;
max-width: 560px;
}
.soumission-section {
padding: 4rem 0 5rem;
}
.soumission-grid {
display: grid;
grid-template-columns: 1fr 380px;
gap: 4rem;
align-items: start;
}
/* Sidebar */
.soumission-sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.sidebar-card {
background-color: var(--color-brand-50);
border: 1.5px solid var(--color-brand-100);
border-radius: 10px;
padding: 2rem;
}
.sidebar-title {
font-family: var(--font-display);
font-size: 1.25rem;
color: var(--color-brand-600);
margin-bottom: 1.5rem;
}
.process-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.process-step {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.process-num {
width: 36px;
height: 36px;
background-color: var(--color-brand-600);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 0.9375rem;
font-weight: 500;
flex-shrink: 0;
}
.process-step h3 {
font-family: var(--font-body);
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-brand-600);
margin-bottom: 0.25rem;
}
.process-step p {
font-size: 0.875rem;
color: var(--color-gray-dark);
opacity: 0.75;
line-height: 1.55;
}
.sidebar-contact {
background-color: var(--color-brand-600);
color: #ffffff;
border-radius: 10px;
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.sidebar-contact h3 {
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 0.25rem;
}
.sidebar-phone,
.sidebar-email {
display: flex;
align-items: center;
gap: 0.625rem;
font-size: 1rem;
font-weight: 700;
color: #ffffff;
transition: opacity 0.2s ease;
}
.sidebar-phone:hover,
.sidebar-email:hover { opacity: 0.8; }
.sidebar-legal {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
margin-top: 0.5rem;
line-height: 1.6;
}
@media (max-width: 900px) {
.soumission-grid {
grid-template-columns: 1fr;
gap: 2.5rem;
}
.soumission-sidebar {
order: -1;
}
}
</style>

223
src/styles/global.css Normal file
View File

@@ -0,0 +1,223 @@
@import "tailwindcss";
/* Custom fonts */
@font-face {
font-family: 'Cocogoose';
src: url('/fonts/Cocogoose-Classic-Medium-trial.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geoform';
src: url('/fonts/Geoform-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geoform';
src: url('/fonts/Geoform.otf') format('opentype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@theme {
/* Cachet brand palette — calibrated to logo */
--color-brand-dark: #314732; /* Dark Green — primary headings, nav */
--color-brand-mid: #7a8876; /* Light Green — accents, borders */
--color-brand-light: #f0f4ef; /* Very light green tint — backgrounds */
--color-brand-50: #f0f4ef;
--color-brand-100: #d6e2d4;
--color-brand-200: #b8cdb5;
--color-brand-300: #97b393;
--color-brand-400: #7a8876; /* Light Green */
--color-brand-500: #5a6e58;
--color-brand-600: #314732; /* Dark Green — CTA buttons */
--color-brand-700: #263a27; /* Hover state */
--color-brand-800: #1c2c1d;
--color-brand-900: #131f13;
/* Neutral palette */
--color-gray-light: #b7b6b6;
--color-gray-dark: #444445;
/* Typography */
--font-display: 'Cocogoose', system-ui, sans-serif;
--font-body: 'Geoform', system-ui, sans-serif;
--font-sans: 'Geoform', system-ui, sans-serif;
}
/* Base layer */
@layer base {
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
color: var(--color-gray-dark);
background-color: #ffffff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3 {
font-family: var(--font-display);
color: var(--color-brand-600);
font-weight: 500;
line-height: 1.15;
}
h4, h5, h6 {
font-family: var(--font-body);
font-weight: 700;
color: var(--color-brand-600);
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
height: auto;
}
}
/* Component layer */
@layer components {
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.75rem;
background-color: var(--color-brand-600);
color: #ffffff;
font-family: var(--font-body);
font-weight: 700;
font-size: 0.9375rem;
letter-spacing: 0.02em;
border-radius: 4px;
border: 2px solid transparent;
transition: background-color 0.2s ease, transform 0.1s ease;
cursor: pointer;
text-decoration: none;
}
.btn-primary:hover {
background-color: var(--color-brand-700);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-outline {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.75rem;
background-color: transparent;
color: var(--color-brand-600);
font-family: var(--font-body);
font-weight: 700;
font-size: 0.9375rem;
letter-spacing: 0.02em;
border-radius: 4px;
border: 2px solid var(--color-brand-600);
transition: background-color 0.2s ease, color 0.2s ease;
cursor: pointer;
text-decoration: none;
}
.btn-outline:hover {
background-color: var(--color-brand-600);
color: #ffffff;
}
.btn-outline-white {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.75rem;
background-color: transparent;
color: #ffffff;
font-family: var(--font-body);
font-weight: 700;
font-size: 0.9375rem;
letter-spacing: 0.02em;
border-radius: 4px;
border: 2px solid rgba(255, 255, 255, 0.8);
transition: background-color 0.2s ease, border-color 0.2s ease;
cursor: pointer;
text-decoration: none;
}
.btn-outline-white:hover {
background-color: rgba(255, 255, 255, 0.15);
border-color: #ffffff;
}
.section-title {
font-family: var(--font-display);
font-size: clamp(1.75rem, 4vw, 2.5rem);
color: var(--color-brand-600);
font-weight: 500;
}
.section-subtitle {
font-family: var(--font-body);
font-size: 1.125rem;
color: var(--color-gray-dark);
opacity: 0.8;
margin-top: 0.5rem;
}
.container-site {
max-width: 1200px;
margin-left: auto;
margin-right: auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.form-label {
display: block;
font-weight: 700;
font-size: 0.875rem;
color: var(--color-brand-600);
margin-bottom: 0.375rem;
letter-spacing: 0.02em;
}
.form-input {
width: 100%;
padding: 0.6875rem 1rem;
border: 1.5px solid var(--color-brand-mid);
border-radius: 4px;
font-family: var(--font-body);
font-size: 1rem;
color: var(--color-gray-dark);
background-color: #ffffff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
outline: none;
}
.form-input:focus {
border-color: var(--color-brand-600);
box-shadow: 0 0 0 3px rgba(49, 71, 50, 0.12);
}
.form-input::placeholder {
color: var(--color-gray-light);
}
}

9
tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}