bunnymommycooks.com Review

TitletagDescriptiontaglanguage
StudioGym Coach IA French
Alexarank
N/A
Ip adress104.21.19.244Nameservertia.ns.cloudflare.com
jack.ns.cloudflare.com
Status code200
robots.txt -http://bunnymommycooks.com/robots.txt
 # As a condition of accessing this website, you agree to abide by the following
# content signals:

# (a)  If a Content-Signal = yes, you may collect content for the corresponding
#      use.
# (b)  If a Content-Signal = no, you may not collect content for the
#      corresponding use.
# (c)  If the website operator does not include a Content-Signal for a
#      corresponding use, the website operator neither grants nor restricts
#      permission via Content-Signal with respect to the corresponding use.

# The content signals and their meanings are:

# search:   building a search index and providing search results (e.g., returning
#           hyperlinks and short excerpts from your website's contents). Search does not
#           include providing AI-generated search summaries.
# ai-input: inputting content into one or more AI models (e.g., retrieval
#           augmented generation, grounding, or other real-time taking of content for
#           generative AI search answers).
# ai-train: training or fine-tuning AI models.
# use:      how AI systems may consume the content (immediate, reference, or full).

# ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS RESERVATIONS OF
# RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION DIRECTIVE 2019/790 ON COPYRIGHT
# AND RELATED RIGHTS IN THE DIGITAL SINGLE MARKET.

# BEGIN Cloudflare Managed content

User-agent: *
Content-Signal: search=yes,ai-train=no,use=reference
Allow: /

User-agent: Amazonbot
Disallow: /

User-agent: Applebot-Extended
Disallow: /

User-agent: Bytespider
Disallow: /

User-agent: CCBot
Disallow: /

User-agent: ClaudeBot
Disallow: /

User-agent: CloudflareBrowserRenderingCrawler
Disallow: /

User-agent: Google-Extended
Disallow: /

User-agent: GPTBot
Disallow: /

User-agent: meta-externalagent
Disallow: /

