Logo GH

Incident-bot et opérations de chat

1) But et valeur

L'incident-bot est une interface de gestion d'incident directement à partir d'un chat d'entreprise (Slack/Teams/Telegram) : une seule entrée de texte → des actions dans des dizaines de systèmes. Il :
  • Réduit MTTA/MTR en automatisant la routine ;
  • crée une boucle unique de faits (SoT) et de communications ;
  • assure la probabilité (audit, temporisation, updates SLA) ;
  • réduit la charge sur l'appel et « enterrement » dans les consoles.

2) Rôles et RACI dans les opérations de chat

Incident Commander (IC) est le propriétaire de l'incident : ouverture/fermeture, priorité, solutions.
Comms Lead (CL) - textes et horaires de mise à jour (externe/interne).
Domain Leaders (Payments/Games/Core/Infra) - techniques et fictions.
Scribe - Temporelle, journal des actions.
Bot Admin - droits/politiques/intégration du bot.

Règle : un incident a un IC et un CL ; le changement de rôle est clairement une équipe de bot.

3) Scénarios de base (end-to-end)

1. Début de l'incident : alert → '/incident nouveau p1 « Deposits EU down » '→ bot crée une carte, var-rum, nomme IC/CL, met le chronomètre du premier update.
2. Ведение: `/incident add-facts`, `/incident status set degraded`, `/incident assign @payments-lead`, `/incident timer 20m`.
3. Communications : '/incident publish status '(brouillon CL), '/incident partners notify', '/incident regulator draft '.
4. Действия: `/runbook psp-failover PSP1→PSP2`, `/feature toggle replay-center off 60m`, `/traffic shift 30% eu→uk`.
5. Fermeture et post-mortem : '/incident resolve ', auto-collection temporelle, '/postmortem generate'.

4) Commandes de bot (noyau)

Création/classification

