chore: restore lead integration docs and ops tooling

This commit is contained in:
Electron Rare Bot
2026-03-19 14:37:45 +00:00
parent afc8d51fb5
commit fd9026519c
9 changed files with 805 additions and 42 deletions
+44 -40
View File
@@ -1,72 +1,76 @@
# Docs README - Source de vérité
Updated: 2026-03-14
Updated: 2026-03-19
Status: active
Repo: `/home/zacus/electron-rare-preview-ovh`
Repo: /root/electron-rare-site
## Etat réel du projet
- Site Astro publie sur OVH via GitHub Actions FTP
- Preview publique: `https://www.lelectronrare.fr/preview/`
- Production publique: `https://www.lelectronrare.fr/`
- Surface publique active: `/`, `/formation/`, `/mentions-legales/`, `robots.txt`, `sitemap.xml`
- Le lab n'est plus une surface publique produit
- Site Astro public en production sur Photon (Docker SSR + Traefik).
- URL publique active: https://www.lelectronrare.fr/ (route www.lelectronrare.fr et lelectronrare.fr).
- Exposition opérationnelle vérifiée: /, /formation/, /mentions-legales/, /404, robots.txt, sitemap.xml.
- Point de soumission lead: POST `/api/submit-lead` (route SSR -> Frappe CRM Lead), avec fallback automatique vers le CRM custom tant que nécessaire.
- Déploiement local datelier public retiré de la surface visible.
- Positionnement actif:
- systemes electroniques specifiques
- familles visibles: electronique, automatisme, energie, stockage, optimisation
- l'embarque reste une competence, pas le titre principal
- les projets multi-techniques s'appuient sur les partenaires adaptes
- l'embarque reste une compétence de support, pas la promesse principale
- Hero actif:
- titre: `Systemes electroniques specifiques, du besoin au livrable fiable`
- fond: oscilloscope en plan rapproche, pas atelier large ni texture carnet legacy
- surcouches: white-contrast propres, lisibles, sans retour a l'imaginaire `studio/figma`
- titre: Systemes electroniques specifiques, du besoin au livrable fiable
- visuel: oscilloscope en plan rapproche, lisibilite white-contrast
- surface de marque conforme au manifeste de production
## Lire dans cet ordre
1. `project-master-todos.md`
2. `design-memory-2026-03-14-white-contrast.md`
3. `da-01-implementation-backlog.md`
4. `EXECUTION-IMMÉDIATE-2026-03-02.md`
5. `SPRINT-PLAYBOOK-2026-03-02.md`
6. `ovh-ftp-preview-solution-2026-03-14.md`
1. project-master-todos.md
2. feature-map-ecosystem.md
3. docs/lead-integration-spec-2026-03-19.md
4. design-memory-2026-03-14-white-contrast.md
5. da-01-implementation-backlog.md
6. EXECUTION-IMMÉDIATE-2026-03-02.md
7. SPRINT-PLAYBOOK-2026-03-02.md
8. oss-lead-form-veille-2026-03-19.md
## Docs actives
- `project-master-todos.md`
- project-master-todos.md
- backlog court terme, priorites, etat reel
- `design-memory-2026-03-14-white-contrast.md`
- lead-integration-spec-2026-03-19.md
- spec d'intégration du formulaire contact vers Frappe CRM + fallback legacy
- oss-lead-form-veille-2026-03-19.md
- veille OSS sur alternatives formulaire/lead + recommandation
- feature-map-ecosystem.md
- cartographie produit/processus + diagrammes Mermaid
- design-memory-2026-03-14-white-contrast.md
- memoire DA et positionnement actif
- `da-01-implementation-backlog.md`
- da-01-implementation-backlog.md
- backlog de consolidation design / UX / contenu
- `EXECUTION-IMMÉDIATE-2026-03-02.md`
- EXECUTION-IMMÉDIATE-2026-03-02.md
- resume executif et ordre de sortie
- `SPRINT-PLAYBOOK-2026-03-02.md`
- commandes et checks operatoires
- `ovh-ftp-preview-solution-2026-03-14.md`
- runbook de deploy OVH FTP via GitHub Actions
- `migration-legacy-to-astro.md`
- regle de non-retour vers le legacy
- SPRINT-PLAYBOOK-2026-03-02.md
- runbook de commandes et checks operatoires
- pipeline-commercial/crm-open-source-benchmark.md
- veille OSS CRM/backoffice + décision de choix
- pipeline-commercial/AGENTS_AND_SKILLS_MATRIX_2026-03-19.md
- répartition agents, missions, plan de lot et priorités
## Docs historiques a ne plus traiter comme source de vérité
- archive principale:
- `docs/archive/2026-03-legacy-plans/README.md`
- `docs/archive/2026-03-legacy-plans/`
- docs/archive/2026-03-legacy-plans/README.md
- docs/archive/2026-03-legacy-plans/
Contenu archive:
- anciens runbooks GitHub Pages et double hebergement
- anciens runbooks GitHub Pages / FTP
- audits et quick references de debut mars 2026
- variantes Figma, wireflows, benchmarks et backlogs d'assets
- anciens snapshots KPI, GTM et copy sheets
- historiques KPI, GTM et copy sheets
Ces documents restent utiles comme archive de contexte, mais ils ont ete ecrits a un moment ou:
- GitHub Pages etait encore traite comme prod active
- le lab etait encore public
- le positionnement etait plus etroit ou plus experimental
Ces documents restent utiles pour contexte, mais ne gouvernent plus les opérations actives.
## Regles de mise à jour
- Toute nouvelle decision produit ou deploiement doit d'abord mettre a jour `project-master-todos.md`
- Toute decision de positionnement, DA ou surface publique doit mettre a jour `design-memory-2026-03-14-white-contrast.md`
- Toute instruction operatoire doit etre recalee dans `SPRINT-PLAYBOOK-2026-03-02.md`
- Toute doc qui devient trompeuse doit etre soit mise a jour, soit marquee comme historique
- Toute nouvelle decision produit ou deploiement doit d'abord mettre a jour project-master-todos.md.
- Toute decision de positionnement, DA ou surface publique doit mettre a jour design-memory-2026-03-14-white-contrast.md.
- Toute instruction operatoire doit etre recalee dans SPRINT-PLAYBOOK-2026-03-02.md.
- Toute doc devenue trompeuse doit etre soit mise a jour, soit archivee.
+44
View File
@@ -0,0 +1,44 @@
# Feature Map Ecosysteme - Electron Rare Site SSR
Date: 2026-03-19
Source de verite: `docs/README.md` + `src/*`
## Portée active
- Home conversion: hero, approche, preuves, missions, FAQ.
- Parcours lead: formulaire de contact + fallback mail.
- Exploitation: logs TUI, vérifications endpoint, déploiement via container.
## Carte des fonctionnalités
| Bloc | Objectif | Fichiers | Etat |
|---|---|---|---|
| Contact form direct | Générer un lead qualifié | `src/components/sections/Contact.astro`, `src/pages/api/submit-lead.ts` | Actif |
| Routing public | Pages et health en prod | `src/pages/index.astro`, `src/pages/formation.astro`, `src/pages/404.astro`, `src/pages/mentions-legales.astro` | Actif |
| Observabilité opérationnelle | Supervision du site en production | `scripts/site-ops-tui.sh` | Actif |
| Ops & déploiement | Build SSR + Docker + Traefik | `Dockerfile`, `infra/docker-compose.site.yml` | Actif |
| SEO / publication | Canonicals + sitemaps + robots | `src/lib/site.ts`, `scripts/build-astro-external.mjs`, `src/pages/sitemap.xml.ts` | Actif |
## Mermaid - Surface active
```mermaid
flowchart TD
V[Visiteur] -->|/ | H[Page Home]
V -->|/formation| F[Landing Formation]
V -->|/mentions-legales| L[Landing Mentions]
V -->|formulaire| C[Contact.astro]
C -->|POST| A[src/pages/api/submit-lead]
A -->|/api/resource/CRM Lead| CRM[Frappe CRM]
A -->|fallback| LEGACY[CRM custom]
CRM -->|dashboards| Ops[Reporting / pilotage]
```
## Mermaid - Contrôle opérationnel
```mermaid
flowchart LR
T[site-ops-tui.sh] -->|status| S[Services docker]
T -->|endpoint| H[HTTP /api/submit-lead]
T -->|logs| L[docker logs + purge]
T -->|analyse| R[Résumé erreurs]
```
+42
View File
@@ -0,0 +1,42 @@
# Spec d'integration lead — Site Public -> Frappe CRM
Date: 2026-03-19
## Objectif
Acheminer les soumissions du formulaire contact en production directement vers Frappe CRM Lead, tout en gardant un fallback sécurisé vers l'ancien CRM custom tant que la bascule n'est pas totalement close.
## Flux cible
1. Visiteur remplit `src/components/sections/Contact.astro`.
2. Clic sur `Envoi` envoie un `POST /api/submit-lead` (JSON).
3. `src/pages/api/submit-lead.ts`:
- valide champs (`name`, `email`, `consent`).
- mappe vers payload Frappe.
- tente `POST ${FRAPPE_URL}/api/resource/CRM Lead`.
- fallback vers `${CRM_FALLBACK_URL}/api/intake/lead` si Frappe indisponible.
4. Réponse UI:
- succès: écran de succès local
- échec: message derreur + trace de tracking
## Contrats techniques
- Environnement requis (runtime Docker):
- `FRAPPE_URL`
- `FRAPPE_API_KEY`
- `FRAPPE_API_SECRET`
- `CRM_FALLBACK_URL` (optionnel)
- Endpoint front: `CONTACT_FORM_ENDPOINT`
- `import.meta.env.PUBLIC_API_URL` + `/api/submit-lead` (prod)
- fallback `'/api/submit-lead'`
## Risques connus
- Si la vérification CORS doit couvrir un nouveau hostname, ajouter lorigine dans `ALLOWED_ORIGINS`.
- Les erreurs côté lead peuvent remonter sans détail JSON détaillé dans le client (redondance volontaire pour éviter fuite de secrets).
## Critères dacceptation
- Un `POST /api/submit-lead` valide crée un lead dans Frappe.
- En cas de KO Frappe, fallback custom répond `2xx` si activé.
- Logs applicatifs montrent un parcours traceable sans fuite de secret.
+43
View File
@@ -0,0 +1,43 @@
# Veille OSS: alternatives pour soumission de leads (2026-03-19)
## Contexte
Basculement du formulaire de contact du site vers une API serveur Frappe CRM, avec fallback legacy.
## Travaux de veille web (références)
- **Frappe Framework / CRM API**
- API REST resource : `POST /api/resource/:doctype` selon docs Framework (`Frappe Lead` est un Doctype dédié en CRM).
- Authentification token basée sur `Authorization: token api_key:api_secret`.
- Sources: [Frappe REST API](https://docs.frappe.io/framework/user/en/api/rest), [Token Auth v15](https://docs.frappe.io/framework/v15/user/en/guides/integration/rest_api/token_based_authentication), [CRM Lead](https://docs.frappe.io/crm/lead).
- **n8n** (orchestration webhooks)
- Intéressant comme pont d'intégration si logique métier/IA/relance doit s'intercaler.
- Docs webhook + self-hosted.
- Sources: [n8n Webhook node docs](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/), [n8n Docs home](https://docs.n8n.io/).
- **Formbricks**
- Plateforme de formulaires/feedback open-source avec mode self-host.
- Peut couvrir le besoin formulaire + analyses côté produit plus riche qu'un simple endpoint.
- Sources: [Formbricks repo](https://github.com/formbricks/formbricks), [Open Source page](https://formbricks.com/docs/overview/open-source).
- **OhMyForm**
- Alternative formulaires type TypeForm/TellForm, orientée simplicité.
- Sources: [OhMyForm repo](https://github.com/ohmyform/ohmyform).
- **Supabase (REST API + BaaS)**
- Très bon quand on veut centraliser auth/stockage/realtime dans une API PostgREST self-hostée.
- Sources: [Supabase API docs](https://supabase.com/docs/guides/api), [Architecture](https://supabase.com/docs/architecture).
## Comparatif rapide (positionnement par usage)
- Besoin immédiat: **Frappe direct** reste la moins risquée (aligné stack actuelle).
- Besoin d'orchestration complexe: **n8n** intéressant en complément, si on veut découpler des canaux.
- Besoin formulaire produit + analytics complet: **Formbricks** ou **OhMyForm** à évaluer selon charge d'exploitation.
- Besoin de backend complet et API SQL-centric: **Supabase** (overhead plus élevé) pour une couche générique.
## Actions proposées
1. Garder Frappe CRM comme source de vérité lead.
2. Utiliser n8n uniquement si déclenchements externes (Slack, ticketing, scoring IA) deviennent nécessaires.
3. Reporter une version de bench trimestrielle de coût opérationnel (CPU, RAM, maintenance, équipe).
+25
View File
@@ -10,6 +10,7 @@ Source of truth: this file for execution priorities, plus [docs/README.md](./REA
- Direction artistique active: `white-contrast`, claire par defaut, sans bouton de bascule public.
- Positionnement actif: systemes electroniques specifiques, avec familles visibles electronique, automatisme, energie, stockage et optimisation; pas limite a l'embarque, avec partenaires mobilises quand le projet devient multi-technique.
- Surface a maintenir: Hero, Approche, Cas concrets, photo strip, video strip, Missions, FAQ, Contact, footer.
- Endpoint lead production: `POST /api/submit-lead` (SSR -> Frappe CRM Lead, fallback optionnel `CRM_FALLBACK_URL`).
## Verites de reference
- Point d'entree documentaire: `docs/README.md`
@@ -19,6 +20,7 @@ Source of truth: this file for execution priorities, plus [docs/README.md](./REA
- Theme/layout: `src/layouts/BaseLayout.astro`, `src/styles/global.css`, `src/styles/home-workbench.css`
- Canonical/base/deploiement: `src/lib/site.ts`, `scripts/build-astro-external.mjs`, `docs/ovh-ftp-preview-solution-2026-03-14.md`
- Tracking actif: `src/lib/tracking.ts` + emitters inline dans `src/layouts/BaseLayout.astro`
- Endpoint opérationnel lead: `src/pages/api/submit-lead.ts` + intégration formulaire `src/components/sections/Contact.astro`.
## Etat courant
- [x] Homepage OVH re-synchronisee dans le repo Astro.
@@ -39,6 +41,7 @@ Source of truth: this file for execution priorities, plus [docs/README.md](./REA
- [x] Preview OVH republie avec le positionnement `electronique · automatisme · energie · stockage · optimisation` (`23098262445`).
- [x] Production OVH republiee avec le meme positionnement (`23098335508`).
- [x] `https://www.lelectronrare.fr/lab/` et `https://www.lelectronrare.fr/preview/lab/` repondent maintenant `404`.
- [x] `POST /api/submit-lead` actif et pointé vers Frappe CRM Lead en production (`electron-rare-site`).
- [x] Le hero blanc utilise maintenant un vrai visuel oscilloscope, avec recadrage et overlays recales pour la DA claire.
- [x] Preview OVH republie avec le hero oscilloscope recadre et le recadrage memoire/docs (`23099694984`).
- [x] Production OVH republiee avec le meme hero oscilloscope (`23099760106`).
@@ -97,3 +100,25 @@ Source of truth: this file for execution priorities, plus [docs/README.md](./REA
- Toute doc active doit parler du site Astro/OVH actuel, pas d'une ancienne homepage statique.
- Les ancres de reference sont celles du code actuel, pas `#projets`.
- Le theme de reference est clair/white-contrast par defaut.
## Deploiement SSR Photon (2026-03-19)
- [x] Creer un dockerfile multi-stage pour build + runtime serve (production legere).
- [x] Creer le compose site et la route Traefik associee.
- [x] Ajouter un .env.example d'exemple pour deploiement et activer FRAPPE_* + PUBLIC_SITE_URL.
- [x] Route Traefik externe: https://www.lelectronrare.fr et lelectronrare.fr (source réelle: /root/zacus-stack/config/traefik/dynamic/electron-rare-site.yml)
- [x] Rebuilder/redeployer le service electron-rare-site sur port 4321.
- [x] Verifier routes: /, /404, /mentions-legales => 200.
- [x] Ajouter la page mentions-legales et la page 404.
- [x] Brancher le formulaire de contact vers Frappe CRM en production.
## Carte de flux lead (prod)
```mermaid
flowchart LR
V[Visiteur] -->|Complète formulaire| F[Contact.astro /api/submit-lead]
F -->|POST JSON| A[src/pages/api/submit-lead]
A -->|POST| R[Frappe CRM Lead]
A -->|fallback (si Frappe KO)| C[CRM custom legacy]
R -->|Lead stocke| D[Dashboard CRM + pipeline]
```
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
set -euo pipefail
SITE_SERVICE="electron-rare-site"
TRAEFIK_SERVICE="zacus-traefik"
COMPOSE_FILE="/root/electron-rare-site/infra/docker-compose.site.yml"
LOG_DIR="/root/electron-rare-site/artifacts/ops"
LOG_FILE="$LOG_DIR/site-ops-tui.log"
DOMAIN="${DOMAIN:-https://www.lelectronrare.fr}"
SITE_BASE_PATH="${SITE_BASE_PATH:-}"
API_ENDPOINT="${API_ENDPOINT:-${DOMAIN}${SITE_BASE_PATH}/api/submit-lead}"
mkdir -p "$LOG_DIR"
log() {
echo "$(date -Iseconds) $*" | tee -a "$LOG_FILE"
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "Missing command: $1" >&2
exit 1
}
}
status_panel() {
log "STATUS: check docker services"
docker compose -f "$COMPOSE_FILE" ps --services --filter "name=$SITE_SERVICE" | xargs -r -I{} docker compose -f "$COMPOSE_FILE" ps {}
log "STATUS: service inspect"
docker inspect -f 'service={{.Name}} status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' "$SITE_SERVICE" "$TRAEFIK_SERVICE" 2>/dev/null || true
}
endpoint_panel() {
log "HTTP checks for $DOMAIN"
for path in "" "/404" "/mentions-legales"; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 6 "$DOMAIN$path" || true)
log "GET $path => $code"
echo "GET $path => $code"
done
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 8 -X POST "$API_ENDPOINT" \
-H 'Content-Type: application/json' \
-d '{"name":"ops-test","email":"ops@test.lelectronrare.fr","needType":"technique","sourceChannel":"site","sourceDetail":"ops_health","consent":true}' || true)
log "POST $API_ENDPOINT => $code"
echo "POST $API_ENDPOINT => $code"
}
analyze_logs() {
log "LOG ANALYSE: $LOG_FILE"
if [ ! -f "$LOG_FILE" ]; then
echo "Aucun log local: $LOG_FILE"
return
fi
total=$(wc -l < "$LOG_FILE")
errors=$(grep -ciE 'error|fatal|exception|traceback' "$LOG_FILE" || true)
warn=$(grep -ciE 'warn|warn:' "$LOG_FILE" || true)
echo "Total lignes: $total"
echo "Lignes erreur: $errors"
echo "Lignes warn: $warn"
if [ "$errors" -gt 0 ]; then
echo "--- top erreurs ---"
grep -niE 'error|fatal|exception|traceback' "$LOG_FILE" | head -n 20
fi
}
logs_panel() {
log "TAIL logs: $SITE_SERVICE (40 lines)"
docker logs --tail 40 "$SITE_SERVICE" | tee -a "$LOG_FILE"
echo "---"
log "TAIL logs: $TRAEFIK_SERVICE (40 lines)"
docker logs --tail 40 "$TRAEFIK_SERVICE" | tee -a "$LOG_FILE"
}
cleanup_logs() {
local keep_days="${1:-7}"
log "CLEANUP: docker json logs if writable"
for svc in "$SITE_SERVICE" "$TRAEFIK_SERVICE"; do
logpath=$(docker inspect -f '{{.LogPath}}' "$svc" 2>/dev/null || true)
if [ -n "$logpath" ] && [ -f "$logpath" ]; then
: > "$logpath"
log "truncated $logpath for $svc"
else
log "no log file for $svc"
fi
done
if [ -d "$LOG_DIR" ]; then
find "$LOG_DIR" -type f -name "*.log" -mtime +"$keep_days" -print -delete | while IFS= read -r removed; do
log "deleted old local artifact log: $removed"
done
fi
log "CLEANUP complete (keep $keep_days days)"
}
menu() {
echo "1) status"
echo "2) logs"
echo "3) endpoints"
echo "4) cleanup"
echo "5) full"
echo "6) analyze logs"
echo "0) quit"
read -r -p "choix: " choice
case "$choice" in
1) status_panel ;;
2) logs_panel ;;
3) endpoint_panel ;;
4) read -r -p "jours a garder [7]: " days; cleanup_logs "${days:-7}" ;;
5) status_panel; logs_panel; endpoint_panel ;;
6) analyze_logs ;;
*) exit 0 ;;
esac
}
case "${1:-menu}" in
status)
require_cmd docker
status_panel
;;
logs)
require_cmd docker
logs_panel
;;
endpoint*|http)
require_cmd curl
endpoint_panel
;;
cleanup)
require_cmd docker
cleanup_logs "${2:-7}"
;;
run)
require_cmd docker
require_cmd curl
status_panel
logs_panel
endpoint_panel
;;
menu|*)
menu
;;
esac
+4 -2
View File
@@ -13,6 +13,8 @@ Ce que j'attends :
Contraintes (délai, budget) :`;
const defaultMailtoHref = `mailto:contact@lelectronrare.fr?subject=${encodeURIComponent("Contact L'Électron Rare")}&body=${encodeURIComponent(DEFAULT_MESSAGE)}`;
const apiBase = (import.meta.env.PUBLIC_API_URL || '').replace(/\/$/, '');
const CONTACT_FORM_ENDPOINT = apiBase ? `${apiBase}/api/submit-lead` : '/api/submit-lead';
---
<section
@@ -99,7 +101,7 @@ const defaultMailtoHref = `mailto:contact@lelectronrare.fr?subject=${encodeURICo
<div data-contact-panel="form" style="display:none;">
<div id="contact-form-container">
<form id="contact-crm-form" class="contact-minitel-form">
<form id="contact-crm-form" class="contact-minitel-form" data-contact-submit-endpoint={CONTACT_FORM_ENDPOINT}>
<div class="contact-minitel-field">
<label for="contact-form-name" class="contact-minitel-label">&gt; NOM :</label>
<input
@@ -228,7 +230,6 @@ const defaultMailtoHref = `mailto:contact@lelectronrare.fr?subject=${encodeURICo
</section>
<script>
const CRM_INTAKE_ENDPOINT = 'https://electron.saillant.cc/api/intake/lead';
const DEFAULT_MESSAGE = `Bonjour Clément,
J'ai un projet / besoin autour d'un système électronique spécifique.
@@ -301,6 +302,7 @@ Contraintes (délai, budget) :`;
const needTypeInput = document.getElementById('contact-form-need-type') as HTMLSelectElement;
const messageArea = document.getElementById('contact-form-message') as HTMLTextAreaElement;
const consentInput = document.getElementById('contact-form-consent') as HTMLInputElement;
const CRM_INTAKE_ENDPOINT = form.dataset.contactSubmitEndpoint || '/api/submit-lead';
const resetContactForm = () => {
nameInput.value = '';
+244
View File
@@ -0,0 +1,244 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
---
<BaseLayout title="404 — Électron introuvable" description="Cette page n'existe pas." canonicalPath="/404">
<main class="page-404">
<svg class="atom" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<!-- Orbital paths -->
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-cyan-soft, #5bd1d866)" stroke-width="0.8" transform="rotate(-30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-amber-soft, #f1c27a66)" stroke-width="0.8" transform="rotate(30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green-soft, #b6d18f44)" stroke-width="0.8" transform="rotate(90 150 150)"/>
<!-- Nucleus glow -->
<circle cx="150" cy="150" r="18" fill="url(#nucleusGlow)"/>
<circle cx="150" cy="150" r="8" fill="var(--accent, #f1c27a)"/>
<!-- Electron 1 (cyan) -->
<circle r="5" fill="var(--electric, #5bd1d8)">
<animateMotion dur="3s" repeatCount="indefinite">
<mpath href="#orbit1"/>
</animateMotion>
</circle>
<circle r="10" fill="var(--electric, #5bd1d8)" opacity="0.3">
<animateMotion dur="3s" repeatCount="indefinite">
<mpath href="#orbit1"/>
</animateMotion>
</circle>
<!-- Electron 2 (amber) -->
<circle r="4" fill="var(--accent, #f1c27a)">
<animateMotion dur="4.2s" repeatCount="indefinite">
<mpath href="#orbit2"/>
</animateMotion>
</circle>
<circle r="9" fill="var(--accent, #f1c27a)" opacity="0.25">
<animateMotion dur="4.2s" repeatCount="indefinite">
<mpath href="#orbit2"/>
</animateMotion>
</circle>
<!-- Electron 3 (missing - dashed orbit, blinking) -->
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green, #b6d18f)" stroke-width="0.5" stroke-dasharray="4 8" transform="rotate(90 150 150)" opacity="0.5">
<animate attributeName="stroke-dashoffset" from="0" to="48" dur="3s" repeatCount="indefinite"/>
</ellipse>
<circle r="3.5" fill="var(--trace-green, #b6d18f)" opacity="0.15">
<animateMotion dur="5.8s" repeatCount="indefinite">
<mpath href="#orbit3"/>
</animateMotion>
<animate attributeName="opacity" values="0.15;0.5;0.15;0" dur="5.8s" repeatCount="indefinite"/>
</circle>
<!-- Hidden motion paths -->
<defs>
<ellipse id="orbit1" cx="150" cy="150" rx="120" ry="45" transform="rotate(-30 150 150)" fill="none"/>
<ellipse id="orbit2" cx="150" cy="150" rx="120" ry="45" transform="rotate(30 150 150)" fill="none"/>
<ellipse id="orbit3" cx="150" cy="150" rx="120" ry="45" transform="rotate(90 150 150)" fill="none"/>
<radialGradient id="nucleusGlow">
<stop offset="0%" stop-color="var(--accent, #f1c27a)" stop-opacity="0.6"/>
<stop offset="100%" stop-color="var(--accent, #f1c27a)" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>
<h1>4<span class="zero">0</span>4</h1>
<p class="tagline" id="tagline"></p>
<p class="explanation" id="explanation"></p>
<div class="actions">
<a href="/" class="btn-home">← Retour au noyau</a>
</div>
<p class="footnote" id="footnote"></p>
</main>
</BaseLayout>
<script>
const messages = [
{
tagline: "Électron introuvable.",
explanation: "Selon le principe d'incertitude de Heisenberg, plus on connaît précisément l'URL, moins on sait où est la page.",
footnote: "Erreur 404 · Couche électronique non peuplée · Probabilité de présence : 0%"
},
{
tagline: "Superposition quantique.",
explanation: "Cette page existe et n'existe pas en même temps. Malheureusement, votre navigateur vient de l'observer.",
footnote: "Erreur 404 · Fonction d'onde effondrée · Chat de Schrödinger : pas trouvé"
},
{
tagline: "Effet tunnel raté.",
explanation: "La page a tenté de traverser la barrière de potentiel du serveur. Elle n'avait pas assez d'énergie.",
footnote: "Erreur 404 · Barrière : 1.21 eV · Énergie requise : insuffisante"
},
{
tagline: "Signal non détecté.",
explanation: "On a branché l'oscilloscope, vérifié la masse, suivi les pistes du PCB. Rien. Le signal s'arrête ici.",
footnote: "Erreur 404 · Multimètre : OL · Continuité : FAIL"
},
{
tagline: "Composant manquant.",
explanation: "La BOM indique un composant à cet emplacement. Mais le pick-and-place a oublié de le poser.",
footnote: "Erreur 404 · Ref: U404 · Empreinte : NOT_POPULATED"
},
{
tagline: "Court-circuit temporel.",
explanation: "Cette page existera peut-être dans le futur. Ou elle a existé dans le passé. La relativité est floue là-dessus.",
footnote: "Erreur 404 · Référentiel : indéterminé · Temps propre : NaN"
},
{
tagline: "Masse flottante.",
explanation: "Sans référence de masse, impossible de mesurer quoi que ce soit. Cette URL flotte dans le vide intersidéral du serveur.",
footnote: "Erreur 404 · GND : déconnecté · Potentiel : indéfini"
},
{
tagline: "Intrication rompue.",
explanation: "Cette page était intriquée avec une autre. Quelqu'un a mesuré l'autre page et celle-ci s'est décohérée.",
footnote: "Erreur 404 · Bell inequality : violated · EPR : paradox"
},
{
tagline: "Fréquence hors bande.",
explanation: "Votre requête émet sur une fréquence que notre serveur ne peut pas capter. Essayez de réaccorder votre URL.",
footnote: "Erreur 404 · Bande passante : 0 Hz · Filtre : rejet total"
},
{
tagline: "Soudure froide.",
explanation: "La connexion entre votre navigateur et cette page présente une soudure froide. Contact intermittent. Page absente.",
footnote: "Erreur 404 · Joint : CMS · Diagnostic : reflow nécessaire"
}
];
let currentIndex = Math.floor(Math.random() * messages.length);
function updateMessage() {
const msg = messages[currentIndex];
const tagline = document.getElementById('tagline');
const explanation = document.getElementById('explanation');
const footnote = document.getElementById('footnote');
tagline.style.opacity = '0';
explanation.style.opacity = '0';
footnote.style.opacity = '0';
setTimeout(() => {
tagline.textContent = msg.tagline;
explanation.textContent = msg.explanation;
footnote.textContent = msg.footnote;
tagline.style.opacity = '1';
explanation.style.opacity = '1';
footnote.style.opacity = '1';
}, 400);
currentIndex = (currentIndex + 1) % messages.length;
}
updateMessage();
setInterval(updateMessage, 10000);
</script>
<style>
.page-404 {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
}
.atom {
width: clamp(200px, 40vw, 280px);
height: auto;
margin-bottom: 24px;
}
h1 {
font-family: 'IBM Plex Mono', monospace;
font-size: clamp(72px, 15vw, 140px);
font-weight: 700;
color: var(--text, #f4eee3);
margin: 0;
line-height: 1;
letter-spacing: -4px;
}
.zero {
display: inline-block;
animation: glitch 4s ease-in-out infinite;
color: var(--electric, #5bd1d8);
}
@keyframes glitch {
0%, 88%, 100% { opacity: 1; transform: none; }
90% { opacity: 0.5; transform: translateX(-3px) skewX(-3deg); color: var(--trace-magenta, #d89b63); }
92% { opacity: 0.8; transform: translateX(2px) skewX(1deg); }
94% { opacity: 0.3; transform: translateX(-1px); color: var(--accent, #f1c27a); }
96% { opacity: 1; transform: none; }
}
.tagline {
font-family: 'Fraunces', serif;
font-size: clamp(20px, 3.5vw, 28px);
color: var(--accent, #f1c27a);
margin: 12px 0 20px;
transition: opacity 0.4s ease;
}
.explanation {
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
color: var(--text-muted, #d6cbb9);
line-height: 1.8;
max-width: 480px;
margin: 0 0 32px;
transition: opacity 0.4s ease;
}
.actions { margin-bottom: 40px; }
.btn-home {
display: inline-block;
padding: 12px 28px;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
font-weight: 500;
color: var(--bg, #0b0a09);
background: var(--accent, #f1c27a);
text-decoration: none;
border-radius: 4px;
transition: all 0.2s;
}
.btn-home:hover {
background: var(--electric, #5bd1d8);
box-shadow: 0 0 20px rgba(91, 209, 216, 0.3);
}
.footnote {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-dim, #b6a894);
opacity: 0.5;
letter-spacing: 0.5px;
transition: opacity 0.4s ease;
}
</style>
+218
View File
@@ -0,0 +1,218 @@
import type { APIRoute } from 'astro';
interface LeadSubmission {
name: string;
email: string;
phone?: string;
organization?: string;
sourceChannel?: string;
sourceDetail?: string;
pagePath?: string;
needType?: string;
}
interface FrappePayload {
first_name: string;
last_name?: string;
email: string;
mobile_no?: string;
organization?: string;
custom_source_channel: string;
custom_source_detail?: string;
custom_need_type: string;
custom_segment: string;
}
// Strict email validation (RFC 5322 simplified)
const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
// Rate limiting: simple in-memory sliding window (per IP)
const rateLimitMap = new Map<string, number[]>();
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 3600_000; // 1 hour
function isRateLimited(ip: string): boolean {
const now = Date.now();
const timestamps = rateLimitMap.get(ip) ?? [];
const recent = timestamps.filter(t => now - t < RATE_WINDOW_MS);
if (recent.length >= RATE_LIMIT) return true;
recent.push(now);
rateLimitMap.set(ip, recent);
return false;
}
// Allowed origins for CORS
const ALLOWED_ORIGINS = new Set([
'https://www.lelectronrare.fr',
'https://lelectronrare.fr',
'https://electron-rare.github.io',
'https://electron.saillant.cc',
]);
function corsHeaders(origin: string | null): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (origin && ALLOWED_ORIGINS.has(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
}
return headers;
}
export const POST: APIRoute = async ({ request, clientAddress }) => {
const origin = request.headers.get('origin');
// CORS check — reject unknown origins (allow null for same-origin)
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return new Response(JSON.stringify({ error: 'forbidden' }), {
status: 403,
headers: corsHeaders(null),
});
}
// Rate limiting
const ip = clientAddress || request.headers.get('x-forwarded-for') || 'unknown';
if (isRateLimited(ip)) {
return new Response(JSON.stringify({ error: 'too many requests' }), {
status: 429,
headers: { ...corsHeaders(origin), 'Retry-After': '3600' },
});
}
const FRAPPE_URL = import.meta.env.FRAPPE_URL || process.env.FRAPPE_URL;
const FRAPPE_API_KEY = import.meta.env.FRAPPE_API_KEY || process.env.FRAPPE_API_KEY;
const FRAPPE_API_SECRET = import.meta.env.FRAPPE_API_SECRET || process.env.FRAPPE_API_SECRET;
const CRM_FALLBACK_URL = import.meta.env.CRM_FALLBACK_URL || process.env.CRM_FALLBACK_URL;
if (!FRAPPE_URL || !FRAPPE_API_KEY || !FRAPPE_API_SECRET) {
console.error('[submit-lead] Missing Frappe env vars');
return new Response(JSON.stringify({ error: 'server misconfigured' }), {
status: 500,
headers: corsHeaders(origin),
});
}
let body: LeadSubmission;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'invalid JSON body' }), {
status: 400,
headers: corsHeaders(origin),
});
}
// Validate required fields
const name = typeof body.name === 'string' ? body.name.trim() : '';
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
if (!name || name.length < 2 || name.length > 200) {
return new Response(JSON.stringify({ error: 'name must be 2-200 characters' }), {
status: 400,
headers: corsHeaders(origin),
});
}
if (!email || !EMAIL_RE.test(email)) {
return new Response(JSON.stringify({ error: 'valid email required' }), {
status: 400,
headers: corsHeaders(origin),
});
}
// Split full name into first/last
const nameParts = name.split(/\s+/);
const firstName = nameParts[0];
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined;
const sourceChannel = body.sourceChannel || 'site';
const needType = body.needType || 'technique';
const segment =
needType === 'formation'
? 'formation'
: sourceChannel === 'fat' || sourceChannel === 'hemisphere'
? 'culture'
: 'industrie';
const frappePayload: FrappePayload = {
first_name: firstName,
email,
custom_source_channel: sourceChannel,
custom_need_type: needType,
custom_segment: segment,
};
if (lastName) frappePayload.last_name = lastName;
if (body.phone) frappePayload.mobile_no = body.phone;
if (body.organization) frappePayload.organization = body.organization;
if (body.sourceDetail || body.pagePath) {
frappePayload.custom_source_detail = body.sourceDetail || body.pagePath;
}
console.info('[submit-lead] Submitting lead', { email, sourceChannel, needType, segment });
const FRAPPE_HOST = import.meta.env.FRAPPE_HOST_HEADER || process.env.FRAPPE_HOST_HEADER || '';
try {
const frappeHeaders: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `token ${FRAPPE_API_KEY}:${FRAPPE_API_SECRET}`,
};
if (FRAPPE_HOST) frappeHeaders['Host'] = FRAPPE_HOST;
const resp = await fetch(`${FRAPPE_URL}/api/resource/CRM Lead`, {
method: 'POST',
headers: frappeHeaders,
body: JSON.stringify(frappePayload),
});
if (!resp.ok) {
// Log status only — never log response body (may contain secrets)
console.error('[submit-lead] Frappe returned', resp.status);
// Dual-write fallback
if (CRM_FALLBACK_URL) {
console.info('[submit-lead] Attempting CRM fallback');
try {
const fallbackResp = await fetch(`${CRM_FALLBACK_URL}/api/intake/lead`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email,
phone: body.phone,
organization: body.organization,
sourceChannel,
needType,
}),
});
if (fallbackResp.ok) {
return new Response(JSON.stringify({ ok: true, via: 'fallback' }), {
status: 201,
headers: corsHeaders(origin),
});
}
console.error('[submit-lead] Fallback returned', fallbackResp.status);
} catch {
console.error('[submit-lead] Fallback network error');
}
}
return new Response(JSON.stringify({ error: 'submission failed' }), {
status: 502,
headers: corsHeaders(origin),
});
}
console.info('[submit-lead] Lead created');
return new Response(JSON.stringify({ ok: true }), {
status: 201,
headers: corsHeaders(origin),
});
} catch {
console.error('[submit-lead] Network error');
return new Response(JSON.stringify({ error: 'internal error' }), {
status: 500,
headers: corsHeaders(origin),
});
}
};