Compare commits
27 Commits
d9dbb43926
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b486c4f3fe | |||
|
|
bc02da9d48 | ||
| 7e474a85a2 | |||
| 8f8fcb83c9 | |||
| 936000b32a | |||
| 5315f1b188 | |||
| d6d54df384 | |||
| f1c880690f | |||
| 9fee97dea6 | |||
| 789dc380c1 | |||
| fe94d4c543 | |||
| f8f59a5134 | |||
| 3d1336c3b2 | |||
| 05eb117e4b | |||
| 84728c9532 | |||
| 5dc33897ce | |||
| 067a151be1 | |||
| 539e537572 | |||
| 60c295d731 | |||
| 0e99cb4cd7 | |||
| 212baf8efc | |||
| 148d664b44 | |||
| d1ad6e0f09 | |||
| 72f652eabe | |||
| b23809eb47 | |||
| cd3cb704be | |||
| b0f2bf142b |
@@ -21,4 +21,6 @@ jobs:
|
|||||||
docker build -t cachetdeco:latest .
|
docker build -t cachetdeco:latest .
|
||||||
docker stop cachetdeco 2>/dev/null || true
|
docker stop cachetdeco 2>/dev/null || true
|
||||||
docker rm cachetdeco 2>/dev/null || true
|
docker rm cachetdeco 2>/dev/null || true
|
||||||
docker run -d --name cachetdeco -p 30001:80 --restart unless-stopped cachetdeco:latest
|
docker run -d --name cachetdeco -p 30001:4321 --restart unless-stopped \
|
||||||
|
-e RESEND_API_KEY=${{ secrets.RESEND_API_KEY }} \
|
||||||
|
cachetdeco:latest
|
||||||
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
.astro/
|
.astro/
|
||||||
|
.wrangler/
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
16
Dockerfile
@@ -1,3 +1,13 @@
|
|||||||
FROM nginx:alpine
|
FROM node:24-alpine AS runtime
|
||||||
COPY dist/ /usr/share/nginx/html/
|
WORKDIR /app
|
||||||
EXPOSE 80
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=4321
|
||||||
|
EXPOSE 4321
|
||||||
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Cachet Peintres Décorateurs — Site Web
|
# Cachet Peintres Décorateurs — Site Web
|
||||||
|
|
||||||
Site vitrine statique pour **Cachet Peintres Décorateurs** (9500-5609 Québec Inc).
|
Site vitrine statique pour **Cachet Peintres Décorateurs** (CACHETDECO).
|
||||||
Construit avec **Astro v5 + Tailwind v4 + Decap CMS**, déployé sur **Cloudflare Pages**.
|
Construit avec **Astro v5 + Tailwind v4 + Decap CMS**, déployé sur **Cloudflare Pages**.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -180,7 +180,6 @@ Aucune refactorisation structurelle n'est nécessaire — l'infrastructure est d
|
|||||||
|
|
||||||
Conformément aux exigences légales québécoises, les informations suivantes doivent apparaître visiblement sur le site :
|
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)
|
- **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.
|
Ces informations sont affichées dans le footer de chaque page.
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { defineConfig } from 'astro/config';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import sitemap from '@astrojs/sitemap';
|
import sitemap from '@astrojs/sitemap';
|
||||||
import react from '@astrojs/react';
|
import react from '@astrojs/react';
|
||||||
import node from '@astrojs/node';
|
import cloudflare from '@astrojs/cloudflare';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'static',
|
output: 'server',
|
||||||
adapter: node({ mode: 'middleware' }),
|
adapter: cloudflare(),
|
||||||
site: 'https://cachetdeco.com',
|
site: 'https://cachetdeco.com',
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: 'fr',
|
defaultLocale: 'fr',
|
||||||
@@ -26,5 +26,8 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
|
ssr: {
|
||||||
|
noExternal: ['motion', 'motion/react'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
76
docs/cloudflare-workers-astro-v6.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Cloudflare Workers + Astro v6 Deployment Notes
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Astro v6 + `@astrojs/cloudflare` adapter (v13+)
|
||||||
|
- Deployed as **Cloudflare Worker** (not Pages)
|
||||||
|
- Build: `npm run build` → Deploy: `npx wrangler deploy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 1 — `wrangler.json` with `pages_build_output_dir` breaks deploy
|
||||||
|
|
||||||
|
**Error:**
|
||||||
|
```
|
||||||
|
The name 'ASSETS' is reserved in Pages projects. Please use a different name for your Assets binding.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Having `pages_build_output_dir` in `wrangler.json` makes Wrangler treat the project as a Pages project. The adapter generates `dist/server/wrangler.json` with an `ASSETS` binding for static assets, which is reserved in Pages context.
|
||||||
|
|
||||||
|
**Fix:** Remove `pages_build_output_dir` from `wrangler.json`, or delete the file entirely. The adapter generates a complete `dist/server/wrangler.json` — Wrangler picks it up automatically via config redirect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 2 — `locals.runtime.env` removed in Astro v6
|
||||||
|
|
||||||
|
**Error:**
|
||||||
|
```
|
||||||
|
Astro.locals.runtime.env has been removed in Astro v6.
|
||||||
|
Use 'import { env } from "cloudflare:workers"' instead.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Astro v6 dropped the `locals.runtime.env` pattern for accessing Cloudflare env vars/bindings.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Astro v5 (broken in v6)
|
||||||
|
const runtime = (locals as any).runtime;
|
||||||
|
const apiKey = runtime?.env?.RESEND_API_KEY;
|
||||||
|
|
||||||
|
// ✅ Astro v6
|
||||||
|
import { env } from 'cloudflare:workers';
|
||||||
|
const apiKey = (env as unknown as Record<string, string>).RESEND_API_KEY;
|
||||||
|
```
|
||||||
|
|
||||||
|
Install types and add to `tsconfig.json`:
|
||||||
|
```bash
|
||||||
|
npm install --save-dev @cloudflare/workers-types
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["@cloudflare/workers-types"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Env vars in Cloudflare Workers dashboard
|
||||||
|
|
||||||
|
- Set vars under **Worker → Settings → Variables and Secrets**
|
||||||
|
- They are accessible via `env` from `cloudflare:workers` at runtime
|
||||||
|
- Do **not** rely on `process.env` or `import.meta.env` for server-side secrets in Workers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wrangler config redirect (how it works)
|
||||||
|
|
||||||
|
When `astro build` runs, `@astrojs/cloudflare` generates `dist/server/wrangler.json`. Wrangler detects this and logs:
|
||||||
|
```
|
||||||
|
Using redirected Wrangler configuration.
|
||||||
|
- Configuration being used: "dist/server/wrangler.json"
|
||||||
|
- Original user's configuration: "wrangler.json"
|
||||||
|
```
|
||||||
|
The generated config includes the worker `main` entry, `assets` binding, `compatibility_date`, and any adapter-detected bindings (e.g. `IMAGES`, `SESSION`).
|
||||||
1050
package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^10.0.4",
|
"@astrojs/cloudflare": "^13.1.7",
|
||||||
"@astrojs/react": "^5.0.2",
|
"@astrojs/react": "^5.0.2",
|
||||||
"@astrojs/sitemap": "^3.7.2",
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
@@ -28,12 +28,14 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.72.0",
|
"react-hook-form": "^7.72.0",
|
||||||
|
"react-social-media-embed": "^2.5.18",
|
||||||
"shadcn": "^4.0.5",
|
"shadcn": "^4.0.5",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20260403.1",
|
||||||
"@types/react": "^19.2.14"
|
"@types/react": "^19.2.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
backend:
|
backend:
|
||||||
name: github
|
name: github
|
||||||
repo: mbcube/cachetdeco # REPLACE with your GitHub org/repo
|
repo: mbcube/cachetdeco
|
||||||
branch: main
|
branch: cloudflare
|
||||||
# For Cloudflare Pages (no Netlify Identity), use PKCE flow:
|
base_url: https://cachetdeco.br-mouad.workers.dev
|
||||||
auth_type: pkce
|
auth_endpoint: /api/auth
|
||||||
app_id: YOUR_GITHUB_OAUTH_APP_CLIENT_ID # REPLACE with your GitHub OAuth App client ID
|
|
||||||
|
|
||||||
locale: fr
|
locale: fr
|
||||||
media_folder: "public/images"
|
media_folder: "public/images"
|
||||||
@@ -347,7 +346,6 @@ collections:
|
|||||||
fields:
|
fields:
|
||||||
- { name: "key", label: "Identifiant unique", widget: "string" }
|
- { name: "key", label: "Identifiant unique", widget: "string" }
|
||||||
- { name: "src", label: "URL du post Facebook (embed)", widget: "string" }
|
- { name: "src", label: "URL du post Facebook (embed)", widget: "string" }
|
||||||
- { name: "height", label: "Hauteur de l'iframe (px)", widget: "number" }
|
|
||||||
|
|
||||||
- name: "settings"
|
- name: "settings"
|
||||||
label: "Paramètres du site"
|
label: "Paramètres du site"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 525 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 287 KiB |
BIN
public/images/logo-full.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/images/logo-text.webp
Normal file
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 134 KiB |
@@ -16,7 +16,7 @@ const legalNotice = footer.legal.replace('{year}', String(year));
|
|||||||
---
|
---
|
||||||
|
|
||||||
<FooterAnimated
|
<FooterAnimated
|
||||||
client:visible
|
client:load
|
||||||
siteName={general.siteName}
|
siteName={general.siteName}
|
||||||
serviceArea={general.serviceArea}
|
serviceArea={general.serviceArea}
|
||||||
phone={general.phone}
|
phone={general.phone}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { Dialog, DialogPanel } from "@headlessui/react";
|
import { Dialog, DialogPanel } from "@headlessui/react";
|
||||||
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
@@ -16,13 +16,44 @@ export default function Header({
|
|||||||
ctaHref: string;
|
ctaHref: string;
|
||||||
}) {
|
}) {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const lastScrollY = useRef(0);
|
||||||
const phoneHref = `tel:${general.phone.replace(/\D/g, "")}`;
|
const phoneHref = `tel:${general.phone.replace(/\D/g, "")}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const currentY = window.scrollY;
|
||||||
|
const delta = currentY - lastScrollY.current;
|
||||||
|
if (delta > 8 && currentY > 80) {
|
||||||
|
setIsVisible(false);
|
||||||
|
} else if (delta < -8) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
setIsScrolled(currentY > 10);
|
||||||
|
lastScrollY.current = currentY;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync state with the actual scroll position on mount
|
||||||
|
const initialY = window.scrollY;
|
||||||
|
lastScrollY.current = initialY;
|
||||||
|
setIsScrolled(initialY > 10);
|
||||||
|
if (initialY > 80) {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="absolute inset-x-0 top-0 z-50">
|
<header
|
||||||
|
className={`fixed inset-x-0 top-0 z-50 transition-all duration-300 ease-in-out`}
|
||||||
|
style={{ transform: isVisible ? "translateY(0)" : "translateY(-100%)" }}
|
||||||
|
>
|
||||||
<nav
|
<nav
|
||||||
aria-label="Global"
|
aria-label="Global"
|
||||||
className="flex items-center justify-between p-6 lg:px-8"
|
className={`flex items-center justify-between p-6 lg:px-8 ${isScrolled ? "bg-[#314732]/95 shadow-md backdrop-blur-sm py-4" : ""} `}
|
||||||
>
|
>
|
||||||
<div className="flex lg:flex-1">
|
<div className="flex lg:flex-1">
|
||||||
<a
|
<a
|
||||||
@@ -56,7 +87,7 @@ export default function Header({
|
|||||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="text-sm/6 font-semibold text-white hover:underline underline-offset-4 decoration-2 drop-shadow"
|
className="text-base/6 font-semibold text-white hover:underline underline-offset-4 decoration-2 drop-shadow"
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</motion.a>
|
</motion.a>
|
||||||
@@ -70,21 +101,21 @@ export default function Header({
|
|||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</a> */}
|
</a> */}
|
||||||
<motion.dl
|
<motion.dl
|
||||||
className="flex gap-x-2 items-center justify-center text-xl font-semibold bg-white rounded-full p-2"
|
className="m-0 inline-flex items-center gap-1.5 rounded-full bg-white px-3 py-2 text-xl font-semibold font-[family-name:var(--font-body)]"
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||||
>
|
>
|
||||||
<dt className="flex-none">
|
<dt className="m-0 flex shrink-0 items-center">
|
||||||
<span className="sr-only">Telephone</span>
|
<span className="sr-only">Telephone</span>
|
||||||
<PhoneIcon
|
<PhoneIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-8 w-8 text-brand-600 "
|
className="h-8 w-8 text-brand-600"
|
||||||
/>
|
/>
|
||||||
</dt>
|
</dt>
|
||||||
<dd>
|
<dd className="m-0 flex min-w-0 items-center justify-center">
|
||||||
<a
|
<a
|
||||||
href={phoneHref}
|
href={phoneHref}
|
||||||
className="text-brand-600 hover:text-brand-700 "
|
className="whitespace-nowrap tabular-nums tracking-tight text-brand-600 hover:text-brand-700"
|
||||||
>
|
>
|
||||||
{general.phone}
|
{general.phone}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -92,45 +92,45 @@ export default function Contact({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative isolate bg-brand-50/50 dark:bg-brand-900">
|
<div className="relative isolate bg-brand-50/50 dark:bg-brand-900">
|
||||||
|
<div className="absolute inset-y-0 left-0 -z-10 w-full overflow-hidden bg-brand-50 ring-1 ring-brand-900/10 lg:w-1/2 dark:bg-brand-900 dark:ring-white/10">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 size-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-brand-200 dark:stroke-white/10"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
x="100%"
|
||||||
|
y={-1}
|
||||||
|
id="83fd4e5a-9d52-42fc-97b6-718e5d7ee527"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<path d="M130 200V.5M.5 .5H200" fill="none" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" strokeWidth={0} className="fill-white dark:fill-brand-900" />
|
||||||
|
<svg x="100%" y={-1} className="overflow-visible fill-brand-50 dark:fill-brand-800/20">
|
||||||
|
<path d="M-470.5 0h201v201h-201Z" strokeWidth={0} />
|
||||||
|
</svg>
|
||||||
|
<rect fill="url(#83fd4e5a-9d52-42fc-97b6-718e5d7ee527)" width="100%" height="100%" strokeWidth={0} />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute top-[calc(100%-13rem)] -left-56 hidden transform-gpu blur-3xl lg:top-[calc(50%-7rem)] lg:left-[max(-14rem,calc(100%-59rem))] dark:block"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
clipPath:
|
||||||
|
'polygon(74.1% 56.1%, 100% 38.6%, 97.5% 73.3%, 85.5% 100%, 80.7% 98.2%, 72.5% 67.7%, 60.2% 37.8%, 52.4% 32.2%, 47.5% 41.9%, 45.2% 65.8%, 27.5% 23.5%, 0.1% 35.4%, 17.9% 0.1%, 27.6% 23.5%, 76.1% 2.6%, 74.1% 56.1%)',
|
||||||
|
}}
|
||||||
|
className="aspect-1155/678 w-288.75 bg-linear-to-br from-brand-300 to-brand-600 opacity-10 dark:opacity-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-2">
|
<div className="mx-auto grid max-w-7xl grid-cols-1 lg:grid-cols-2">
|
||||||
<motion.div {...slideIn("left", 0.05)} className="relative px-6 pt-12 pb-20 lg:static">
|
<motion.div {...slideIn("left", 0.05)} className="relative px-6 pt-12 pb-20 lg:static">
|
||||||
<div className="mx-auto max-w-xl lg:mx-0 lg:max-w-lg">
|
<div className="mx-auto max-w-xl lg:mx-0 lg:max-w-lg">
|
||||||
<div className="absolute inset-y-0 left-0 -z-10 w-full overflow-hidden bg-brand-50 ring-1 ring-brand-900/10 lg:w-1/2 dark:bg-brand-900 dark:ring-white/10">
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute inset-0 size-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-brand-200 dark:stroke-white/10"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<pattern
|
|
||||||
x="100%"
|
|
||||||
y={-1}
|
|
||||||
id="83fd4e5a-9d52-42fc-97b6-718e5d7ee527"
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
patternUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<path d="M130 200V.5M.5 .5H200" fill="none" />
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect width="100%" height="100%" strokeWidth={0} className="fill-white dark:fill-brand-900" />
|
|
||||||
<svg x="100%" y={-1} className="overflow-visible fill-brand-50 dark:fill-brand-800/20">
|
|
||||||
<path d="M-470.5 0h201v201h-201Z" strokeWidth={0} />
|
|
||||||
</svg>
|
|
||||||
<rect fill="url(#83fd4e5a-9d52-42fc-97b6-718e5d7ee527)" width="100%" height="100%" strokeWidth={0} />
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute top-[calc(100%-13rem)] -left-56 hidden transform-gpu blur-3xl lg:top-[calc(50%-7rem)] lg:left-[max(-14rem,calc(100%-59rem))] dark:block"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
clipPath:
|
|
||||||
'polygon(74.1% 56.1%, 100% 38.6%, 97.5% 73.3%, 85.5% 100%, 80.7% 98.2%, 72.5% 67.7%, 60.2% 37.8%, 52.4% 32.2%, 47.5% 41.9%, 45.2% 65.8%, 27.5% 23.5%, 0.1% 35.4%, 17.9% 0.1%, 27.6% 23.5%, 76.1% 2.6%, 74.1% 56.1%)',
|
|
||||||
}}
|
|
||||||
className="aspect-1155/678 w-288.75 bg-linear-to-br from-brand-300 to-brand-600 opacity-10 dark:opacity-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.dl {...fadeUp(0.1)} className="mt-10 space-y-4 text-base/7 text-brand-700 dark:text-brand-100">
|
<motion.dl {...fadeUp(0.1)} className="mt-10 space-y-4 text-base/7 text-brand-700 dark:text-brand-100">
|
||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4">
|
||||||
<dt className="flex-none">
|
<dt className="flex-none">
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ export default function WithAlternatingSections({
|
|||||||
<div className="mx-auto max-w-3xl text-center">
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
<motion.h2
|
<motion.h2
|
||||||
{...fadeUp}
|
{...fadeUp}
|
||||||
className="text-4xl font-bold tracking-tight text-brand-600 sm:text-5xl"
|
className="text-4xl font-bold tracking-tight text-brand-600 sm:text-5xl lg:text-4xl"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
<motion.p {...fadeUp} className="mt-4 text-lg text-brand-500">
|
<motion.p {...fadeUp} className="mt-4 text-lg text-brand-500 lg:text-base">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,11 +67,11 @@ export default function WithAlternatingSections({
|
|||||||
>
|
>
|
||||||
<motion.h3
|
<motion.h3
|
||||||
{...fadeUp}
|
{...fadeUp}
|
||||||
className="text-4xl font-semibold tracking-tight text-brand-600"
|
className="text-4xl font-semibold tracking-tight text-brand-600 lg:text-3xl"
|
||||||
>
|
>
|
||||||
{feature.name}
|
{feature.name}
|
||||||
</motion.h3>
|
</motion.h3>
|
||||||
<motion.p {...fadeUp} className="mt-2 text-base text-brand-800">
|
<motion.p {...fadeUp} className="mt-2 text-base text-brand-800 lg:text-[0.9375rem] lg:leading-relaxed font-[family-name:var(--font-body)]">
|
||||||
{feature.description}
|
{feature.description}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { FacebookEmbed } from 'react-social-media-embed';
|
||||||
|
|
||||||
interface TestimonialItem {
|
interface TestimonialItem {
|
||||||
key: string;
|
key: string;
|
||||||
src: string;
|
src: string;
|
||||||
height: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionLabel: string;
|
sectionLabel: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -19,7 +20,6 @@ const iframeProps = {
|
|||||||
allowFullScreen: true,
|
allowFullScreen: true,
|
||||||
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share',
|
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share',
|
||||||
style: { border: 'none', overflow: 'hidden' } as React.CSSProperties,
|
style: { border: 'none', overflow: 'hidden' } as React.CSSProperties,
|
||||||
className: 'w-full rounded-2xl',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fadeUp = (delay: number) => ({
|
const fadeUp = (delay: number) => ({
|
||||||
@@ -84,7 +84,7 @@ export default function Grid({ sectionLabel, title, items }: Props) {
|
|||||||
</motion.h2>
|
</motion.h2>
|
||||||
<motion.p
|
<motion.p
|
||||||
{...fadeUp(0.1)}
|
{...fadeUp(0.1)}
|
||||||
className="mt-2 text-4xl font-semibold tracking-tight text-balance text-brand-600 sm:text-5xl dark:text-white"
|
className="mt-2 text-4xl font-semibold tracking-tight text-balance text-brand-600 sm:text-5xl lg:text-4xl dark:text-white"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
@@ -92,9 +92,10 @@ export default function Grid({ sectionLabel, title, items }: Props) {
|
|||||||
<div className="mx-auto mt-16 grid max-w-2xl grid-cols-1 grid-rows-1 gap-8 text-sm/6 text-gray-900 sm:mt-20 sm:grid-cols-2 xl:mx-0 xl:max-w-none xl:grid-flow-col xl:grid-cols-4 dark:text-gray-100">
|
<div className="mx-auto mt-16 grid max-w-2xl grid-cols-1 grid-rows-1 gap-8 text-sm/6 text-gray-900 sm:mt-20 sm:grid-cols-2 xl:mx-0 xl:max-w-none xl:grid-flow-col xl:grid-cols-4 dark:text-gray-100">
|
||||||
<motion.figure
|
<motion.figure
|
||||||
{...slideUp(0.05)}
|
{...slideUp(0.05)}
|
||||||
className="rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 sm:col-span-2 xl:col-start-2 xl:row-end-1 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10"
|
className="overflow-hidden rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 sm:col-span-2 xl:col-start-2 xl:row-end-1 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10"
|
||||||
>
|
>
|
||||||
<iframe {...iframeProps} src={featured.src} height={featured.height} />
|
<FacebookEmbed url={featured.src} width="100%" />
|
||||||
|
|
||||||
</motion.figure>
|
</motion.figure>
|
||||||
{groups.map((group, groupIdx) => (
|
{groups.map((group, groupIdx) => (
|
||||||
<div key={groupIdx} className="space-y-8 xl:contents xl:space-y-0">
|
<div key={groupIdx} className="space-y-8 xl:contents xl:space-y-0">
|
||||||
@@ -109,9 +110,10 @@ export default function Grid({ sectionLabel, title, items }: Props) {
|
|||||||
>
|
>
|
||||||
<motion.figure
|
<motion.figure
|
||||||
{...slideUp(delay)}
|
{...slideUp(delay)}
|
||||||
className="rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10"
|
className={`rounded-2xl bg-white shadow-lg ring-1 ring-gray-900/5 dark:bg-gray-800/75 dark:shadow-none dark:ring-white/10 overflow-hidden`}
|
||||||
>
|
>
|
||||||
<iframe {...iframeProps} src={t.src} height={t.height} />
|
|
||||||
|
<FacebookEmbed url={t.src} width="100%" />
|
||||||
</motion.figure>
|
</motion.figure>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function WithTieredImages({
|
|||||||
</motion.h2>
|
</motion.h2>
|
||||||
<motion.p
|
<motion.p
|
||||||
{...fadeUp(0.1)}
|
{...fadeUp(0.1)}
|
||||||
className="mt-2 text-4xl font-bold tracking-tight text-white sm:text-5xl"
|
className="mt-2 text-4xl font-bold tracking-tight text-white sm:text-5xl lg:text-4xl"
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
@@ -121,8 +121,8 @@ export default function WithTieredImages({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-base font-medium text-white">{card.title}</dt>
|
<dt className="text-base font-medium text-white lg:text-[0.9375rem]">{card.title}</dt>
|
||||||
<dd className="mt-3 text-base text-white/90">{card.description}</dd>
|
<dd className="mt-3 text-base text-white/90 lg:text-[0.9375rem] lg:leading-relaxed font-[family-name:var(--font-body)]">{card.description}</dd>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const canonicalUrl = canonical
|
|||||||
|
|
||||||
const ogImageUrl = ogImage
|
const ogImageUrl = ogImage
|
||||||
? ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage}`
|
? ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage}`
|
||||||
: `${siteUrl}/images/logo-vignette.svg`;
|
: `${siteUrl}/images/logo-full.webp`;
|
||||||
|
|
||||||
// JSON-LD LocalBusiness structured data
|
// JSON-LD LocalBusiness structured data
|
||||||
const structuredData = {
|
const structuredData = {
|
||||||
@@ -47,7 +47,7 @@ const structuredData = {
|
|||||||
email: general.email,
|
email: general.email,
|
||||||
address: {
|
address: {
|
||||||
'@type': 'PostalAddress',
|
'@type': 'PostalAddress',
|
||||||
addressLocality: 'Laval',
|
addressLocality: 'Sainte-Catherine',
|
||||||
addressRegion: 'QC',
|
addressRegion: 'QC',
|
||||||
addressCountry: 'CA',
|
addressCountry: 'CA',
|
||||||
},
|
},
|
||||||
@@ -92,7 +92,7 @@ const structuredData = {
|
|||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/images/logo-vignette.svg" />
|
<link rel="icon" type="image/png" href="/favicon.svg" />
|
||||||
|
|
||||||
<!-- JSON-LD -->
|
<!-- JSON-LD -->
|
||||||
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
|
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ export default function FullWidthWithBackgroundImageAndLargeContent({
|
|||||||
<motion.h2
|
<motion.h2
|
||||||
{...fadeUp(0)}
|
{...fadeUp(0)}
|
||||||
id="cta-heading"
|
id="cta-heading"
|
||||||
className="text-3xl font-bold tracking-tight text-brand-50 sm:text-4xl lg:text-5xl"
|
className="text-3xl font-bold tracking-tight text-brand-50 sm:text-4xl lg:text-4xl"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
<motion.p {...fadeUp(0.1)} className="mt-4 text-lg text-brand-50/95 sm:text-xl">
|
<motion.p {...fadeUp(0.1)} className="mt-4 text-lg text-brand-50/95 sm:text-xl lg:text-lg font-[family-name:var(--font-body)]">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
<motion.a
|
<motion.a
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ type Props = {
|
|||||||
footer: {
|
footer: {
|
||||||
tagline: string;
|
tagline: string;
|
||||||
facebookLabel: string;
|
facebookLabel: string;
|
||||||
|
servicesTitle: string;
|
||||||
|
serviceLinks: { label: string; href: string }[];
|
||||||
serviceAreasTitle: string;
|
serviceAreasTitle: string;
|
||||||
serviceAreas: string[];
|
serviceAreas: string[];
|
||||||
navTitle: string;
|
navTitle: string;
|
||||||
contactTitle: string;
|
contactTitle: string;
|
||||||
rbq: string;
|
rbq: string;
|
||||||
neq: string;
|
|
||||||
};
|
};
|
||||||
navLinks: NavLink[];
|
navLinks: NavLink[];
|
||||||
legalNotice: string;
|
legalNotice: string;
|
||||||
@@ -61,7 +62,7 @@ export default function FooterAnimated({
|
|||||||
whileInView="whileInView"
|
whileInView="whileInView"
|
||||||
viewport={{ amount: 0.2, once: false }}
|
viewport={{ amount: 0.2, once: false }}
|
||||||
>
|
>
|
||||||
<div className="container-site grid grid-cols-1 gap-7 pb-12 min-[541px]:grid-cols-2 min-[541px]:gap-8 min-[901px]:grid-cols-[1.5fr_2fr_1fr_1fr] min-[901px]:gap-10">
|
<div className="container-site grid grid-cols-1 gap-7 pb-12 min-[541px]:grid-cols-2 min-[541px]:gap-8 min-[901px]:grid-cols-[1.5fr_1fr_1fr_1fr] min-[901px]:gap-10">
|
||||||
<motion.div {...fadeUp(0)}>
|
<motion.div {...fadeUp(0)}>
|
||||||
<motion.a {...hoverPop} href="/" aria-label={siteName}>
|
<motion.a {...hoverPop} href="/" aria-label={siteName}>
|
||||||
<img
|
<img
|
||||||
@@ -163,15 +164,18 @@ export default function FooterAnimated({
|
|||||||
|
|
||||||
<motion.div {...fadeUp(0.08)}>
|
<motion.div {...fadeUp(0.08)}>
|
||||||
<h3 className="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
<h3 className="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
||||||
{footer.serviceAreasTitle}
|
{footer.servicesTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="m-0 list-none columns-2 gap-x-6 p-0 [&>li]:mb-2">
|
<ul className="m-0 flex list-none flex-col gap-2.5 p-0">
|
||||||
{footer.serviceAreas.map((area) => (
|
{footer.serviceLinks.map((item, idx) => (
|
||||||
<li
|
<li key={`${item.label}-${idx}`}>
|
||||||
key={area}
|
<motion.a
|
||||||
className="text-[0.875rem] text-white/70 break-inside-avoid"
|
{...hoverPop}
|
||||||
>
|
href="/#services"
|
||||||
{area}
|
className="inline-flex text-[0.9375rem] text-white/80 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</motion.a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -264,6 +268,24 @@ export default function FooterAnimated({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="container-site border-t border-white/15 pb-12 pt-10 min-[901px]:pt-12">
|
||||||
|
<motion.div {...fadeUp(0.12)}>
|
||||||
|
<h3 className="mb-[1.125rem] font-[family-name:var(--font-body)] text-[0.8125rem] font-bold uppercase tracking-[0.08em] text-white/50">
|
||||||
|
{footer.serviceAreasTitle}
|
||||||
|
</h3>
|
||||||
|
<ul className="m-0 list-none columns-2 gap-x-6 p-0 sm:columns-3 lg:columns-5 [&>li]:mb-2">
|
||||||
|
{footer.serviceAreas.map((area) => (
|
||||||
|
<li
|
||||||
|
key={area}
|
||||||
|
className="text-[0.875rem] text-white/70 break-inside-avoid"
|
||||||
|
>
|
||||||
|
{area}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<motion.div {...fadeUp(0.1)} className="border-t border-white/15 py-[1.125rem]">
|
<motion.div {...fadeUp(0.1)} className="border-t border-white/15 py-[1.125rem]">
|
||||||
<div className="container-site flex flex-wrap items-center justify-between gap-4 max-[540px]:flex-col max-[540px]:items-start max-[540px]:gap-2">
|
<div className="container-site flex flex-wrap items-center justify-between gap-4 max-[540px]:flex-col max-[540px]:items-start max-[540px]:gap-2">
|
||||||
<p className="text-[0.8125rem] text-white/50">{legalNotice}</p>
|
<p className="text-[0.8125rem] text-white/50">{legalNotice}</p>
|
||||||
@@ -279,10 +301,8 @@ export default function FooterAnimated({
|
|||||||
MBCube Consulting
|
MBCube Consulting
|
||||||
</motion.a>
|
</motion.a>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
<p className="flex items-center gap-2.5 text-xs tracking-[0.02em] text-white/40">
|
<p className="text-xs tracking-[0.02em] text-white/40">
|
||||||
<span>{footer.rbq}</span>
|
<span>{footer.rbq}</span>
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span>{footer.neq}</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const slides = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const autoplayPlugin = Autoplay({
|
const autoplayPlugin = Autoplay({
|
||||||
delay: 4000,
|
delay: general.carouselDelay,
|
||||||
stopOnInteraction: false,
|
stopOnInteraction: false,
|
||||||
stopOnMouseEnter: false,
|
stopOnMouseEnter: false,
|
||||||
});
|
});
|
||||||
@@ -49,7 +49,7 @@ export default function Hero({ content }: { content: HeroContent }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900">
|
<div className="bg-white dark:bg-gray-900">
|
||||||
<div className="relative isolate overflow-hidden h-screen min-h-[600px]">
|
<div className="relative isolate overflow-hidden h-screen min-h-[800px]">
|
||||||
<Carousel
|
<Carousel
|
||||||
className="absolute inset-0 -z-10 size-full [&_[data-slot='carousel-content']]:h-full"
|
className="absolute inset-0 -z-10 size-full [&_[data-slot='carousel-content']]:h-full"
|
||||||
opts={{ loop: true, dragFree: false }}
|
opts={{ loop: true, dragFree: false }}
|
||||||
@@ -63,66 +63,42 @@ export default function Hero({ content }: { content: HeroContent }) {
|
|||||||
alt={slide.alt}
|
alt={slide.alt}
|
||||||
className="size-full object-cover"
|
className="size-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/50" />
|
<div className="absolute inset-0 bg-gradient-to-b from-black/00 via-black/35 to-black/60" />
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
))}
|
))}
|
||||||
</CarouselContent>
|
</CarouselContent>
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
|
||||||
<div className="relative z-10 mx-auto max-w-7xl px-6 lg:px-8 h-full flex items-center justify-center pt-14">
|
<div className="relative z-10 mx-auto max-w-7xl px-6 lg:px-8 h-full flex items-center justify-center pt-14">
|
||||||
<div className="mx-auto max-w-4xl text-center">
|
<div className="mx-auto max-w-4xl text-center lg:mt-20">
|
||||||
<div className="relative rounded-lg px-6 py-2">
|
<div className="relative rounded-lg px-6 py-2">
|
||||||
<motion.div
|
<svg
|
||||||
className="absolute inset-0 rounded-lg bg-brand-50"
|
className="relative"
|
||||||
initial={{ opacity: 0 }}
|
viewBox="0 0 1390 500"
|
||||||
animate={{ opacity: 1 }}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
transition={{ delay: 0.85, duration: 2, ease: [0.22, 1, 0.36, 1] }}
|
>
|
||||||
/>
|
|
||||||
<svg className="relative" viewBox="0 0 1390 500" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<clipPath id="cachet-rise-clip">
|
|
||||||
<motion.rect
|
|
||||||
x="0"
|
|
||||||
initial={{ y: 334, height: 0 }}
|
|
||||||
animate={{ y: 0, height: 334 }}
|
|
||||||
transition={{
|
|
||||||
duration: 1,
|
|
||||||
ease: [0.22, 1, 0.36, 1],
|
|
||||||
delay: 0.85,
|
|
||||||
}}
|
|
||||||
width="1390"
|
|
||||||
/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="decorateurs-drop-clip">
|
|
||||||
<motion.rect
|
|
||||||
x="0"
|
|
||||||
initial={{ y: 334, height: 0 }}
|
|
||||||
animate={{ y: 334, height: 166 }}
|
|
||||||
transition={{
|
|
||||||
duration: 1,
|
|
||||||
ease: [0.22, 1, 0.36, 1],
|
|
||||||
delay: 0.85,
|
|
||||||
}}
|
|
||||||
width="1390"
|
|
||||||
/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<motion.text
|
<motion.text
|
||||||
x="3"
|
x="3"
|
||||||
y="312"
|
y="312"
|
||||||
font-family="Cocogoose Classic Trial"
|
fontFamily="Cocogoose"
|
||||||
font-size="350"
|
fontSize="350"
|
||||||
font-weight="600"
|
stroke="white"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
className="fill-brand-600"
|
className="fill-brand-600"
|
||||||
clipPath="url(#cachet-rise-clip)"
|
{...fadeUp(1.05)}
|
||||||
>
|
>
|
||||||
CACHET
|
CACHET
|
||||||
</motion.text>
|
</motion.text>
|
||||||
<motion.path
|
<motion.path
|
||||||
id="Rectangle"
|
id="Rectangle"
|
||||||
fill="#b0b0ac"
|
fill="#b0b0ac"
|
||||||
fill-rule="evenodd"
|
fillRule="evenodd"
|
||||||
stroke="none"
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
d="M 228 360 L 1161 360 L 1161 334 L 228 334 Z"
|
d="M 228 360 L 1161 360 L 1161 334 L 228 334 Z"
|
||||||
initial={{ scaleX: 0 }}
|
initial={{ scaleX: 0 }}
|
||||||
animate={{ scaleX: 1 }}
|
animate={{ scaleX: 1 }}
|
||||||
@@ -137,23 +113,27 @@ export default function Hero({ content }: { content: HeroContent }) {
|
|||||||
transformOrigin: "0% 50%",
|
transformOrigin: "0% 50%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<text
|
<motion.text
|
||||||
x="228"
|
x="228"
|
||||||
y="435"
|
y="435"
|
||||||
font-family="Geoform"
|
fontFamily="Geoform"
|
||||||
font-size="72"
|
fontSize="72"
|
||||||
font-weight="700"
|
fontWeight="700"
|
||||||
className="fill-graphite-600"
|
className="fill-graphite-600"
|
||||||
clipPath="url(#decorateurs-drop-clip)"
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...fadeUp(1.05)}
|
||||||
>
|
>
|
||||||
PEINTRES DECORATEURS
|
PEINTRES DECORATEURS
|
||||||
</text>
|
</motion.text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
{...fadeUp(1.15)}
|
{...fadeUp(1.15)}
|
||||||
className="p-4 mt-8 text-lg font-medium text-pretty text-white sm:text-xl/8 lg:text-2xl/8"
|
className="p-4 text-lg font-medium font-[family-name:var(--font-body)] text-pretty text-white sm:text-xl/8 lg:text-2xl/8"
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ const fadeUp = (delay: number) => ({
|
|||||||
<motion.h2
|
<motion.h2
|
||||||
{...fadeUp(0.15)}
|
{...fadeUp(0.15)}
|
||||||
id="sale-heading"
|
id="sale-heading"
|
||||||
className="text-4xl font-bold tracking-tight text-white sm:text-5xl lg:text-6xl"
|
className="text-4xl font-bold tracking-tight text-white sm:text-5xl lg:text-5xl"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
<motion.p
|
<motion.p
|
||||||
{...fadeUp(0.3)}
|
{...fadeUp(0.3)}
|
||||||
className="mx-auto mt-4 max-w-xl text-xl text-white"
|
className="mx-auto mt-4 max-w-xl text-xl text-white lg:text-xl font-[family-name:var(--font-body)]"
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const contact = defineCollection({
|
|||||||
}),
|
}),
|
||||||
breadcrumbHome: z.string(),
|
breadcrumbHome: z.string(),
|
||||||
rbq: z.string(),
|
rbq: z.string(),
|
||||||
neq: z.string(),
|
neq: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ const footer = defineCollection({
|
|||||||
serviceAreasTitle: z.string(),
|
serviceAreasTitle: z.string(),
|
||||||
legal: z.string(),
|
legal: z.string(),
|
||||||
rbq: z.string(),
|
rbq: z.string(),
|
||||||
neq: z.string(),
|
neq: z.string().optional(),
|
||||||
facebookLabel: z.string(),
|
facebookLabel: z.string(),
|
||||||
serviceLinks: z.array(
|
serviceLinks: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -209,7 +209,6 @@ const testimonials = defineCollection({
|
|||||||
z.object({
|
z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
src: z.string().url(),
|
src: z.string().url(),
|
||||||
height: z.number(),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"facebookCta": "Suivez-nous sur Facebook",
|
"facebookCta": "Suivez-nous sur Facebook",
|
||||||
"map": {
|
"map": {
|
||||||
"title": "Notre territoire de service",
|
"title": "Notre territoire de service",
|
||||||
"region": "Laval & Rive-Nord, Québec",
|
"region": "Rive Sud & Grand Montréal, Québec",
|
||||||
"mapsButton": "Voir sur Google Maps"
|
"mapsButton": "Voir sur Google Maps"
|
||||||
},
|
},
|
||||||
"ctaCard": {
|
"ctaCard": {
|
||||||
@@ -24,6 +24,5 @@
|
|||||||
"button": "Soumission gratuite"
|
"button": "Soumission gratuite"
|
||||||
},
|
},
|
||||||
"breadcrumbHome": "Accueil",
|
"breadcrumbHome": "Accueil",
|
||||||
"rbq": "RBQ : ",
|
"rbq": "RBQ : "
|
||||||
"neq": "NEQ : "
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,34 +4,89 @@
|
|||||||
"servicesTitle": "Nos services",
|
"servicesTitle": "Nos services",
|
||||||
"contactTitle": "Contact",
|
"contactTitle": "Contact",
|
||||||
"serviceAreasTitle": "Zone de service",
|
"serviceAreasTitle": "Zone de service",
|
||||||
"legal": "© {year} 9500-5609 Québec Inc. Tous droits réservés.",
|
"legal": "© {year} CACHETDECO. Tous droits réservés.",
|
||||||
"rbq": "RBQ : 5839 8736 01",
|
"rbq": "RBQ : 5839 8736 01",
|
||||||
"neq": "NEQ : 9500-5609",
|
|
||||||
"facebookLabel": "Facebook",
|
"facebookLabel": "Facebook",
|
||||||
"serviceLinks": [
|
"serviceLinks": [
|
||||||
{ "label": "Peinture intérieure", "href": "/services#peinture-interieure" },
|
{ "label": "Peinture intérieure", "href": "/#services" },
|
||||||
{ "label": "Peinture extérieure", "href": "/services#peinture-exterieure" },
|
{ "label": "Peinture extérieure", "href": "/#services" },
|
||||||
{ "label": "Peinture commerciale", "href": "/services#peinture-commerciale" },
|
{ "label": "Peinture commerciale", "href": "/#services" },
|
||||||
{ "label": "Décoration intérieure", "href": "/services#decoration-interieure" }
|
{ "label": "Solutions clef en main", "href": "/#services" },
|
||||||
|
{ "label": "Finitions de luxe", "href": "/#services" }
|
||||||
],
|
],
|
||||||
"serviceAreas": [
|
"serviceAreas": [
|
||||||
"Saint-Basile-le-Grand",
|
"Montréal (Ville-centre, avec 19 arrondissements)",
|
||||||
"Longueuil",
|
"Baie-D'Urfé",
|
||||||
"Brossard",
|
"Beaconsfield",
|
||||||
"Saint-Hubert",
|
"Côte-Saint-Luc",
|
||||||
"Saint-Lambert",
|
"Dollard-des-Ormeaux",
|
||||||
"Saint-Bruno-de-Montarville",
|
"Dorval",
|
||||||
"Beloeil",
|
"Hampstead",
|
||||||
"Mont-Saint-Hilaire",
|
"Kirkland",
|
||||||
"Sainte-Julie",
|
"L'Île-Dorval",
|
||||||
|
"Mont-Royal (Ville de Mont-Royal)",
|
||||||
|
"Montréal-Est",
|
||||||
|
"Montréal-Ouest",
|
||||||
|
"Pointe-Claire",
|
||||||
|
"Sainte-Anne-de-Bellevue",
|
||||||
|
"Senneville",
|
||||||
|
"Westmount",
|
||||||
|
"Belœil",
|
||||||
"Boucherville",
|
"Boucherville",
|
||||||
"La Prairie",
|
"Brossard",
|
||||||
"Carignan",
|
|
||||||
"Candiac",
|
"Candiac",
|
||||||
"McMasterville",
|
"Carignan",
|
||||||
"Otterburn Park",
|
|
||||||
"Chambly",
|
"Chambly",
|
||||||
"Partout sur la Rive-Sud",
|
"Châteauguay",
|
||||||
"Montréal"
|
"Delson",
|
||||||
|
"La Prairie",
|
||||||
|
"Longueuil",
|
||||||
|
"Greenfield Park",
|
||||||
|
"Saint-Hubert",
|
||||||
|
"Vieux-Longueuil",
|
||||||
|
"McMasterville",
|
||||||
|
"Mont-Saint-Hilaire",
|
||||||
|
"Otterburn Park",
|
||||||
|
"Saint-Basile-le-Grand",
|
||||||
|
"Saint-Bruno-de-Montarville",
|
||||||
|
"Sainte-Catherine",
|
||||||
|
"Saint-Constant",
|
||||||
|
"Saint-Isidore",
|
||||||
|
"Sainte-Julie",
|
||||||
|
"Saint-Lambert",
|
||||||
|
"Saint-Philippe",
|
||||||
|
"Saint-Jean-sur-Richelieu",
|
||||||
|
"Varennes",
|
||||||
|
"Saint-Mathias",
|
||||||
|
"Beauharnois",
|
||||||
|
"Salaberry-de-Valleyfield",
|
||||||
|
"Saint-Étienne-de-Beauharnois",
|
||||||
|
"Saint-Stanislas-de-Kostka",
|
||||||
|
"Saint-Urbain-Premier",
|
||||||
|
"Sainte-Martine",
|
||||||
|
"Saint-Louis-de-Gonzague",
|
||||||
|
"Coteau-du-Lac",
|
||||||
|
"Hudson",
|
||||||
|
"L'Île-Cadieux",
|
||||||
|
"L'Île-Perrot",
|
||||||
|
"Les Cèdres",
|
||||||
|
"Les Coteaux",
|
||||||
|
"Notre-Dame-de-l'Île-Perrot",
|
||||||
|
"Pincourt",
|
||||||
|
"Pointe-des-Cascades",
|
||||||
|
"Pointe-Fortune",
|
||||||
|
"Rigaud",
|
||||||
|
"Rivière-Beaudette",
|
||||||
|
"Saint-Clet",
|
||||||
|
"Saint-Lazare",
|
||||||
|
"Saint-Polycarpe",
|
||||||
|
"Saint-Télesphore",
|
||||||
|
"Saint-Zotique",
|
||||||
|
"Sainte-Justine-de-Newton",
|
||||||
|
"Sainte-Marthe",
|
||||||
|
"Terrasse-Vaudreuil",
|
||||||
|
"Très-Saint-Rédempteur",
|
||||||
|
"Vaudreuil-Dorion",
|
||||||
|
"Vaudreuil-sur-le-Lac"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "CACHET",
|
"title": "CACHET",
|
||||||
"title2": "PEINTRES DÉCORATEURS",
|
"title2": "PEINTRES DÉCORATEURS",
|
||||||
"subtitle": "Travaux de peinture résidentielle et commerciale, décoration intérieure et extérieure. Qualité professionnelle sur la Rive-Nord et Laval.",
|
"subtitle": "Travaux de peinture résidentielle et commerciale pour l'intérieur et l'extérieur. Qualité professionnelle dans le Grand Montréal et sur la Rive Sud.",
|
||||||
"cta": "Contactez-nous",
|
"cta": "Contactez-nous",
|
||||||
"ctaHref": "/contact"
|
"ctaHref": "/contact"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
{
|
{
|
||||||
|
"title": "Navigation",
|
||||||
"links": [
|
"links": [
|
||||||
{ "name": "Accueil", "href": "/" },
|
{
|
||||||
{ "name": "Services", "href": "/#services" },
|
"name": "Accueil",
|
||||||
{ "name": "Contact", "href": "/contact" },
|
"href": "/"
|
||||||
{ "name": "Soumission", "href": "/submission" }
|
},
|
||||||
|
{
|
||||||
|
"name": "Services",
|
||||||
|
"href": "/#services"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Contact",
|
||||||
|
"href": "/contact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Soumission",
|
||||||
|
"href": "/submission"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"cta": {
|
"cta": {
|
||||||
"label": "Soumission gratuite",
|
"label": "Soumission gratuite",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
{
|
{
|
||||||
"icon": "location",
|
"icon": "location",
|
||||||
"title": "Localisation",
|
"title": "Localisation",
|
||||||
"description": "Nous sommes situés à Laval et sur la Rive-Nord. Nous pouvons vous accompagner dans vos projets de peinture et décoration."
|
"description": "Nous sommes situés sur la Rive Sud et dans le Grand Montréal. Nous pouvons vous accompagner dans vos projets de peinture et décoration."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"icon": "star",
|
"icon": "star",
|
||||||
|
|||||||
7
src/content/services/fr/clef-en-main.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: Solutions clef en main
|
||||||
|
description: De la réparation de plâtre au ménage, nous proposons une variété de solutions pour faciliter le processus de rénovation. Nous proposons également des options pour sélectionner la couleur et l'agencement de vos espaces intérieurs afin de les adapter à vos besoins en décoration. Contactez-nous avec les détails de votre projet pour une soumission gratuite!
|
||||||
|
icon: renovation
|
||||||
|
order: 5
|
||||||
|
featured: false
|
||||||
|
---
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
8
src/content/services/fr/finition-de-luxe.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Finitions de luxe
|
||||||
|
description: Pour une personnalisation maximale de vos espaces, nous proposons des enduits à la chaux, des enduits de plâtre vénitien, ainsi que des fresques et des murales.
|
||||||
|
icon: decoration
|
||||||
|
order: 4
|
||||||
|
featured: false
|
||||||
|
---
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Peinture commerciale
|
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.
|
description: Solutions de peinture adaptées aux besoins des bureaux, des commerces et des institutions. Nos partenaires certifiés par la Commission de la Construction du Québec seront là pour vous guider dans un large éventail de projets.
|
||||||
icon: commercial
|
icon: commercial
|
||||||
order: 3
|
order: 3
|
||||||
featured: true
|
featured: true
|
||||||
|
|||||||
@@ -1,25 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Peinture extérieure
|
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.
|
description: Préservez et embellissez vos espaces extérieurs! Réfection d'espaces extérieurs tels que les terrasses, les balcons, les rambardes, les rampes et les garde-corps. Des travaux qui peuvent résister aux conditions climatiques du Québec pour une durabilité maximale.
|
||||||
icon: exterior
|
icon: exterior
|
||||||
order: 2
|
order: 2
|
||||||
featured: true
|
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)
|
|
||||||
|
|||||||
@@ -1,25 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Peinture intérieure
|
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.
|
description: Réaménagement complet de votre espace intérieur. Nous nous chargeons de préparer les surfaces, d'appliquer soigneusement et de finaliser de manière impeccable tous les types de pièces.
|
||||||
icon: interior
|
icon: interior
|
||||||
order: 1
|
order: 1
|
||||||
featured: true
|
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
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
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 locatives
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"brandName": "Cachet",
|
"brandName": "Cachet",
|
||||||
"siteName": "Cachet Peintres Décorateurs",
|
"siteName": "Cachet Peintres Décorateurs",
|
||||||
"legalName": "9500-5609 Québec Inc",
|
"legalName": "CACHETDECO",
|
||||||
"tagline": "Peintres Décorateurs professionnels",
|
"tagline": "Peintres Décorateurs professionnels",
|
||||||
"taglineHero": "Qualité et expertise en peinture résidentielle et commerciale",
|
"taglineHero": "Qualité et expertise en peinture résidentielle et commerciale",
|
||||||
"phone": "(514) 552-1049",
|
"phone": "(514) 552-1049",
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
"fromAddressSubmission": "Cachet Peintres Décorateurs <notifications@mbcubeconsulting.ca>",
|
"fromAddressSubmission": "Cachet Peintres Décorateurs <notifications@mbcubeconsulting.ca>",
|
||||||
"toAddressSubmission": "mouad@mbcubeconsulting.ca",
|
"toAddressSubmission": "mouad@mbcubeconsulting.ca",
|
||||||
"address": "Sainte Catherine, Québec",
|
"address": "Sainte Catherine, Québec",
|
||||||
"serviceArea": "Laval, Rive-Nord, Montréal",
|
"serviceArea": "Rive Sud et Grand Montréal",
|
||||||
"facebook": "https://www.facebook.com/cachetdecor/",
|
"facebook": "https://www.facebook.com/cachetdecor/",
|
||||||
"instagram": "https://www.instagram.com/cachet.deco/",
|
"instagram": "https://www.instagram.com/cachet.deco/",
|
||||||
"linkedin": "",
|
"linkedin": "",
|
||||||
"rbq": "5839 8736 01",
|
"rbq": "5839 8736 01",
|
||||||
"neq": "9500-5609"
|
"carouselDelay": 4000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"defaultTitle": "Cachet Peintres Décorateurs | Laval & Rive-Nord",
|
"defaultTitle": "Cachet Peintres Décorateurs | Rive Sud et Grand Montréal",
|
||||||
"titleTemplate": "%s | Cachet Peintres Décorateurs",
|
"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.",
|
"defaultDescription": "Peintres décorateurs professionnels sur la Rive Sud et dans le Grand Montréal. Peinture résidentielle et commerciale, intérieur et extérieur. 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",
|
"keywords": "peintre Rive Sud, peintre Grand Montréal, peinture résidentielle Rive Sud, peinture résidentielle Montréal, peinture commerciale Rive Sud, peinture commerciale Montréal, peintres décorateurs Rive Sud, peintres décorateurs Montréal, peinture extérieure Rive Sud, peinture extérieure Montréal, rénovation Rive Sud, rénovation Montréal, soumission peinture gratuite Rive Sud, soumission peinture gratuite Montréal",
|
||||||
"ogImage": "/images/logo-vignette.svg",
|
"ogImage": "/images/logo-full.webp",
|
||||||
"twitterCard": "summary_large_image"
|
"twitterCard": "summary_large_image"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"phoneLabel": "Téléphone",
|
"phoneLabel": "Téléphone",
|
||||||
"phonePlaceholder": "(450) 555-1234",
|
"phonePlaceholder": "(450) 555-1234",
|
||||||
"workAddressLabel": "Adresse des travaux",
|
"workAddressLabel": "Adresse des travaux",
|
||||||
"workAddressPlaceholder": "123 rue Principale, Laval, QC",
|
"workAddressPlaceholder": "123 rue Principale, Sainte-Catherine, QC",
|
||||||
"serviceTypeLabel": "Type de service",
|
"serviceTypeLabel": "Type de service",
|
||||||
"serviceTypePlaceholder": "Sélectionnez un service",
|
"serviceTypePlaceholder": "Sélectionnez un service",
|
||||||
"serviceTypeOptions": {
|
"serviceTypeOptions": {
|
||||||
|
|||||||
@@ -4,28 +4,23 @@
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"key": "sylvie-gauthier",
|
"key": "sylvie-gauthier",
|
||||||
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fsylvie.gauthier.73594%2Fposts%2Fpfbid0DHM1833TLKru4cbkTnZq3KLNHrSR8B8rQAgGosGzAbpGMccvLffoNmCLgEo8GUNPl&show_text=true&width=500",
|
"src": "https://www.facebook.com/sylvie.gauthier.73594/posts/10220386644130734"
|
||||||
"height": 208
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "jean-christophe-ali",
|
"key": "Dan Beasy",
|
||||||
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fjean.christophe.ali.2025%2Fposts%2Fpfbid02qsqq2Rn4vRx1gCG3q5e5M2ByPXH6zMux59fWvotfsQxpuWyUh46TZuwCND9iKpwnl&show_text=true&width=500",
|
"src": "https://www.facebook.com/dbinpg/posts/10151893154834955"
|
||||||
"height": 194
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "charlotte-lafrance",
|
"key": "charlotte-lafrance",
|
||||||
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fcharlotte.lafrance.CL%2Fposts%2Fpfbid026Jw7aer2r84q27kgcZ1PKg8q2sqKgZc6nJspP42CTEAU6ac8FF4Ptj8ap8XGh6Wl&show_text=true&width=500",
|
"src": "https://www.facebook.com/charlotte.lafrance.CL/posts/10166899061800442"
|
||||||
"height": 166
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "colin-griffin",
|
"key": "colin-griffin",
|
||||||
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fcolin.griffin.165%2Fposts%2Fpfbid0iCA6hoF5X32gUJSBcDUiXP2hxhxzMmEiC5Att3CDDwFckrHa7yxyGiLBmXQhQBUFl&show_text=true&width=500",
|
"src": "https://www.facebook.com/colin.griffin.165/posts/10166940799480355"
|
||||||
"height": 222
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "hollowman-la-rock",
|
"key": "hollowman-la-rock",
|
||||||
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fhollowman.la.rock%2Fposts%2Fpfbid0hPR3RpaSLZtZkgpaEzKsNKAF4hUeKbDzkicAZ9jsbCpXKRRsYD63C32rw78uvRoml&show_text=true&width=500",
|
"src": "https://www.facebook.com/hollowman.la.rock/posts/1365396367225994"
|
||||||
"height": 170
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const navigationItems = nav.links.map((link) => ({ name: link.name, href: link.h
|
|||||||
navigation={navigationItems}
|
navigation={navigationItems}
|
||||||
ctaLabel={nav.cta.label}
|
ctaLabel={nav.cta.label}
|
||||||
ctaHref={nav.cta.href}
|
ctaHref={nav.cta.href}
|
||||||
client:load
|
client:only="react"
|
||||||
/>
|
/>
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
20
src/pages/api/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env } from 'cloudflare:workers';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
const clientId = (env as any).GITHUB_CLIENT_ID;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const redirectUrl = new URL('https://github.com/login/oauth/authorize');
|
||||||
|
redirectUrl.searchParams.set('client_id', clientId);
|
||||||
|
redirectUrl.searchParams.set('redirect_uri', url.origin + '/api/callback');
|
||||||
|
redirectUrl.searchParams.set('scope', 'repo user');
|
||||||
|
redirectUrl.searchParams.set('state', crypto.getRandomValues(new Uint8Array(12)).join(''));
|
||||||
|
return Response.redirect(redirectUrl.href, 302);
|
||||||
|
} catch (error: any) {
|
||||||
|
return new Response(error.message, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
57
src/pages/api/callback.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env } from 'cloudflare:workers';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
function renderBody(status: string, content: unknown): string {
|
||||||
|
return `<script>
|
||||||
|
const receiveMessage = (message) => {
|
||||||
|
window.opener.postMessage(
|
||||||
|
'authorization:github:${status}:${JSON.stringify(content)}',
|
||||||
|
message.origin
|
||||||
|
);
|
||||||
|
window.removeEventListener("message", receiveMessage, false);
|
||||||
|
}
|
||||||
|
window.addEventListener("message", receiveMessage, false);
|
||||||
|
window.opener.postMessage("authorizing:github", "*");
|
||||||
|
</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
const clientId = (env as any).GITHUB_CLIENT_ID;
|
||||||
|
const clientSecret = (env as any).GITHUB_CLIENT_SECRET;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
|
||||||
|
const response = await fetch('https://github.com/login/oauth/access_token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'user-agent': 'cachetdeco-github-oauth',
|
||||||
|
'accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (await response.json()) as any;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return new Response(renderBody('error', result), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(renderBody('success', { token: result.access_token, provider: 'github' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return new Response(error.message, {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env } from 'cloudflare:workers';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import general from '@/content/settings/general.json';
|
import general from '@/content/settings/general.json';
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ function buildContactNotificationHtml(data: ContactBody): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin:24px 0 0;text-align:center;font-size:11px;color:#999">
|
<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
|
Cachet Peintres Décorateurs · CACHETDECO · RBQ 5839 8736 01
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -88,7 +89,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = import.meta.env.RESEND_API_KEY;
|
const apiKey = (env as unknown as Record<string, string>).RESEND_API_KEY;
|
||||||
const toAddress = general.emailToContact?.trim();
|
const toAddress = general.emailToContact?.trim();
|
||||||
const fromAddress = general.emailFromContact?.trim();
|
const fromAddress = general.emailFromContact?.trim();
|
||||||
if (!apiKey || !toAddress || !fromAddress) {
|
if (!apiKey || !toAddress || !fromAddress) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Buffer } from 'node:buffer';
|
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env } from 'cloudflare:workers';
|
||||||
import type { EmailAttachment } from '@/lib/email';
|
import type { EmailAttachment } from '@/lib/email';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import general from '@/content/settings/general.json';
|
import general from '@/content/settings/general.json';
|
||||||
@@ -137,7 +137,7 @@ function buildNotificationHtml(data: SubmissionPayload, attachmentNames: string[
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin:24px 0 0;text-align:center;font-size:11px;color:#999">
|
<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
|
Cachet Peintres Décorateurs · RBQ 5839 8736 01
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -206,13 +206,15 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const safeName = sanitizeFilename(file.name);
|
const safeName = sanitizeFilename(file.name);
|
||||||
attachmentNames.push(safeName);
|
attachmentNames.push(safeName);
|
||||||
const buf = await file.arrayBuffer();
|
const buf = await file.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
const binary = bytes.reduce((acc, b) => acc + String.fromCharCode(b), '');
|
||||||
attachments.push({
|
attachments.push({
|
||||||
filename: safeName,
|
filename: safeName,
|
||||||
content: Buffer.from(buf).toString('base64'),
|
content: btoa(binary),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = import.meta.env.RESEND_API_KEY;
|
const apiKey = (env as unknown as Record<string, string>).RESEND_API_KEY;
|
||||||
const fromAddress = general.fromAddressSubmission?.trim();
|
const fromAddress = general.fromAddressSubmission?.trim();
|
||||||
const toAddress = general.toAddressSubmission?.trim();
|
const toAddress = general.toAddressSubmission?.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const contact = contactEntry.data;
|
|||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
title={contact.title}
|
title={contact.title}
|
||||||
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons Laval, la Rive-Nord et les environs.`}
|
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons la Rive Sud, le Grand Montréal et les environs.`}
|
||||||
canonical="/contact"
|
canonical="/contact"
|
||||||
>
|
>
|
||||||
<ShortHero
|
<ShortHero
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const heroContent = {
|
|||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
title={seo.defaultTitle}
|
title={seo.defaultTitle}
|
||||||
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.`}
|
description={`${general.siteName} — Peintres décorateurs professionnels sur la Rive Sud et dans le Grand Montréal. Peinture résidentielle et commerciale, intérieur et extérieur. Soumission gratuite.`}
|
||||||
canonical="/"
|
canonical="/"
|
||||||
>
|
>
|
||||||
<Hero client:load content={heroContent} />
|
<Hero client:load content={heroContent} />
|
||||||
@@ -94,7 +94,7 @@ const heroContent = {
|
|||||||
subtitle={nav.servicesSection.subtitle}
|
subtitle={nav.servicesSection.subtitle}
|
||||||
/>
|
/>
|
||||||
<Testimonials
|
<Testimonials
|
||||||
client:load
|
client:only="react"
|
||||||
sectionLabel={testimonialsData.sectionLabel}
|
sectionLabel={testimonialsData.sectionLabel}
|
||||||
title={testimonialsData.title}
|
title={testimonialsData.title}
|
||||||
items={testimonialsData.items}
|
items={testimonialsData.items}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const submissionPage = submissionEntry.data;
|
|||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
title={submissionPage.title}
|
title={submissionPage.title}
|
||||||
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons Laval, la Rive-Nord et les environs.`}
|
description={`Contactez Cachet Peintres Décorateurs — ${general.phone} — ${general.email}. Nous servons la Rive Sud, le Grand Montréal et les environs.`}
|
||||||
canonical="/submission"
|
canonical="/submission"
|
||||||
>
|
>
|
||||||
<ShortHero
|
<ShortHero
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@fontsource-variable/geist";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -187,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: 'Geist Variable', sans-serif;
|
--font-sans: 'Cocogoose', sans-serif;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
},
|
},
|
||||||
"jsx": "react-jsx",
|
"types": ["@cloudflare/workers-types"],
|
||||||
"jsxImportSource": "react"
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||