Files
Portfolio-Skycel/includes/functions.php

590 lines
31 KiB
PHP

<?php
/**
* Fonctions helpers du portfolio
*/
require_once __DIR__ . '/../vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailerException;
/**
* Inclut un template avec des données
* @param string $name Nom du template (sans .php)
* @param array $data Variables à passer au template
*/
function include_template(string $name, array $data = []): void
{
extract($data);
include __DIR__ . "/../templates/{$name}.php";
}
/**
* Charge et parse un fichier JSON
* @param string $filename Nom du fichier dans le dossier data/
* @return array Données décodées ou tableau vide en cas d'erreur
*/
function loadJsonData(string $filename): array
{
$path = __DIR__ . "/../data/{$filename}";
if (!file_exists($path)) {
error_log("JSON file not found: {$filename}");
return [];
}
$content = file_get_contents($path);
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("JSON parse error in {$filename}: " . json_last_error_msg());
return [];
}
return $data;
}
/**
* Récupère tous les projets
* @return array Liste des projets
*/
function getProjects(): array
{
$data = loadJsonData('projects.json');
return $data['projects'] ?? [];
}
/**
* Récupère les projets par catégorie
* @param string $category Catégorie (vedette|secondaire)
* @return array Projets filtrés
*/
function getProjectsByCategory(string $category): array
{
return array_filter(getProjects(), fn($p) => $p['category'] === $category);
}
/**
* Récupère un projet par son slug
* @param string $slug Slug du projet
* @return array|null Projet ou null si non trouvé
*/
function getProjectBySlug(string $slug): ?array
{
$projects = getProjects();
foreach ($projects as $project) {
if ($project['slug'] === $slug) {
return $project;
}
}
return null;
}
/**
* Récupère les technologies uniques de tous les projets
* @return array Liste triée des technologies
*/
function getAllTechnologies(): array
{
$technologies = [];
foreach (getProjects() as $project) {
foreach ($project['technologies'] ?? [] as $tech) {
if (!in_array($tech, $technologies)) {
$technologies[] = $tech;
}
}
}
sort($technologies);
return $technologies;
}
/**
* Génère le HTML pour une image projet optimisée
* Utilise <picture> pour WebP avec fallback JPG
*
* @param string $filename Nom du fichier image (ex: project-thumb.webp)
* @param string $alt Texte alternatif
* @param int $width Largeur en pixels
* @param int $height Hauteur en pixels
* @param bool $lazy Activer le lazy loading (défaut: true)
* @param string $class Classes CSS additionnelles
* @return string HTML de l'image
*/
function projectImage(string $filename, string $alt, int $width, int $height, bool $lazy = true, string $class = ''): string
{
$alt = htmlspecialchars($alt, ENT_QUOTES, 'UTF-8');
$class = htmlspecialchars($class, ENT_QUOTES, 'UTF-8');
$lazyAttr = $lazy ? 'loading="lazy"' : '';
// Détermine les chemins WebP et fallback
$basePath = '/assets/img/projects/';
$webpFile = $filename;
// Si le fichier n'est pas .webp, on essaie de trouver la version .webp
if (!str_ends_with($filename, '.webp')) {
$webpFile = preg_replace('/\.(jpg|jpeg|png)$/i', '.webp', $filename);
}
// Fallback: remplace .webp par .jpg
$fallbackFile = str_replace('.webp', '.jpg', $webpFile);
// Image par défaut si fichier manquant
$defaultImage = $basePath . 'default-project.svg';
return <<<HTML
<picture>
<source srcset="{$basePath}{$webpFile}" type="image/webp">
<img
src="{$basePath}{$fallbackFile}"
alt="{$alt}"
width="{$width}"
height="{$height}"
{$lazyAttr}
class="{$class}"
onerror="this.onerror=null; this.src='{$defaultImage}';"
>
</picture>
HTML;
}
/**
* Compte les projets par technologie
* @return array Tableau associatif [technologie => nombre]
*/
function getProjectCountByTech(): array
{
$projects = getProjects();
$count = [];
foreach ($projects as $project) {
foreach ($project['technologies'] ?? [] as $tech) {
$count[$tech] = ($count[$tech] ?? 0) + 1;
}
}
return $count;
}
/**
* Récupère les projets utilisant une technologie
* @param string $tech Nom de la technologie
* @return array Projets filtrés
*/
function getProjectsByTech(string $tech): array
{
return array_filter(getProjects(), function($project) use ($tech) {
return in_array($tech, $project['technologies'] ?? []);
});
}
/**
* Retourne l'icône SVG d'un outil
* @param string $icon Identifiant de l'outil
* @return string SVG de l'icône
*/
function getToolIcon(string $icon): string
{
$icons = [
'github' => '<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>',
'vscode' => '<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"/></svg>',
'figma' => '<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M15.852 8.981h-4.588V0h4.588c2.476 0 4.49 2.014 4.49 4.49s-2.014 4.491-4.49 4.491zM12.735 7.51h3.117c1.665 0 3.019-1.355 3.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0 1.471H8.148c-2.476 0-4.49-2.014-4.49-4.49S5.672 0 8.148 0h4.588v8.981zm-4.587-7.51c-1.665 0-3.019 1.355-3.019 3.019s1.354 3.02 3.019 3.02h3.117V1.471H8.148zm4.587 15.019H8.148c-2.476 0-4.49-2.014-4.49-4.49s2.014-4.49 4.49-4.49h4.588v8.98zM8.148 8.981c-1.665 0-3.019 1.355-3.019 3.019s1.355 3.019 3.019 3.019h3.117V8.981H8.148zM8.172 24c-2.489 0-4.515-2.014-4.515-4.49s2.014-4.49 4.49-4.49h4.588v4.441c0 2.503-2.047 4.539-4.563 4.539zm-.024-7.51a3.023 3.023 0 0 0-3.019 3.019c0 1.665 1.365 3.019 3.044 3.019 1.705 0 3.093-1.376 3.093-3.068v-2.97H8.148zm7.704 0h-.098c-2.476 0-4.49-2.014-4.49-4.49s2.014-4.49 4.49-4.49h.098c2.476 0 4.49 2.014 4.49 4.49s-2.014 4.49-4.49 4.49zm-.098-7.509c-1.665 0-3.019 1.355-3.019 3.019s1.355 3.019 3.019 3.019h.098c1.665 0 3.019-1.355 3.019-3.019s-1.355-3.019-3.019-3.019h-.098z"/></svg>',
'notion' => '<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.98-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.886l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z"/></svg>',
'docker' => '<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.185.185v1.888c0 .102.084.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>',
'linux' => '<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139zm.529 3.405h.013c.213 0 .396.062.584.198.19.135.33.332.438.533.105.259.158.459.166.724 0-.02.006-.04.006-.06v.105a.086.086 0 01-.004-.021l-.004-.024a1.807 1.807 0 01-.15.706.953.953 0 01-.213.335.71.71 0 00-.088-.042c-.104-.045-.198-.064-.284-.133a1.312 1.312 0 00-.22-.066c.05-.06.146-.133.183-.198.053-.128.082-.264.088-.402v-.02a1.21 1.21 0 00-.061-.4c-.045-.134-.101-.2-.183-.333-.084-.066-.167-.132-.267-.132h-.016c-.093 0-.176.03-.262.132a.8.8 0 00-.205.334 1.18 1.18 0 00-.09.4v.019c.002.089.008.179.02.267-.193-.067-.438-.135-.607-.202a1.635 1.635 0 01-.018-.2v-.02a1.772 1.772 0 01.15-.768c.082-.22.232-.406.43-.533a.985.985 0 01.594-.2zm-2.962.059h.036c.142 0 .27.048.399.135.146.129.264.288.344.465.09.199.14.4.153.667v.004c.007.134.006.2-.002.266v.08c-.03.007-.056.018-.083.024-.152.055-.274.135-.393.2.012-.09.013-.18.003-.267v-.015c-.012-.133-.04-.2-.082-.333a.613.613 0 00-.166-.267.248.248 0 00-.183-.064h-.021c-.071.006-.13.04-.186.132a.552.552 0 00-.12.27.944.944 0 00-.023.33v.015c.012.135.037.2.08.334.046.134.098.2.166.268.01.009.02.018.034.024-.07.057-.117.07-.176.136a.304.304 0 01-.131.068 2.62 2.62 0 01-.275-.402 1.772 1.772 0 01-.155-.667 1.759 1.759 0 01.08-.668 1.43 1.43 0 01.283-.535c.128-.133.26-.2.418-.2zm1.37 1.706c.332 0 .733.065 1.216.399.293.2.523.269 1.052.468h.003c.255.136.405.266.478.399v-.131a.571.571 0 01.016.47c-.123.31-.516.643-1.063.842v.002c-.268.135-.501.333-.775.465-.276.135-.588.292-1.012.267a1.139 1.139 0 01-.448-.067 3.566 3.566 0 01-.322-.198c-.195-.135-.363-.332-.612-.465v-.005h-.005c-.4-.246-.616-.512-.686-.71-.07-.268-.005-.47.193-.6.224-.135.38-.271.483-.336.104-.074.143-.102.176-.131h.002v-.003c.169-.202.436-.47.839-.601.139-.036.294-.065.466-.065zm2.8 2.142c.358 1.417 1.196 3.475 1.735 4.473.286.534.855 1.659 1.102 3.024.156-.005.33.018.513.064.646-1.671-.546-3.467-1.089-3.966-.22-.2-.232-.335-.123-.335.59.534 1.365 1.572 1.646 2.757.13.535.16 1.104.021 1.67.067.028.135.06.205.067 1.032.534 1.413.938 1.23 1.537v-.002c-.06-.135-.12-.2-.246-.335-.126-.135-.267-.265-.42-.4-.12-.07-.27-.131-.452-.2-.146-.064-.292-.135-.39-.2v-.002c.006-.064.016-.133.025-.2.056-.468.027-.936-.098-1.403-.095-.332-.24-.73-.45-1.137.143.07.29.135.446.2.36.135.7.202 1.036.135.337-.064.665-.267.91-.669v-.002c-.12.135-.26.2-.416.266a1.477 1.477 0 01-.49.064c-.075 0-.146-.003-.215-.02-.36-.135-.748-.27-1.228-.605l-.002-.002c-.325-.2-.79-.6-1.015-1.135-.04-.106-.073-.2-.095-.333a.786.786 0 00-.006-.067c.022.065.05.135.074.2.129.259.249.534.465.8.197.2.42.335.637.401.182.066.36.067.514.067h.012c.05 0 .097-.003.143-.012a.472.472 0 00-.1.066c-.24.2-.468.336-.664.401-.15.066-.316.068-.4.068h-.022c-.25-.003-.456-.091-.629-.2-.257-.135-.36-.336-.494-.535-.155-.199-.35-.465-.518-.668-.085-.2-.174-.335-.179-.469-.004-.067.002-.132.032-.199h-.003c.02-.064.05-.136.098-.2.3-.533 1.032-2.135 1.17-2.67.208-.865.39-1.932.455-2.667.065-.733.055-1.335-.063-1.67a2.33 2.33 0 00-.207-.398v-.002c.039.004.081.008.122.014.136.016.267.038.395.066zm-.033-.002v.003-.003zm0 .003h-.002.002zm-8.673.203a.075.075 0 01.015.001c.056.01.137.064.165.199.03.135 0 .332-.107.535-.178.334-.532.534-.896.535-.197 0-.39-.067-.538-.2a.76.76 0 01-.241-.4c-.017-.066-.012-.135.005-.2.087-.4.59-.533.91-.667.124-.067.22-.068.294-.003.073.003.15.003.23.003v-.003h.165c.003 0 .003 0 .003-.001zm1.396 1.602c.185 0 .355.005.555.067a.82.82 0 01.5.398c.061.135.096.267.123.401v.003c.007.065.012.135.012.199 0 .2-.02.335-.106.535-.036.066-.1.199-.132.265h-.002c-.02.065-.03.135-.03.2 0 .133.028.267.084.398.166.336.461.8 1.07 1.136v-.002c-.002 0-.003 0-.003.002-.042.028-.084.066-.118.098-.054.05-.101.099-.14.147a.98.98 0 00-.165.32c-.112.327-.063.665.073.933.27.533.662.935 1.13 1.137.333.134.75.2 1.142.132l.003-.002c.057-.01.114-.024.17-.038-.002.028.003.057.005.085v.001c.012.135.04.2.082.333.033.12.07.201.12.32-.1.07-.2.137-.296.203a.85.85 0 00-.248.265c-.053.065-.095.135-.117.2a.36.36 0 00-.022.132c0 .065.012.135.045.2.039.069.09.13.15.186l.003.003c.048.045.102.087.16.12.01.006.02.01.03.014.052.04.108.076.167.108.123.066.261.135.406.2.22.134.472.33.68.534.23.267.41.534.506.869.037.133.06.268.07.4-.143.202-.285.335-.412.403a.612.612 0 01-.31.066h-.025a1.05 1.05 0 01-.374-.133v-.001c-.295-.133-.685-.2-1.129-.136-.4.135-.757.402-1.09.668-.347.2-.676.332-1.025.465-.348.134-.75.2-1.149.2-.401 0-.803-.067-1.122-.267-.32-.199-.552-.534-.605-.935 0-.134.014-.266.04-.4a2.03 2.03 0 01.277-.667c.124-.2.25-.336.4-.402a.75.75 0 01.333-.066h.021c.1 0 .2.008.296.027.11.02.218.047.324.08-.066-.134-.114-.268-.148-.4-.04-.135-.056-.267-.056-.4v-.001c0-.2.04-.401.11-.535.074-.133.183-.265.31-.398.068-.066.143-.133.221-.2.096-.066.204-.132.32-.199-.26-.132-.474-.265-.64-.4a2.037 2.037 0 01-.414-.469h.001c-.147-.131-.35-.331-.559-.465-.303-.2-.508-.267-.735-.267a.792.792 0 00-.197.02c-.113.02-.227.067-.336.145-.114.066-.218.135-.31.2a1.49 1.49 0 00-.313.338c-.104.133-.194.266-.267.398-.155.267-.265.535-.31.803-.053.268-.053.535.015.802.003.014.007.027.012.04a1.18 1.18 0 00-.057-.066c-.1-.133-.206-.266-.294-.4a1.79 1.79 0 01-.203-.468 1.53 1.53 0 01-.066-.535c0-.2.02-.4.073-.535v-.002c.123-.466.37-.864.677-1.135.33-.268.713-.4 1.102-.4h.026c.15.001.298.017.444.05l.003.002c.003 0 .006-.003.006-.007V8.5c0-.2-.018-.4-.058-.535a1.174 1.174 0 00-.273-.667c-.22-.268-.5-.403-.812-.536h-.003v-.001c-.178-.135-.345-.2-.532-.333a.79.79 0 01-.318-.535c-.014-.066-.01-.135.012-.2a.431.431 0 01.05-.1c.026-.035.032-.067.11-.068h.005c.089-.001.174.02.25.065.1.066.18.132.26.2.1.067.18.132.247.2.07.066.143.07.2.067.077-.001.15-.016.217-.053.067-.035.134-.06.2-.12l.003-.003a.5.5 0 01.104-.065h.001c.16-.065.32-.132.52-.2zm6.163 1.602v.003-.003zm-8.257.268a.2.2 0 01.038.003c.088.009.178.08.219.2.04.134.02.334-.097.535a.96.96 0 01-.409.4 1.083 1.083 0 01-.38.12h-.005c-.09 0-.176-.02-.256-.065a.643.643 0 01-.195-.166.503.503 0 01-.081-.193.507.507 0 01-.002-.184c.036-.2.138-.333.29-.4.153-.067.314-.132.463-.2.103-.064.22-.056.327.003.035.018.068.003.091-.003v-.002c.007-.003.013-.003.013-.003h-.003c.007-.002.006-.003.005-.003-.006-.024-.005-.024.003-.024v-.018h-.003.018-.011zm7.408 4.533c-.069.16-.12.334-.16.535a2.77 2.77 0 00-.055.601c.005.2.04.402.098.603.11.334.335.665.64.865.305.199.666.266 1.008.199.343-.066.67-.267.893-.533h-.001c-.135.133-.28.2-.427.268a1.1 1.1 0 01-.431.066h-.031c-.11 0-.22-.022-.324-.066-.206-.088-.39-.2-.553-.333-.186-.137-.302-.198-.454-.4a1.255 1.255 0 01-.203-.468v-.002z"/></svg>',
];
return $icons[$icon] ?? '<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>';
}
/**
* Récupère tous les témoignages
* @return array Liste des témoignages
*/
function getTestimonials(): array
{
$data = loadJsonData('testimonials.json');
return $data['testimonials'] ?? [];
}
/**
* Récupère les témoignages mis en avant
* @return array Témoignages avec featured = true
*/
function getFeaturedTestimonials(): array
{
return array_filter(getTestimonials(), fn($t) => ($t['featured'] ?? false) === true);
}
/**
* Récupère le témoignage lié à un projet
* @param string $projectSlug Slug du projet
* @return array|null Témoignage ou null si non trouvé
*/
function getTestimonialByProject(string $projectSlug): ?array
{
foreach (getTestimonials() as $testimonial) {
if (($testimonial['project_slug'] ?? '') === $projectSlug) {
return $testimonial;
}
}
return null;
}
/**
* Génère un token CSRF et le stocke en session
* @return string Token CSRF
*/
function generateCsrfToken(): string
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $token;
$_SESSION['csrf_token_time'] = time();
return $token;
}
/**
* Vérifie la validité d'un token CSRF
* @param string $token Token à vérifier
* @param int $maxAge Durée de validité en secondes (défaut: 1 heure)
* @return bool True si valide
*/
function verifyCsrfToken(string $token, int $maxAge = 3600): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token']) || empty($_SESSION['csrf_token_time'])) {
return false;
}
// Vérifier l'expiration
if (time() - $_SESSION['csrf_token_time'] > $maxAge) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Vérifie le token reCAPTCHA v3 auprès de Google
* @param string $token Token reçu du client
* @return float Score (0.0 à 1.0), 0.3 si échec (dégradation gracieuse)
*/
function verifyRecaptcha(string $token): float
{
// Si pas de clé secrète configurée, retourner un score acceptable
if (!defined('RECAPTCHA_SECRET_KEY') || empty(RECAPTCHA_SECRET_KEY)) {
return 0.9;
}
// Si pas de token, retourner un score bas mais pas bloquant
if (empty($token)) {
error_log('reCAPTCHA: token vide');
return 0.3;
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => http_build_query([
'secret' => RECAPTCHA_SECRET_KEY,
'response' => $token,
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? ''
]),
'timeout' => 10
]
]);
$response = @file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $context);
if ($response === false) {
error_log('reCAPTCHA: impossible de contacter Google');
return 0.3; // Dégradation gracieuse
}
$result = json_decode($response, true);
if (!($result['success'] ?? false)) {
error_log('reCAPTCHA: échec - ' . json_encode($result['error-codes'] ?? []));
return 0.0;
}
return (float) ($result['score'] ?? 0.0);
}
/**
* Valide et nettoie les données du formulaire de contact
* @param array $input Données brutes
* @return array Données nettoyées
* @throws Exception si validation échoue
*/
function validateContactData(array $input): array
{
$errors = [];
// Champs requis
$required = ['nom', 'prenom', 'email', 'categorie', 'objet', 'message'];
$labels = [
'nom' => 'Nom',
'prenom' => 'Prénom',
'email' => 'Email',
'categorie' => 'Catégorie',
'objet' => 'Objet',
'message' => 'Message'
];
foreach ($required as $field) {
if (empty(trim($input[$field] ?? ''))) {
$errors[] = "Le champ {$labels[$field]} est requis";
}
}
// Validation email
$email = trim($input['email'] ?? '');
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = "L'adresse email n'est pas valide";
}
// Validation catégorie
$validCategories = ['projet', 'poste', 'autre'];
$categorie = $input['categorie'] ?? '';
if ($categorie && !in_array($categorie, $validCategories)) {
$errors[] = "Catégorie invalide";
}
// Validation longueurs
if (strlen($input['nom'] ?? '') > 100) {
$errors[] = "Le nom est trop long (max 100 caractères)";
}
if (strlen($input['prenom'] ?? '') > 100) {
$errors[] = "Le prénom est trop long (max 100 caractères)";
}
if (strlen($input['objet'] ?? '') > 200) {
$errors[] = "L'objet est trop long (max 200 caractères)";
}
if (strlen($input['message'] ?? '') > 5000) {
$errors[] = "Le message est trop long (max 5000 caractères)";
}
if (strlen($input['entreprise'] ?? '') > 200) {
$errors[] = "Le nom d'entreprise est trop long (max 200 caractères)";
}
// Longueurs minimales
if (strlen(trim($input['nom'] ?? '')) > 0 && strlen(trim($input['nom'])) < 2) {
$errors[] = "Le nom doit contenir au moins 2 caractères";
}
if (strlen(trim($input['prenom'] ?? '')) > 0 && strlen(trim($input['prenom'])) < 2) {
$errors[] = "Le prénom doit contenir au moins 2 caractères";
}
if (strlen(trim($input['objet'] ?? '')) > 0 && strlen(trim($input['objet'])) < 5) {
$errors[] = "L'objet doit contenir au moins 5 caractères";
}
if (strlen(trim($input['message'] ?? '')) > 0 && strlen(trim($input['message'])) < 20) {
$errors[] = "Le message doit contenir au moins 20 caractères";
}
// Si erreurs, les lancer
if (!empty($errors)) {
throw new Exception(implode('. ', $errors));
}
// Nettoyer et retourner
return [
'nom' => htmlspecialchars(trim($input['nom']), ENT_QUOTES, 'UTF-8'),
'prenom' => htmlspecialchars(trim($input['prenom']), ENT_QUOTES, 'UTF-8'),
'email' => filter_var(trim($input['email']), FILTER_SANITIZE_EMAIL),
'entreprise' => htmlspecialchars(trim($input['entreprise'] ?? ''), ENT_QUOTES, 'UTF-8'),
'categorie' => $input['categorie'],
'objet' => htmlspecialchars(trim($input['objet']), ENT_QUOTES, 'UTF-8'),
'message' => htmlspecialchars(trim($input['message']), ENT_QUOTES, 'UTF-8'),
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'inconnue',
'date' => date('d/m/Y à H:i:s'),
];
}
/**
* Envoie l'email de contact
* @param array $data Données validées et nettoyées
* @return bool True si envoyé avec succès
*/
function sendContactEmail(array $data): bool
{
// Vérifier config minimale
if (!defined('CONTACT_EMAIL') || empty(CONTACT_EMAIL)) {
error_log('CONTACT_EMAIL non configuré');
return false;
}
if (!defined('SMTP_HOST') || !defined('SMTP_PORT')) {
error_log('SMTP_HOST/SMTP_PORT non configurés');
return false;
}
// Autoload PHPMailer (Composer)
$autoload = __DIR__ . '/../vendor/autoload.php';
if (!file_exists($autoload)) {
error_log('PHPMailer autoload introuvable. As-tu installé Composer ?');
return false;
}
require_once $autoload;
$categorieLabels = [
'projet' => 'Projet freelance',
'poste' => 'Proposition de poste',
'autre' => 'Autre demande'
];
$categorie = $categorieLabels[$data['categorie']] ?? 'Autre';
$entreprise = $data['entreprise'] ?: 'Non renseignée';
$subject = "[Portfolio] {$categorie} - {$data['objet']}";
$body = "═══════════════════════════════════════════\n";
$body .= "NOUVEAU MESSAGE - PORTFOLIO\n";
$body .= "═══════════════════════════════════════════\n\n";
$body .= "DE: {$data['prenom']} {$data['nom']}\n";
$body .= "EMAIL: {$data['email']}\n";
$body .= "ENTREPRISE: {$entreprise}\n";
$body .= "CATÉGORIE: {$categorie}\n\n";
$body .= "───────────────────────────────────────────\n";
$body .= "OBJET: {$data['objet']}\n";
$body .= "───────────────────────────────────────────\n\n";
$body .= "MESSAGE:\n\n";
$body .= "{$data['message']}\n\n";
$body .= "═══════════════════════════════════════════\n";
$body .= "Envoyé le {$data['date']}\n";
$body .= "IP: {$data['ip']}\n";
$body .= "═══════════════════════════════════════════";
$mail = new PHPMailer(true);
try {
// Mode SMTP
$mail->isSMTP();
$mail->Host = SMTP_HOST;
$mail->Port = SMTP_PORT;
// Auth SMTP si configurée
$hasAuth = !empty(SMTP_USERNAME) && !empty(SMTP_PASSWORD);
$mail->SMTPAuth = $hasAuth;
if ($hasAuth) {
$mail->Username = SMTP_USERNAME;
$mail->Password = SMTP_PASSWORD;
}
// Encryption
$enc = strtolower((string) SMTP_ENCRYPTION);
if ($enc === 'tls') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
} elseif ($enc === 'ssl') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} else {
// none
$mail->SMTPSecure = false;
$mail->SMTPAutoTLS = false;
}
$mail->CharSet = 'UTF-8';
$mail->Encoding = 'base64';
// Expéditeur (doit être un alias autorisé si Proton)
$fromAddress = defined('MAIL_FROM_ADDRESS') ? MAIL_FROM_ADDRESS : SMTP_USERNAME;
$fromName = defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Portfolio - Contact';
if (empty($fromAddress)) {
error_log('MAIL_FROM_ADDRESS/SMTP_USERNAME vide : impossible de définir From');
return false;
}
$mail->setFrom($fromAddress, $fromName);
// Reply-To = email du visiteur
if (!empty($data['email'])) {
$mail->addReplyTo($data['email'], trim(($data['prenom'] ?? '') . ' ' . ($data['nom'] ?? '')));
}
// Destinataire
$mail->addAddress(CONTACT_EMAIL);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->isHTML(false);
$mail->SMTPDebug = 2;
$mail->Debugoutput = function ($str, $level) {
error_log("PHPMailer debug($level): $str");
};
// Timeouts/log (utile en debug)
$mail->Timeout = 10;
$mail->send();
return true;
} catch (\Throwable $e) {
error_log('Mail send unexpected error: ' . $e->getMessage());
return false;
}
}
/*function sendContactEmail(array $data): bool
{
$categorieLabels = [
'projet' => 'Projet freelance',
'poste' => 'Proposition de poste',
'autre' => 'Autre demande'
];
$categorie = $categorieLabels[$data['categorie']] ?? 'Autre';
$entreprise = $data['entreprise'] ?: 'Non renseignée';
$subject = "[Portfolio] {$categorie} - {$data['objet']}";
$body = "═══════════════════════════════════════════\n";
$body .= "NOUVEAU MESSAGE - PORTFOLIO\n";
$body .= "═══════════════════════════════════════════\n\n";
$body .= "DE: {$data['prenom']} {$data['nom']}\n";
$body .= "EMAIL: {$data['email']}\n";
$body .= "ENTREPRISE: {$entreprise}\n";
$body .= "CATÉGORIE: {$categorie}\n\n";
$body .= "───────────────────────────────────────────\n";
$body .= "OBJET: {$data['objet']}\n";
$body .= "───────────────────────────────────────────\n\n";
$body .= "MESSAGE:\n\n";
$body .= "{$data['message']}\n\n";
$body .= "═══════════════════════════════════════════\n";
$body .= "Envoyé le {$data['date']}\n";
$body .= "IP: {$data['ip']}\n";
$body .= "═══════════════════════════════════════════";
// Vérifier que CONTACT_EMAIL est défini
if (!defined('CONTACT_EMAIL') || empty(CONTACT_EMAIL)) {
error_log('CONTACT_EMAIL non configuré');
return false;
}
$headers = implode("\r\n", [
'From: noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost'),
'Reply-To: ' . $data['email'],
'Content-Type: text/plain; charset=UTF-8',
'X-Mailer: PHP/' . phpversion(),
'X-Priority: 1'
]);
$result = @mail(CONTACT_EMAIL, $subject, $body, $headers);
if (!$result) {
error_log("Échec envoi email contact: " . json_encode($data));
}
return $result;
}*/