`/incident new p{1|2|3|4} "" [components=…] [regions=…]`</p> <p>`/incident severity set p2`, `/incident tag add payments,psp`</p> <p>Propriété et rôles</p> <p>`/incident ic @user`, `/incident comms @user`, `/incident assign @user [domain]`</p> <p>Minuteries et Apdates SLO</p> <p><b>'/incident next-update 15m ', '/incident remind' (bot pingouin CL), '/incident eta set 18:</b> 30 '</p> <p>Faits et statut</p> <p>`/incident fact "auth-success PSP1 -25% TR/EU"`, `/incident status {investigating|degraded|monitoring|resolved}`</p> <p>Paquets de comma</p> <p>`/incident draft public|partners|regulator`, `/incident publish public`</p> <p>Intégration</p> <p>'/runbook <action> '(répertoire auto-action), '/dashboard link <id>', '/ticket create <system> '</p> <p>Fermeture/post-mortem</p> <p>`/incident resolve [reason=…]`, `/postmortem generate`, `/postmortem assign @owner`</p> <div style="display:flex; align-items:flex-start; gap:12px; padding:12px 14px; margin:16px 0; border-radius:12px; background:#0f1624; color:#e6edf3; border-left:4px solid #2f9aff; border:1px solid rgba(255,255,255,0.05); box-shadow:0 0 12px rgba(255,255,255,.03);"> <span style="font-size:18px; line-height:1.2; color:#5ab8ff;">💡</span> <span style="flex:1; opacity:.95;">Toutes les commandes sont logées avec le temps, l'auteur, les paramètres et le résultat.</span> </div> <p>5) Intégrations (minimum nécessaire)</p> <p>Monitoring/Observability : alerts, SLI/SLO (burn-rate), links sur les dashboards.<br>Incident Manager (ITSM) : synchronisation bidirectionnelle de l'état/des champs.<br>Page d'état : brouillons et publication via CL (policy-gate).<br>Fournisseurs (PSP/KYC/Studios de jeux) : guides de contact, lettres/canaux rapides.<br>Release/Feature Flags : Pieds/rebonds canaris, liens vers les releases.<br>Runbooks/Auto-remediation : catalogue d'actions sécurisées avec guardrails.<br>CMDB/propriétaires : auto-assignation des leaders de domaine, escalade.<br>Stockage temporel : WORM/immutable pour l'audit/post-mortem.</p> <p>6) L'architecture du bot</p> <p>Gateway (Chat Adapter) : Interfaces Slack/Teams/Telegram.<br>Command Parser + Policy Engine : autorisation, validation, SoD et tolérances.<br>Orchestrator : scripts d'incidents, minuteries, rappels.<br>Integrations Layer : clients à l'ITSM, surveillance, page de statut, versions, runbooks.<br>Evidence Store : événements, faits, diffamations de messages, pièces jointes (WORM).<br>Metrics & Audit : métriques de qualité, logs d'action, traçage de commandes.</p> <p>7) Politiques, droits et sécurité</p> <p>RBAC/ABAC : qui peut créer/fermer, changer de severity, publier à l'extérieur.<br>SoD : Comms publish exige un rôle CL ; Action à risque élevé (route PSP, exportation PII) - contrôle double.<br>Droits JIT : émission temporaire aux chefs de domaine pendant la durée de l'incident.<br>Signature et cryptage : webhooks/requêtes aux systèmes - HMAC/mTLS.<br>Protection contre « fat-finger » : confirmation des commandes dangereuses, dry-run et TTL pour l'action.<br>Hygiène PII : masquage dans les brouillons/logs ; interdiction des PII dans les canaux ouverts.</p> <p>8) Flux d'automatisation (exemple)</p> <p>Alert P1 → bot crée une salle de var ('# inc-2025-11-01-001'), un pingouin de garde (IC, CL, Paiements/Infra).<br>Lie les dashboards/SLI, ouvre un tiquet dans l'ITSM, prépare le modèle de la première mise à jour publique.<br>Pose les minuteries : « prochain update dans 15 min », rappel CL.<br>Предлагает runbooks: “PSP reroute 30% → PSP2”, “degrade replay-center”, “autoscale settle-workers”.<br>Lors de la publication - enregistre la version du texte et publie dans la page de statut/réseaux sociaux (via CL).<br>À la fermeture - recueille le temps, les métriques, le brouillon post-mortem, l'envoi VIP/partenaires.</p> <p>9) Temporisation et probabilité</p> <p>Chaque événement est logé : 'T + mm : description, auteur/bot, commande, résultat, références'.<br>Les révisions de messages (diff), la liaison aux versions/fichflags/travaux planifiés sont prises en charge.<br>Exportation : PDF/CSV pour l'audit et les régulateurs.</p> <p>10) Métriques (KPI/KRI ChatOps)</p> <p>MTTA (chat) : d'alerte à '/incident nouveau '.<br>MTTS (setup) : jusqu'à ce que la salle de var soit prête et que les rôles soient attribués.<br>Cadence adherence : respecter les intervalles des apdées publiques.<br>Taux d'utilisation du runbook : proportion d'incidents avec des actions automatisées.<br>Score de cohérence : écarts entre les canaux = 0 est la cible.<br>Pager fatigue↓ : réduction des pagers manuels avec le même/meilleur SLO.<br>SLA Postmortem : proportion de post-mortem collectés ≤ D + 5.</p> <p>11) Catalogue de modèles (fragments)</p> <u style="text-underline-offset:7px; text-decoration-color:rgba(200, 220, 255, 0.35);">Création de P1 :</u> <pre style="margin:14px 0; padding:12px 14px; background:#0f1624; color:#e5e7eb; border:1px solid rgba(255,255,255,.10); border-radius:12px; overflow:auto; box-shadow:0 0 12px rgba(255,255,255,.03); direction:ltr; text-align:left; unicode-bidi:bidi-override;"><code> /incident new p1 "Deposits EU down" components=payments,deposits regions=EU</code></pre> <u style="text-underline-offset:7px; text-decoration-color:rgba(200, 220, 255, 0.35);">Première mise à jour publique (via CL) :</u> <pre style="margin:14px 0; padding:12px 14px; background:#0f1624; color:#e5e7eb; border:1px solid rgba(255,255,255,.10); border-radius:12px; overflow:auto; box-shadow:0 0 12px rgba(255,255,255,.03); direction:ltr; text-align:left; unicode-bidi:bidi-override;"><code> /incident draft public /incident publish public</code></pre> <u style="text-underline-offset:7px; text-decoration-color:rgba(200, 220, 255, 0.35);">Itinéraire PSP et dégradation des fiches :</u> <pre style="margin:14px 0; padding:12px 14px; background:#0f1624; color:#e5e7eb; border:1px solid rgba(255,255,255,.10); border-radius:12px; overflow:auto; box-shadow:0 0 12px rgba(255,255,255,.03); direction:ltr; text-align:left; unicode-bidi:bidi-override;"><code> /runbook psp-failover PSP1→PSP2 30% /feature toggle replay-center off 45m</code></pre> <u style="text-underline-offset:7px; text-decoration-color:rgba(200, 220, 255, 0.35);">Post-mortem :</u> <pre style="margin:14px 0; padding:12px 14px; background:#0f1624; color:#e5e7eb; border:1px solid rgba(255,255,255,.10); border-radius:12px; overflow:auto; box-shadow:0 0 12px rgba(255,255,255,.03); direction:ltr; text-align:left; unicode-bidi:bidi-override;"><code> /postmortem generate /postmortem assign @owner</code></pre> <p>12) Intégration dans les processus</p> <p>Communications : lien avec « Communication en cas d'incident » et « Pages d'état du système ».<br>Observabilité : liens rapides vers SLO/SLI et synthétiques ; application automatique des graphiques.<br>Alerting : Mise en route de l'incident lors de la P1/P2 ; déduplication des signaux en un seul flux.<br>Auto-corrections : runbooks à un seul bouton avec guardrails et retours.<br>Workflow Engine : humains-tasks (4-eyes), minuteries d'escalade, chèques-feuilles.</p> <p>13) Feuille de route pour la mise en œuvre (4-8 semaines)</p> <p>Ned. 1-2 : MVP des commandes : '/incident nouveau ', rôles (IC/CL), var-rum, minuterie d'update, communication avec ITSM et surveillance.<br>Ned. 3-4 : modèles de messages (publics/partenaires/régulateurs), page de statut (chernovik→publikatsiya), catalogue 5-7 runbooks.<br>Ned. 5-6 : policy-as-code (RBAC/SoD/JIT), dual control on high-risk, magazine WORM, dashboard KPI ChatOps.<br>Ned. 7-8 : tabletop-exercice de P1/P2, intégration avec les versions/fichflags, auto-collection post-mortem, localisation.</p> <p>14) Anti-modèles</p> <p>« Tout à travers les robots » sans guardrails → des actions dangereuses accidentelles.<br>Publication dans une page de statut sans rôle CL/Legal Review.<br>Les commandes sans logs/versions → non prouvables.<br>Formes complexes (20 + champs) dans le chat - vitesse baisse ; meilleures commandes courtes + liens.<br>Pas de minuteurs d'update → « silence » à P1.<br>L'absence d'intégration avec le CMDB/les propriétaires → le chaos dans les affectations.</p> <p>15) Résultat</p> <p>L'incident-bot et ChatOps ne sont pas un « bot avec des équipes », mais une plate-forme d'exploitation : démarrage rapide de l'incident, discipline des updates, actions automatisées avec des restrictions de sécurité, observabilité de bout en bout et probabilité. Ce circuit réduit de manière prévisible MTR, améliore la qualité des communications et protège les revenus de l'entreprise iGaming aux moments de pointe.</p> </article> </div> </section> <style> /* =================== INPUT SYSTEM =================== */ .gh-input-h { --h:44px; height:var(--h); } .gh-chip, .gh-ccode, .gh-text, .gh-tel{ height:var(--h); padding:.55rem .75rem; border-radius:1rem; font-size:.875rem; line-height:1.2; border:1px solid rgba(255,255,255,.15); background:rgba(16,26,46,.6); color:#fff; outline:none; transition:border-color .15s ease, box-shadow .15s ease, background-color .15s ease; } .gh-chip{ width:4.5rem; display:inline-flex; align-items:center; justify-content:center; color:#cbd5e1; } .gh-ccode{ width:4.5rem; text-align:center; } .gh-ccode[readonly]{ color:#cbd5e1; cursor:default; user-select:none; } .gh-text, .gh-tel{ flex:1; min-width:0; } .gh-group{ display:flex; gap:.5rem; align-items:stretch; } .gh-focus:focus{ box-shadow:0 0 0 2px rgba(47,154,255,.25); border-color:rgba(47,154,255,.55); } /* =================== LABEL ROW =================== */ .gh-label-row{ display:grid; grid-template-columns: auto max-content; align-items:center; gap:.5rem; margin-bottom:.375rem; } .gh-label-row .gh-label{ font-size:.75rem; color:#94a3b8; } .gh-label-row .gh-ghost{ justify-self:end; text-align:center; white-space:nowrap; font-size:.675rem; letter-spacing:.06em; text-transform:uppercase; border:1px dashed rgba(255,255,255,.18); color:#9aa4b2; border-radius:999px; padding:.15rem .45rem; opacity:.85; transition:opacity .2s ease, color .2s ease, border-color .2s ease; pointer-events:none; } .gh-label-row .gh-ghost.-stub{ opacity:0; border-color:transparent; } .gh-optional.is-active .gh-ghost{ opacity:.25; color:#94a3b8; border-color:rgba(255,255,255,.12); } .gh-error{ box-shadow:0 0 0 2px rgba(244,63,94,.45)!important; border-color:rgba(244,63,94,.6)!important; } .gh-field + .gh-field{ margin-top:.9rem; } .gh-hint{ margin-top:.35rem; font-size:.6875rem; color:#94a3b8; } @media (max-width: 360px){ .gh-chip, .gh-ccode{ width:4.1rem; } } /* ====== КНОПКА быстрых контактов ====== */ /* Базово — ведёт себя как обычная inline-flex кнопка */ .gh-qs-trigger { position: relative; /* z-index не задаём, чтобы не лезла выше оверлеев */ transition: opacity .2s ease, transform .2s ease, visibility .2s ease; } /* Когда открыт теговый список — кнопку полностью прячем */ html.tag-modal-open .gh-qs-trigger { pointer-events: none; opacity: 0; visibility: hidden; transform: translateY(4px); } </style> <!-- CONTACT --> <section id="contact" class="py-12 lg:py-16"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <!-- Заголовок --> <div class="mb-4"> <div class="-mx-4 dir-ltr"> <span class="ml-4 text-[10px] uppercase tracking-widest font-semibold text-aqua-300 bg-aqua-500/10 border border-aqua-500/30 rounded-full px-2 py-0.5 inline-block mb-2"> Contact </span> <div class="fade-mask"> <div class="fade-inner gh-scrollbar-none touch-x px-4"> <h2 class="text-3xl font-bold text-white drop-shadow-[0_0_8px_rgba(47,154,255,0.35)] whitespace-nowrap"> Prendre contact </h2> </div> </div> </div> </div> <div class="mt-8 grid md:grid-cols-2 gap-10 lg:gap-12 items-start"> <!-- Левая колонка --> <div class="space-y-3 text-sm"> <p class="text-slate-400 text-[13px] pl-0 sm:pl-[2.4rem]" style="line-height:1.25; margin-bottom:18px;"> <span style="display:block; font-weight:800; margin-bottom:11px;">Contactez-nous pour toute question ou demande d’assistance.</span><span style="display:block; height:5px;"></span><span style="display:block; font-weight:400;">Nous sommes toujours prêts à vous aider !</span> </p> <!-- Email --> <div class="flex items-start gap-2"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path d="M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414zM0 4.697v7.104l5.803-3.558zM6.761 8.83l-6.57 4.027A2 2 0 0 0 2 14h12a2 2 0 0 0 1.808-1.144l-6.57-4.027L8 9.586zm3.436-.586L16 11.801V4.697z"/> </svg> <div> <div class="font-medium text-white">Email</div> <a href="mailto:support@gamblehub.io?subject=Demande%20%C3%A0%20Gamble%20Hub&body=Bonjour%20%21%0A%0AJe%20souhaite%20poser%20une%20question%20concernant%20votre%20produit%20ou%20service.%0A%0AVeuillez%20me%20contacter%20%C3%A0%20un%20moment%20qui%20vous%20convient.%0A%0AMerci%20%21" class="text-slate-400 text-xs break-all hover:text-aqua-400 transition dir-ltr" rel="nofollow noopener"> support@gamblehub.io </a> </div> </div> <!-- Telegram --> <div class="flex items-start gap-2"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8.287 5.906q-1.168.486-4.666 2.01-.567.225-.595.442c-.03.243.275.339.69.47l.175.055c.408.133.958.288 1.243.294q.39.01.868-.32 3.269-2.206 3.374-2.23c.05-.012.12-.026.166.016s.042.12.037.141c-.03.129-1.227 1.241-1.846 1.817-.193.18-.33.307-.358.336a8 8 0 0 1-.188.186c-.38.366-.664.64.015 1.088.327.216.589.393.85.571.284.194.568.387.936.629q.14.092.27.187c.331.236.63.448.997.414.214-.02.435-.22.547-.82.265-1.417.786-4.486.906-5.751a1.4 1.4 0 0 0-.013-.315.34.34 0 0 0-.114-.217.53.53 0 0 0-.31-.093c-.3.005-.763.166-2.984 1.09"/> </svg> <div> <div class="font-medium text-white">Telegram</div> <a href="https://t.me/Gamble_GC" target="_blank" rel="nofollow noopener noreferrer" class="text-slate-400 text-xs hover:text-aqua-400 transition dir-ltr"> @Gamble_GC </a> </div> </div> <!-- WhatsApp --> <div class="flex items-start gap-2 mt-3"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path d="M13.601 2.326A7.85 7.85 0 0 0 7.994 0C3.627 0 .068 3.558.064 7.926c0 1.399.366 2.76 1.057 3.965L0 16l4.204-1.102a7.9 7.9 0 0 0 3.79.965h.004c4.368 0 7.926-3.558 7.93-7.93A7.9 7.9 0 0 0 13.6 2.326zM7.994 14.521a6.6 6.6 0 0 1-3.356-.92l-.240-.144-2.494.654.666-2.433-.156-.251a6.56 6.56 0 0 1-1.007-3.505c0-3.626 2.957-6.584 6.591-6.584a6.56 6.56 0 0 1 4.66 1.931 6.56 6.56 0 0 1 1.928 4.66c-.004 3.639-2.961 6.592-6.592 6.592m3.615-4.934c-.197-.099-1.17-.578-1.353-.646-.182-.065-.315-.099-.445.099-.133.197-.513.646-.627.775-.114.133-.232.148-.43.05-.197-.1-.836-.308-1.592-.985-.59-.525-.985-1.175-1.103-1.372-.114-.198-.011-.304.088-.403.087-.088.197-.232.296-.346.10-.114.133-.198.198-.33.065-.134.034-.248-.015-.347-.05-.099-.445-1.076-.612-1.47-.16-.389-.323-.335-.445-.34-.114-.007-.247-.007-.38-.007a.73.73 0 0 0-.529.247c-.182.198-.691.677-.691 1.654s.71 1.916.81 2.049c.098.133 1.394 2.132 3.383 2.992.47.205.84.326 1.129.418.475.152.904.129 1.246.08.38-.058 1.171-.48 1.338-.943.164-.464.164-.86.114-.943-.049-.084-.182-.133-.38-.232"/> </svg> <div> <div class="font-medium text-white">WhatsApp</div> <a href="https://wa.me/447893931020" target="_blank" rel="nofollow noopener noreferrer" class="text-slate-400 text-xs hover:text-aqua-400 transition dir-ltr"> +44 7893 931 020 </a> </div> </div> <!-- Триггер быстрой формы --> <a href="#" class="gh-qs-trigger relative inline-flex items-center justify-center gap-2 px-5 pr-10 py-2 rounded-full text-[13px] font-semibold text-white border border-white/10 bg-gradient-to-r from-aqua-600/40 to-brand-500/40 hover:from-aqua-600/60 hover:to-brand-500/60 focus:outline-none focus:ring-2 focus:ring-aqua-400/50 transition-all duration-200" data-qs-open data-qs-subject="Quick: Prendre contact" data-qs-pref="email"> <span class="relative z-10"> Commencer l’intégration </span> <!-- SVG "палец" --> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" class="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 opacity-95 pointer-events-none drop-shadow-[0_2px_8px_rgba(47,154,255,0.35)]"> <path d="M8.5 1.75v2.716l.047-.002c.312-.012.742-.016 1.051.046.28.056.543.18.738.288.273.152.456.385.56.642l.132-.012c.312-.024.794-.038 1.158.108.37.148.689.487.88.716q.113.137.195.248h.582a2 2 0 0 1 1.99 2.199l-.272 2.715a3.5 3.5 0 0 1-.444 1.389l-1.395 2.441A1.5 1.5 0 0 1 12.42 16H6.118a1.5 1.5 0 0 1-1.342-.83l-1.215-2.43L1.07 8.589a1.517 1.517 0 0 1 2.373-1.852L5 8.293V1.75a1.75 1.75 0 0 1 3.5 0"/> </svg> </a> </div> <!-- Правая колонка: форма --> <div class="relative rounded-3xl border border-white/15 bg-gradient-to-br from-night-800/80 to-night-900/80 p-6 backdrop-blur-sm"> <!-- Toast для основной формы --> <div id="ghToastMain" class="pointer-events-none absolute inset-x-4 top-[30rem] z-50 -translate-y-2 opacity-0 transition-all duration-300"></div> <form id="ghContactForm" class="space-y-4" autocomplete="off" novalidate> <!-- honeypot / служебные --> <input type="text" name="company" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true"/> <input type="hidden" name="started_at" id="ghStartedAt"/> <input type="hidden" name="channel" id="ghChannel" value="email"/> <input type="hidden" name="whatsapp_full" id="ghWaFull" value=""/> <p class="text-xs text-slate-400"> L’Email est <span class="text-slate-200">obligatoire</span>. Telegram ou WhatsApp — <span class="text-slate-300">optionnels</span>. </p> <!-- Name --> <div class="gh-field" data-field="name"> <div class="gh-label-row"> <span class="gh-label">Votre nom</span> <span class="gh-ghost -stub">optionnel</span> </div> <input id="name" name="name" type="text" required class="gh-text gh-input-h gh-focus w-full" placeholder="Jean Dupont"/> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <!-- Email --> <div class="gh-field" data-field="email" id="fieldEmail"> <div class="gh-label-row"> <span class="gh-label">Email</span> <span class="gh-ghost -stub">optionnel</span> </div> <input id="email" name="email" type="email" required class="gh-text gh-input-h gh-focus w-full" placeholder="you@company.com"/> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <!-- Subject --> <div class="gh-field" data-field="subject"> <div class="gh-label-row"> <span class="gh-label">Objet</span> <span class="gh-ghost -stub">optionnel</span> </div> <input id="subject" name="subject" type="text" required class="gh-text gh-input-h gh-focus w-full" placeholder="Demande d’intégration, tarifs, partenariat…"/> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <!-- Message --> <div class="gh-field" data-field="message"> <div class="gh-label-row"> <span class="gh-label">Message</span> <span class="gh-ghost -stub">optionnel</span> </div> <textarea id="message" name="message" rows="4" required class="w-full rounded-2xl border border-white/15 bg-night-700/60 px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-aqua-500/40" placeholder="Décrivez brièvement votre demande"></textarea> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <!-- Telegram (optional) --> <div class="gh-field gh-optional" data-field="telegram" id="fieldTelegram"> <div class="gh-label-row"> <span class="gh-label">Telegram</span> <span class="gh-ghost">optionnel</span> </div> <div class="gh-group"> <span class="gh-chip gh-input-h">@</span> <input id="telegram" name="telegram" type="text" inputmode="text" autocomplete="off" maxlength="32" class="gh-text gh-input-h gh-focus" placeholder="your_handle"/> </div> <div class="gh-hint">Si vous indiquez Telegram — nous vous répondrons aussi là-bas.</div> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <!-- WhatsApp (optional) --> <div class="gh-field gh-optional" data-field="whatsapp" id="fieldWhatsApp"> <div class="gh-label-row"> <span class="gh-label">WhatsApp</span> <span class="gh-ghost">optionnel</span> </div> <div class="gh-group"> <input id="ccode" name="ccode" type="text" value="+" readonly tabindex="-1" aria-readonly="true" class="gh-ccode gh-input-h dir-ltr"/> <input id="whatsapp" name="whatsapp" type="tel" inputmode="tel" autocomplete="tel" maxlength="24" class="gh-tel gh-input-h gh-focus dir-ltr" placeholder="380 00 000 00 00"/> </div> <div class="gh-hint dir-ltr">Format : +code pays et numéro (ex. +33XXXXXXXXX).</div> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <!-- Submit --> <div class="pt-2"> <button id="ghSubmitBtn" type="submit" class="w-full rounded-2xl border border-aqua-500/40 bg-gradient-to-r from-aqua-600 to-brand-500 px-4 py-2 text-sm font-semibold text-white hover:from-aqua-500 hover:to-brand-400 focus:outline-none focus:ring-2 focus:ring-aqua-400/50 transition-all duration-200"> Envoyer la demande </button> <p class="mt-2 text-[11px] text-slate-500">En cliquant sur ce bouton, vous acceptez le traitement de vos données.</p> </div> </form> </div> </div> </div> </section> <!-- === Модалка быстрой формы === --> <div id="ghQuickModal" class="fixed inset-0 z-[90] hidden"> <!-- overlay --> <div id="ghQuickOverlay" class="absolute inset-0 bg-black/60 backdrop-blur-sm opacity-0 transition-opacity duration-200"></div> <!-- dialog --> <div class="absolute inset-0 p-4 flex items-center justify-center"> <div id="ghQuickPanel" class="relative w-full max-w-md rounded-3xl border border-white/15 bg-gradient-to-br from-night-800/80 to-night-900/80 backdrop-blur-sm opacity-0 translate-y-2 scale-[.98] transition-[opacity,transform] duration-200" style="max-height:calc(100vh - 2rem); overflow-y:auto; -webkit-overflow-scrolling:touch;"> <!-- Toast для модалки --> <div id="ghToastQuick" class="pointer-events-none absolute inset-x-4 top-4 z-20 -translate-y-2 opacity-0 transition-all duration-300"></div> <!-- header --> <div class="flex items-start justify-between p-4 pb-3"> <div> <h3 class="text-lg font-semibold text-white">Contact rapide</h3> </div> <button type="button" id="ghQuickClose" class="rounded-xl p-2 text-slate-300 hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-aqua-500/40" aria-label="Close"> <svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="m12 10.586 4.95-4.95 1.414 1.414L13.414 12l4.95 4.95-1.414 1.414L12 13.414l-4.95 4.95-1.414-1.414L10.586 12l-4.95-4.95L7.05 5.636z"/></svg> </button> </div> <!-- STATES WRAPPER --> <div class="p-4 pt-0"> <!-- FORM STATE --> <form id="ghQuickForm" class="space-y-4" autocomplete="off" novalidate> <!-- honeypot + служебные --> <input type="text" name="company" class="hidden" tabindex="-1" aria-hidden="true"> <input type="hidden" name="started_at" id="ghQuickStartedAt"> <input type="hidden" name="channel" id="ghQuickChannel" value="email"> <input type="hidden" name="subject" id="ghQuickSubject" value=""> <p class="text-xs text-slate-400 leading-relaxed"> Enter at least one contact. </p> <!-- Email --> <div class="gh-field" data-field="quick-email"> <label for="ghQuickEmail" class="block text-xs text-slate-400 mb-1">Email</label> <input id="ghQuickEmail" name="email" type="email" inputmode="email" autocomplete="email" maxlength="128" class="gh-text gh-input-h gh-focus w-full dir-ltr" placeholder="you@company.com"> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <!-- Telegram --> <div class="gh-field" data-field="quick-telegram"> <label for="ghQuickTelegram" class="block text-xs text-slate-400 mb-1">Telegram</label> <div class="gh-group"> <span class="gh-chip gh-input-h dir-ltr" aria-hidden="true">@</span> <input id="ghQuickTelegram" name="telegram" type="text" inputmode="text" autocomplete="off" maxlength="32" class="gh-text gh-input-h gh-focus dir-ltr" placeholder="your_handle"> </div> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <!-- WhatsApp --> <div class="gh-field" data-field="quick-whatsapp"> <label for="ghQuickWhatsApp" class="block text-xs text-slate-400 mb-1">WhatsApp</label> <div class="gh-group"> <span class="gh-chip gh-input-h dir-ltr" aria-hidden="true">+</span> <input id="ghQuickWhatsApp" name="whatsapp" type="tel" inputmode="tel" autocomplete="tel" maxlength="24" class="gh-tel gh-input-h gh-focus dir-ltr" placeholder="380 00 000 00 00"> </div> <div class="gh-error-slot mt-1 text-[11px] leading-4 text-rose-300 hidden"></div> </div> <div id="ghQuickContactsError" class="hidden rounded-xl border border-rose-400/30 bg-rose-500/10 px-3 py-2 text-[11px] leading-4 text-rose-300"> </div> <!-- Кнопка --> <div class="pt-2 pb-1"> <button id="ghQuickSend" type="submit" class="w-full rounded-2xl border border-aqua-500/40 bg-gradient-to-r from-aqua-600 to-brand-500 px-4 py-2 text-sm font-semibold text-white hover:from-aqua-500 hover:to-brand-400 focus:outline-none focus:ring-2 focus:ring-aqua-400/50 transition-all duration-200"> Envoyer </button> </div> </form> <!-- SUCCESS STATE --> <div id="ghQuickSuccess" class="hidden"> <div class="rounded-2xl border border-white/10 bg-white/5 p-4"> <div class="flex items-start gap-3"> <div class="shrink-0 mt-0.5"> <svg width="20" height="20" viewBox="0 0 24 24" class="opacity-90"><path fill="currentColor" d="M9.5 16.2 5.8 12.5l-1.6 1.6L9.5 20 20 9.5l-1.6-1.6z"/></svg> </div> <div class="text-sm"> <div class="font-semibold text-white">Demande envoyée</div> <p class="mt-1 text-slate-300">Merci ! Nous avons reçu votre contact et reviendrons vers vous bientôt. La réponse arrivera sur le canal indiqué.</p> </div> </div> </div> <div class="mt-3 flex gap-2"> <button id="ghQuickOk" type="button" class="flex-1 rounded-2xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-aqua-400/40"> OK / fermer </button> <button id="ghQuickAgain" type="button" class="rounded-2xl border border-aqua-500/40 bg-gradient-to-r from-aqua-600 to-brand-500 px-4 py-2 text-sm font-semibold text-white hover:from-aqua-500 hover:to-brand-400 focus:outline-none focus:ring-2 focus:ring-aqua-400/50"> Nouvelle demande </button> </div> </div> </div> <!-- /STATES WRAPPER --> </div> </div> </div> <script> /* ========= единый helper для тостов ========= */ function ghShowToastIn(container, type, text) { const el = (typeof container === 'string') ? document.getElementById(container) : container; if (!el) return; const base = 'inline-flex items-start gap-3 rounded-2xl px-4 py-3 shadow-glow border ring-1 backdrop-blur-md ' + 'absolute right-0'; const ok = 'bg-teal-600/80 border-teal-400/60 text-white'; const err = 'bg-rose-500/80 border-rose-400/60 text-white'; el.innerHTML = ` <div class="${base} ${type === 'ok' ? ok : err} dir-ltr"> <div class="mt-0.5 shrink-0"> ${ type === 'ok' ? '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M9.5 16.2 5.8 12.5l-1.6 1.6L9.5 20 20 9.5l-1.6-1.6z"/></svg>' : '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 1 0 .001 20.001A10 10 0 0 0 12 2m1 14h-2v-2h2zm0-4h-2V7h2z"/></svg>' } </div> <div class="text-sm leading-5">${text}</div> </div>`; el.style.opacity = '1'; el.style.transform = 'translateY(0)'; clearTimeout(el._t); el._t = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateY(-20px)'; }, 3500); } /* ========= МОДАЛКА: quick contact ========= */ document.addEventListener('DOMContentLoaded', () => { const $modal = document.getElementById('ghQuickModal'); const $overlay = document.getElementById('ghQuickOverlay'); const $panel = document.getElementById('ghQuickPanel'); const $openers = document.querySelectorAll('[data-qs-open]'); const $close = document.getElementById('ghQuickClose'); const $form = document.getElementById('ghQuickForm'); const $success = document.getElementById('ghQuickSuccess'); const $okBtn = document.getElementById('ghQuickOk'); const $againBtn = document.getElementById('ghQuickAgain'); const $email = document.getElementById('ghQuickEmail'); const $telegram = document.getElementById('ghQuickTelegram'); const $whatsapp = document.getElementById('ghQuickWhatsApp'); const $contactsError = document.getElementById('ghQuickContactsError'); const $channel = document.getElementById('ghQuickChannel'); const $subject = document.getElementById('ghQuickSubject'); const $started = document.getElementById('ghQuickStartedAt'); const $sendBtn = document.getElementById('ghQuickSend'); const emailLike = s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); const tgValid = s => /^[A-Za-z0-9_]{5,}$/.test(s); const waValid = s => /^\+\d{7,15}$/.test(s); function maskEmail(v) { v = (v || '').normalize('NFKC'); v = v.replace(/[^\x20-\x7E]/g, ''); return v.replace(/\s+/g, '').slice(0, 128); } function maskTelegram(v) { v = (v || '').replace(/^@+/, ''); return v.replace(/[^A-Za-z0-9_]/g, '').slice(0, 32); } function maskWhatsapp(v) { const digits = (v || '').replace(/\D/g, '').slice(0, 15); const groups = []; for (let i = 0; i < digits.length; i += 3) { groups.push(digits.slice(i, i + 3)); } return groups.join(' '); } function qGetSlot($el) { const wrap = $el.closest('.gh-field') || $el.parentElement; let slot = wrap.querySelector('.gh-error-slot'); if (!slot) { slot = document.createElement('div'); slot.className = 'gh-error-slot mt-1 text-[11px] leading-4 text-rose-300'; wrap.appendChild(slot); } return slot; } function qMarkError($el, msg) { if (!$el) return; $el.classList.add('gh-error'); $el.setAttribute('aria-invalid', 'true'); const slot = qGetSlot($el); slot.textContent = msg || ''; slot.classList.remove('hidden'); } function qClearError($el) { if (!$el) return; $el.classList.remove('gh-error'); $el.removeAttribute('aria-invalid'); const wrap = $el.closest('.gh-field') || $el.parentElement; const slot = wrap.querySelector('.gh-error-slot'); if (slot) { slot.textContent = ''; slot.classList.add('hidden'); } } function clearContactsError() { if (!$contactsError) return; $contactsError.textContent = ''; $contactsError.classList.add('hidden'); } function showContactsError(text) { if (!$contactsError) return; $contactsError.textContent = text; $contactsError.classList.remove('hidden'); } function clearAllQuickErrors() { [$email, $telegram, $whatsapp].forEach(qClearError); clearContactsError(); } function clearQuickContacts() { if ($email) $email.value = ''; if ($telegram) $telegram.value = ''; if ($whatsapp) $whatsapp.value = ''; clearAllQuickErrors(); } function showFormState() { $success.classList.add('hidden'); $form.classList.remove('hidden'); $sendBtn.disabled = false; $sendBtn.innerHTML = 'Envoyer'; $started.value = String(Date.now()); setTimeout(() => $email.focus(), 60); } function showSuccessState() { $form.classList.add('hidden'); $success.classList.remove('hidden'); setTimeout(() => $okBtn.focus(), 30); } function openModal(subj) { clearQuickContacts(); $subject.value = subj || 'Quick Request'; $channel.value = 'email'; $started.value = String(Date.now()); showFormState(); $modal.classList.remove('hidden'); requestAnimationFrame(() => { $overlay.classList.remove('opacity-0'); $panel.classList.remove('opacity-0', 'translate-y-2', 'scale-[.98]'); }); setTimeout(() => $email.focus(), 120); } function closeModal() { $overlay.classList.add('opacity-0'); $panel.classList.add('opacity-0', 'translate-y-2', 'scale-[.98]'); setTimeout(() => { $modal.classList.add('hidden'); }, 200); } $openers.forEach(el => { el.addEventListener('click', e => { e.preventDefault(); openModal(el.getAttribute('data-qs-subject')); }); }); $close.addEventListener('click', closeModal); $okBtn.addEventListener('click', closeModal); $againBtn.addEventListener('click', () => { clearQuickContacts(); showFormState(); }); $modal.addEventListener('click', e => { if (e.target === $modal) closeModal(); }); document.addEventListener('keydown', e => { if (!$modal.classList.contains('hidden') && e.key === 'Escape') { closeModal(); } }); $email.addEventListener('input', () => { $email.value = maskEmail($email.value); qClearError($email); clearContactsError(); }); $telegram.addEventListener('input', () => { $telegram.value = maskTelegram($telegram.value); qClearError($telegram); clearContactsError(); }); $whatsapp.addEventListener('input', () => { $whatsapp.value = maskWhatsapp($whatsapp.value); qClearError($whatsapp); clearContactsError(); }); [$email, $telegram, $whatsapp].forEach($el => { $el.addEventListener('focus', () => { qClearError($el); clearContactsError(); }); }); $form.addEventListener('submit', async e => { e.preventDefault(); clearAllQuickErrors(); const emailVal = maskEmail($email.value.trim()); const telegramHandle = maskTelegram($telegram.value.trim()); const whatsappDigits = $whatsapp.value.replace(/\D/g, '').slice(0, 15); $email.value = emailVal; $telegram.value = telegramHandle; $whatsapp.value = maskWhatsapp(whatsappDigits); const hasEmail = emailVal.length > 0; const hasTelegram = telegramHandle.length > 0; const hasWhatsApp = whatsappDigits.length > 0; if (!hasEmail && !hasTelegram && !hasWhatsApp) { const text = 'Enter at least one contact: email, Telegram or WhatsApp.'; showContactsError(text); ghShowToastIn('ghToastQuick', 'err', text); return; } let hasError = false; if (hasEmail && !emailLike(emailVal)) { qMarkError($email, 'Invalid email'); hasError = true; } if (hasTelegram && !tgValid(telegramHandle)) { qMarkError($telegram, 'Only letters, digits and _ (min 5)'); hasError = true; } const whatsappFull = hasWhatsApp ? ('+' + whatsappDigits) : ''; if (hasWhatsApp && !waValid(whatsappFull)) { qMarkError($whatsapp, 'Invalid WhatsApp number'); hasError = true; } if (hasError) { ghShowToastIn('ghToastQuick', 'err', 'Check the contact details.'); return; } const primaryChannel = hasEmail ? 'email' : (hasTelegram ? 'telegram' : 'whatsapp'); $channel.value = primaryChannel; const fd = new FormData(); fd.append('started_at', $started.value || String(Date.now())); fd.append('company', ''); fd.append('channel', primaryChannel); fd.append('subject', $subject.value || 'Quick Request'); fd.append( 'message', 'Quick contact request.\nAt least one contact method was provided.\n\nThis message was sent from the quick modal.' ); if (hasEmail) fd.append('email', emailVal); if (hasTelegram) fd.append('telegram', '@' + telegramHandle); if (hasWhatsApp) fd.append('whatsapp_full', whatsappFull); const old = $sendBtn.innerHTML; $sendBtn.disabled = true; $sendBtn.innerHTML = 'Sending…'; try { const res = await fetch('/ajax/contact.php', { method: 'POST', headers: {'X-Requested-With': 'XMLHttpRequest'}, body: fd, credentials: 'same-origin' }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { if (res.status === 429 && data.retry_after) { ghShowToastIn( 'ghToastQuick', 'err', data.error_human || ('Please wait ' + data.retry_after + 's') ); } else { ghShowToastIn( 'ghToastQuick', 'err', (data && (data.error_human || data.error)) || ('HTTP ' + res.status) ); } return; } ghShowToastIn('ghToastQuick', 'ok', 'La demande a été envoyée. Nous vous contacterons prochainement.'); clearQuickContacts(); showSuccessState(); } catch (error) { ghShowToastIn('ghToastQuick', 'err', 'Network error. Try again later.'); } finally { $sendBtn.disabled = false; $sendBtn.innerHTML = old; } }); $started.value = String(Date.now()); }); /* ========= ОСНОВНАЯ ФОРМА ========= */ document.addEventListener('DOMContentLoaded', () => { const $form = document.getElementById('ghContactForm'); const $btn = document.getElementById('ghSubmitBtn'); const $name = document.getElementById('name'); const $email = document.getElementById('email'); const $subject = document.getElementById('subject'); const $message = document.getElementById('message'); const $tg = document.getElementById('telegram'); const $ccode = document.getElementById('ccode'); const $whats = document.getElementById('whatsapp'); const $waFull = document.getElementById('ghWaFull'); const $channel = document.getElementById('ghChannel'); const $started = document.getElementById('ghStartedAt'); const asciiOnly = s => s.replace(/[^\x20-\x7E]/g,''); const emailLike = s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); const tgValid = s => /^[A-Za-z0-9_]{5,}$/.test(s); const onlyDigits = s => s.replace(/\D+/g,''); const phoneFullValid = s => /^\+\d{7,15}$/.test(s); function getSlot($el) { const wrap = $el.closest('.gh-field') || $el.parentElement; let slot = wrap.querySelector('.gh-error-slot'); if (!slot) { slot = document.createElement('div'); slot.className = 'gh-error-slot mt-1 text-[11px] leading-4 text-rose-300'; wrap.appendChild(slot); } return slot; } function markError($el, msg) { if (!$el) return; $el.classList.add('gh-error'); $el.setAttribute('aria-invalid','true'); const slot = getSlot($el); slot.textContent = msg; slot.classList.remove('hidden'); } function clearError($el) { if (!$el) return; $el.classList.remove('gh-error'); $el.removeAttribute('aria-invalid'); const wrap = $el.closest('.gh-field') || $el.parentElement; const slot = wrap.querySelector('.gh-error-slot'); if (slot) { slot.textContent = ''; slot.classList.add('hidden'); } } [$name,$email,$subject,$message,$tg,$ccode,$whats].forEach(($el)=>{ if(!$el) return; $el.addEventListener('input', () => clearError($el)); $el.addEventListener('focus', () => clearError($el)); }); (function(){ const optWraps = document.querySelectorAll('.gh-optional'); const digits = s => (s||'').replace(/\D+/g,''); const refresh = (wrap)=>{ const hasFocus = document.activeElement && wrap.contains(document.activeElement); let hasVal = false; if (wrap.id === 'fieldWhatsApp') { const tel = wrap.querySelector('#whatsapp'); hasVal = digits(tel && tel.value).length > 0; } else if (wrap.id === 'fieldTelegram') { const tg = wrap.querySelector('#telegram'); hasVal = !!(tg && tg.value && tg.value.trim().length > 0); } else { const inputs = wrap.querySelectorAll('input,textarea'); hasVal = Array.from(inputs).some(i => (i.value||'').trim().length>0); } wrap.classList.toggle('is-active', hasFocus || hasVal); }; optWraps.forEach(wrap=>{ refresh(wrap); wrap.addEventListener('focusin', ()=>refresh(wrap)); wrap.addEventListener('focusout', ()=>setTimeout(()=>refresh(wrap), 10)); wrap.querySelectorAll('input,textarea').forEach(i=>{ i.addEventListener('input', ()=>refresh(wrap)); i.addEventListener('change', ()=>refresh(wrap)); i.addEventListener('keyup', ()=>refresh(wrap)); }); }); })(); if ($started) $started.value = String(Date.now()); if ($ccode) { $ccode.value = '+'; $ccode.readOnly = true; $ccode.setAttribute('aria-readonly','true'); $ccode.setAttribute('tabindex','-1'); } if ($whats) { $whats.setAttribute('autocomplete','tel'); $whats.maxLength = 24; $whats.addEventListener('input', () => { let v = $whats.value.replace(/[^\d()\-\s]/g, ''); v = v.replace(/\s{2,}/g, ' '); $whats.value = v.slice(0, 24); }); } if ($email) { $email.setAttribute('autocomplete','email'); $email.maxLength = 128; $email.addEventListener('input', () => { let v = $email.value.replace(/[^\x20-\x7E]/g, ''); v = v.replace(/\s+/g, ''); $email.value = v; }); } if ($tg) { $tg.setAttribute('autocomplete','off'); $tg.maxLength = 32; $tg.addEventListener('input', () => { let v = $tg.value.trim(); if (v.startsWith('@')) v = v.slice(1); v = v.replace(/[^A-Za-z0-9_]/g, ''); $tg.value = v; }); } $form.addEventListener('submit', async (e) => { e.preventDefault(); [$name,$email,$subject,$message,$tg,$ccode,$whats].forEach(clearError); const nameVal = ($name.value || '').trim(); const emailVal = asciiOnly(($email.value || '').trim()); const subjVal = ($subject.value || '').trim(); const msgVal = ($message.value || '').trim(); let tgVal = ($tg ? $tg.value.trim() : ''); if (tgVal.startsWith('@')) tgVal = tgVal.slice(1); tgVal = tgVal.replace(/[^A-Za-z0-9_]/g,''); const telDigits = onlyDigits($whats ? $whats.value : ''); const waFull = '+' + telDigits; if (nameVal.length < 2) { markError($name,'Enter your name (min 2 chars)'); ghShowToastIn('ghToastMain','err','Please enter your name.'); return; } if (!emailLike(emailVal)) { markError($email,'Invalid email'); ghShowToastIn('ghToastMain','err','Invalid email.'); return; } if (subjVal.length < 2) { markError($subject,'Subject is required'); ghShowToastIn('ghToastMain','err','Please specify a subject.'); return; } if (msgVal.length < 5) { markError($message,'Message is too short'); ghShowToastIn('ghToastMain','err','Message is too short.'); return; } if ($whats && telDigits.length > 0) { if (!phoneFullValid(waFull)) { markError($whats,'Invalid phone format'); ghShowToastIn('ghToastMain','err','Invalid phone number.'); return; } } if ($tg && tgVal && !tgValid(tgVal)) { markError($tg,'Only letters, digits and _ (min 5)'); ghShowToastIn('ghToastMain','err','Invalid Telegram handle.'); return; } if ($waFull) $waFull.value = (telDigits.length ? waFull : ''); if ($channel) $channel.value = 'email'; if ($started && !$started.value) $started.value = String(Date.now()); const fd = new FormData($form); fd.set('telegram', tgVal ? ('@' + tgVal) : ''); const oldHtml = $btn.innerHTML; $btn.disabled = true; $btn.innerHTML = 'Envoi…'; try { const res = await fetch('/ajax/contact.php', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd, credentials: 'same-origin' }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) { const msg = (data && (data.error_human || data.error)) ? (data.error_human || data.error) : ('HTTP ' + res.status); if (res.status === 429 && data.retry_after) { ghShowToastIn('ghToastMain','err', data.error_human || ('Please wait '+data.retry_after+'s')); } else { ghShowToastIn('ghToastMain','err', msg || 'Send error'); } if (msg === 'Name required') markError($name, 'Name required'); if (msg === 'Invalid email') markError($email, 'Invalid email'); if (msg === 'Invalid telegram') markError($tg, 'Invalid telegram'); if (msg === 'Invalid phone') markError($whats, 'Invalid phone'); return; } ghShowToastIn('ghToastMain','ok','Demande envoyée avec succès. Nous vous contacterons prochainement.'); $form.reset(); if ($waFull) $waFull.value = ''; if ($channel) $channel.value = 'email'; if ($ccode) $ccode.value = '+'; document.querySelectorAll('.gh-optional').forEach(w=>w.classList.remove('is-active')); } catch (err) { ghShowToastIn('ghToastMain','err','Network error. Try again later.'); } finally { $btn.disabled = false; $btn.innerHTML = oldHtml; } }); }); </script> </main> <style> /* =================== GAMBLEHUB FOOTER =================== */ .gh-footer { position: relative; border-top: 1px solid rgba(255, 255, 255, 0.10); background: linear-gradient( 180deg, rgba(7, 13, 25, 0.82) 0%, rgba(5, 10, 20, 0.96) 100% ); } .gh-footer::before { content: ""; position: absolute; top: -1px; left: 50%; width: min(34rem, 72%); height: 1px; transform: translateX(-50%); pointer-events: none; background: linear-gradient( 90deg, transparent, rgba(255, 255, 255, 0.16), transparent ); } .gh-footer-inner { width: 100%; max-width: 80rem; margin: 0 auto; padding: 2.5rem 1rem 1.25rem; } /* =================== ОСНОВНОЙ БЛОК =================== */ .gh-footer-main { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2rem; } /* =================== ЛОГО И ОПИСАНИЕ =================== */ .gh-footer-brand { min-width: 0; } .gh-footer-logo { display: inline-flex; align-items: center; max-width: 100%; text-decoration: none; } .gh-footer-logo img { display: block; width: 150px; height: auto; max-width: 100%; } .gh-footer-slogan { max-width: 27rem; margin: 1rem 0 0; color: #8491a4; font-size: 0.8125rem; line-height: 1.65; } /* =================== МЕНЮ =================== */ .gh-footer-menus { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; min-width: 0; } .gh-footer-column { min-width: 0; } .gh-footer-title { margin: 0 0 0.875rem; color: #e2e8f0; font-size: 0.875rem; line-height: 1.35; font-weight: 700; } /* * Нормализация HTML из BottomMenu() */ .gh-footer-menu, .gh-footer-menu ul, .gh-footer-menu ol { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; width: 100%; min-width: 0; margin: 0; padding: 0; list-style: none; } .gh-footer-menu li { width: 100%; min-width: 0; margin: 0; padding: 0; list-style: none; } .gh-footer-menu a { position: relative; display: inline-flex; align-items: center; max-width: 100%; color: #94a3b8; font-size: 0.8125rem; line-height: 1.5; text-decoration: none !important; overflow-wrap: anywhere; transition: color 0.2s ease; } .gh-footer-menu a:hover { color: #e7fbff; } /* Активный пункт меню */ .gh-footer-menu .gh-bottom-menu-link.is-active { color: #e7fbff; } .gh-footer-menu .gh-bottom-menu-link.is-active::after { content: ""; position: absolute; right: 0; bottom: -3px; left: 0; height: 1px; border-radius: 999px; background: rgba(231, 251, 255, 0.68); pointer-events: none; } /* =================== НИЖНЯЯ СТРОКА =================== */ .gh-footer-bottom { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; margin-top: 2rem; padding-top: 1.25rem; border-top: 1px solid rgba(255, 255, 255, 0.075); } .gh-footer-copy, .gh-footer-created { margin: 0; color: #64748b; font-size: 0.75rem; line-height: 1.55; text-align: center; } .gh-footer-created strong { color: #a9b5c5; font-weight: 600; } /* =================== УЗКАЯ МОБИЛКА =================== */ @media (max-width: 420px) { .gh-footer-inner { padding-top: 2rem; } .gh-footer-main { gap: 1.75rem; } .gh-footer-menus { grid-template-columns: minmax(0, 1fr); gap: 1.75rem; } .gh-footer-logo img { width: 145px; } .gh-footer-slogan { margin-top: 0.875rem; font-size: 0.8125rem; } .gh-footer-title { margin-bottom: 0.75rem; font-size: 0.9375rem; } .gh-footer-menu a { font-size: 0.8125rem; } } /* =================== ПЛАНШЕТ =================== */ @media (min-width: 640px) { .gh-footer-inner { padding-right: 1.5rem; padding-left: 1.5rem; } .gh-footer-main { grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.2fr); align-items: start; gap: 3rem; } .gh-footer-menus { gap: 2.5rem; } .gh-footer-bottom { flex-direction: row; justify-content: space-between; gap: 1rem; } .gh-footer-copy { text-align: left; } .gh-footer-created { text-align: right; } } /* =================== ДЕСКТОП =================== */ @media (min-width: 1024px) { .gh-footer-inner { padding: 2.75rem 2rem 1.25rem; } .gh-footer-main { grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.15fr); gap: 4.5rem; } .gh-footer-brand { padding-right: 3rem; border-right: 1px solid rgba(255, 255, 255, 0.075); } .gh-footer-menus { gap: 4rem; } } /* =================== RTL =================== */ [dir="rtl"] .gh-footer-menu, [dir="rtl"] .gh-footer-menu ul, [dir="rtl"] .gh-footer-menu ol { align-items: flex-start; } @media (min-width: 640px) { [dir="rtl"] .gh-footer-copy { text-align: right; } [dir="rtl"] .gh-footer-created { text-align: left; } } @media (min-width: 1024px) { [dir="rtl"] .gh-footer-brand { padding-right: 0; padding-left: 3rem; border-right: 0; border-left: 1px solid rgba(255, 255, 255, 0.075); } } @media (prefers-reduced-motion: reduce) { .gh-footer-menu a { transition: none; } } </style> <!-- FOOTER --> <footer class="gh-footer"> <div class="gh-footer-inner"> <div class="gh-footer-main"> <!-- ЛОГО И ОПИСАНИЕ --> <div class="gh-footer-brand"> <a href="/fr/" class="gh-footer-logo" aria-label="GambleHub" > <img src="/images/logo.svg" width="150" height="29" alt="GambleHub" loading="lazy" decoding="async" > </a> <p class="gh-footer-slogan"> GambleHub propose un large choix de jeux, notamment des machines à sous, des puzzles, des jeux d’adresse et des sports virtuels. </p> </div> <!-- МЕНЮ --> <div class="gh-footer-menus"> <!-- COMPANY --> <div class="gh-footer-column"> <div class="gh-footer-title"> Entreprise </div> <div class="gh-footer-menu"> <a href="/fr/about-us/" class="gh-bottom-menu-link">À propos de nous</a><a href="/fr/services/" class="gh-bottom-menu-link">Services</a><a href="/fr/features/" class="gh-bottom-menu-link">Possibilités</a><a href="/fr/partners/" class="gh-bottom-menu-link">Partenaires</a><a href="/fr/contact-us/" class="gh-bottom-menu-link">Contacts</a> </div> </div> <!-- LEGAL --> <div class="gh-footer-column"> <div class="gh-footer-title"> Mentions légales </div> <div class="gh-footer-menu"> <a href="/fr/responsible-gaming/" class="gh-bottom-menu-link">Un jeu responsable</a><a href="/fr/gaming-license/" class="gh-bottom-menu-link">Licence de jeu</a><a href="/fr/privacy-policy/" class="gh-bottom-menu-link">Politique de confidentialité</a><a href="/fr/terms-and-conditions/" class="gh-bottom-menu-link">Conditions d'utilisation</a><a href="/fr/cookie-policy/" class="gh-bottom-menu-link">Politique de cookies</a> </div> </div> </div> </div> <!-- НИЖНЯЯ СТРОКА --> <div class="gh-footer-bottom dir-ltr"> <p class="gh-footer-copy"> Copyright © GambleHub. 2024 ® All Rights Reserved. </p> <p class="gh-footer-created"> Created by <strong>GambleHub</strong> </p> </div> </div> </footer> <!-- Единственный JS: мобильное меню --> <script> (function(){ const btn = document.getElementById('navToggle'); const panel = document.getElementById('navPanel'); if(!btn || !panel) return; // БЕЗ блокировки фона — ничего не трогаем у <html> const isOpen = () => panel.classList.contains('active'); const open = () => panel.classList.add('active'); const close = () => panel.classList.remove('active'); // Бургер — переключение btn.addEventListener('click', (e)=>{ e.stopPropagation(); isOpen() ? close() : open(); }); // Клик внутри меню — не закрывает panel.addEventListener('click', e => e.stopPropagation()); // Клик вне меню/кнопки — закрывает document.addEventListener('click', (e)=>{ if (isOpen() && !panel.contains(e.target) && !btn.contains(e.target)) close(); }); // Esc — закрыть document.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && isOpen()) close(); }); })(); </script> <script> (function(){ const RECENT_LIMIT = 6; const STORE_KEY = 'lang_recent_codes'; function uid(){ return Math.random().toString(36).slice(2,8); } function readRecent(){ try { return JSON.parse(localStorage.getItem(STORE_KEY) || '[]'); } catch(e){ return []; } } function pushRecent(code){ const arr = readRecent().filter(c=>c!==code); arr.unshift(code); localStorage.setItem(STORE_KEY, JSON.stringify(arr.slice(0, RECENT_LIMIT))); } function closeAll(except){ document.querySelectorAll('.language-menu.show').forEach(m=>{ if (m!==except) m.classList.remove('show'); }); } // Делегирование document.addEventListener('click', (e)=>{ const wrap = e.target.closest('.language-selector'); const isBtn = e.target.closest('.language-button'); if (wrap && isBtn){ const menu = wrap.querySelector('.language-menu'); if (!menu) return; closeAll(menu); menu.classList.toggle('show'); const input = menu.querySelector('.language-search input'); if (menu.classList.contains('show') && input){ setTimeout(()=>input.focus(), 0); } return; } // Выбор языка const link = e.target.closest('.language-menu a[data-lang]'); if (link){ const code = link.getAttribute('data-lang'); if (code) pushRecent(code); // стандартный переход по href произойдет сам return; } // Клик вне — закрыть if (!e.target.closest('.language-menu')) closeAll(); }); // Клавиатура: Esc/стрелки/Home/End/Enter/Space document.addEventListener('keydown', (e)=>{ const openMenu = document.querySelector('.language-menu.show'); if (!openMenu) return; const focusables = [...openMenu.querySelectorAll('a[data-lang]')]; const idx = focusables.indexOf(document.activeElement); if (e.key === 'Escape'){ closeAll(); return; } if (e.key === 'ArrowDown'){ e.preventDefault(); (focusables[idx+1] || focusables[0])?.focus(); } if (e.key === 'ArrowUp'){ e.preventDefault(); (focusables[idx-1] || focusables[focusables.length-1])?.focus(); } if (e.key === 'Home'){ e.preventDefault(); focusables[0]?.focus(); } if (e.key === 'End'){ e.preventDefault(); focusables[focusables.length-1]?.focus(); } if (e.key === ' ' || e.key === 'Enter'){ if (document.activeElement?.tagName === 'A'){ document.activeElement.click(); } } }); // Живой поиск document.addEventListener('input', (e)=>{ const input = e.target.closest('.language-search input'); if (!input) return; const menu = input.closest('.language-menu'); if (!menu) return; const q = input.value.trim().toLowerCase(); menu.querySelectorAll('a[data-lang]').forEach(a=>{ const hay = (a.dataset.search || '').toLowerCase(); a.style.display = hay.includes(q) ? '' : 'none'; }); }); // Рендер «Недавние» (когда меню впервые открывается) document.addEventListener('click', (e)=>{ const btn = e.target.closest('.language-button'); if (!btn) return; const menu = btn.parentElement.querySelector('.language-menu'); if (!menu) return; const recentWrap = menu.querySelector('[data-recent]'); if (!recentWrap) return; if (recentWrap.dataset.inited === '1') return; const recent = readRecent(); if (!recent.length){ recentWrap.remove(); return; } // Показать только те, что реально есть в меню const map = {}; menu.querySelectorAll('a[data-lang]').forEach(a=>{ map[a.dataset.lang] = a.outerHTML; }); const inner = recent.map(code=>map[code]).filter(Boolean).join(''); if (inner){ recentWrap.insertAdjacentHTML('beforeend', inner); recentWrap.dataset.inited = '1'; } else { recentWrap.remove(); } }); })(); </script> <script> (function(){ const pillsWrap = document.querySelector('.filter-pills'); const gridWrap = document.getElementById('providersGrid'); if (!pillsWrap || !gridWrap) return; const pills = Array.from(pillsWrap.querySelectorAll('[data-cat]')); const px = (gridWrap.getAttribute('data-px') || '').trim(); const url = (gridWrap.getAttribute('data-url') || '').trim(); let current = 9; let inflight; // ---------- helpers ---------- function setActive(catId){ pills.forEach(btn => { const on = parseInt(btn.dataset.cat,10) === catId; btn.classList.toggle('is-active', on); btn.setAttribute('aria-selected', on ? 'true' : 'false'); btn.setAttribute('role', 'tab'); }); } function gridColsClass(){ return 'providers-grid mt-6 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3'; } // Берём ТОЛЬКО основной грид — прямой ребёнок providersGrid function getMainGrid(){ // :scope гарантирует, что не схватим UL внутри модалки return gridWrap.querySelector(':scope > ul.providers-grid'); } // скелетон — только внутри основного UL function renderSkeleton(count = 8){ const items = Array.from({length: count}) .map(() => '<li class="gh-skel gh-skel-card"></li>') .join(''); let ul = getMainGrid(); if (!ul) { ul = document.createElement('ul'); ul.className = gridColsClass(); gridWrap.appendChild(ul); } ul.innerHTML = items; } function ensureOverlay(){ let ov = gridWrap.querySelector('.gh-preload__overlay'); if (!ov) { ov = document.createElement('div'); ov.className = 'gh-preload__overlay'; ov.setAttribute('aria-hidden', 'true'); ov.innerHTML = '<div class="gh-spinner" role="progressbar" aria-label="Loading"></div>'; gridWrap.appendChild(ov); } return ov; } async function loadCat(catId){ if (inflight) inflight.abort(); inflight = new AbortController(); setActive(catId); gridWrap.setAttribute('aria-busy','true'); renderSkeleton(8); const overlay = ensureOverlay(); overlay.classList.add('is-visible'); const started = performance.now(); const minShow = 450; try{ const body = new URLSearchParams({ id: String(catId), px, url}); const resp = await fetch('/ajax/providers.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, credentials: 'same-origin', body, signal: inflight.signal }); if(!resp.ok) throw new Error('Network error'); const html = await resp.text(); const elapsed = performance.now() - started; if (elapsed < minShow) { await new Promise(r => setTimeout(r, minShow - elapsed)); } // Меняем только основной UL, модалку не трогаем const oldGrid = getMainGrid(); if (oldGrid) { // html = <ul class="providers-grid ...">...</ul> oldGrid.outerHTML = html; } else { // на всякий случай, если по каким-то причинам UL ещё нет gridWrap.insertAdjacentHTML('beforeend', html); } } catch(err){ if (err.name === 'AbortError') return; console.error(err); gridWrap.innerHTML = ` <div class="mt-10 text-center text-slate-300"> <div class="inline-block border border-white/10 rounded-2xl px-6 py-8 bg-white/5 max-w-md"> <p class="text-lg font-semibold text-white"> Ошибка загрузки </p> <p class="text-sm mt-1 text-slate-400"> Пожалуйста, попробуйте ещё раз </p> </div> </div>`; } finally { gridWrap.removeAttribute('aria-busy'); const overlay = gridWrap.querySelector('.gh-preload__overlay'); if (overlay) overlay.classList.remove('is-visible'); } } // ---------- events ---------- pillsWrap.addEventListener('click', (e) => { const btn = e.target.closest('[data-cat]'); if (!btn) return; const catId = parseInt(btn.dataset.cat, 10); if (catId === current) return; current = catId; loadCat(current); }); setActive(current); })(); </script> <script> document.addEventListener('DOMContentLoaded', () => { const io = new IntersectionObserver((entries, obs) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('is-inview'); obs.unobserve(entry.target); } }); }, { threshold: 0.3 }); document.querySelectorAll('[data-reveal] .reveal-item').forEach(el => io.observe(el)); }); </script> <script> // === Smooth scroll with offset (GambleHub style) === document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', function (e) { const targetId = this.getAttribute('href').substring(1); const target = document.getElementById(targetId); if (target) { e.preventDefault(); // Смещение в пикселях (можно менять) const offset = 70; // Расчёт позиции с учётом текущего скролла const y = target.getBoundingClientRect().top + window.scrollY - offset; // Плавный скролл window.scrollTo({ top: y, behavior: 'smooth' }); // Добавим лёгкую подсветку цели (опционально) target.classList.add('ring-1', 'ring-aqua-400/40', 'transition'); setTimeout(() => target.classList.remove('ring-1', 'ring-aqua-400/40'), 1000); } }); }); </script> <script> document.querySelectorAll('a, button').forEach(el => { el.addEventListener('click', () => { if (typeof gtag === 'function') { gtag('event', 'click', { event_category: 'interaction', event_label: el.dataset.role || el.dataset.href || 'unknown', value: 1 }); } }); }); </script> <!-- ===================== VIDEO MODAL ===================== --> <div id="ghVideoModal" class="fixed inset-0 z-[90] hidden items-center justify-center p-4 bg-black/70 backdrop-blur-sm transition-opacity duration-300 opacity-0"> <!-- overlay закрытия --> <div class="absolute inset-0" onclick="ghCloseVideoModal()"></div> <!-- content --> <div id="ghVideoModalContent" class="relative w-full max-w-md rounded-3xl bg-night-900 border border-white/10 p-8 gh-glow text-center transform scale-95 opacity-0 transition-all duration-300 ease-out"> <!-- YouTube icon --> <div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-3xl bg-red-600/20 text-red-400 shadow-[0_0_30px_rgba(255,0,0,.25)]"> <svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" fill="currentColor" viewBox="0 0 16 16"> <path d="M8.051 1.999h.089c.822.003 4.987.033 6.11.335a2.01 2.01 0 0 1 1.415 1.42c.101.38.172.883.22 1.402l.01.104.022.26.008.104c.065.914.073 1.77.074 1.957v.075c-.001.194-.01 1.108-.082 2.06l-.008.105-.009.104c-.05.572-.124 1.14-.235 1.558a2.01 2.01 0 0 1-1.415 1.42c-1.16.312-5.569.334-6.18.335h-.142c-.309 0-1.587-.006-2.927-.052l-.17-.006-.087-.004-.171-.007-.171-.007c-1.11-.049-2.167-.128-2.654-.26a2.01 2.01 0 0 1-1.415-1.419c-.111-.417-.185-.986-.235-1.558L.09 9.82l-.008-.104A31 31 0 0 1 0 7.68v-.123c.002-.215.01-.958.064-1.778l.007-.103.003-.052.008-.104.022-.26.01-.104c.048-.519.119-1.023.22-1.402a2.01 2.01 0 0 1 1.415-1.42c.487-.13 1.544-.21 2.654-.26l.17-.007.172-.006.086-.003.171-.007A100 100 0 0 1 7.858 2zM6.4 5.209v4.818l4.157-2.408z"/> </svg> </div> <!-- Title --> <h2 class="text-lg font-semibold text-white mb-2"> La vidéo sera bientôt mise à jour </h2> <!-- Text --> <p class="text-slate-400 text-sm leading-relaxed mb-6"> La vidéo est actuellement en cours de mise à jour.<br>Veuillez revenir plus tard — la nouvelle version est en route. </p> <!-- Close button --> <button onclick="ghCloseVideoModal()" class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-slate-200 hover:border-white/20 hover:bg-white/10 transition"> Fermer </button> </div> </div> <!-- ===================== / VIDEO MODAL ===================== --> <script> function ghOpenVideoModal() { const modal = document.getElementById('ghVideoModal'); const content = document.getElementById('ghVideoModalContent'); modal.classList.remove('hidden'); modal.classList.add('flex'); // блокируем скролл document.body.style.overflow = "hidden"; // запускаем анимацию (fade + scale) setTimeout(() => { modal.classList.remove('opacity-0'); content.classList.remove('opacity-0', 'scale-95'); }, 20); } function ghCloseVideoModal() { const modal = document.getElementById('ghVideoModal'); const content = document.getElementById('ghVideoModalContent'); // анимация скрытия modal.classList.add('opacity-0'); content.classList.add('opacity-0', 'scale-95'); // ждём 300ms пока анимация пойдёт setTimeout(() => { modal.classList.add('hidden'); modal.classList.remove('flex'); document.body.style.overflow = ""; }, 300); } </script> <!-- ===================== SOCIAL MODAL ===================== --> <div id="ghSocialModal" class="fixed inset-0 z-[90] hidden items-center justify-center p-4 bg-black/70 backdrop-blur-sm transition-opacity duration-300 opacity-0"> <!-- overlay --> <div class="absolute inset-0" onclick="ghCloseSocialModal()"></div> <!-- content --> <div id="ghSocialModalContent" class="relative w-full max-w-md rounded-3xl bg-night-900 border border-white/10 p-8 gh-glow text-center transform scale-95 opacity-0 transition-all duration-300 ease-out"> <!-- icons row — теперь компактный, как YouTube-иконка по смыслу --> <div class="flex items-center justify-center gap-3 mb-6"> <div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 text-slate-200 shadow-[0_0_18px_rgba(255,255,255,.08)]"> <!-- X --> <svg width="24" height="24" viewBox="0 0 24 24" fill="none"> <path d="M3 3l18 18M21 3L3 21" stroke="currentColor" stroke-width="1.6"/> <path d="M14.5 3h3.9L12.9 11l6.1 10h-3.9L12 14.6 8.9 21H5l6.1-10L5 3h3.9L12 9.4 14.5 3z" fill="currentColor" opacity=".9"/> </svg> </div> <div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 text-slate-200 shadow-[0_0_18px_rgba(255,255,255,.08)]"> <!-- LinkedIn --> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path d="M4.98 3.5C4.98 4.88 3.86 6 2.5 6S0 4.88 0 3.5 1.12 1 2.5 1s2.48 1.12 2.48 2.5zM0 8.5h5V23H0V8.5zM8 8.5h4.8v2h.07c.67-1.2 2.3-2.46 4.73-2.46C21.86 8.04 24 10 24 13.86V23h-5v-7.7c0-1.84-.03-4.2-2.56-4.2-2.56 0-2.96 2-2.96 4.06V23H8V8.5z"/> </svg> </div> <div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 text-slate-200 shadow-[0_0_18px_rgba(255,255,255,.08)]"> <!-- YouTube --> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path d="M23.5 6.2a3.4 3.4 0 0 0-2.4-2.4C19.2 3.3 12 3.3 12 3.3s-7.2 0-9.1.5A3.4 3.4 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3.4 3.4 0 0 0 2.4 2.4c1.9.5 9.1.5 9.1.5s7.2 0 9.1-.5a3.4 3.4 0 0 0 2.4-2.4c.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8ZM9.6 15.5v-7l6.2 3.5-6.2 3.5Z"/> </svg> </div> </div> <!-- Title --> <h2 class="text-lg font-semibold text-white mb-3"> Nous sommes actuellement très occupés par des projets </h2> <!-- Text --> <p class="text-slate-400 text-sm leading-relaxed mb-6"> En ce moment, toute notre attention est consacrée au développement et à l’intégration de vos produits.<br><br>Mais nous faisons de notre mieux pour trouver du temps pour les réseaux sociaux également. Du nouveau contenu arrivera très bientôt — restez connectés ! </p> <!-- Close button --> <button onclick="ghCloseSocialModal()" class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-slate-200 hover:border-white/20 hover:bg-white/10 transition"> Fermer </button> </div> </div> <!-- ===================== / SOCIAL MODAL ===================== --> <script> function ghOpenSocialModal() { const modal = document.getElementById('ghSocialModal'); const content = document.getElementById('ghSocialModalContent'); modal.classList.remove('hidden'); modal.classList.add('flex'); document.body.style.overflow = "hidden"; setTimeout(() => { modal.classList.remove('opacity-0'); content.classList.remove('opacity-0', 'scale-95'); }, 20); } function ghCloseSocialModal() { const modal = document.getElementById('ghSocialModal'); const content = document.getElementById('ghSocialModalContent'); modal.classList.add('opacity-0'); content.classList.add('opacity-0', 'scale-95'); setTimeout(() => { modal.classList.add('hidden'); modal.classList.remove('flex'); document.body.style.overflow = ""; }, 300); } </script> </body> </html>