# END Cloudflare Managed Content

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>StudioGym Coach IA</title>
  <style>
    /* ── StudioGym design system ── */
    @font-face {
      font-family: 'goboldregular';
      src: local('Impact'), local('Arial Narrow');
      font-weight: normal;
      font-style: normal;
    }

    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    :root {
      /* Couleurs StudioGym */
      --bg:           #1d1d1b;   /* sg-black-background */
      --surface:      #111110;   /* plus sombre que le bg */
      --surface2:     #272724;   /* cards et inputs */
      --surface3:     #323230;   /* hover, sélection */
      --border:       #3a3a37;
      --accent:       #a4c408;   /* sg-color-primary / sg-color-green */
      --accent-dim:   #80af60;   /* sg-color-lightgreen */
      --text:         #efecec;   /* sg-color-offwhite */
      --text-muted:   #8a8884;   /* dérivé sg-color-darkgrey */
      --danger:       #cc5920;   /* sg-color-error */
      --warning:      #f9ac23;   /* sg-color-warning */
      --golden:       #d5af60;   /* sg-color-golden */
      --user-bubble:  #1a2410;   /* vert très sombre pour bulles utilisateur */
      --ai-bubble:    #111110;
      --radius:       4px;       /* StudioGym est plutôt carré */
      --font-display: 'goboldregular', Impact, 'Arial Narrow', sans-serif;
      --font-body:    'Segoe UI', system-ui, sans-serif;
    }

    body {
      font-family: var(--font-body);
      background: var(--bg);
      color: var(--text);
      height: 100dvh;
      display: flex;
      flex-direction: column;
    }

    /* ── Header ── */
    header {
      background: var(--surface);
      border-bottom: 3px solid var(--accent);
      padding: 0 20px;
      height: 56px;
      display: flex;
      align-items: center;
      gap: 14px;
      flex-shrink: 0;
    }

    .logo {
      height: 34px;
      width: auto;
      flex-shrink: 0;
      display: flex;
      align-items: center;
    }
    .logo svg {
      height: 34px;
      width: auto;
    }

    .header-title {
      display: flex;
      flex-direction: column;
    }
    header h1 {
      font-family: var(--font-display);
      font-size: 15px;
      font-weight: 900;
      letter-spacing: .06em;
      text-transform: uppercase;
      color: var(--text);
      line-height: 1.1;
    }
    header p {
      font-size: 11px;
      color: var(--text-muted);
      text-transform: uppercase;
      letter-spacing: .04em;
      margin-top: 2px;
    }

    .status-dot {
      width: 8px; height: 8px; border-radius: 50%;
      background: #555; margin-left: auto; flex-shrink: 0;
      transition: background .3s;
    }
    .status-dot.ok  { background: var(--accent); }
    .status-dot.err { background: var(--danger); }

    /* ── Settings bar ── */
    .settings {
      background: var(--surface);
      border-bottom: 1px solid var(--border);
      padding: 8px 20px;
      display: flex; gap: 20px; align-items: center; flex-wrap: wrap;
      flex-shrink: 0;
    }
    .settings label {
      font-size: 11px;
      color: var(--text-muted);
      text-transform: uppercase;
      letter-spacing: .05em;
      display: flex; gap: 8px; align-items: center;
    }
    .settings input, .settings select {
      background: var(--surface2);
      border: 1px solid var(--border);
      color: var(--text);
      padding: 5px 10px;
      font-size: 12px;
      font-family: var(--font-body);
    }
    .settings input:focus, .settings select:focus {
      outline: none;
      border-color: var(--accent);
    }
    .settings input { width: 200px; }

    /* ── Messages ── */
    #messages {
      flex: 1; overflow-y: auto; padding: 20px;
      display: flex; flex-direction: column; gap: 16px;
    }
    #messages::-webkit-scrollbar { width: 4px; }
    #messages::-webkit-scrollbar-track { background: transparent; }
    #messages::-webkit-scrollbar-thumb { background: var(--border); }

    .msg { display: flex; gap: 10px; max-width: 100%; }
    .msg.user { flex-direction: row-reverse; }

    .avatar {
      width: 34px; height: 34px;
      display: flex; align-items: center; justify-content: center;
      font-family: var(--font-display);
      font-size: 12px; font-weight: 900;
      letter-spacing: .04em;
      flex-shrink: 0;
      align-self: flex-end;
    }
    .msg.user .avatar {
      background: var(--user-bubble);
      border: 1px solid var(--accent);
      color: var(--accent);
    }
    .msg.ai .avatar {
      background: var(--accent);
      color: var(--bg);
    }

    .bubble {
      max-width: min(700px, 84vw);
      background: var(--ai-bubble);
      border: 1px solid var(--border);
      border-top: 2px solid var(--surface3);
      padding: 14px 16px;
      font-size: 14px; line-height: 1.65;
    }
    .msg.user .bubble {
      background: var(--user-bubble);
      border: 1px solid var(--accent);
      border-top: 2px solid var(--accent);
    }

    /* Markdown-like formatting */
    .bubble p  { margin-bottom: 8px; }
    .bubble p:last-child { margin-bottom: 0; }
    .bubble strong { color: var(--accent); font-weight: 700; }
    .bubble em { color: var(--accent-dim); font-style: normal; font-weight: 600; }

    /* ── Cards ── */
    .cards { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }

    .card {
      background: var(--surface2);
      border: 1px solid var(--border);
      border-left: 3px solid var(--accent);
      overflow: hidden;
    }
    .card:hover { border-left-color: var(--accent-dim); background: var(--surface3); }
    .card-body { padding: 12px 14px; }

    /* Programme card — image gauche, contenu droite */
    .program-card {
      display: flex;
      align-items: flex-start;
    }
    .program-card .card-body {
      flex: 1;
      padding: 12px 14px 12px 16px;
    }

    .card-img {
      width: 140px;
      height: auto;
      max-height: 140px;
      object-fit: contain;
      display: block;
      flex-shrink: 0;
      background: var(--surface);
    }
    .card-img-placeholder {
      width: 140px;
      height: 140px;
      flex-shrink: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      background: var(--surface);
      font-family: var(--font-display);
      font-size: 11px;
      letter-spacing: .06em;
      text-transform: uppercase;
      color: var(--text-muted);
      text-align: center;
      padding: 8px;
    }

    @media (max-width: 480px) {
      .program-card {
        flex-direction: column;
      }
      .program-card .card-img,
      .program-card .card-img-placeholder {
        width: 100%;
        height: auto;
        max-height: 180px;
      }
      .program-card .card-body {
        padding: 12px 14px;
      }
    }

    .card-header { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 6px; }
    .card-title {
      font-family: var(--font-display);
      font-size: 13px;
      font-weight: 900;
      letter-spacing: .05em;
      text-transform: uppercase;
      color: var(--text);
    }
    .card-sub { font-size: 12px; color: var(--text-muted); margin-top: 3px; }

    .badge {
      font-family: var(--font-display);
      font-size: 10px;
      letter-spacing: .06em;
      text-transform: uppercase;
      padding: 3px 9px;
      background: transparent;
      border: 1px solid var(--border);
      color: var(--text-muted);
      white-space: nowrap; flex-shrink: 0;
    }
    .badge.beginner     { border-color: var(--accent); color: var(--accent); }
    .badge.intermediate { border-color: var(--golden); color: var(--golden); }
    .badge.expert       { border-color: var(--danger); color: var(--danger); }
    .badge.no-equip     { border-color: var(--text-muted); color: var(--text-muted); }

    .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
    .tag {
      font-size: 11px; padding: 2px 8px;
      background: transparent;
      border: 1px solid var(--border);
      color: var(--text-muted);
      text-transform: uppercase;
      letter-spacing: .03em;
    }
    .tag.muscle   { border-color: #3a5040; color: var(--accent-dim); }
    .tag.bodypart { border-color: #4a4020; color: var(--golden); }

    /* ── Program schedule table ── */
    .week-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 10px; }
    .week-table th {
      background: var(--surface);
      color: var(--text-muted);
      padding: 7px 10px; text-align: left;
      font-family: var(--font-display);
      font-size: 11px; letter-spacing: .07em; text-transform: uppercase;
      border-bottom: 2px solid var(--accent);
    }
    .week-table td {
      padding: 7px 10px;
      border-bottom: 1px solid var(--border);
      vertical-align: top;
    }
    .week-table tr:last-child td { border-bottom: none; }
    .week-table tr:hover td { background: var(--surface3); }

    .rest-row td { color: var(--text-muted); font-style: italic; }

    .week-label {
      font-family: var(--font-display);
      font-size: 12px; letter-spacing: .08em; text-transform: uppercase;
      color: var(--accent);
      margin-bottom: 6px;
      margin-top: 14px;
    }
    .week-label:first-child { margin-top: 0; }

    .mode-label {
      display: inline-block; font-size: 10px; padding: 1px 6px;
      font-family: var(--font-display); letter-spacing: .05em; text-transform: uppercase;
      background: transparent;
      border: 1px solid var(--border);
      color: var(--text-muted);
      margin-bottom: 3px;
    }
    .mode-label.superset   { border-color: var(--golden); color: var(--golden); }
    .mode-label.stretching { border-color: var(--accent); color: var(--accent); }

    /* ── Progress stats ── */
    .progress-grid {
      display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
      gap: 8px; margin-top: 12px;
    }
    .stat-card {
      background: var(--surface2);
      border: 1px solid var(--border);
      border-top: 2px solid var(--accent);
      padding: 12px; text-align: center;
    }
    .stat-value {
      font-family: var(--font-display);
      font-size: 24px; font-weight: 900;
      color: var(--accent);
      letter-spacing: .02em;
    }
    .stat-label {
      font-size: 10px; color: var(--text-muted);
      text-transform: uppercase; letter-spacing: .07em;
      margin-top: 4px;
    }

    /* ── Input area ── */
    .input-area {
      background: var(--surface);
      border-top: 3px solid var(--accent);
      padding: 14px 20px; flex-shrink: 0;
    }
    .input-row { display: flex; gap: 8px; }
    #userInput {
      flex: 1;
      background: var(--surface2);
      border: 1px solid var(--border);
      color: var(--text);
      padding: 10px 14px; font-size: 14px;
      font-family: var(--font-body);
      resize: none; min-height: 44px; max-height: 140px;
      transition: border-color .2s;
    }
    #userInput::placeholder { color: var(--text-muted); }
    #userInput:focus { outline: none; border-color: var(--accent); }

    #sendBtn {
      background: var(--accent);
      color: var(--bg);
      border: none;
      padding: 0 20px;
      font-size: 18px; cursor: pointer;
      font-weight: 900;
      transition: background .2s, opacity .2s;
      flex-shrink: 0;
    }
    #sendBtn:hover   { background: var(--accent-dim); }
    #sendBtn:disabled { opacity: .35; cursor: default; }

    .input-hint {
      font-size: 11px; color: var(--text-muted);
      text-transform: uppercase; letter-spacing: .04em;
      margin-top: 7px;
    }

    /* ── Suggestions ── */
    .suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
    .suggestion {
      font-size: 12px; padding: 6px 14px;
      background: transparent;
      border: 1px solid var(--border);
      color: var(--text-muted);
      cursor: pointer;
      text-transform: uppercase;
      letter-spacing: .04em;
      font-family: var(--font-body);
      transition: border-color .15s, color .15s;
    }
    .suggestion:hover { border-color: var(--accent); color: var(--accent); }

    /* ── Typing indicator ── */
    .typing { display: flex; gap: 5px; align-items: center; padding: 4px 0; }
    .typing span {
      width: 7px; height: 7px;
      background: var(--accent);
      animation: bounce .9s infinite;
    }
    .typing span:nth-child(2) { animation-delay: .15s; background: var(--accent-dim); }
    .typing span:nth-child(3) { animation-delay: .30s; background: var(--accent); opacity: .6; }
    @keyframes bounce {
      0%, 60%, 100% { transform: translateY(0); }
      30% { transform: translateY(-6px); }
    }

    /* ── Safety warning ── */
    .safety-warning {
      background: #1a0c06;
      border: 1px solid var(--danger);
      border-left: 3px solid var(--danger);
      padding: 12px 14px; margin-top: 10px;
      font-size: 12px; color: #e89070;
    }
    .safety-warning strong { color: var(--danger); }

    @media (max-width: 480px) {
      .settings { gap: 10px; }
      .settings input { width: 120px; }
      #messages { padding: 12px; }
      .bubble { max-width: 92vw; }
    }
  </style>
