diff --git a/site/blueprints/pages/white-paper.yml b/site/blueprints/pages/white-paper.yml new file mode 100644 index 0000000..57d1c52 --- /dev/null +++ b/site/blueprints/pages/white-paper.yml @@ -0,0 +1,108 @@ +title: Livre blanc +icon: file +status: + draft: + label: Brouillon + text: Non visible + listed: + label: Publié + text: Visible publiquement +tabs: + content: + label: Contenu + icon: text + columns: + main: + width: 2/3 + sections: + header: + type: fields + fields: + intro: + label: Description courte + type: writer + nodes: false + marks: + - bold + - italic + - link + maxlength: 200 + sidebar: + width: 1/3 + sections: + meta: + type: fields + fields: + published: + label: Date de publication + type: date + display: DD/MM/YYYY + required: true + default: today + translate: false + cover: + label: Image de couverture + type: files + layout: cards + max: 1 + accept: image/* + translate: false + image: + cover: true + ratio: 3/4 + uploads: + template: image + downloadFile: + label: Fichier PDF + type: files + max: 1 + accept: application/pdf + translate: false + 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 diff --git a/site/blueprints/pages/white-papers.yml b/site/blueprints/pages/white-papers.yml new file mode 100644 index 0000000..816c625 --- /dev/null +++ b/site/blueprints/pages/white-papers.yml @@ -0,0 +1,43 @@ +title: Livres blancs +icon: book +status: + draft: + label: Brouillon + text: La page est accessible uniquement pour les éditeurs connectés + listed: + label: Public + text: La page est accessible par tout le monde +tabs: + content: + label: Contenu + icon: text + sections: + content: + type: fields + fields: + intro: + type: writer + marks: + - bold + - italic + - green + - pixel + - underline + - strike + - clear + - link + nodes: + - heading + headings: + - 1 + help: Section de texte centrée (optionnelle). + items: + label: Livres blancs + type: pages + layout: cards + sortBy: published desc + template: white-paper + image: + ratio: 3/4 + cover: true + info: "{{ page.published.toDate('d/m/Y') }}" diff --git a/site/blueprints/site.yml b/site/blueprints/site.yml index f5a74b9..f4a3748 100644 --- a/site/blueprints/site.yml +++ b/site/blueprints/site.yml @@ -1,85 +1,113 @@ title: World Game -columns: - - width: 1/2 - sections: - navigation: - type: pages - label: Menu - help: Accédez aux pages et réordonnez-les - layout: list - size: small - sortable: true - create: false - 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 +tabs: + mainTab: + label: Principal + columns: + - width: 1/2 + sections: + navigation: + type: pages + label: Menu + help: Accédez aux pages et réordonnez-les + layout: list size: small - help: Collez le code SVG de l'icône + sortable: true + create: false + status: listed - - width: 1/2 - sections: - contact: - type: fields + - width: 1/2 fields: - legalNotices: - label: Mentions légales (PDF) + 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 - 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 + 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 diff --git a/site/config/config.php b/site/config/config.php index c556463..40b0846 100644 --- a/site/config/config.php +++ b/site/config/config.php @@ -11,4 +11,8 @@ return [ ], 'thumbs' => require __DIR__ . '/thumbs.php', + + 'routes' => [ + require(__DIR__ . '/routes/download-white-paper.php') + ], ]; diff --git a/site/config/menu.php b/site/config/menu.php index ab988d0..c88dcc5 100644 --- a/site/config/menu.php +++ b/site/config/menu.php @@ -29,7 +29,8 @@ return [ 'portfolio' => menuItem('portfolio', 'Portfolio','images', 'pages/portfolio'), 'jouer' => menuItem('jouer', 'Jouer', 'play', 'pages/jouer'), 'a-propos' => menuItem('a-propos', 'À propos', 'users', 'pages/a-propos'), - 'blog' => menuItem('blog', 'Blog', 'text', 'pages/blog'), + 'blog' => menuItem('blog', 'Blog', 'text', 'pages/blog'), + 'white-papers' => menuItem('livres-blancs', 'Livres blancs', 'book', 'pages/livres-blancs'), '-', 'users', 'system', diff --git a/site/config/routes/download-white-paper.php b/site/config/routes/download-white-paper.php new file mode 100644 index 0000000..41d93aa --- /dev/null +++ b/site/config/routes/download-white-paper.php @@ -0,0 +1,93 @@ + $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'); + } + + // ── 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; + } +]; diff --git a/site/templates/white-paper.json.php b/site/templates/white-paper.json.php new file mode 100644 index 0000000..11ea537 --- /dev/null +++ b/site/templates/white-paper.json.php @@ -0,0 +1,13 @@ + $page->published()->toDate('d/m/Y'), + 'intro' => $page->intro()->inline()->value(), + 'cover' => $page->cover()->toFile()?->url(), + 'fileUrl' => $page->downloadFile()->toFile()?->url(), +]; + +$pageData = array_merge($genericData, $specificData); + +header('Content-Type: application/json'); +echo json_encode($pageData); diff --git a/site/templates/white-paper.php b/site/templates/white-paper.php new file mode 100644 index 0000000..f31b64d --- /dev/null +++ b/site/templates/white-paper.php @@ -0,0 +1,2 @@ + + diff --git a/site/templates/white-papers.json.php b/site/templates/white-papers.json.php new file mode 100644 index 0000000..cfdce03 --- /dev/null +++ b/site/templates/white-papers.json.php @@ -0,0 +1,26 @@ +children()->listed()->sortBy('published', 'desc'); + +$mapItem = function ($item) { + return [ + 'title' => $item->title()->value(), + 'slug' => $item->slug(), + 'published' => $item->published()->toDate('d/m/Y'), + 'intro' => $item->intro()->value(), + 'cover' => $item->cover()->toFile()?->url(), + ]; +}; + +$singleSlug = $items->count() === 1 ? $items->first()->slug() : null; + +$specificData = [ + 'intro' => $page->intro()->value(), + 'items' => $items->map($mapItem)->values(), + 'singleSlug' => $singleSlug, +]; + +$pageData = array_merge($genericData, $specificData); + +header('Content-Type: application/json'); +echo json_encode($pageData); diff --git a/site/templates/white-papers.php b/site/templates/white-papers.php new file mode 100644 index 0000000..f31b64d --- /dev/null +++ b/site/templates/white-papers.php @@ -0,0 +1,2 @@ + + diff --git a/src/App.svelte b/src/App.svelte index daa35b0..719d74d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -16,6 +16,7 @@ import Game from '@views/Game.svelte' import Blog from '@views/Blog.svelte' import Article from '@views/Article.svelte' + import WhitePapers from '@views/WhitePapers.svelte' import Default from '@views/Default.svelte' const templates = { @@ -28,6 +29,7 @@ game: Game, blog: Blog, article: Article, + 'white-papers': WhitePapers, default: Default } @@ -56,10 +58,22 @@ window.addEventListener('resize', handleResize) const handleKeydown = (e) => { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return + + // Si on est sur une sous-page (ex: /livres-blancs/slug), ne pas changer de slide + const activePath = slides.active?.path ?? '' + const currentPath = window.location.pathname.replace(/^\/en/, '') || '/' + const isSubPage = activePath && currentPath.startsWith(activePath + '/') + + if (isSubPage) { + if (e.key === 'ArrowLeft') history.back() + return + } + if (e.key === 'ArrowRight') { const next = slides.all[slides.activeIndex + 1] if (next) slideTo(next.path) - } else if (e.key === 'ArrowLeft') { + } else { const prev = slides.all[slides.activeIndex - 1] if (prev) slideTo(prev.path) } diff --git a/src/components/layout/Cursor.svelte b/src/components/layout/Cursor.svelte index ac57847..f2d9e53 100644 --- a/src/components/layout/Cursor.svelte +++ b/src/components/layout/Cursor.svelte @@ -14,7 +14,7 @@ } 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 = () => { diff --git a/src/components/layout/Footer.svelte b/src/components/layout/Footer.svelte index 767047e..2fe610c 100644 --- a/src/components/layout/Footer.svelte +++ b/src/components/layout/Footer.svelte @@ -124,7 +124,7 @@ margin-left: -16.6vw; } - :global(.blog .page-scrollable-footer) { + :global(.collection .page-scrollable-footer) { margin-left: -12.4vw; margin-top: 5rem; } diff --git a/src/i18n/index.js b/src/i18n/index.js index 21f2f6d..4e6dd2d 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -47,6 +47,31 @@ const dict = { privacy: { fr: "Confidentialité", en: "Privacy" }, // About our_team: { fr: "NOTRE ÉQUIPE", en: "OUR TEAM" }, + // White Papers + white_paper_label: { fr: "LIVRE BLANC", en: "WHITE PAPER" }, + 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_firstname: { fr: "Prénom*", en: "First name*" }, + wp_lastname: { fr: "Nom*", en: "Last name*" }, + wp_company: { fr: "Société", en: "Company" }, + wp_role: { fr: "Fonction", en: "Role" }, + 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_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: { fr: "MENU", en: "MENU" }, connect: { fr: "CONNECT", en: "CONNECT" }, diff --git a/src/styles/buttons.css b/src/styles/buttons.css index 5ab2b9b..c578ba5 100644 --- a/src/styles/buttons.css +++ b/src/styles/buttons.css @@ -2,6 +2,10 @@ button { border: none; } +button[disabled] { + cursor: none; +} + /* Button */ .button { width: 14vmax; @@ -26,7 +30,7 @@ button { outline: 2px solid #04fea0; } -.button:hover { +.button:not([disabled]):hover { background-color: initial; background-position: 0; outline: 2px solid #04fea0; @@ -64,6 +68,5 @@ button { /* Clickable elements */ .clickable { - cursor: pointer; user-select: none; } diff --git a/src/styles/collection.css b/src/styles/collection.css new file mode 100644 index 0000000..1312a19 --- /dev/null +++ b/src/styles/collection.css @@ -0,0 +1,180 @@ +/* Shared styles for collection pages (Blog, WhitePapers) */ + +/* --- Header / Intro --- */ +.collection-header { + text-align: center; + padding: 6rem 0 3rem; + max-width: 40rem; + margin: auto; +} + +.collection-header h1 { + font-size: var(--font-size-title-main); + text-transform: uppercase; + margin-bottom: 2rem; +} + +.collection-header p { + font-size: var(--font-size-subtitle); + font-weight: 400; +} + +.collection-header :global(h1) { + font-family: "Terminal", sans-serif; + font-size: var(--font-size-title-main); + text-transform: uppercase; + margin-bottom: 1.5rem; +} + +.collection-header :global(p) { + font-size: var(--font-size-subtitle); + line-height: 1.6; + max-width: 640px; + margin: 0 auto; + opacity: 0.9; +} + +/* --- Card --- */ +.collection-card { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 2rem; + padding: 1.5rem 0; +} + +.collection-card-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 640px; +} + +.collection-card-date { + color: #d9d9d9; + font-size: var(--font-size-paragraph); +} + +.collection-card-title { + font-family: "Danzza", sans-serif; + font-size: 40px; + max-width: 80%; + font-weight: 700; +} + +.collection-card-title a { + transition: color 0.2s; +} + +.collection-card-title a:hover { + color: var(--color-primary); +} + +.collection-card-description { + color: #d9d9d9; + font-family: "Danzza", sans-serif; + font-size: var(--font-size-paragraph); + font-weight: 400; +} + +.collection-card-readmore { + color: var(--color-primary); + font-family: "Danzza", sans-serif; + font-size: var(--font-size-paragraph); + font-weight: 500; + text-transform: uppercase; +} + +.collection-card-readmore .arrow { + margin-left: 5px; +} + +/* --- Image --- */ +.collection-card-image img { + width: 300px; + height: 169px; + object-fit: cover; + transition: transform 0.3s; +} + +.collection-card-image img:hover { + transform: scale(1.05); +} + +.collection-card-image--featured img { + width: auto; + height: 300px; +} + +/* --- Divider --- */ +.collection-divider { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.15); + margin: 0; +} + +/* --- Featured --- */ +.collection-card--featured .collection-card-title { + font-size: 36px; + line-height: 1.3; +} + +/* --- Loading --- */ +.collection-loading { + text-align: center; + padding: 4rem 0; + opacity: 0.6; +} + +/* --- Mobile --- */ +@media (max-width: 700px) { + .collection-header { + padding: 4rem 0 2rem; + } + + .collection-header :global(h1) { + font-size: var(--font-size-title-main-mobile); + } + + .collection-header :global(p) { + font-size: var(--font-size-subtitle-mobile); + } + + .collection-card { + flex-direction: column; + } + + .collection-card-image img, + .collection-card-image--featured img { + width: 100%; + height: auto; + aspect-ratio: 16/9; + } + + .collection-card--featured .collection-card-title { + font-size: var(--font-size-title-section-mobile); + } + + .collection-card-title { + font-size: var(--font-size-title-section-mobile); + } +} + +/* --- Tablet --- */ +@media (min-width: 701px) and (max-width: 912px) { + .collection-header :global(h1) { + font-size: var(--font-size-title-main-tablet); + } + + .collection-card { + flex-direction: column; + } + + .collection-card-image img, + .collection-card-image--featured img { + width: 100%; + height: auto; + aspect-ratio: 16/9; + } +} diff --git a/src/styles/fonts.css b/src/styles/fonts.css index 6b34a2a..33a5c1d 100644 --- a/src/styles/fonts.css +++ b/src/styles/fonts.css @@ -65,3 +65,7 @@ background-clip: text; -webkit-background-clip: text; } + +.pixel { + font-family: "Terminal", sans-serif; +} diff --git a/src/styles/index.css b/src/styles/index.css index d437c36..0db6dc0 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -3,6 +3,7 @@ @import './reset.css'; @import './fonts.css'; @import './layout.css'; +@import './collection.css'; @import './buttons.css'; @import './cursor.css'; @import './utils.css'; diff --git a/src/views/Blog.svelte b/src/views/Blog.svelte index d4295b6..aeb0029 100644 --- a/src/views/Blog.svelte +++ b/src/views/Blog.svelte @@ -95,10 +95,11 @@ } }) - // Reset article view when leaving the blog slide + // Reset après la fin de la transition de slide (1100ms) pour éviter le flash $effect(() => { if (!isActive && articleData) { - articleData = null + const timer = setTimeout(() => { articleData = null }, 1100) + return () => clearTimeout(timer) } }) @@ -106,7 +107,7 @@
{#if data?.intro} -
+
{@html data.intro}
{/if} {#if featured} -