Logo GH

Incident-bot and chat operations

1) Purpose and value

Incident bot is an incident management interface directly from corporate chat (Slack/Teams/Telegram): one text input → actions in dozens of systems. Is he:
  • reduces MTTA/MTTR by automating routine;
  • creates a single loop of facts (SoT) and communications;
  • provides provability (audit, timelines, SLA updates);
  • reduces the load on on-call and "burial" in consoles.

2) Roles and RACI in chat operations

Incident Commander (IC) - incident owner: opening/closing, priority, solutions.
Comms Lead (CL) - texts and schedule of updates (external/internal).
Domain Leads (Payments/Games/Core/Infra) - technical facts and fixes.
Scribe - timeline, action log.
Bot Admin - bot rights/policies/integrations.

Rule: one incident has one IC and one CL; role reversal - by explicit bot command.

3) End-to-end scenarios

1. Start of the incident: alert → '/incident new p1 "Deposits EU down" '→ the bot creates a card, var-room, assigns IC/CL, sets a timer for the first update.
2. Ведение: `/incident add-facts`, `/incident status set degraded`, `/incident assign @payments-lead`, `/incident timer 20m`.
3. Communications: '/incident publish status' (draft 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. Closing and post-mortem: '/incident resolve ', auto-collect timeline, '/postmortem generate'.

4) Bot commands (core)

Create/Classify

`/incident new p{1|2|3|4} "" [components=…] [regions=…]`</p> <p>`/incident severity set p2`, `/incident tag add payments,psp`</p> <p>Ownership and roles</p> <p>`/incident ic @user`, `/incident comms @user`, `/incident assign @user [domain]`</p> <p>Timers and SLO updates</p> <p><b>'/incident next-update 15m ', '/incident remind' (bot pings CL), '/incident eta set 18:</b> 30'</p> <p>Facts and status</p> <p>`/incident fact "auth-success PSP1 -25% TR/EU"`, `/incident status {investigating|degraded|monitoring|resolved}`</p> <p>Comm packs</p> <p>`/incident draft public|partners|regulator`, `/incident publish public`</p> <p>Integration</p> <p>'/runbook <action> ', '/dashboard link <id>', '/ticket create <system> '</p> <p>Closure/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;">All commands are logged with time, author, parameters, and result.</span> </div> <p>5) Integrations (minimum required)</p> <p>Monitoring: alerts, SLI/SLO (burn-rate), links to dashboards.<br>Incident Manager (ITSM): two-way status/field synchronization.<br>Status page: drafts and publishing via CL (policy-gate).<br>Providers (PSP/KYC/Game Studios): contact directories, quick letters/channels.<br>Release/Feature Flags: canary stops/pullbacks, release links.<br>Runbooks/Auto-remediation: Safe Action Catalog with guardrails.<br>CMDB/owners: auto-assignment of domain leads, escalation.<br>Timeline storage: WORM/immutable for audit/post-mortems.</p> <p>6) Bot architecture</p> <p>Gateway (Chat Adapter): Slack/Teams/Telegram interfaces.<br>Command Parser + Policy Engine: authorization, validation, SoD and tolerances.<br>Orchestrator: incident scenarios, timers, reminders.<br>Integrations Layer: clients to ITSM, monitoring, status page, releases, runbooks.<br>Evidence Store: events, facts, message diffuses, attachments (WORM).<br>Metrics & Audit: quality metrics, action logs, command tracing.</p> <p>7) Policies, rights and security</p> <p>RBAC/ABAC: who can create/close, change severity, publish outside.<br>SoD: Comms publish requires CL role; high-risk actions (PSP routing, PII export) - dual control.<br>JIT rights: temporary issuance to domain leaders at the time of the incident.<br>Signature and encryption: webhooks/requests to systems - HMAC/mTLS.<br>Fat-finger protection: confirmation of dangerous commands, dry-run and TTL for action.<br>PII hygiene: masking in drafts/logs; PII inhibition in open channels.</p> <p>8) Automation flows (example)</p> <p>Alert P1 → bot creates a var-room ('# inc-2025-11-01-001'), pings on duty (IC, CL, Payments/Infra).<br>Binds dashboards/SLI, opens a ticket in ITSM, prepares a template for the first public update.<br>Sets timers: "next update in 15 minutes," CL reminders.<br>Предлагает runbooks: “PSP reroute 30% → PSP2”, “degrade replay-center”, “autoscale settle-workers”.<br>When publishing - fixes the version of the text and publishes it to the status page/social network (via CL).<br>When closing - collects timeline, metrics, draft post-mortem, VIP/partner mailing.</p> <p>9) Timelines and provability</p> <p>Each event is logged: 'T + mm: description, author/bot, command, result, links'.<br>Edits of messages (diff), binding to releases/feature flags/planned work are supported.<br>Export: PDF/CSV for audit and regulators.</p> <p>10) Metrics (KPI/KRI ChatOps)</p> <p>MTTA (chat): alert to '/incident new '.<br>MTTS (setup): before the var room is ready and roles are assigned.<br>Cadence adherence: adherence to public update intervals.<br>Runbook usage rate: proportion of incidents with automated activities.<br>Consistency score: discrepancies between channels = 0 - target.<br>Pager fatigue↓: reduced manual pagers with the same/better SLO.<br>Postmortem SLA: proportion of post-mortems collected ≤ D + 5.</p> <p>11) Template catalog (fragments)</p> <u style="text-underline-offset:7px; text-decoration-color:rgba(200, 220, 255, 0.35);">Create 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);">First public update (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);">PSP routing and feature degradation:</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) Embedding in processes</p> <p>Communications: Link to Incident Communication and System Status Pages.<br>Observability: quick links to SLO/SLI and synthetics; auto-attachment of graphs.<br>Alerting: auto-creation of an incident during P1/P2; a single stream of signals.<br>Auto-fixes: one-button runbooks with guardrails and rollbacks.<br>Workflow Engine: human-tasks (4-eyes), escalation timers, checklists.</p> <p>13) Implementation Roadmap (4-8 weeks)</p> <p>Ned. 1-2: MVP teams: '/incident new ', roles (IC/CL), var room, update timer, communication with ITSM and monitoring.<br>Ned. 3-4: message templates (public/partners/regulators), status page (chernovik→publikatsiya), catalog 5-7 runbooks.<br>Ned. 5-6: policy-as-code (RBAC/SoD/JIT), dual control at high-risk, WORM magazine, ChatOps KPI dashboard.<br>Ned. 7-8: tabletop P1/P2 exercises, integration with releases/feature flags, auto-collection of post-mortem, localization.</p> <p>14) Antipatterns</p> <p>"All over the bot" without guardrails → random dangerous actions.<br>Posts to status page without CL/Legal-review role.<br>Commands without logs/versions → unprovability.<br>Complex forms (20 + fields) in chat - the speed drops; better short commands + links.<br>There are no update timers → "silence" at P1.<br>Lack of CMDB/owner integration → assignment chaos.</p> <p>15) The bottom line</p> <p>Incident-bot and ChatOps are not a "bot with commands," but an operating platform: a quick start to an incident, update discipline, automated actions with safe restrictions, end-to-end observability and provability. Such a circuit predictably reduces MTTR, improves the quality of communications and protects the revenue of the iGaming business at peak times.</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"> Get in Touch </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;">Reach out with any questions or support needs.</span><span style="display:block; height:5px;"></span><span style="display:block; font-weight:400;">We are always ready to help!</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=Inquiry%20to%20Gamble%20Hub&body=Hello%21%0A%0AI%20would%20like%20to%20ask%20a%20question%20about%20your%20product%2Fservice.%0A%0APlease%20contact%20me%20at%20your%20convenience.%0A%0AThank%20you%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: Get in Touch" data-qs-pref="email"> <span class="relative z-10"> Start Integration </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"> Email is <span class="text-slate-200">required</span>. Telegram or WhatsApp — <span class="text-slate-300">optional</span>. </p> <!-- Name --> <div class="gh-field" data-field="name"> <div class="gh-label-row"> <span class="gh-label">Your Name</span> <span class="gh-ghost -stub">optional</span> </div> <input id="name" name="name" type="text" required class="gh-text gh-input-h gh-focus w-full" placeholder="John Doe"/> <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">optional</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">Subject</span> <span class="gh-ghost -stub">optional</span> </div> <input id="subject" name="subject" type="text" required class="gh-text gh-input-h gh-focus w-full" placeholder="Integration request, pricing, partnership…"/> <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">optional</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="Briefly describe what you need"></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">optional</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">If you include Telegram — we will reply there as well, in addition to Email.</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">optional</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: +country code and number (e.g., +380XXXXXXXXX).</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"> Send Request </button> <p class="mt-2 text-[11px] text-slate-500">By clicking this button, you agree to data processing.</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">Quick Contact</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"> Send </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">Request Sent</div> <p class="mt-1 text-slate-300">Thank you! We’ve received your contact and will get back to you soon. You’ll receive a reply on the selected channel.</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 / Close </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"> New Application </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 = 'Send'; $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', 'The request has been sent. We will contact you shortly.'); 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 = '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) { 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','Request successfully sent. We will contact you shortly.'); $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="/" 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 offers a wide range of games including slots, puzzles, skill-based challenges, and virtual sports. </p> </div> <!-- МЕНЮ --> <div class="gh-footer-menus"> <!-- COMPANY --> <div class="gh-footer-column"> <div class="gh-footer-title"> Company </div> <div class="gh-footer-menu"> <a href="/about-us/" class="gh-bottom-menu-link">About us</a><a href="/services/" class="gh-bottom-menu-link">Services</a><a href="/features/" class="gh-bottom-menu-link">Opportunities</a><a href="/partners/" class="gh-bottom-menu-link">Partners</a><a href="/contact-us/" class="gh-bottom-menu-link">Contact information</a> </div> </div> <!-- LEGAL --> <div class="gh-footer-column"> <div class="gh-footer-title"> Legal </div> <div class="gh-footer-menu"> <a href="/responsible-gaming/" class="gh-bottom-menu-link">Responsible play</a><a href="/gaming-license/" class="gh-bottom-menu-link">Gaming license</a><a href="/privacy-policy/" class="gh-bottom-menu-link">Privacy Notice</a><a href="/terms-and-conditions/" class="gh-bottom-menu-link">Terms of use</a><a href="/cookie-policy/" class="gh-bottom-menu-link">Cookie Policy</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"> The video will be updated soon </h2> <!-- Text --> <p class="text-slate-400 text-sm leading-relaxed mb-6"> The video is currently being updated.<br>Please check back later — the new version is on its way. </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"> Close </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"> We are currently very busy with projects </h2> <!-- Text --> <p class="text-slate-400 text-sm leading-relaxed mb-6"> Right now our full focus is on developing and integrating your products.<br><br>But we are doing our best to find time for social media as well. New content will be updated very soon — stay tuned! </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"> Close </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>