</head>
<body>

<header>
  <div class="logo">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 442.04 188.64" fill="#efecec">
      <g><rect width="442.04" height="19.64"/><rect y="169" width="410.87" height="19.64"/></g>
      <g>
        <path d="M0,63.83Q0,42.92,20.61,42.91T41.28,63.83V77.24H26.91V63.83q0-6.7-6.25-6.75h-.1q-6.19,0-6.19,6.75v9q0,6.64,13.45,16.3t13.46,23v12.59q0,21-20.62,21T0,124.76V111.35H14.37v13.41q0,6.75,6.29,6.75t6.25-6.75V113.38q0-7.2-13.46-16.85T0,73.17Z"/>
        <path d="M88.91,44.49V58.65H75.46V144.1H61.09V58.65H47.63V44.49Z"/>
        <path d="M137.05,124.76q0,20.91-20.66,21t-20.62-21V44.49h14.37v80.27q0,6.75,6.25,6.75t6.29-6.75V44.49h14.37Z"/>
        <path d="M148,44.49h22.13q19.14,0,19.14,21v57.72q0,20.87-20.05,20.92H148Zm14.36,14.16v71.29h6.3q6.24,0,6.24-6.76V65.46q0-6.81-6.24-6.81Z"/>
        <path d="M199.92,44.49h14.37V144.1H199.92Z"/>
        <path d="M266.24,124.76q0,20.91-20.66,21t-20.62-21V63.83q0-20.91,20.62-20.92t20.66,20.92ZM251.87,63.83q0-6.7-6.24-6.75h-.1q-6.19,0-6.2,6.75v60.93q0,6.75,6.25,6.75t6.29-6.75Z"/>
        <path d="M316.92,124.76q0,20.91-20.66,21t-20.62-21V63.83q0-20.91,20.62-20.92t20.66,20.92V77.24H302.55V63.83q0-6.7-6.24-6.75h-.1q-6.19,0-6.2,6.75v60.93q0,6.75,6.25,6.75t6.29-6.75V101.4h-7.26V87.19h21.63Z"/>
        <path d="M338,44.49l9.8,38.84,9.8-38.84h15.23L355.41,101l-.4,1.21V144.1H340.64V102.21l-.46-1.21L322.77,44.49Z"/>
        <path d="M411,112.72l16.7-68.23H442V144.1H428.28V88.2L416,144.1h-9.74L393.6,88.2v55.9H379.69V44.49h14.37Z"/>
      </g>
    </svg>
  </div>
  <div class="header-title">
    <h1>StudioGym Coach IA</h1>
    <p id="statusText">Connexion en cours...</p>
  </div>
  <div class="status-dot" id="statusDot"></div>
