scaffold
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal 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
21
.gitignore
vendored
Normal 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
194
README.md
Normal 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 **1–2 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
26
astro.config.mjs
Normal 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
30
branding logo.md
Normal file
File diff suppressed because one or more lines are too long
17
branding notes.md
Normal file
17
branding notes.md
Normal file
File diff suppressed because one or more lines are too long
8
branding.md
Normal file
8
branding.md
Normal file
File diff suppressed because one or more lines are too long
BIN
fonts/cocogoose_classic/Cocogoose-Classic-ExtraBold-trial.ttf
Executable file
BIN
fonts/cocogoose_classic/Cocogoose-Classic-ExtraBold-trial.ttf
Executable file
Binary file not shown.
BIN
fonts/cocogoose_classic/Cocogoose-Classic-Family-CC-BY-NCLicensepdf.pdf
Executable file
BIN
fonts/cocogoose_classic/Cocogoose-Classic-Family-CC-BY-NCLicensepdf.pdf
Executable file
Binary file not shown.
BIN
fonts/cocogoose_classic/Cocogoose-Classic-Medium-trial.ttf
Executable file
BIN
fonts/cocogoose_classic/Cocogoose-Classic-Medium-trial.ttf
Executable file
Binary file not shown.
Binary file not shown.
BIN
fonts/cocogoose_classic/Cocogoose_classic_by_Zetafonts.png
Normal file
BIN
fonts/cocogoose_classic/Cocogoose_classic_by_Zetafonts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 726 KiB |
BIN
fonts/geoform/Geoform Character Set.pdf
Normal file
BIN
fonts/geoform/Geoform Character Set.pdf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform Poster.pdf
Normal file
BIN
fonts/geoform/Geoform Poster.pdf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-Bold.otf
Normal file
BIN
fonts/geoform/Geoform-Bold.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-BoldItalic.otf
Normal file
BIN
fonts/geoform/Geoform-BoldItalic.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-ExtraBold.otf
Normal file
BIN
fonts/geoform/Geoform-ExtraBold.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-ExtraBoldItalic.otf
Normal file
BIN
fonts/geoform/Geoform-ExtraBoldItalic.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-ExtraLight.otf
Normal file
BIN
fonts/geoform/Geoform-ExtraLight.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-ExtraLightItalic.otf
Normal file
BIN
fonts/geoform/Geoform-ExtraLightItalic.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-Heavy.otf
Normal file
BIN
fonts/geoform/Geoform-Heavy.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-HeavyItalic.ttf
Normal file
BIN
fonts/geoform/Geoform-HeavyItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-Italic.ttf
Normal file
BIN
fonts/geoform/Geoform-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-Light.otf
Normal file
BIN
fonts/geoform/Geoform-Light.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-LightItalic.otf
Normal file
BIN
fonts/geoform/Geoform-LightItalic.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-Medium.otf
Normal file
BIN
fonts/geoform/Geoform-Medium.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-MediumItalic.ttf
Normal file
BIN
fonts/geoform/Geoform-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-Thin.otf
Normal file
BIN
fonts/geoform/Geoform-Thin.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform-ThinItalic.otf
Normal file
BIN
fonts/geoform/Geoform-ThinItalic.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Geoform.otf
Normal file
BIN
fonts/geoform/Geoform.otf
Normal file
Binary file not shown.
BIN
fonts/geoform/Typeson EULA.pdf
Normal file
BIN
fonts/geoform/Typeson EULA.pdf
Normal file
Binary file not shown.
175
functions/api/soumission.ts
Normal file
175
functions/api/soumission.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
};
|
||||
6202
package-lock.json
generated
Normal file
6202
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal 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
70
public/admin/config.yml
Normal 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
13
public/admin/index.html
Normal 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
5
public/favicon.svg
Normal 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 |
BIN
public/fonts/Cocogoose-Classic-Medium-trial.ttf
Executable file
BIN
public/fonts/Cocogoose-Classic-Medium-trial.ttf
Executable file
Binary file not shown.
BIN
public/fonts/Geoform-Bold.otf
Normal file
BIN
public/fonts/Geoform-Bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/Geoform.otf
Normal file
BIN
public/fonts/Geoform.otf
Normal file
Binary file not shown.
BIN
public/images/logo.png
Normal file
BIN
public/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
7
public/robots.txt
Normal file
7
public/robots.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Block CMS admin from indexing
|
||||
Disallow: /admin/
|
||||
|
||||
Sitemap: https://cachetdeco.com/sitemap-index.xml
|
||||
92
src/components/CTABanner.astro
Normal file
92
src/components/CTABanner.astro
Normal 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
279
src/components/Footer.astro
Normal 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
242
src/components/Header.astro
Normal 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
192
src/components/Hero.astro
Normal 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>
|
||||
16
src/components/LanguagePicker.astro
Normal file
16
src/components/LanguagePicker.astro
Normal 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>
|
||||
99
src/components/SEOHead.astro
Normal file
99
src/components/SEOHead.astro
Normal 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)} />
|
||||
112
src/components/ServiceCard.astro
Normal file
112
src/components/ServiceCard.astro
Normal 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>
|
||||
90
src/components/ServicesPreview.astro
Normal file
90
src/components/ServicesPreview.astro
Normal 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>
|
||||
390
src/components/SoumissionForm.astro
Normal file
390
src/components/SoumissionForm.astro
Normal 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
17
src/content.config.ts
Normal 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 };
|
||||
25
src/content/services/fr/decoration-interieure.md
Normal file
25
src/content/services/fr/decoration-interieure.md
Normal 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
|
||||
26
src/content/services/fr/peinture-commerciale.md
Normal file
26
src/content/services/fr/peinture-commerciale.md
Normal 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
|
||||
25
src/content/services/fr/peinture-exterieure.md
Normal file
25
src/content/services/fr/peinture-exterieure.md
Normal 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)
|
||||
25
src/content/services/fr/peinture-interieure.md
Normal file
25
src/content/services/fr/peinture-interieure.md
Normal 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
|
||||
24
src/content/services/fr/renovation-complete.md
Normal file
24
src/content/services/fr/renovation-complete.md
Normal 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
|
||||
14
src/content/settings/general.json
Normal file
14
src/content/settings/general.json
Normal 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"
|
||||
}
|
||||
8
src/content/settings/seo.json
Normal file
8
src/content/settings/seo.json
Normal 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
9
src/i18n/config.ts
Normal 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
92
src/i18n/ui.ts
Normal 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
17
src/i18n/utils.ts
Normal 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}`;
|
||||
}
|
||||
54
src/layouts/BaseLayout.astro
Normal file
54
src/layouts/BaseLayout.astro
Normal 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
64
src/pages/404.astro
Normal 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
454
src/pages/contact.astro
Normal 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
139
src/pages/index.astro
Normal 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
233
src/pages/services.astro
Normal 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
258
src/pages/soumission.astro
Normal 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 24–48 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
223
src/styles/global.css
Normal 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
9
tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user