Feat: sécurisation formulaire white paper + stockage leads
- Honeypot, timing check, rate limiting IP, validation serveur - Déduplication par email : enrichissement des champs vides si contact existant - Blueprint white-paper : onglet "Contacts intéressés" (champ structure contactDatabase) - Blueprint site.yml : ajout onglet "Données d'usage" pour vue globale des leads - Route externalisée dans site/config/routes/download-white-paper.php - isDownloadable côté client (prénom, nom, email valide, consentement) - Cursor : pas de hover sur boutons disabled - Buttons : hover désactivé si disabled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
974067d986
commit
67d8159787
8 changed files with 312 additions and 112 deletions
|
|
@ -59,3 +59,50 @@ tabs:
|
||||||
accept: application/pdf
|
accept: application/pdf
|
||||||
translate: false
|
translate: false
|
||||||
help: Fichier téléchargé après soumission du formulaire
|
help: Fichier téléchargé après soumission du formulaire
|
||||||
|
leads:
|
||||||
|
label: Contacts intéressés
|
||||||
|
icon: users
|
||||||
|
sections:
|
||||||
|
contactDatabase:
|
||||||
|
type: fields
|
||||||
|
fields:
|
||||||
|
contactDatabase:
|
||||||
|
label: Visiteurs ayant téléchargé
|
||||||
|
type: structure
|
||||||
|
translate: false
|
||||||
|
columns:
|
||||||
|
firstName:
|
||||||
|
label: Prénom
|
||||||
|
width: 1/4
|
||||||
|
lastName:
|
||||||
|
label: Nom
|
||||||
|
width: 1/4
|
||||||
|
email:
|
||||||
|
label: Email
|
||||||
|
width: 1/4
|
||||||
|
company:
|
||||||
|
label: Entreprise
|
||||||
|
width: 1/4
|
||||||
|
role:
|
||||||
|
label: Fonction
|
||||||
|
downloadedAt:
|
||||||
|
label: Date
|
||||||
|
fields:
|
||||||
|
firstName:
|
||||||
|
type: text
|
||||||
|
label: Prénom
|
||||||
|
lastName:
|
||||||
|
type: text
|
||||||
|
label: Nom
|
||||||
|
email:
|
||||||
|
type: email
|
||||||
|
label: Email
|
||||||
|
company:
|
||||||
|
type: text
|
||||||
|
label: Entreprise
|
||||||
|
role:
|
||||||
|
type: text
|
||||||
|
label: Fonction
|
||||||
|
downloadedAt:
|
||||||
|
type: text
|
||||||
|
label: Date
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,113 @@
|
||||||
title: World Game
|
title: World Game
|
||||||
|
|
||||||
columns:
|
tabs:
|
||||||
- width: 1/2
|
mainTab:
|
||||||
sections:
|
label: Principal
|
||||||
navigation:
|
columns:
|
||||||
type: pages
|
- width: 1/2
|
||||||
label: Menu
|
sections:
|
||||||
help: Accédez aux pages et réordonnez-les
|
navigation:
|
||||||
layout: list
|
type: pages
|
||||||
size: small
|
label: Menu
|
||||||
sortable: true
|
help: Accédez aux pages et réordonnez-les
|
||||||
create: false
|
layout: list
|
||||||
status: listed
|
|
||||||
|
|
||||||
- width: 1/2
|
|
||||||
fields:
|
|
||||||
siteTitle:
|
|
||||||
label: Titre du site
|
|
||||||
type: text
|
|
||||||
translate: true
|
|
||||||
siteTagline:
|
|
||||||
label: Tagline
|
|
||||||
type: text
|
|
||||||
translate: true
|
|
||||||
siteDescription:
|
|
||||||
label: Description
|
|
||||||
type: textarea
|
|
||||||
translate: true
|
|
||||||
footerLogo:
|
|
||||||
label: Logo
|
|
||||||
type: files
|
|
||||||
layout: cards
|
|
||||||
max: 1
|
|
||||||
help: Utilisé en pied de page (page À propos, Blog et Article).
|
|
||||||
|
|
||||||
- width: 1/2
|
|
||||||
fields:
|
|
||||||
contactEmail:
|
|
||||||
label: Email
|
|
||||||
type: email
|
|
||||||
width: 1/2
|
|
||||||
translate: false
|
|
||||||
contactAddress:
|
|
||||||
label: Adresse
|
|
||||||
type: writer
|
|
||||||
buttons: false
|
|
||||||
nodes: false
|
|
||||||
width: 1/2
|
|
||||||
placeholder: 33 rue Jean Dupont
|
|
||||||
socialLinks:
|
|
||||||
label: Liens réseaux sociaux
|
|
||||||
type: structure
|
|
||||||
translate: false
|
|
||||||
fields:
|
|
||||||
label:
|
|
||||||
label: Nom
|
|
||||||
required: true
|
|
||||||
type: text
|
|
||||||
width: 1/3
|
|
||||||
placeholder: "Ex: LinkedIn, Instagram..."
|
|
||||||
url:
|
|
||||||
label: URL
|
|
||||||
required: true
|
|
||||||
type: url
|
|
||||||
width: 1/3
|
|
||||||
picto:
|
|
||||||
label: Icône SVG
|
|
||||||
type: textarea
|
|
||||||
width: 1/3
|
|
||||||
buttons: false
|
|
||||||
size: small
|
size: small
|
||||||
help: Collez le code SVG de l'icône
|
sortable: true
|
||||||
|
create: false
|
||||||
|
status: listed
|
||||||
|
|
||||||
- width: 1/2
|
- width: 1/2
|
||||||
sections:
|
|
||||||
contact:
|
|
||||||
type: fields
|
|
||||||
fields:
|
fields:
|
||||||
legalNotices:
|
siteTitle:
|
||||||
label: Mentions légales (PDF)
|
label: Titre du site
|
||||||
|
type: text
|
||||||
|
translate: true
|
||||||
|
siteTagline:
|
||||||
|
label: Tagline
|
||||||
|
type: text
|
||||||
|
translate: true
|
||||||
|
siteDescription:
|
||||||
|
label: Description
|
||||||
|
type: textarea
|
||||||
|
translate: true
|
||||||
|
footerLogo:
|
||||||
|
label: Logo
|
||||||
type: files
|
type: files
|
||||||
|
layout: cards
|
||||||
max: 1
|
max: 1
|
||||||
accept: application/pdf
|
help: Utilisé en pied de page (page À propos, Blog et Article).
|
||||||
|
|
||||||
|
- width: 1/2
|
||||||
|
fields:
|
||||||
|
contactEmail:
|
||||||
|
label: Email
|
||||||
|
type: email
|
||||||
|
width: 1/2
|
||||||
translate: false
|
translate: false
|
||||||
|
contactAddress:
|
||||||
|
label: Adresse
|
||||||
|
type: writer
|
||||||
|
buttons: false
|
||||||
|
nodes: false
|
||||||
|
width: 1/2
|
||||||
|
placeholder: 33 rue Jean Dupont
|
||||||
|
socialLinks:
|
||||||
|
label: Liens réseaux sociaux
|
||||||
|
type: structure
|
||||||
|
translate: false
|
||||||
|
fields:
|
||||||
|
label:
|
||||||
|
label: Nom
|
||||||
|
required: true
|
||||||
|
type: text
|
||||||
|
width: 1/3
|
||||||
|
placeholder: "Ex: LinkedIn, Instagram..."
|
||||||
|
url:
|
||||||
|
label: URL
|
||||||
|
required: true
|
||||||
|
type: url
|
||||||
|
width: 1/3
|
||||||
|
picto:
|
||||||
|
label: Icône SVG
|
||||||
|
type: textarea
|
||||||
|
width: 1/3
|
||||||
|
buttons: false
|
||||||
|
size: small
|
||||||
|
help: Collez le code SVG de l'icône
|
||||||
|
|
||||||
|
- width: 1/2
|
||||||
|
sections:
|
||||||
|
contact:
|
||||||
|
type: fields
|
||||||
|
fields:
|
||||||
|
legalNotices:
|
||||||
|
label: Mentions légales (PDF)
|
||||||
|
type: files
|
||||||
|
max: 1
|
||||||
|
accept: application/pdf
|
||||||
|
translate: false
|
||||||
|
dataTab:
|
||||||
|
label: Données d'usage
|
||||||
|
icon: chart
|
||||||
|
fields:
|
||||||
|
contactDatabase:
|
||||||
|
label: Ont téléchargé un livre blanc
|
||||||
|
type: structure
|
||||||
|
fields:
|
||||||
|
firstName:
|
||||||
|
label: Prénom
|
||||||
|
type: text
|
||||||
|
required: true
|
||||||
|
lastName:
|
||||||
|
label: Nom
|
||||||
|
type: text
|
||||||
|
required: true
|
||||||
|
company:
|
||||||
|
label: Société
|
||||||
|
type: text
|
||||||
|
role:
|
||||||
|
label: Fonction
|
||||||
|
type: text
|
||||||
|
email:
|
||||||
|
type: email
|
||||||
|
required: true
|
||||||
|
|
|
||||||
|
|
@ -13,25 +13,6 @@ return [
|
||||||
'thumbs' => require __DIR__ . '/thumbs.php',
|
'thumbs' => require __DIR__ . '/thumbs.php',
|
||||||
|
|
||||||
'routes' => [
|
'routes' => [
|
||||||
[
|
require(__DIR__ . '/routes/download-white-paper.php')
|
||||||
'pattern' => ['(:any)/(:any)/download', 'en/(:any)/(:any)/download'],
|
|
||||||
'method' => 'POST',
|
|
||||||
'action' => function (string $parent, string $slug) {
|
|
||||||
$page = kirby()->page($parent . '/' . $slug);
|
|
||||||
if (!$page || $page->intendedTemplate()->name() !== 'white-paper') {
|
|
||||||
http_response_code(404);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['error' => 'Not found']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
// TODO: store/email form data ($kirby->request()->body())
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => true,
|
|
||||||
'fileUrl' => $page->downloadFile()->toFile()?->url(),
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
99
site/config/routes/download-white-paper.php
Normal file
99
site/config/routes/download-white-paper.php
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
function wpReject(int $code, string $message): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => $message]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pattern' => ['(:any)/(:any)/download', 'en/(:any)/(:any)/download'],
|
||||||
|
'method' => 'POST',
|
||||||
|
'action' => function (string $parent, string $slug) {
|
||||||
|
|
||||||
|
$page = page($parent . '/' . $slug);
|
||||||
|
if (!$page || $page->intendedTemplate()->name() !== 'white-paper') {
|
||||||
|
wpReject(404, 'Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = kirby()->request()->body()->toArray();
|
||||||
|
|
||||||
|
// ── Honeypot ──────────────────────────────────────────────
|
||||||
|
if (!empty($body['_hp'])) {
|
||||||
|
wpReject(400, 'Bad request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timing check (min 3 s) ────────────────────────────────
|
||||||
|
$openedAt = isset($body['_t']) ? (int)$body['_t'] : 0;
|
||||||
|
if ($openedAt === 0 || (time() * 1000 - $openedAt) < 3000) {
|
||||||
|
wpReject(400, 'Too fast');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rate limiting (5 req / hour / IP) ─────────────────────
|
||||||
|
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
|
$cacheKey = 'wp-dl-' . md5($ip);
|
||||||
|
$cache = kirby()->cache('pages');
|
||||||
|
$hits = (int)($cache->get($cacheKey) ?? 0);
|
||||||
|
if ($hits >= 5) {
|
||||||
|
wpReject(429, 'Too many requests');
|
||||||
|
}
|
||||||
|
$cache->set($cacheKey, $hits + 1, 60); // TTL 60 min
|
||||||
|
|
||||||
|
// ── Validation des champs requis ──────────────────────────
|
||||||
|
$firstName = trim($body['firstName'] ?? '');
|
||||||
|
$lastName = trim($body['lastName'] ?? '');
|
||||||
|
$email = trim($body['email'] ?? '');
|
||||||
|
|
||||||
|
if ($firstName === '' || $lastName === '' || $email === '') {
|
||||||
|
wpReject(422, 'Missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
wpReject(422, 'Invalid email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stocker le lead dans contactDatabase ──────────────────
|
||||||
|
$company = trim($body['company'] ?? '');
|
||||||
|
$role = trim($body['role'] ?? '');
|
||||||
|
$entries = $page->contactDatabase()->toStructure()->toArray();
|
||||||
|
|
||||||
|
$existingIndex = null;
|
||||||
|
foreach ($entries as $i => $entry) {
|
||||||
|
if (strtolower($entry['email'] ?? '') === strtolower($email)) {
|
||||||
|
$existingIndex = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingIndex !== null) {
|
||||||
|
// Contact déjà présent — on enrichit les champs vides uniquement
|
||||||
|
if ($company !== '' && empty($entries[$existingIndex]['company'])) {
|
||||||
|
$entries[$existingIndex]['company'] = $company;
|
||||||
|
}
|
||||||
|
if ($role !== '' && empty($entries[$existingIndex]['role'])) {
|
||||||
|
$entries[$existingIndex]['role'] = $role;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$entries[] = [
|
||||||
|
'firstName' => $firstName,
|
||||||
|
'lastName' => $lastName,
|
||||||
|
'email' => $email,
|
||||||
|
'company' => $company,
|
||||||
|
'role' => $role,
|
||||||
|
'downloadedAt' => date('d/m/Y H:i'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
kirby()->impersonate('kirby', function () use ($page, $entries) {
|
||||||
|
$page->update(['contactDatabase' => \Kirby\Data\Data::encode($entries, 'yaml')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'fileUrl' => $page->downloadFile()->toFile()?->url(),
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseOver = (e) => {
|
const handleMouseOver = (e) => {
|
||||||
onTarget = !!e.target.closest('a, button, [role="button"], [tabindex]')
|
onTarget = !!e.target.closest('a, button:not([disabled]), [role="button"]:not([disabled]), [tabindex]')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseOut = () => {
|
const handleMouseOut = () => {
|
||||||
|
|
|
||||||
|
|
@ -50,16 +50,28 @@ const dict = {
|
||||||
// White Papers
|
// White Papers
|
||||||
white_paper_label: { fr: "LIVRE BLANC", en: "WHITE PAPER" },
|
white_paper_label: { fr: "LIVRE BLANC", en: "WHITE PAPER" },
|
||||||
read_wp: { fr: "Télécharger", en: "Download" },
|
read_wp: { fr: "Télécharger", en: "Download" },
|
||||||
wp_form_intro: { fr: "Renseignez vos informations pour télécharger notre livre blanc.", en: "Fill in your information to download our white paper." },
|
wp_form_intro: {
|
||||||
|
fr: "Renseignez vos informations pour télécharger notre livre blanc.",
|
||||||
|
en: "Fill in your information to download our white paper.",
|
||||||
|
},
|
||||||
wp_firstname: { fr: "Prénom*", en: "First name*" },
|
wp_firstname: { fr: "Prénom*", en: "First name*" },
|
||||||
wp_lastname: { fr: "Nom*", en: "Last name*" },
|
wp_lastname: { fr: "Nom*", en: "Last name*" },
|
||||||
wp_company: { fr: "Société*", en: "Company*" },
|
wp_company: { fr: "Société", en: "Company" },
|
||||||
wp_role: { fr: "Fonction*", en: "Role*" },
|
wp_role: { fr: "Fonction", en: "Role" },
|
||||||
wp_email: { fr: "E-mail*", en: "E-mail*" },
|
wp_email: { fr: "E-mail*", en: "E-mail*" },
|
||||||
wp_consent: { fr: "En cochant cette case, j'accepte d'être recontacté par la société World Game. Mes données ne seront ni vendues, ni partagées.", en: "By checking this box, I agree to be contacted by World Game. My data will not be sold or shared." },
|
wp_consent: {
|
||||||
wp_download: { fr: "TÉLÉCHARGEMENT", en: "DOWNLOAD" },
|
fr: "En cochant cette case, j'accepte d'être recontacté par la société World Game. Mes données ne seront ni vendues, ni partagées.",
|
||||||
wp_success: { fr: "Votre demande a été enregistrée. Le téléchargement devrait démarrer.", en: "Your request has been registered. The download should start." },
|
en: "By checking this box, I agree to be contacted by World Game. My data will not be sold or shared.",
|
||||||
wp_error: { fr: "Une erreur est survenue, veuillez réessayer.", en: "An error occurred, please try again." },
|
},
|
||||||
|
wp_download: { fr: "TÉLÉCHARGER", en: "DOWNLOAD" },
|
||||||
|
wp_success: {
|
||||||
|
fr: "Votre demande a été enregistrée. Le téléchargement devrait démarrer.",
|
||||||
|
en: "Your request has been registered. The download should start.",
|
||||||
|
},
|
||||||
|
wp_error: {
|
||||||
|
fr: "Une erreur est survenue, veuillez réessayer.",
|
||||||
|
en: "An error occurred, please try again.",
|
||||||
|
},
|
||||||
// Menu
|
// Menu
|
||||||
menu: { fr: "MENU", en: "MENU" },
|
menu: { fr: "MENU", en: "MENU" },
|
||||||
connect: { fr: "CONNECT", en: "CONNECT" },
|
connect: { fr: "CONNECT", en: "CONNECT" },
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ button {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Button */
|
/* Button */
|
||||||
.button {
|
.button {
|
||||||
width: 14vmax;
|
width: 14vmax;
|
||||||
|
|
@ -26,7 +30,7 @@ button {
|
||||||
outline: 2px solid #04fea0;
|
outline: 2px solid #04fea0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:not([disabled]):hover {
|
||||||
background-color: initial;
|
background-color: initial;
|
||||||
background-position: 0;
|
background-position: 0;
|
||||||
outline: 2px solid #04fea0;
|
outline: 2px solid #04fea0;
|
||||||
|
|
@ -64,6 +68,5 @@ button {
|
||||||
|
|
||||||
/* Clickable elements */
|
/* Clickable elements */
|
||||||
.clickable {
|
.clickable {
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,22 @@
|
||||||
let consent = $state(false)
|
let consent = $state(false)
|
||||||
let submitting = $state(false)
|
let submitting = $state(false)
|
||||||
let status = $state(null) // null | 'success' | 'error'
|
let status = $state(null) // null | 'success' | 'error'
|
||||||
let showForm = $state(false)
|
let showForm = $state(false)
|
||||||
|
let honeypot = $state('')
|
||||||
|
let formOpenedAt = $state(0)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && formOpenedAt === 0) formOpenedAt = Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
let isEmailValid = $derived.by(() => {
|
||||||
|
const emailValidator = /^[\w\-\.]+@([\w-]+\.)+[\w-]{2,}$/gm
|
||||||
|
return emailValidator.test(email)
|
||||||
|
})
|
||||||
|
|
||||||
|
let isDownloadable = $derived.by(() => {
|
||||||
|
return firstName.length > 0 && lastName.length > 0 && email.length > 0 && isEmailValid && consent
|
||||||
|
})
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -24,7 +39,7 @@
|
||||||
const res = await fetch(`${prefix}/${data.uri}/download`, {
|
const res = await fetch(`${prefix}/${data.uri}/download`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ firstName, lastName, company, role, email })
|
body: JSON.stringify({ firstName, lastName, company, role, email, _hp: honeypot, _t: formOpenedAt })
|
||||||
})
|
})
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
if (result.fileUrl) {
|
if (result.fileUrl) {
|
||||||
|
|
@ -77,10 +92,15 @@
|
||||||
<input class="input" type="text" placeholder={t('wp_firstname')} bind:value={firstName} required />
|
<input class="input" type="text" placeholder={t('wp_firstname')} bind:value={firstName} required />
|
||||||
<input class="input" type="text" placeholder={t('wp_lastname')} bind:value={lastName} required />
|
<input class="input" type="text" placeholder={t('wp_lastname')} bind:value={lastName} required />
|
||||||
</div>
|
</div>
|
||||||
<input class="input" type="text" placeholder={t('wp_company')} bind:value={company} required />
|
<input class="input" type="text" placeholder={t('wp_company')} bind:value={company} />
|
||||||
<input class="input" type="text" placeholder={t('wp_role')} bind:value={role} required />
|
<input class="input" type="text" placeholder={t('wp_role')} bind:value={role} />
|
||||||
<input class="input" type="email" placeholder={t('wp_email')} bind:value={email} required />
|
<input class="input" type="email" placeholder={t('wp_email')} bind:value={email} required />
|
||||||
|
|
||||||
|
<div class="hp" aria-hidden="true">
|
||||||
|
<label for="website">Website</label>
|
||||||
|
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="consent">
|
<label class="consent">
|
||||||
<input type="checkbox" bind:checked={consent} required />
|
<input type="checkbox" bind:checked={consent} required />
|
||||||
<span>{t('wp_consent')}</span>
|
<span>{t('wp_consent')}</span>
|
||||||
|
|
@ -92,7 +112,7 @@
|
||||||
<p class="status status--error">{t('wp_error')}</p>
|
<p class="status status--error">{t('wp_error')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" class="submit button" disabled={submitting || !consent}>
|
<button type="submit" class="submit button" disabled={submitting || !isDownloadable}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
<path d="M12 16L7 11H10V4H14V11H17L12 16Z" fill="currentColor"/>
|
<path d="M12 16L7 11H10V4H14V11H17L12 16Z" fill="currentColor"/>
|
||||||
<path d="M5 20H19V18H5V20Z" fill="currentColor"/>
|
<path d="M5 20H19V18H5V20Z" fill="currentColor"/>
|
||||||
|
|
@ -228,6 +248,16 @@
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hp {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.consent {
|
.consent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue