Compare commits

...

25 Commits

Author SHA1 Message Date
b486c4f3fe cleanup
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 18s
2026-04-03 16:10:25 -04:00
mbcube
bc02da9d48 Update Navigation “main” 2026-04-03 16:06:41 -04:00
7e474a85a2 add gh auth config for decap 2026-04-03 15:49:23 -04:00
8f8fcb83c9 update decap 2026-04-03 15:20:39 -04:00
936000b32a add gh app 2026-04-03 15:17:00 -04:00
5315f1b188 fix decap config 2026-04-03 14:53:55 -04:00
d6d54df384 reset 2026-04-03 14:29:01 -04:00
f1c880690f top level env var 2026-04-03 14:26:58 -04:00
9fee97dea6 update cf content branch 2026-04-03 14:16:56 -04:00
789dc380c1 add log 2026-04-03 14:14:40 -04:00
fe94d4c543 fix apis 2026-04-03 14:09:06 -04:00
f8f59a5134 reomve wrangler 2026-04-03 13:55:17 -04:00
3d1336c3b2 fix some bugs 2026-04-03 13:52:13 -04:00
05eb117e4b audit 2026-04-03 13:30:12 -04:00
84728c9532 cloudflare deployment 2026-04-03 11:19:29 -04:00
5dc33897ce fix stroke
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-03-30 14:09:37 -04:00
067a151be1 1.01 audrey's edits
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-03-28 15:37:45 -04:00
539e537572 v-1.001
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
2026-03-27 12:56:15 -04:00
60c295d731 fix stupid animation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-03-26 23:43:47 -04:00
0e99cb4cd7 fix env keys
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-03-26 18:53:19 -04:00
212baf8efc add env
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-03-26 18:45:47 -04:00
148d664b44 fix logo for metatags
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-03-26 18:41:12 -04:00
d1ad6e0f09 fix build
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-03-26 18:20:25 -04:00
72f652eabe fix pipeline
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 33s
2026-03-26 18:01:12 -04:00
b23809eb47 fix build
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-03-26 17:58:04 -04:00
57 changed files with 1523 additions and 343 deletions

View File

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

@@ -1,6 +1,7 @@
# Build output # Build output
dist/ dist/
.astro/ .astro/
.wrangler/
# Dependencies # Dependencies
node_modules/ node_modules/

View File

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

View File

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

View File

@@ -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'],
},
}, },
}); });

View 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`).

0
guidline Normal file
View File

1050
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 134 KiB

View File

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

View File

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

View File

@@ -92,9 +92,6 @@ 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="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">
<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"> <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 <svg
aria-hidden="true" aria-hidden="true"
@@ -131,6 +128,9 @@ export default function Contact({
/> />
</div> </div>
</div> </div>
<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">
<div className="mx-auto max-w-xl lg:mx-0 lg:max-w-lg">
<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">

View File

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

View File

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

View File

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

View File

@@ -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)} />

View File

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

View File

@@ -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"
className="inline-flex text-[0.9375rem] text-white/80 transition-colors hover:text-white"
> >
{area} {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>

View File

@@ -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,72 +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, scaleY: 0 }} viewBox="0 0 1390 500"
animate={{ opacity: 1, scaleY: 1 }} xmlns="http://www.w3.org/2000/svg"
style={{ originY: "50%" }} >
transition={{ delay: 0.85, duration: 1, 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={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
style={{ originY: "100%" }}
transition={{
duration: 1,
ease: [0.22, 1, 0.36, 1],
delay: 0.85,
}}
height="334"
width="1390"
/>
</clipPath>
<clipPath id="decorateurs-drop-clip">
<motion.rect
x="0"
y="334"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
style={{ originY: "0%" }}
transition={{
duration: 1,
ease: [0.22, 1, 0.36, 1],
delay: .95,
}}
height="166"
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 }}
@@ -143,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>

View File

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

View File

@@ -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(),
}), }),
), ),
}), }),

View File

@@ -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 : "
} }

View File

@@ -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"
] ]
} }

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,11 +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
"carouselDelay": 2500
} }

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
}, },
"types": ["@cloudflare/workers-types"],
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "react" "jsxImportSource": "react"
} }