fix(seo): smart title suffix + OG image par défaut 1200×630
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m1s
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m1s
Audit SEO révélait deux pertes de valeur :
1. **Titres trop longs** (86 chars sur la home, 71 chars sur les articles).
Google tronque à ~60 chars dans le SERP. Le suffixe automatique
`— Rubis sur l'ongle` (20 chars) écrasait le message-clé.
Layout.astro fait maintenant un suffix smart :
* Si title <45 chars ET ne contient pas "Rubis" → suffix `— Rubis` (8 chars)
* Sinon → titre tel quel (la brand est déjà couverte par og:site_name +
JSON-LD publisher + le hostname rubis.pro visible dans la SERP).
Résultat : home 64 chars, article 51 chars, "Mentions légales" → 24 chars
avec suffix.
2. **Pas d'`og:image` sur les pages sans hero** (home, légal). Sur
LinkedIn/X/Slack, aucune preview image — perte d'engagement énorme.
Ajout d'un og-default.png 1200×630 (165 KB optimisé) dans
apps/landing/public/, monté par fallback par Layout.astro quand la
page ne fournit pas d'`ogImage` explicite. Twitter:card devient
toujours `summary_large_image`.
L'image a été générée via le nouvel outil HTML
docs/marketing/assets/og-default.html (clone de la mécanique du
linkedin-banner.html — clic sur "Télécharger PNG" et go).
Compose : brand + tagline + mock card 124 rubis + CTA rubis.pro.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b2dd991c58
commit
eda5436d12
BIN
apps/landing/public/og-default.png
Normal file
BIN
apps/landing/public/og-default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
@ -16,12 +16,20 @@ import { SiteFooter } from "../components/SiteFooter";
|
|||||||
|
|
||||||
const SITE_URL = "https://rubis.pro";
|
const SITE_URL = "https://rubis.pro";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OG image par défaut (1200×630) servie depuis apps/landing/public/.
|
||||||
|
* Utilisée pour les partages sociaux quand aucune image n'est fournie
|
||||||
|
* par la page (cas de la home + pages légales). Les articles de blog
|
||||||
|
* ont leur propre hero qui prend précédence.
|
||||||
|
*/
|
||||||
|
const DEFAULT_OG_IMAGE = `${SITE_URL}/og-default.png`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
/** Path absolu de la page courante (ex. "/blog/foo"). Default = location actuelle. */
|
/** Path absolu de la page courante (ex. "/blog/foo"). Default = location actuelle. */
|
||||||
pathname?: string;
|
pathname?: string;
|
||||||
/** OG image absolue (1200×630 idéal). */
|
/** OG image absolue (1200×630 idéal). Fallback sur og-default.png si non fournie. */
|
||||||
ogImage?: string;
|
ogImage?: string;
|
||||||
/** OG type. Default "website". Mettre "article" sur les pages de blog. */
|
/** OG type. Default "website". Mettre "article" sur les pages de blog. */
|
||||||
ogType?: "website" | "article";
|
ogType?: "website" | "article";
|
||||||
@ -44,7 +52,20 @@ const {
|
|||||||
jsonLd,
|
jsonLd,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const fullTitle = title.includes("Rubis") ? title : `${title} — Rubis sur l'ongle`;
|
/**
|
||||||
|
* Suffixe brand intelligent :
|
||||||
|
* - Si le titre est court (<45 chars) ET ne contient pas "Rubis", on ajoute
|
||||||
|
* " — Rubis" pour la cohérence brand dans les SERP.
|
||||||
|
* - Sinon (titre long ou déjà brandé), on laisse tel quel : Google tronque
|
||||||
|
* à ~60 chars dans le snippet, autant garder le message-clé en entier.
|
||||||
|
*
|
||||||
|
* Le branding reste assuré par og:site_name + JSON-LD publisher + le hostname
|
||||||
|
* rubis.pro visible dans la SERP — pas besoin de le marteler dans le title.
|
||||||
|
*/
|
||||||
|
const fullTitle =
|
||||||
|
title.length < 45 && !title.includes("Rubis") ? `${title} — Rubis` : title;
|
||||||
|
|
||||||
|
const resolvedOgImage = ogImage ?? DEFAULT_OG_IMAGE;
|
||||||
const url = `${SITE_URL}${pathname ?? Astro.url.pathname}`;
|
const url = `${SITE_URL}${pathname ?? Astro.url.pathname}`;
|
||||||
const robots = noindex ? "noindex,nofollow" : "index,follow,max-image-preview:large";
|
const robots = noindex ? "noindex,nofollow" : "index,follow,max-image-preview:large";
|
||||||
const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||||
@ -72,22 +93,17 @@ const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
|||||||
<meta property="og:title" content={fullTitle} />
|
<meta property="og:title" content={fullTitle} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:url" content={url} />
|
<meta property="og:url" content={url} />
|
||||||
{
|
<meta property="og:image" content={resolvedOgImage} />
|
||||||
ogImage && (
|
<meta property="og:image:width" content="1200" />
|
||||||
<>
|
<meta property="og:image:height" content="630" />
|
||||||
<meta property="og:image" content={ogImage} />
|
<meta property="og:image:alt" content={title} />
|
||||||
<meta property="og:image:width" content="1200" />
|
|
||||||
<meta property="og:image:height" content="630" />
|
|
||||||
<meta property="og:image:alt" content={title} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{/* Twitter Card */}
|
{/* Twitter Card — toujours summary_large_image puisqu'on a maintenant
|
||||||
<meta name="twitter:card" content={ogImage ? "summary_large_image" : "summary"} />
|
un og-default.png 1200×630 servi par défaut sur les pages sans hero. */}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content={fullTitle} />
|
<meta name="twitter:title" content={fullTitle} />
|
||||||
<meta name="twitter:description" content={description} />
|
<meta name="twitter:description" content={description} />
|
||||||
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
<meta name="twitter:image" content={resolvedOgImage} />
|
||||||
|
|
||||||
{/* Favicons */}
|
{/* Favicons */}
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|||||||
425
docs/marketing/assets/og-default.html
Normal file
425
docs/marketing/assets/og-default.html
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
OG image par défaut — 1200 × 630 px (ratio 1.91:1, standard Open Graph).
|
||||||
|
Servie sur rubis.pro/og-default.png par Astro static (apps/landing/public/).
|
||||||
|
|
||||||
|
Pourquoi ce ratio :
|
||||||
|
- Facebook / LinkedIn / Twitter / Slack utilisent tous le carrousel
|
||||||
|
1200 × 630 (~1.91:1) pour les preview cards.
|
||||||
|
- Les images plus larges (LinkedIn banner 1584 × 280) sont rognées en
|
||||||
|
preview car le viewport square les recoupe.
|
||||||
|
|
||||||
|
Ouvre ce fichier dans Chrome et clique :
|
||||||
|
- "Télécharger PNG" → og-default.png, prêt à poser dans
|
||||||
|
apps/landing/public/og-default.png
|
||||||
|
|
||||||
|
La page est volontairement BUSY (mock card visible, tagline complète) —
|
||||||
|
l'objectif est de transmettre le produit en un coup d'œil quand quelqu'un
|
||||||
|
partage rubis.pro sur LinkedIn / X / Slack / iMessage.
|
||||||
|
-->
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>OG image par défaut — Rubis sur l'ongle</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--rubis: #9f1239;
|
||||||
|
--rubis-deep: #771328;
|
||||||
|
--rubis-light: #c9415c;
|
||||||
|
--rubis-glow: #fbe4ea;
|
||||||
|
--ink: #1a1410;
|
||||||
|
--ink-2: #4f4640;
|
||||||
|
--ink-3: #8a7f76;
|
||||||
|
--line: #e8e0d6;
|
||||||
|
--cream: #faf7f2;
|
||||||
|
--cream-2: #f5efe7;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #d9d4cb;
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.controls h2 {
|
||||||
|
font-family: "Bricolage Grotesque", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1a1410;
|
||||||
|
margin: 0 16px 0 0;
|
||||||
|
letter-spacing: -0.012em;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #9f1239;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 6px rgba(159, 18, 57, 0.25);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #771328; transform: translateY(-1px); }
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #1a1410;
|
||||||
|
border: 1px solid #e8e0d6;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: #faf7f2; }
|
||||||
|
.btn:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
.hint { font-size: 12.5px; color: #4f4640; margin-left: 8px; }
|
||||||
|
|
||||||
|
/* ===== OG image — 1200 × 630 ===== */
|
||||||
|
.og {
|
||||||
|
width: 1200px;
|
||||||
|
height: 630px;
|
||||||
|
background: var(--cream);
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.15fr 0.85fr;
|
||||||
|
gap: 56px;
|
||||||
|
padding: 64px 72px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Halo rubis discret en haut-droite */
|
||||||
|
.og::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -240px;
|
||||||
|
right: -180px;
|
||||||
|
width: 520px;
|
||||||
|
height: 520px;
|
||||||
|
background: radial-gradient(circle, var(--rubis-glow) 0%, transparent 65%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Gem watermark très transparent en pied */
|
||||||
|
.og::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -200px;
|
||||||
|
left: 25%;
|
||||||
|
width: 380px;
|
||||||
|
height: 380px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
background: var(--rubis);
|
||||||
|
opacity: 0.04;
|
||||||
|
border-radius: 28px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== LEFT — brand + tagline ============== */
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 580px;
|
||||||
|
}
|
||||||
|
.brand-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.gem-svg {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(159, 18, 57, 0.18));
|
||||||
|
}
|
||||||
|
.brand-name {
|
||||||
|
font-family: "Bricolage Grotesque", sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.brand-name .suffix {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 18px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-family: "Bricolage Grotesque", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 56px;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.028em;
|
||||||
|
}
|
||||||
|
.tagline em {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--rubis);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pitch {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.pitch b {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url {
|
||||||
|
margin-top: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: "Bricolage Grotesque", sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: white;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
align-self: flex-start;
|
||||||
|
box-shadow: 0 2px 8px rgba(26, 20, 16, 0.04);
|
||||||
|
}
|
||||||
|
.url::before {
|
||||||
|
content: "";
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background: var(--rubis);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== RIGHT — mock card ============== */
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 28px 30px;
|
||||||
|
box-shadow:
|
||||||
|
0 24px 48px -16px rgba(26, 20, 16, 0.18),
|
||||||
|
0 6px 12px -4px rgba(26, 20, 16, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rubis-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.rubis-hero .gem-big {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
filter: drop-shadow(0 4px 10px rgba(159, 18, 57, 0.32));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rubis-count {
|
||||||
|
font-family: "Bricolage Grotesque", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: -0.022em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.rubis-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.rubis-sub b { font-weight: 600; color: var(--ink); }
|
||||||
|
|
||||||
|
.kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 22px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
font-family: "Bricolage Grotesque", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.kpi-delta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--rubis);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed var(--line);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.activity b { color: var(--ink); font-weight: 600; }
|
||||||
|
.activity time { color: var(--ink-3); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="controls">
|
||||||
|
<h2>OG image — 1200 × 630</h2>
|
||||||
|
<button id="exportPng" class="btn btn-primary">🖼️ Télécharger og-default.png</button>
|
||||||
|
<span class="hint">Pose ensuite dans <code>apps/landing/public/og-default.png</code></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="og">
|
||||||
|
<!-- ============ LEFT ============ -->
|
||||||
|
<div class="left">
|
||||||
|
<div class="brand-row">
|
||||||
|
<svg class="gem-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<polygon points="50,6 6,50 50,50" fill="#9F1239" fill-opacity="1" />
|
||||||
|
<polygon points="50,6 94,50 50,50" fill="#9F1239" fill-opacity="0.8" />
|
||||||
|
<polygon points="6,50 50,50 50,94" fill="#9F1239" fill-opacity="0.65" />
|
||||||
|
<polygon points="50,50 94,50 50,94" fill="#9F1239" fill-opacity="0.48" />
|
||||||
|
<polygon points="50,6 94,50 50,94 6,50" fill="none" stroke="#9F1239" stroke-width="1.6" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span class="brand-name">Rubis<span class="suffix">sur l'ongle</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="tagline">
|
||||||
|
Vos factures relancées <em>toutes seules</em>.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="pitch">
|
||||||
|
Le SaaS de relance pour TPE-PME françaises. <b>5 heures par semaine</b>
|
||||||
|
récupérées, automatiquement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="url">rubis.pro</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ RIGHT — mock card ============ -->
|
||||||
|
<div class="right">
|
||||||
|
<div class="card">
|
||||||
|
<div class="rubis-hero">
|
||||||
|
<svg class="gem-big" viewBox="0 0 100 100">
|
||||||
|
<polygon points="50,6 6,50 50,50" fill="#9F1239" fill-opacity="1" />
|
||||||
|
<polygon points="50,6 94,50 50,50" fill="#9F1239" fill-opacity="0.8" />
|
||||||
|
<polygon points="6,50 50,50 50,94" fill="#9F1239" fill-opacity="0.65" />
|
||||||
|
<polygon points="50,50 94,50 50,94" fill="#9F1239" fill-opacity="0.48" />
|
||||||
|
<polygon points="50,6 94,50 50,94 6,50" fill="none" stroke="#9F1239" stroke-width="1.6" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="rubis-count">124 rubis</div>
|
||||||
|
<div class="rubis-sub">≈ <b>24 h 48</b> libérées ce mois</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<div>
|
||||||
|
<div class="kpi-label">Encaissé</div>
|
||||||
|
<div class="kpi-value">14 320 €</div>
|
||||||
|
<div class="kpi-delta">+ 2 800 € vs avril</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="kpi-label">DSO</div>
|
||||||
|
<div class="kpi-value">38 j</div>
|
||||||
|
<div class="kpi-delta">↘ −6 j depuis Rubis</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity">
|
||||||
|
<span>✓ Facture <b>F-2024-035</b> encaissée</span>
|
||||||
|
<time>10:02</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lib CDN pour export PNG -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const og = document.querySelector(".og");
|
||||||
|
const btnPng = document.getElementById("exportPng");
|
||||||
|
|
||||||
|
// Capture en 2× pour rendu net, puis le navigateur télécharge en 2400 × 1260
|
||||||
|
// (LinkedIn / Twitter / Facebook downsamplent au format final 1200 × 630).
|
||||||
|
const captureOptions = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
canvasWidth: 1200 * 2,
|
||||||
|
canvasHeight: 630 * 2,
|
||||||
|
pixelRatio: 2,
|
||||||
|
backgroundColor: "#FAF7F2",
|
||||||
|
cacheBust: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
btnPng.addEventListener("click", async () => {
|
||||||
|
btnPng.disabled = true;
|
||||||
|
btnPng.textContent = "⏳ Génération…";
|
||||||
|
try {
|
||||||
|
await document.fonts.ready;
|
||||||
|
const dataUrl = await htmlToImage.toPng(og, captureOptions);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "og-default.png";
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
btnPng.textContent = "✓ PNG téléchargé";
|
||||||
|
setTimeout(() => {
|
||||||
|
btnPng.textContent = "🖼️ Télécharger og-default.png";
|
||||||
|
btnPng.disabled = false;
|
||||||
|
}, 1800);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Export PNG :", err);
|
||||||
|
alert("Erreur export PNG : " + err.message);
|
||||||
|
btnPng.disabled = false;
|
||||||
|
btnPng.textContent = "🖼️ Télécharger og-default.png";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user