Compare commits

...

27 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
cd3cb704be switch up animation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 27s
2026-03-26 16:43:22 -04:00
b0f2bf142b faster hero 2026-03-26 16:27:05 -04:00
57 changed files with 1524 additions and 337 deletions

View File

@@ -21,4 +21,6 @@ jobs:
docker build -t cachetdeco:latest .
docker stop 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
dist/
.astro/
.wrangler/
# Dependencies
node_modules/

View File

@@ -1,3 +1,13 @@
FROM nginx:alpine
COPY dist/ /usr/share/nginx/html/
EXPOSE 80
FROM node:24-alpine AS runtime
WORKDIR /app
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
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**.
## 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 :
- **Raison sociale :** 9500-5609 Québec Inc
- **Numéro RBQ :** 5839 8736 01 (obligatoire pour tout service nécessitant une modification d'un bâtiment)
Ces informations sont affichées dans le footer de chaque page.

View File

@@ -2,11 +2,11 @@ import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import sitemap from '@astrojs/sitemap';
import react from '@astrojs/react';
import node from '@astrojs/node';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'static',
adapter: node({ mode: 'middleware' }),
output: 'server',
adapter: cloudflare(),
site: 'https://cachetdeco.com',
i18n: {
defaultLocale: 'fr',
@@ -26,5 +26,8 @@ export default defineConfig({
],
vite: {
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"
},
"dependencies": {
"@astrojs/node": "^10.0.4",
"@astrojs/cloudflare": "^13.1.7",
"@astrojs/react": "^5.0.2",
"@astrojs/sitemap": "^3.7.2",
"@fontsource-variable/geist": "^5.2.8",
@@ -28,12 +28,14 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.0",
"react-social-media-embed": "^2.5.18",
"shadcn": "^4.0.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260403.1",
"@types/react": "^19.2.14"
}
}

View File

@@ -1,10 +1,9 @@
backend:
name: github
repo: mbcube/cachetdeco # REPLACE with your GitHub org/repo
branch: main
# For Cloudflare Pages (no Netlify Identity), use PKCE flow:
auth_type: pkce
app_id: YOUR_GITHUB_OAUTH_APP_CLIENT_ID # REPLACE with your GitHub OAuth App client ID
repo: mbcube/cachetdeco
branch: cloudflare
base_url: https://cachetdeco.br-mouad.workers.dev
auth_endpoint: /api/auth
locale: fr
media_folder: "public/images"
@@ -347,7 +346,6 @@ collections:
fields:
- { name: "key", label: "Identifiant unique", widget: "string" }
- { name: "src", label: "URL du post Facebook (embed)", widget: "string" }
- { name: "height", label: "Hauteur de l'iframe (px)", widget: "number" }
- name: "settings"
label: "Paramètres du site"

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
client:visible
client:load
siteName={general.siteName}
serviceArea={general.serviceArea}
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 { Dialog, DialogPanel } from "@headlessui/react";
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
@@ -16,13 +16,44 @@ export default function Header({
ctaHref: string;
}) {
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, "")}`;
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 (
<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
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">
<a
@@ -56,7 +87,7 @@ export default function Header({
transition={{ type: "spring", stiffness: 400, damping: 25 }}
key={item.name}
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}
</motion.a>
@@ -70,21 +101,21 @@ export default function Header({
{ctaLabel}
</a> */}
<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 }}
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>
<PhoneIcon
aria-hidden="true"
className="h-8 w-8 text-brand-600 "
className="h-8 w-8 text-brand-600"
/>
</dt>
<dd>
<dd className="m-0 flex min-w-0 items-center justify-center">
<a
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}
</a>

View File

@@ -92,45 +92,45 @@ export default function Contact({
return (
<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">
<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">
<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">
<div className="flex gap-x-4">
<dt className="flex-none">

View File

@@ -44,11 +44,11 @@ export default function WithAlternatingSections({
<div className="mx-auto max-w-3xl text-center">
<motion.h2
{...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}
</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}
</motion.p>
</div>
@@ -67,11 +67,11 @@ export default function WithAlternatingSections({
>
<motion.h3
{...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}
</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}
</motion.p>
</div>

View File

@@ -1,11 +1,12 @@
import { motion } from "motion/react";
import { FacebookEmbed } from 'react-social-media-embed';
interface TestimonialItem {
key: string;
src: string;
height: number;
}
import { motion } from "motion/react";
interface Props {
sectionLabel: string;
title: string;
@@ -19,7 +20,6 @@ const iframeProps = {
allowFullScreen: true,
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share',
style: { border: 'none', overflow: 'hidden' } as React.CSSProperties,
className: 'w-full rounded-2xl',
}
const fadeUp = (delay: number) => ({
@@ -84,7 +84,7 @@ export default function Grid({ sectionLabel, title, items }: Props) {
</motion.h2>
<motion.p
{...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}
</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">
<motion.figure
{...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>
{groups.map((group, groupIdx) => (
<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
{...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>
</div>
)

View File

@@ -75,7 +75,7 @@ export default function WithTieredImages({
</motion.h2>
<motion.p
{...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}
</motion.p>
@@ -121,8 +121,8 @@ export default function WithTieredImages({
</svg>
</div>
<div>
<dt className="text-base font-medium text-white">{card.title}</dt>
<dd className="mt-3 text-base text-white/90">{card.description}</dd>
<dt className="text-base font-medium text-white lg:text-[0.9375rem]">{card.title}</dt>
<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>
</motion.div>
))}

View File

@@ -33,7 +33,7 @@ const canonicalUrl = canonical
const ogImageUrl = ogImage
? ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage}`
: `${siteUrl}/images/logo-vignette.svg`;
: `${siteUrl}/images/logo-full.webp`;
// JSON-LD LocalBusiness structured data
const structuredData = {
@@ -47,7 +47,7 @@ const structuredData = {
email: general.email,
address: {
'@type': 'PostalAddress',
addressLocality: 'Laval',
addressLocality: 'Sainte-Catherine',
addressRegion: 'QC',
addressCountry: 'CA',
},
@@ -92,7 +92,7 @@ const structuredData = {
<!-- Favicon -->
<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 -->
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />

View File

@@ -53,11 +53,11 @@ export default function FullWidthWithBackgroundImageAndLargeContent({
<motion.h2
{...fadeUp(0)}
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}
</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}
</motion.p>
<motion.a

View File

@@ -14,12 +14,13 @@ type Props = {
footer: {
tagline: string;
facebookLabel: string;
servicesTitle: string;
serviceLinks: { label: string; href: string }[];
serviceAreasTitle: string;
serviceAreas: string[];
navTitle: string;
contactTitle: string;
rbq: string;
neq: string;
};
navLinks: NavLink[];
legalNotice: string;
@@ -61,7 +62,7 @@ export default function FooterAnimated({
whileInView="whileInView"
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.a {...hoverPop} href="/" aria-label={siteName}>
<img
@@ -163,15 +164,18 @@ export default function FooterAnimated({
<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">
{footer.serviceAreasTitle}
{footer.servicesTitle}
</h3>
<ul className="m-0 list-none columns-2 gap-x-6 p-0 [&>li]:mb-2">
{footer.serviceAreas.map((area) => (
<li
key={area}
className="text-[0.875rem] text-white/70 break-inside-avoid"
>
{area}
<ul className="m-0 flex list-none flex-col gap-2.5 p-0">
{footer.serviceLinks.map((item, idx) => (
<li key={`${item.label}-${idx}`}>
<motion.a
{...hoverPop}
href="/#services"
className="inline-flex text-[0.9375rem] text-white/80 transition-colors hover:text-white"
>
{item.label}
</motion.a>
</li>
))}
</ul>
@@ -264,6 +268,24 @@ export default function FooterAnimated({
</motion.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]">
<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>
@@ -279,10 +301,8 @@ export default function FooterAnimated({
MBCube Consulting
</motion.a>
</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 aria-hidden="true">·</span>
<span>{footer.neq}</span>
</p>
</div>
</motion.div>

View File

@@ -29,7 +29,7 @@ const slides = [
];
const autoplayPlugin = Autoplay({
delay: 4000,
delay: general.carouselDelay,
stopOnInteraction: false,
stopOnMouseEnter: false,
});
@@ -49,7 +49,7 @@ export default function Hero({ content }: { content: HeroContent }) {
return (
<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
className="absolute inset-0 -z-10 size-full [&_[data-slot='carousel-content']]:h-full"
opts={{ loop: true, dragFree: false }}
@@ -63,66 +63,42 @@ export default function Hero({ content }: { content: HeroContent }) {
alt={slide.alt}
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>
))}
</CarouselContent>
</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="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">
<motion.div
className="absolute inset-0 rounded-lg bg-brand-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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>
<svg
className="relative"
viewBox="0 0 1390 500"
xmlns="http://www.w3.org/2000/svg"
>
<motion.text
x="3"
y="312"
font-family="Cocogoose Classic Trial"
font-size="350"
font-weight="600"
fontFamily="Cocogoose"
fontSize="350"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="fill-brand-600"
clipPath="url(#cachet-rise-clip)"
{...fadeUp(1.05)}
>
CACHET
</motion.text>
<motion.path
id="Rectangle"
fill="#b0b0ac"
fill-rule="evenodd"
stroke="none"
fillRule="evenodd"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
d="M 228 360 L 1161 360 L 1161 334 L 228 334 Z"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
@@ -137,23 +113,27 @@ export default function Hero({ content }: { content: HeroContent }) {
transformOrigin: "0% 50%",
}}
/>
<text
<motion.text
x="228"
y="435"
font-family="Geoform"
font-size="72"
font-weight="700"
fontFamily="Geoform"
fontSize="72"
fontWeight="700"
className="fill-graphite-600"
clipPath="url(#decorateurs-drop-clip)"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...fadeUp(1.05)}
>
PEINTRES DECORATEURS
</text>
</motion.text>
</svg>
</div>
<motion.p
{...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}
</motion.p>

View File

@@ -44,13 +44,13 @@ const fadeUp = (delay: number) => ({
<motion.h2
{...fadeUp(0.15)}
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}
</motion.h2>
<motion.p
{...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}
</motion.p>

View File

@@ -88,7 +88,7 @@ const contact = defineCollection({
}),
breadcrumbHome: z.string(),
rbq: z.string(),
neq: z.string(),
neq: z.string().optional(),
}),
});
@@ -164,7 +164,7 @@ const footer = defineCollection({
serviceAreasTitle: z.string(),
legal: z.string(),
rbq: z.string(),
neq: z.string(),
neq: z.string().optional(),
facebookLabel: z.string(),
serviceLinks: z.array(
z.object({
@@ -209,7 +209,6 @@ const testimonials = defineCollection({
z.object({
key: z.string(),
src: z.string().url(),
height: z.number(),
}),
),
}),

View File

@@ -15,7 +15,7 @@
"facebookCta": "Suivez-nous sur Facebook",
"map": {
"title": "Notre territoire de service",
"region": "Laval & Rive-Nord, Québec",
"region": "Rive Sud & Grand Montréal, Québec",
"mapsButton": "Voir sur Google Maps"
},
"ctaCard": {
@@ -24,6 +24,5 @@
"button": "Soumission gratuite"
},
"breadcrumbHome": "Accueil",
"rbq": "RBQ : ",
"neq": "NEQ : "
"rbq": "RBQ : "
}

View File

@@ -4,34 +4,89 @@
"servicesTitle": "Nos services",
"contactTitle": "Contact",
"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",
"neq": "NEQ : 9500-5609",
"facebookLabel": "Facebook",
"serviceLinks": [
{ "label": "Peinture intérieure", "href": "/services#peinture-interieure" },
{ "label": "Peinture extérieure", "href": "/services#peinture-exterieure" },
{ "label": "Peinture commerciale", "href": "/services#peinture-commerciale" },
{ "label": "Décoration intérieure", "href": "/services#decoration-interieure" }
{ "label": "Peinture intérieure", "href": "/#services" },
{ "label": "Peinture extérieure", "href": "/#services" },
{ "label": "Peinture commerciale", "href": "/#services" },
{ "label": "Solutions clef en main", "href": "/#services" },
{ "label": "Finitions de luxe", "href": "/#services" }
],
"serviceAreas": [
"Saint-Basile-le-Grand",
"Longueuil",
"Brossard",
"Saint-Hubert",
"Saint-Lambert",
"Saint-Bruno-de-Montarville",
"Beloeil",
"Mont-Saint-Hilaire",
"Sainte-Julie",
"Montréal (Ville-centre, avec 19 arrondissements)",
"Baie-D'Urfé",
"Beaconsfield",
"Côte-Saint-Luc",
"Dollard-des-Ormeaux",
"Dorval",
"Hampstead",
"Kirkland",
"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",
"La Prairie",
"Carignan",
"Brossard",
"Candiac",
"McMasterville",
"Otterburn Park",
"Carignan",
"Chambly",
"Partout sur la Rive-Sud",
"Montréal"
"Châteauguay",
"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",
"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",
"ctaHref": "/contact"
}

View File

@@ -1,9 +1,22 @@
{
{
"title": "Navigation",
"links": [
{ "name": "Accueil", "href": "/" },
{ "name": "Services", "href": "/#services" },
{ "name": "Contact", "href": "/contact" },
{ "name": "Soumission", "href": "/submission" }
{
"name": "Accueil",
"href": "/"
},
{
"name": "Services",
"href": "/#services"
},
{
"name": "Contact",
"href": "/contact"
},
{
"name": "Soumission",
"href": "/submission"
}
],
"cta": {
"label": "Soumission gratuite",

View File

@@ -25,7 +25,7 @@
{
"icon": "location",
"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",

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
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
order: 3
featured: true

View File

@@ -1,25 +1,7 @@
---
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
order: 2
featured: true
---
La peinture extérieure protège votre investissement tout en rehaussant l'apparence de votre propriété. Nous utilisons des produits spécialement formulés pour résister aux hivers québécois.
## Ce qui est inclus
- Nettoyage haute pression des surfaces
- Réparation des fissures et dommages
- Application d'apprêt spécialisé
- 2 couches de peinture extérieure haute performance
- Protection et calfeutrage aux joints
## Nos spécialités
- Revêtements en bois, aluminium et vinyle
- Briques et stucco
- Balcons, vérandas et galeries
- Clôtures et cabanons
- Portes et fenêtres (cadres et contours)

View File

@@ -1,25 +1,7 @@
---
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
order: 1
featured: true
---
Nos peintres décorateurs réalisent tous vos projets de peinture intérieure avec professionnalisme et souci du détail. De la préparation minutieuse des surfaces à la finition parfaite, chaque étape est effectuée avec rigueur.
## Ce qui est inclus
- Évaluation et préparation des surfaces (rebouchage, sablage, apprêt)
- Application de 2 couches de peinture de qualité professionnelle
- Protection des planchers, meubles et garnitures
- Nettoyage complet après les travaux
- Garantie sur la main-d'œuvre
## Nos spécialités
- Salons et salles à manger
- Chambres à coucher
- Cuisines et salles de bain
- Sous-sols et espaces récréatifs
- Couloirs et entrées

View File

@@ -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",
"siteName": "Cachet Peintres Décorateurs",
"legalName": "9500-5609 Québec Inc",
"legalName": "CACHETDECO",
"tagline": "Peintres Décorateurs professionnels",
"taglineHero": "Qualité et expertise en peinture résidentielle et commerciale",
"phone": "(514) 552-1049",
@@ -11,10 +11,10 @@
"fromAddressSubmission": "Cachet Peintres Décorateurs <notifications@mbcubeconsulting.ca>",
"toAddressSubmission": "mouad@mbcubeconsulting.ca",
"address": "Sainte Catherine, Québec",
"serviceArea": "Laval, Rive-Nord, Montréal",
"serviceArea": "Rive Sud et Grand Montréal",
"facebook": "https://www.facebook.com/cachetdecor/",
"instagram": "https://www.instagram.com/cachet.deco/",
"linkedin": "",
"rbq": "5839 8736 01",
"neq": "9500-5609"
"carouselDelay": 4000
}

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",
"defaultDescription": "Peintres décorateurs professionnels à Laval et sur la Rive-Nord. Peinture résidentielle et commerciale, décoration intérieure et extérieure. RBQ 5839 8736 01. Soumission gratuite.",
"keywords": "peintre Laval, peinture résidentielle Laval, peinture commerciale Rive-Nord, décoration intérieure Laval, peintres décorateurs Québec, peinture extérieure Laval, rénovation Rive-Nord, soumission peinture gratuite",
"ogImage": "/images/logo-vignette.svg",
"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 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-full.webp",
"twitterCard": "summary_large_image"
}

View File

@@ -11,7 +11,7 @@
"phoneLabel": "Téléphone",
"phonePlaceholder": "(450) 555-1234",
"workAddressLabel": "Adresse des travaux",
"workAddressPlaceholder": "123 rue Principale, Laval, QC",
"workAddressPlaceholder": "123 rue Principale, Sainte-Catherine, QC",
"serviceTypeLabel": "Type de service",
"serviceTypePlaceholder": "Sélectionnez un service",
"serviceTypeOptions": {

View File

@@ -4,28 +4,23 @@
"items": [
{
"key": "sylvie-gauthier",
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fsylvie.gauthier.73594%2Fposts%2Fpfbid0DHM1833TLKru4cbkTnZq3KLNHrSR8B8rQAgGosGzAbpGMccvLffoNmCLgEo8GUNPl&show_text=true&width=500",
"height": 208
"src": "https://www.facebook.com/sylvie.gauthier.73594/posts/10220386644130734"
},
{
"key": "jean-christophe-ali",
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fjean.christophe.ali.2025%2Fposts%2Fpfbid02qsqq2Rn4vRx1gCG3q5e5M2ByPXH6zMux59fWvotfsQxpuWyUh46TZuwCND9iKpwnl&show_text=true&width=500",
"height": 194
"key": "Dan Beasy",
"src": "https://www.facebook.com/dbinpg/posts/10151893154834955"
},
{
"key": "charlotte-lafrance",
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fcharlotte.lafrance.CL%2Fposts%2Fpfbid026Jw7aer2r84q27kgcZ1PKg8q2sqKgZc6nJspP42CTEAU6ac8FF4Ptj8ap8XGh6Wl&show_text=true&width=500",
"height": 166
"src": "https://www.facebook.com/charlotte.lafrance.CL/posts/10166899061800442"
},
{
"key": "colin-griffin",
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fcolin.griffin.165%2Fposts%2Fpfbid0iCA6hoF5X32gUJSBcDUiXP2hxhxzMmEiC5Att3CDDwFckrHa7yxyGiLBmXQhQBUFl&show_text=true&width=500",
"height": 222
"src": "https://www.facebook.com/colin.griffin.165/posts/10166940799480355"
},
{
"key": "hollowman-la-rock",
"src": "https://www.facebook.com/plugins/post.php?href=https%3A%2F%2Fwww.facebook.com%2Fhollowman.la.rock%2Fposts%2Fpfbid0hPR3RpaSLZtZkgpaEzKsNKAF4hUeKbDzkicAZ9jsbCpXKRRsYD63C32rw78uvRoml&show_text=true&width=500",
"height": 170
"src": "https://www.facebook.com/hollowman.la.rock/posts/1365396367225994"
}
]
}

View File

@@ -36,7 +36,7 @@ const navigationItems = nav.links.map((link) => ({ name: link.name, href: link.h
navigation={navigationItems}
ctaLabel={nav.cta.label}
ctaHref={nav.cta.href}
client:load
client:only="react"
/>
<main id="main-content">
<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 { env } from 'cloudflare:workers';
import { sendEmail } from '@/lib/email';
import general from '@/content/settings/general.json';
@@ -63,7 +64,7 @@ function buildContactNotificationHtml(data: ContactBody): string {
</div>
</div>
<p style="margin:24px 0 0;text-align:center;font-size:11px;color:#999">
Cachet Peintres Décorateurs · 9500-5609 Québec Inc · RBQ 5839 8736 01
Cachet Peintres Décorateurs · CACHETDECO · RBQ 5839 8736 01
</p>
</div>
</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 fromAddress = general.emailFromContact?.trim();
if (!apiKey || !toAddress || !fromAddress) {

View File

@@ -1,5 +1,5 @@
import { Buffer } from 'node:buffer';
import type { APIRoute } from 'astro';
import { env } from 'cloudflare:workers';
import type { EmailAttachment } from '@/lib/email';
import { sendEmail } from '@/lib/email';
import general from '@/content/settings/general.json';
@@ -137,7 +137,7 @@ function buildNotificationHtml(data: SubmissionPayload, attachmentNames: string[
</div>
</div>
<p style="margin:24px 0 0;text-align:center;font-size:11px;color:#999">
Cachet Peintres Décorateurs · 9500-5609 Québec Inc · RBQ 5839 8736 01
Cachet Peintres Décorateurs · RBQ 5839 8736 01
</p>
</div>
</body>
@@ -206,13 +206,15 @@ export const POST: APIRoute = async ({ request }) => {
const safeName = sanitizeFilename(file.name);
attachmentNames.push(safeName);
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
const binary = bytes.reduce((acc, b) => acc + String.fromCharCode(b), '');
attachments.push({
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 toAddress = general.toAddressSubmission?.trim();

View File

@@ -16,7 +16,7 @@ const contact = contactEntry.data;
<Layout
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"
>
<ShortHero

View File

@@ -82,7 +82,7 @@ const heroContent = {
<Layout
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="/"
>
<Hero client:load content={heroContent} />
@@ -94,7 +94,7 @@ const heroContent = {
subtitle={nav.servicesSection.subtitle}
/>
<Testimonials
client:load
client:only="react"
sectionLabel={testimonialsData.sectionLabel}
title={testimonialsData.title}
items={testimonialsData.items}

View File

@@ -16,7 +16,7 @@ const submissionPage = submissionEntry.data;
<Layout
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"
>
<ShortHero

View File

@@ -1,7 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@@ -187,7 +186,7 @@
}
@theme inline {
--font-sans: 'Geist Variable', sans-serif;
--font-sans: 'Cocogoose', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);

View File

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