</header>

<div class="settings">
  <label>
    URL API
    <input id="apiUrl" type="text" value="http://159.203.36.21/api" />
  </label>
  <label>
    ID utilisateur (optionnel)
    <input id="userId" type="text" placeholder="MongoDB ObjectId" style="width:180px" />
  </label>
  <label>
    Langue
    <select id="locale">
      <option value="fr">Français</option>
      <option value="en">English</option>
    </select>
  </label>
</div>

<div id="messages">
  <div class="suggestions" id="suggestions">
    <button class="suggestion" onclick="suggest(this)">Programmes débutant sans équipement</button>
    <button class="suggestion" onclick="suggest(this)">Exercices pour les biceps et la poitrine</button>
    <button class="suggestion" onclick="suggest(this)">J'ai mal au genou, qu'est-ce que je dois éviter ?</button>
    <button class="suggestion" onclick="suggest(this)">Montre mes progrès ce mois-ci</button>
    <button class="suggestion" onclick="suggest(this)">Comment faire un squat correctement ?</button>
  </div>

  <div class="msg ai">
    <div class="avatar">SG</div>
    <div class="bubble">
      Bonjour ! Je suis votre <strong>Coach IA StudioGym</strong>.<br><br>
      Comment puis-je vous aider aujourd'hui ?
    </div>
  </div>
