Compare commits

...

15 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
16 changed files with 1131 additions and 91 deletions

1
.gitignore vendored
View File

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

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: 'server',
adapter: node({ mode: 'standalone' }),
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`).

893
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",
@@ -35,6 +35,7 @@
"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"

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,18 +16,49 @@ 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
href="/"
className="flex items-center justify-center bg-[white] rounded-full shadow-md p-2 lg:translate-y-1.5"
className="flex items-center justify-center bg-[white] rounded-full shadow-md p-2"
>
<div className="rounded-full ">
<span className="sr-only">{general.siteName}</span>
@@ -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>

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

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

@@ -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';
@@ -88,7 +89,7 @@ export const POST: APIRoute = async ({ request }) => {
});
}
const apiKey = process.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';
@@ -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 = process.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

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