yb2026/inc/profile.php

323 lines
16 KiB
PHP

<?php
/**
* Page d'admin « Profil » : poste, coordonnées, textes clés, parcours,
* compétences et services, modifiables sans toucher au code.
*
* ACF Pro n'est plus actif (licence perdue, voir plugin acf-pro-shim en lecture
* seule) : les répéteurs sont donc faits maison, stockés dans 4 options WP :
* yb_identity (tableau clé => texte), yb_parcours, yb_skills, yb_services (listes).
*/
/** Valeurs par défaut (= contenu de référence du design v3). */
function yb2026_profile_defaults()
{
return array(
'yb_identity' => array(
'poste' => 'Chargé support logiciel MCO',
'entreprise' => 'SeipraScore',
'casquettes' => 'Développeur web · Admin sys & réseaux',
'localisation' => 'Région lyonnaise',
'region' => 'Auvergne-Rhône-Alpes',
'ville_schema' => 'Lyon',
'email' => 'contact@exemple.fr',
'telephone' => '06 00 00 00 00',
'mobilite' => 'Permis B',
'hero_texte' => 'Support logiciel et maintien en conditions opérationnelles chez SeipraScore. Développeur web et admin Linux à côté, en continu.',
'apropos' => '',
'intro' => '',
'accroche' => 'Un projet ? Une question ? Un échange ?',
),
'yb_parcours' => array(),
'yb_skills' => array(),
'yb_services' => array(),
);
}
/** Description des champs de chaque répéteur : clé => [libellé, type]. */
function yb2026_profile_repeaters()
{
return array(
'yb_parcours' => array(
'title' => 'Parcours',
'help' => 'Du plus récent au plus ancien. La date et le lieu s\'affichent en majuscules automatiquement.',
'fields' => array('date' => array('Période', 'text'), 'titre' => array('Intitulé', 'text'), 'lieu' => array('Lieu / établissement', 'text'), 'desc' => array('Description', 'textarea')),
),
'yb_skills' => array(
'title' => 'Compétences techniques',
'help' => 'Barres de la page Qui suis-je. Pourcentage = remplissage de la barre (0 à 100).',
'fields' => array('name' => array('Compétence', 'text'), 'level' => array('Niveau', 'text'), 'pct' => array('%', 'number')),
),
'yb_services' => array(
'title' => 'Services (page Compétences)',
'help' => 'Cartes numérotées ( 01 ), ( 02 )… dans l\'ordre de la liste.',
'fields' => array('titre' => array('Titre', 'text'), 'desc' => array('Description', 'textarea')),
),
);
}
/** Libellés des champs d'identité. */
function yb2026_identity_fields()
{
return array(
'poste' => array('Poste', 'text', 'Kicker du hero, fiche Qui suis-je, données structurées.'),
'entreprise' => array('Entreprise', 'text', 'Badge de la nav (« @ ENTREPRISE »), hero, schema.org worksFor.'),
'casquettes' => array('Casquettes', 'text', 'Sous-titre du hero, avant la localisation.'),
'localisation' => array('Localisation', 'text', 'Volontairement large : pas de ville précise.'),
'region' => array('Région', 'text', 'Ajoutée après la localisation dans le footer, le menu mobile et le contact.'),
'ville_schema' => array('Ville (SEO uniquement)', 'text', 'Utilisée seulement dans les données structurées schema.org, jamais affichée.'),
'email' => array('E-mail', 'text', ''),
'telephone' => array('Téléphone', 'text', ''),
'mobilite' => array('Mobilité', 'text', ''),
'hero_texte' => array('Texte du hero (à droite)', 'textarea', 'Accueil, paragraphe au-dessus de « SCROLL TO EXPLORE ».'),
'apropos' => array('Texte « À propos » de l\'accueil', 'textarea', 'Section CODE. SERVEURS. RÉSEAUX. Les mots entre *astérisques* passent en lime.'),
'intro' => array('Intro de la page Qui suis-je', 'textarea', ''),
'accroche' => array('Accroche du bloc contact', 'text', 'Au-dessus de « TRAVAILLONS ENSEMBLE ».'),
);
}
/** Lecture d'une option du profil, fusionnée avec les valeurs par défaut. */
function yb2026_profile($option)
{
$defaults = yb2026_profile_defaults();
$value = get_option($option, null);
if ($option === 'yb_identity') {
return array_merge($defaults['yb_identity'], is_array($value) ? array_filter($value, 'strlen') : array());
}
return is_array($value) ? $value : $defaults[$option];
}
/** Raccourci : un champ d'identité. */
function yb2026_id($key)
{
$id = yb2026_profile('yb_identity');
return isset($id[$key]) ? $id[$key] : '';
}
/** Localisation complète : « Région lyonnaise, Auvergne-Rhône-Alpes ». */
function yb2026_location_long()
{
$loc = yb2026_id('localisation');
$reg = yb2026_id('region');
return $reg ? $loc . ', ' . $reg : $loc;
}
/** « 06 00 00 00 00 » → « tel:+33600000000 ». */
function yb2026_tel_href($tel)
{
$digits = preg_replace('/\D/', '', (string) $tel);
return 'tel:' . (strpos($digits, '0') === 0 ? '+33' . substr($digits, 1) : '+' . $digits);
}
/** Texte avec *mots* mis en lime (échappé). */
function yb2026_lime_marks($text)
{
return preg_replace('/\*([^*]+)\*/u', '<span class="lime">$1</span>', esc_html($text));
}
/* ── Admin ──────────────────────────────────────────────────────────── */
add_action('admin_menu', function () {
add_menu_page('Profil', 'Profil', 'edit_pages', 'yb-profil', 'yb2026_profile_page', 'dashicons-id-alt', 3);
});
function yb2026_profile_page()
{
$identity = yb2026_profile('yb_identity');
?>
<div class="wrap yb-profil">
<h1>Profil</h1>
<p>Ces informations alimentent tout le site : hero, nav, Qui suis-je, Compétences, Contact, footer et données SEO.</p>
<?php if (isset($_GET['updated'])) : ?><div class="notice notice-success is-dismissible"><p>Profil enregistré.</p></div><?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="yb_save_profile">
<?php wp_nonce_field('yb_save_profile'); ?>
<h2>Situation et coordonnées</h2>
<table class="form-table" role="presentation">
<?php foreach (yb2026_identity_fields() as $key => $f) : ?>
<tr>
<th scope="row"><label for="yb-id-<?php echo esc_attr($key); ?>"><?php echo esc_html($f[0]); ?></label></th>
<td>
<?php if ($f[1] === 'textarea') : ?>
<textarea class="large-text" rows="3" id="yb-id-<?php echo esc_attr($key); ?>" name="yb_identity[<?php echo esc_attr($key); ?>]"><?php echo esc_textarea($identity[$key]); ?></textarea>
<?php else : ?>
<input class="regular-text" type="text" id="yb-id-<?php echo esc_attr($key); ?>" name="yb_identity[<?php echo esc_attr($key); ?>]" value="<?php echo esc_attr($identity[$key]); ?>">
<?php endif; ?>
<?php if ($f[2]) : ?><p class="description"><?php echo esc_html($f[2]); ?></p><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php foreach (yb2026_profile_repeaters() as $opt => $rep) :
$rows = yb2026_profile($opt); ?>
<h2><?php echo esc_html($rep['title']); ?></h2>
<p class="description"><?php echo esc_html($rep['help']); ?></p>
<table class="widefat striped yb-rep" data-opt="<?php echo esc_attr($opt); ?>">
<thead><tr>
<?php foreach ($rep['fields'] as $f) : ?><th><?php echo esc_html($f[0]); ?></th><?php endforeach; ?>
<th class="yb-rep__actions"></th>
</tr></thead>
<tbody>
<?php foreach ($rows as $i => $row) yb2026_profile_row($opt, $rep['fields'], $i, $row); ?>
</tbody>
</table>
<template class="yb-rep__tpl" data-opt="<?php echo esc_attr($opt); ?>"><?php yb2026_profile_row($opt, $rep['fields'], '__i__', array()); ?></template>
<p><button type="button" class="button yb-rep__add" data-opt="<?php echo esc_attr($opt); ?>">+ Ajouter une ligne</button></p>
<?php endforeach; ?>
<?php submit_button('Enregistrer le profil'); ?>
</form>
</div>
<style>
.yb-profil .yb-rep td { vertical-align: top; }
.yb-profil .yb-rep input[type=text], .yb-profil .yb-rep textarea { width: 100%; }
.yb-profil .yb-rep input[type=number] { width: 5em; }
.yb-profil .yb-rep__actions { width: 7.5em; white-space: nowrap; }
.yb-profil .yb-rep__actions .button { min-width: 2em; padding: 0 .4em; }
</style>
<script>
(function () {
var n = Date.now();
document.querySelectorAll('.yb-rep__add').forEach(function (btn) {
btn.addEventListener('click', function () {
var opt = btn.dataset.opt;
var tpl = document.querySelector('.yb-rep__tpl[data-opt="' + opt + '"]');
var body = document.querySelector('.yb-rep[data-opt="' + opt + '"] tbody');
body.insertAdjacentHTML('beforeend', tpl.innerHTML.replace(/__i__/g, 'n' + (n++)));
});
});
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-row-act]');
if (!b) return;
var tr = b.closest('tr');
var act = b.dataset.rowAct;
if (act === 'del') tr.remove();
if (act === 'up' && tr.previousElementSibling) tr.parentNode.insertBefore(tr, tr.previousElementSibling);
if (act === 'down' && tr.nextElementSibling) tr.parentNode.insertBefore(tr.nextElementSibling, tr);
});
})();
</script>
<?php
}
/** Une ligne de répéteur (aussi utilisée comme modèle <template>). */
function yb2026_profile_row($opt, $fields, $i, $row)
{
echo '<tr>';
foreach ($fields as $key => $f) {
$name = $opt . '[' . $i . '][' . $key . ']';
$val = isset($row[$key]) ? $row[$key] : '';
echo '<td>';
if ($f[1] === 'textarea') {
echo '<textarea rows="2" name="' . esc_attr($name) . '" aria-label="' . esc_attr($f[0]) . '">' . esc_textarea($val) . '</textarea>';
} else {
$extra = $f[1] === 'number' ? ' min="0" max="100"' : '';
echo '<input type="' . esc_attr($f[1]) . '"' . $extra . ' name="' . esc_attr($name) . '" value="' . esc_attr($val) . '" aria-label="' . esc_attr($f[0]) . '">';
}
echo '</td>';
}
echo '<td class="yb-rep__actions">'
. '<button type="button" class="button" data-row-act="up" aria-label="Monter">↑</button> '
. '<button type="button" class="button" data-row-act="down" aria-label="Descendre">↓</button> '
. '<button type="button" class="button" data-row-act="del" aria-label="Supprimer">✕</button>'
. '</td></tr>';
}
add_action('admin_post_yb_save_profile', function () {
if (!current_user_can('edit_pages')) wp_die('Accès refusé.');
check_admin_referer('yb_save_profile');
$identity = array();
$raw = isset($_POST['yb_identity']) ? (array) wp_unslash($_POST['yb_identity']) : array();
foreach (yb2026_identity_fields() as $key => $f) {
$val = isset($raw[$key]) ? $raw[$key] : '';
$identity[$key] = $f[1] === 'textarea' ? sanitize_textarea_field($val) : sanitize_text_field($val);
}
update_option('yb_identity', $identity, false);
foreach (yb2026_profile_repeaters() as $opt => $rep) {
$rows = isset($_POST[$opt]) ? (array) wp_unslash($_POST[$opt]) : array();
$clean = array();
foreach ($rows as $row) {
$item = array();
foreach ($rep['fields'] as $key => $f) {
$val = isset($row[$key]) ? $row[$key] : '';
if ($f[1] === 'number') $item[$key] = max(0, min(100, (int) $val));
elseif ($f[1] === 'textarea') $item[$key] = sanitize_textarea_field($val);
else $item[$key] = sanitize_text_field($val);
}
/* Ligne vide ignorée */
if (implode('', array_map('strval', array_filter($item, 'is_string')))) $clean[] = $item;
}
update_option($opt, $clean, false);
}
wp_safe_redirect(admin_url('admin.php?page=yb-profil&updated=1'));
exit;
});
/* ── Données structurées schema.org Person ─────────────────────────────
SEOPress émet déjà un Person minimal avec le même @id : les deux nœuds
fusionnent, celui-ci ajoute poste, employeur et zone géographique. */
add_action('wp_head', function () {
$id = yb2026_profile('yb_identity');
$person = array(
'@context' => 'https://schema.org',
'@type' => 'Person',
'@id' => home_url('/#person'),
'name' => 'Yasser Boulayoune',
'url' => home_url('/'),
'jobTitle' => $id['poste'],
'worksFor' => array('@type' => 'Organization', 'name' => $id['entreprise']),
'address' => array('@type' => 'PostalAddress', 'addressLocality' => $id['ville_schema'], 'addressRegion' => $id['region'], 'addressCountry' => 'FR'),
'knowsAbout' => array('Support logiciel', 'MCO', 'Développement web', 'Administration systèmes et réseaux', 'Linux', 'Docker', 'WordPress', 'Symfony'),
);
echo '<script type="application/ld+json">' . wp_json_encode($person, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>' . "\n";
}, 20);
/* ── Protection anti-collecte de l'email et du téléphone ───────────────
Dans le HTML, ces valeurs n'apparaissent jamais en clair : elles sont
encodées (ROT13 → inversion → base64) et décodées en JS au premier geste
d'un visiteur humain (voir YBProtect dans effects.js). Un robot qui lit
le HTML brut ne trouve ni « @ », ni « mailto: », ni numéro. */
/** Encode une chaîne (décodage JS : atob → inversion → ROT13). */
function yb2026_obf($s)
{
return base64_encode(strrev(str_rot13((string) $s)));
}
/** Texte protégé : affiché « •••• » tant que le JS ne l'a pas décodé. */
function yb2026_pv($value)
{
return '<span class="yb-pv" data-pv="' . esc_attr(yb2026_obf($value)) . '">' . str_repeat('•', 8) . '</span>';
}
/** Attributs d'un lien protégé (href décodé au premier geste). */
function yb2026_pv_href($href)
{
return 'href="#" data-pv-href="' . esc_attr(yb2026_obf($href)) . '"';
}
/** Contenu des pages (mentions légales…) : email et téléphone du profil protégés automatiquement. */
add_filter('the_content', function ($html) {
$email = yb2026_id('email');
$tel = yb2026_id('telephone');
if ($email) {
/* <a href="mailto:x">x</a> → lien protégé */
$html = preg_replace_callback('#<a\b([^>]*?)href=(["\'])mailto:' . preg_quote($email, '#') . '\2([^>]*)>(.*?)</a>#is', function ($m) use ($email) {
$inner = str_ireplace($email, yb2026_pv($email), $m[4]);
return '<a' . $m[1] . yb2026_pv_href('mailto:' . $email) . $m[3] . '>' . $inner . '</a>';
}, $html);
/* Email en texte brut restant (hors attributs) */
$html = preg_replace_callback('#(>[^<]*?)' . preg_quote($email, '#') . '#i', function ($m) use ($email) { return $m[1] . yb2026_pv($email); }, $html);
}
if ($tel) {
$digits = preg_replace('/\D/', '', $tel);
$loose = implode('[\s.\-]?', str_split($digits)); // « 06 00 00 00 00 », « 06.00… », « 0600000000 »
$html = preg_replace_callback('#(>[^<]*?)' . $loose . '#', function ($m) use ($tel) { return $m[1] . yb2026_pv($tel); }, $html);
}
return $html;
}, 30);