</div>

<div class="input-area">
  <div class="input-row">
    <textarea id="userInput" placeholder="Posez votre question sur votre entraînement..." rows="1"></textarea>
    <button id="sendBtn" onclick="sendMessage()" title="Envoyer">➤</button>
  </div>
  <div class="input-hint">Entrée pour envoyer · Maj+Entrée pour un saut de ligne</div>
</div>

<script>
  let conversationHistory = [];

  // ── Health check on load ──
  async function checkHealth() {
    const base = document.getElementById('apiUrl').value.trim();
    try {
      const r = await fetch(base + '/health');
      const data = await r.json();
      const ok = data.mongodb === 'connected';
      document.getElementById('statusDot').className = 'status-dot ' + (ok ? 'ok' : 'err');
      document.getElementById('statusText').textContent = ok
        ? `Connecté · ${data.tools?.length ?? 0} outils`
        : 'MongoDB déconnecté';
    } catch {
      document.getElementById('statusDot').className = 'status-dot err';
      document.getElementById('statusText').textContent = 'Serveur inaccessible';
    }
  }
  checkHealth();
  setInterval(checkHealth, 30000);

  // ── Auto-resize textarea ──
  const input = document.getElementById('userInput');
  input.addEventListener('input', () => {
    input.style.height = 'auto';
    input.style.height = Math.min(input.scrollHeight, 140) + 'px';
  });
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
  });

  function suggest(btn) {
    input.value = btn.textContent;
    document.getElementById('suggestions').style.display = 'none';
    sendMessage();
  }

  // ── Main send ──
  async function sendMessage() {
    const message = input.value.trim();
    if (!message) return;

    const base   = document.getElementById('apiUrl').value.trim();
    const locale = document.getElementById('locale').value;
    const userId = document.getElementById('userId').value.trim() || undefined;

    appendMessage('user', message);
    input.value = '';
    input.style.height = 'auto';
    document.getElementById('sendBtn').disabled = true;
    document.getElementById('suggestions').style.display = 'none';

    const typingId = appendTyping();

    let rateLimited = false;

    try {
      const res = await fetch(base + '/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message, conversationHistory, locale, userId }),
      });

      removeTyping(typingId);

      if (res.status === 429) {
        const err = await res.json().catch(() => ({}));
        rateLimited = true;
        appendMessage('ai', err.response || "Notre coach IA reçoit beaucoup de demandes en ce moment ! Veuillez patienter quelques secondes et réessayer votre question. 💪");
        startCooldown(Number(err.retryAfter) > 0 ? Number(err.retryAfter) : 10);
        return;
      }

      if (!res.ok) {
        const err = await res.json().catch(() => ({ error: res.statusText }));
        appendMessage('ai', `⚠️ Erreur : ${err.error ?? res.statusText}`);
        return;
      }

      const data = await res.json();
      conversationHistory = data.conversationHistory ?? conversationHistory;

      renderAIResponse(data);

    } catch (err) {
      removeTyping(typingId);
      appendMessage('ai', `⚠️ Impossible de contacter le serveur à **${base}**. Le serveur REST est-il démarré ?`);
    } finally {
      if (!rateLimited) {
        document.getElementById('sendBtn').disabled = false;
        input.focus();
      }
    }
  }

  // ── Rate-limit cooldown — keeps the send button disabled and shows a
  // countdown until the server-recommended retry-after delay elapses ──
  function startCooldown(seconds) {
    const sendBtn = document.getElementById('sendBtn');
    const hint = document.querySelector('.input-hint');
    const originalHint = hint.textContent;
    sendBtn.disabled = true;

    let remaining = seconds;
    hint.textContent = `Veuillez patienter... (${remaining}s)`;

    const interval = setInterval(() => {
      remaining -= 1;
      if (remaining <= 0) {
        clearInterval(interval);
        hint.textContent = originalHint;
        sendBtn.disabled = false;
        input.focus();
      } else {
        hint.textContent = `Veuillez patienter... (${remaining}s)`;
      }
    }, 1000);
  }

  // ── Render helpers ──
  function appendMessage(role, text) {
    const msgs = document.getElementById('messages');
    const div  = document.createElement('div');
    div.className = 'msg ' + role;
    div.innerHTML = `
      <div class="avatar">${role === 'user' ? '👤' : 'SG'}</div>
      <div class="bubble">${formatText(text)}</div>`;
    msgs.appendChild(div);
    scrollBottom();
    return div;
  }

  function appendTyping() {
    const msgs = document.getElementById('messages');
    const id   = 'typing-' + Date.now();
    const div  = document.createElement('div');
    div.className = 'msg ai'; div.id = id;
    div.innerHTML = `
      <div class="avatar">SG</div>
      <div class="bubble">
        <div class="typing"><span></span><span></span><span></span></div>
      </div>`;
    msgs.appendChild(div);
    scrollBottom();
    return id;
  }

  function removeTyping(id) {
    document.getElementById(id)?.remove();
  }

  function scrollBottom() {
    const msgs = document.getElementById('messages');
    msgs.scrollTop = msgs.scrollHeight;
  }

  // ── Smart renderer — detects structured data and renders cards ──
  function renderAIResponse(apiResponse) {
    const msgs = document.getElementById('messages');
    const wrap = document.createElement('div');
    wrap.className = 'msg ai';
    wrap.innerHTML = `<div class="avatar">SG</div><div class="bubble" id="ai-bubble-last"></div>`;
    msgs.appendChild(wrap);
    scrollBottom();

    const bubble = wrap.querySelector('#ai-bubble-last');

    // Text response
    if (apiResponse.response) {
      bubble.innerHTML = formatText(apiResponse.response);
    }

    // Structured data rendering
    const data = apiResponse.data;
    if (!data) return;

    // Exercises list
    if (data.exercises?.length) {
      const cards = renderExerciseCards(data.exercises);
      bubble.appendChild(cards);
    }

    // Programs list
    if (data.programs?.length) {
      const cards = renderProgramCards(data.programs);
      bubble.appendChild(cards);
    }

    // Program schedule
    if (data.weeks?.length) {
      const table = renderProgramSchedule(data);
      bubble.appendChild(table);
    }

    // User progress
    if (data.summary?.totalSessions !== undefined) {
      const stats = renderProgressStats(data.summary);
      bubble.appendChild(stats);
    }

    // Contraindicated exercises
    if (data.contraindicatedExercises?.length) {
      const warn = renderContraindicated(data);
      bubble.appendChild(warn);
    }

    scrollBottom();
  }

  function renderExerciseCards(exercises) {
    const wrap = document.createElement('div');
    wrap.className = 'cards';
    for (const ex of exercises.slice(0, 8)) {
      const card = document.createElement('div');
      card.className = 'card';
      card.innerHTML = `
        <div class="card-body">
          <div class="card-header">
            <div>
              <div class="card-title">${esc(ex.name)}</div>
              <div class="card-sub">${esc(ex.shortText ?? '')}</div>
            </div>
            ${ex.needWeights ? '<span class="badge">🏋️ Équipement</span>' : '<span class="badge no-equip">🏠 Poids du corps</span>'}
          </div>
          <div class="tags">
            ${(ex.muscles ?? []).map(m => `<span class="tag muscle">💪 ${esc(m)}</span>`).join('')}
            ${(ex.bodyParts ?? []).map(b => `<span class="tag bodypart">🎯 ${esc(b)}</span>`).join('')}
            ${ex.category ? `<span class="tag">${esc(ex.category)}</span>` : ''}
          </div>
        </div>`;
      wrap.appendChild(card);
    }
    return wrap;
  }

  function renderProgramCards(programs) {
    const wrap = document.createElement('div');
    wrap.className = 'cards';
    for (const p of programs) {
      const levelClass = (p.level ?? '').toLowerCase();
      const card = document.createElement('div');
      card.className = 'card program-card';

      // Image or placeholder
      let imageHtml;
      if (p.imageUrl) {
        imageHtml = `<img
          class="card-img"
          src="${esc(p.imageUrl)}"
          alt="${esc(p.description)}"
          loading="lazy"
          onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"
        /><div class="card-img-placeholder" style="display:none">${esc(p.description)}</div>`;
      } else {
        imageHtml = `<div class="card-img-placeholder">${esc(p.description)}</div>`;
      }

      card.innerHTML = `
        ${imageHtml}
        <div class="card-body">
          <div class="card-header">
            <div>
              <div class="card-title">${esc(p.description)}</div>
              <div class="card-sub">${p.weekCount ?? '?'} semaines · ${p.duration ?? '?'} min · ${p.calories ? p.calories + ' kcal' : ''}</div>
            </div>
            <span class="badge ${levelClass}">${p.level ?? ''}</span>
          </div>
          ${p.objective ? `<div class="card-sub" style="margin-top:4px">🎯 ${esc(p.objective)}</div>` : ''}
          <div class="tags">
            ${p.noEquipmentNeeded ? '<span class="badge no-equip">🏠 Sans équipement</span>' : ''}
            ${p.gender === 'F' ? '<span class="tag">👩 Femme</span>' : p.gender === 'M' ? '<span class="tag">👨 Homme</span>' : ''}
            ${(p.tags ?? []).map(t => `<span class="tag">${esc(t)}</span>`).join('')}
          </div>
        </div>`;
      wrap.appendChild(card);
    }
    return wrap;
  }

  function renderProgramSchedule(data) {
    const wrap = document.createElement('div');
    wrap.style.marginTop = '12px';

    for (const week of data.weeks ?? []) {
      const title = document.createElement('p');
      title.className = 'week-label';
      title.textContent = `Semaine ${week.weekNumber}`;
      wrap.appendChild(title);

      const table = document.createElement('table');
      table.className = 'week-table';
      table.innerHTML = '<thead><tr><th>Jour</th><th>Exercices</th></tr></thead>';
      const tbody = document.createElement('tbody');

      for (const day of week.days ?? []) {
        const tr = document.createElement('tr');
        if (day.isRestDay) tr.className = 'rest-row';

        const exercises = (day.blocks ?? []).flatMap(b =>
          (b.exercises ?? []).map(ex => {
            const modeClass = b.mode?.includes('SUPERSET') ? 'superset' : b.mode?.includes('STRETCHING') ? 'stretching' : '';
            const modeLabel = b.modeLabel !== 'Exercise' ? `<span class="mode-label ${modeClass}">${esc(b.modeLabel)}</span><br>` : '';
            const reps = ex.reps ? ` · ${ex.reps} reps` : '';
            const sets = ex.sets ? `${ex.sets} sets` : '';
            const hold = ex.hold ? ` · ${ex.hold}s hold` : '';
            return `${modeLabel}${esc(ex.name)} — ${sets}${reps}${hold}`;
          })
        );

        tr.innerHTML = `
          <td style="white-space:nowrap;color:var(--text-muted)">${esc(day.dayName)}</td>
          <td>${day.isRestDay ? '<em>Jour de repos</em>' : exercises.join('<br>') || '<em>Repos</em>'}</td>`;
        tbody.appendChild(tr);
      }
      table.appendChild(tbody);
      wrap.appendChild(table);
    }
    return wrap;
  }

  function renderProgressStats(summary) {
    const grid = document.createElement('div');
    grid.className = 'progress-grid';
    const stats = [
      { value: summary.totalSessions,      label: 'Séances' },
      { value: summary.sessionsPerWeek,    label: 'Par semaine' },
      { value: summary.totalCaloriesBurned ? summary.totalCaloriesBurned + ' kcal' : '—', label: 'Calories brûlées' },
      { value: summary.totalTimeMin ? summary.totalTimeMin + ' min' : '—', label: 'Temps d\'entraînement' },
      { value: summary.avgExertion ?? '—', label: 'Effort moyen' },
      { value: summary.uniqueExercises,    label: 'Exercices réalisés' },
    ];
    for (const s of stats) {
      const card = document.createElement('div');
      card.className = 'stat-card';
      card.innerHTML = `<div class="stat-value">${s.value ?? '—'}</div><div class="stat-label">${s.label}</div>`;
      grid.appendChild(card);
    }
    return grid;
  }

  function renderContraindicated(data) {
    const div = document.createElement('div');
    div.className = 'safety-warning';
    div.innerHTML = `<strong>⚠️ Alerte sécurité :</strong> ${esc(data.warning ?? '')}<br><br>` +
      (data.contraindicatedExercises ?? []).slice(0, 6).map(ex =>
        `• <strong>${esc(ex.name)}</strong> — ${esc(ex.careNote ?? '').slice(0, 120)}…`
      ).join('<br>');
    return div;
  }

  // ── Text formatter — converts basic markdown to HTML ──
  function formatText(text) {
    return text
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
      .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
      .replace(/\*(.+?)\*/g, '<em>$1</em>')
      .replace(/`(.+?)`/g, '<code style="background:var(--surface2);padding:1px 4px;font-family:monospace">$1</code>')
      .replace(/\n\n/g, '</p><p>')
      .replace(/\n/g, '<br>')
      .replace(/^/, '<p>').replace(/$/, '</p>');
  }

  function esc(str) {
    return String(str ?? '')
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }
</script>
</body>
</html>
HTTP/1.1 200 OK
Date: Sat, 04 Jul 2026 00:43:14 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Server: cloudflare
Last-Modified: Wed, 01 Jul 2026 05:10:58 GMT
Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
Vary: Accept-Encoding
Server-Timing: cfCacheStatus;desc="DYNAMIC"
Server-Timing: cfEdge;dur=164,cfOrigin;dur=7
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
X-Xss-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Report-To: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=afIWds56Y1CCdcIA18J%2Bh8F%2BHDfiVuqm7Zui8psdUahLOUHdJaXqlg47cRhlLwt8vbImYzjmHgWt%2FneZlkU05EpH8HUDUI7Pvjv2zA9nK4q1ckRyevddFAgrUmBomwSlyohgk6%2FQ"}]}
Cf-Cache-Status: DYNAMIC
Content-Encoding: br
CF-RAY: a15a18372bc7e391-NRT
alt-svc: h3=":443"; ma=86400

iframe