Élő chat

Kérdésed van? Írj nekünk most.

Munkatársunk itt, az oldalon válaszol. Az üzenetedet akkor is megkapjuk, ha épp nem vagyunk gépnél, és e-mailben jelentkezünk.

Crisp élő chat — adatlap

Crisp élő chat UUI oldalon: saját indítógomb, hozzájárulás után lustán betöltve, a Website ID a configból.

Forráskódtoolkit/integrations/components/Chatbot.astro
toolkit/integrations/components/Chatbot.astroastro
---
/**
 * Chatbot.astro — lebegő chat-widget, Untitled UI stílusban.
 *
 * Három mód a configból (`integrationsConfig.chatProvider`):
 *   • "none"   → nem renderel semmit (korai kilépés).
 *   • "script" → harmadik-fél chat-widget (Tawk.to / Crisp / Chatwoot stb.).
 *                A scriptet LUSTÁN és CSAK marketing-consent után injektáljuk:
 *                a saját UU launcher gomb a facade — kattintásra (vagy ha már
 *                van consent, betöltődéskor) húzza be a `chatScriptSrc`-t,
 *                onnantól a külső widget veszi át a UI-t. Build-időben semmilyen
 *                hálózati hívás nincs (a script csak kliensoldalon, futásidőben).
 *   • "crisp"  → Crisp élő chat: a "script" mód útja, beépített loaderrel
 *                (`crispWebsiteId` → `CRISP_WEBSITE_ID` + client.crisp.chat/l.js).
 *                Betöltés után a Crisp saját buborékja veszi át, a UU launcher eltűnik.
 *   • "faq"    → beépített, backend nélküli GYIK / gyors-válasz bot. Kliensoldali
 *                kulcsszó-kereső a lenti `faqItems` listában; ha nincs találat →
 *                kapcsolat-fallback (WhatsApp, ha be van állítva).
 *
 * Statikus SSR: a markup szerveroldalon renderel, a logikát egy Astro <script>
 * (bundle-ölt ES modul) adja — nincs client: direktíva.
 *
 * @uui-source: ADAPT src/catalog/components/slideout-menus/message-chat-menu.tsx
 *
 * Consent: a "script" mód a P5 cookie-consenthez kötött (legal-hu/lib/consent).
 * Marketing-consent nélkül a panel egy UU fallback-kártyát mutat a sütik
 * engedélyezésére (a P5 banner újranyitása a `[data-uui-cookie-reopen]`
 * konvención keresztül). A "faq" mód nem tölt harmadik-fél tartalmat → nem gate-elt.
 */
import { integrationsConfig } from "../integrations.config";
import {
  MessageChatCircle,
  MessageSmileCircle,
  Send01,
  XClose,
  Phone,
  SearchLg,
  Stars01,
} from "@untitledui-pro/icons/line";

const { chatProvider, chatScriptSrc, crispWebsiteId, whatsapp } = integrationsConfig;

/**
 * Beépített GYIK a "faq" módhoz. Éles oldalon írd át a saját kérdéseidre.
 * Minden elem kulcsszavakkal segíti a kliensoldali keresőt.
 */
type FaqItem = { q: string; a: string; keywords: string[] };
const faqItems: FaqItem[] = [
  {
    q: "Mikor vagytok nyitva?",
    a: "Hétköznap 9:00 és 17:00 között állunk rendelkezésedre. Hétvégén előzetes egyeztetéssel.",
    keywords: ["nyitva", "nyitvatartás", "óra", "mikor", "időpont"],
  },
  {
    q: "Hol találom a műhelyt?",
    a: "Címünk: 1075 Budapest, Liget utca 14. A térkép alapján könnyen ide találsz.",
    keywords: ["hol", "cím", "térkép", "megközelítés", "parkolás"],
  },
  {
    q: "Hogyan kérhetek ajánlatot?",
    a: "Írj nekünk a kapcsolati űrlapon vagy WhatsAppon, és 24 órán belül válaszolunk.",
    keywords: ["ajánlat", "árajánlat", "ár", "költség", "kapcsolat", "email"],
  },
  {
    q: "Mennyi a szállítási idő?",
    a: "A legtöbb megrendelést 3–5 munkanapon belül teljesítjük. Egyedi munkák esetén külön egyeztetünk.",
    keywords: ["szállítás", "szállítási", "idő", "mennyi", "kiszállítás", "csomag"],
  },
];

// Korai kilépés: "none" módban semmi nem renderel.
const enabled = chatProvider === "script" || chatProvider === "crisp" || chatProvider === "faq";

// "script" és "crisp" = harmadik-fél widget, közös consent-gated út.
const isThirdParty = chatProvider === "script" || chatProvider === "crisp";
const CRISP_LOADER = "https://client.crisp.chat/l.js";
const thirdPartySrc =
  chatProvider === "crisp" ? (crispWebsiteId ? CRISP_LOADER : "") : chatScriptSrc;

// WhatsApp gyors-link (csak ha be van állítva). Csak számjegyeket tartunk meg.
const waDigits = (whatsapp || "").replace(/[^\d]/g, "");
const waHref = waDigits ? `https://wa.me/${waDigits}` : "";

// "script" mód, de nincs URL → nem konfigurált → graceful: a panel jelzi.
const scriptConfigured = isThirdParty && Boolean(thirdPartySrc);
---

{
  enabled && (
    <div
      id="uui-chat-root"
      class="uui-chat-root fixed bottom-4 right-4 z-[70] flex flex-col items-end gap-3 sm:bottom-6 sm:right-6"
      data-uui-chat-mode={chatProvider}
      data-uui-chat-src={isThirdParty ? thirdPartySrc || undefined : undefined}
      data-uui-crisp-id={chatProvider === "crisp" ? crispWebsiteId || undefined : undefined}
      data-uui-chat-wa={waHref || undefined}
    >
      {/* ---------- Panel ---------- */}
      <section
        id="uui-chat-panel"
        role="dialog"
        aria-modal="false"
        aria-labelledby="uui-chat-title"
        aria-label="Chat"
        hidden
        class="flex w-[min(92vw,22rem)] max-h-[min(70vh,32rem)] flex-col overflow-hidden rounded-2xl border border-secondary bg-primary shadow-xl"
      >
        {/* Fejléc */}
        <header class="flex items-center gap-3 border-b border-secondary bg-secondary px-4 py-3">
          <span
            class="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-solid"
            aria-hidden="true"
          >
            <MessageSmileCircle className="size-5 text-white" />
          </span>
          <div class="min-w-0 flex-1">
            <h2 id="uui-chat-title" class="truncate text-sm font-semibold text-primary">
              Segíthetünk?
            </h2>
            <p class="truncate text-xs text-tertiary">Általában percek alatt válaszolunk</p>
          </div>
          <button
            type="button"
            data-uui-chat-action="close"
            aria-label="Chat bezárása"
            class="shrink-0 rounded-lg p-2 text-tertiary transition hover:bg-tertiary focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
          >
            <XClose className="size-5" aria-hidden="true" />
          </button>
        </header>

        {/* Üzenetlista (faq mód) / státusz */}
        <div
          id="uui-chat-log"
          class="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
          aria-live="polite"
        >
          {chatProvider === "faq" && (
            <>
              {/* Üdvözlő bot-buborék */}
              <div class="flex items-end gap-2">
                <span
                  class="flex size-7 shrink-0 items-center justify-center rounded-full bg-brand-section"
                  aria-hidden="true"
                >
                  <MessageSmileCircle className="size-4 text-brand-secondary" />
                </span>
                <p class="max-w-[80%] rounded-2xl rounded-bl-sm bg-secondary px-3.5 py-2 text-sm text-primary">
                  Szia! Írd be a kérdésed, vagy válassz az alábbiak közül.
                </p>
              </div>

              {/* Gyors-válasz chipek */}
              <ul class="flex flex-wrap gap-2" data-uui-chat-quick>
                {faqItems.map((item, i) => (
                  <li>
                    <button
                      type="button"
                      data-uui-chat-quick-index={i}
                      class="rounded-full border border-secondary bg-primary px-3 py-1.5 text-xs font-medium text-secondary transition hover:bg-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
                    >
                      {item.q}
                    </button>
                  </li>
                ))}
              </ul>
            </>
          )}

          {isThirdParty && scriptConfigured && (
            <>
              {/* Marketing-consent VAN → ez a felirat látszik, miközben a külső widget tölt. */}
              <div data-uui-chat-script-loading hidden class="flex items-center gap-2 text-sm text-tertiary">
                <Stars01 className="size-4 animate-pulse text-brand-secondary" aria-hidden="true" />
                Élő chat betöltése…
              </div>
              {/* Marketing-consent NINCS → consent-fallback kártya. */}
              <div data-uui-chat-consent class="rounded-xl border border-secondary bg-secondary p-4">
                <p class="text-sm font-semibold text-primary">Élő chat</p>
                <p class="mt-1 text-sm text-tertiary">
                  Ez a tartalom külső szolgáltatótól töltődik. Engedélyezd a marketing sütiket a megjelenítéshez.
                </p>
                <button
                  type="button"
                  data-uui-cookie-reopen
                  class="mt-3 inline-flex items-center justify-center rounded-lg bg-brand-solid px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-solid_hover focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
                >
                  Sütik engedélyezése
                </button>
              </div>
            </>
          )}

          {isThirdParty && !scriptConfigured && (
            <p class="rounded-xl border border-secondary bg-secondary p-4 text-sm text-tertiary">
              Az élő chat jelenleg nincs beállítva. Írj nekünk a kapcsolati űrlapon.
            </p>
          )}
        </div>

        {/* Lábléc: beviteli sor (faq mód) + WhatsApp gyors-link */}
        {chatProvider === "faq" && (
          <form
            id="uui-chat-form"
            class="flex items-center gap-2 border-t border-secondary p-3"
            autocomplete="off"
          >
            <span class="relative flex-1">
              <span class="pointer-events-none absolute inset-y-0 left-3 flex items-center" aria-hidden="true">
                <SearchLg className="size-4 text-tertiary" />
              </span>
              <input
                id="uui-chat-input"
                type="text"
                name="q"
                placeholder="Írd be a kérdésed…"
                aria-label="Kérdés"
                class="w-full rounded-lg border border-secondary bg-primary py-2.5 pl-9 pr-3 text-sm text-primary placeholder:text-placeholder focus:border-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
              />
            </span>
            <button
              type="submit"
              aria-label="Küldés"
              class="inline-flex size-10 shrink-0 items-center justify-center rounded-lg bg-brand-solid text-white shadow-sm transition hover:bg-brand-solid_hover focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
            >
              <Send01 className="size-5" aria-hidden="true" />
            </button>
          </form>
        )}

        {waHref && (
          <a
            href={waHref}
            target="_blank"
            rel="noopener noreferrer"
            class="flex items-center justify-center gap-2 border-t border-secondary bg-secondary px-4 py-3 text-sm font-semibold text-brand-secondary transition hover:bg-tertiary focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
          >
            <Phone className="size-4" aria-hidden="true" />
            Írj nekünk WhatsAppon
          </a>
        )}
      </section>

      {/* ---------- Launcher (FAB) ---------- */}
      <button
        type="button"
        id="uui-chat-launcher"
        data-uui-chat-action="toggle"
        aria-label="Chat megnyitása"
        aria-expanded="false"
        aria-controls="uui-chat-panel"
        class="flex size-14 items-center justify-center rounded-full bg-brand-solid text-white shadow-lg transition hover:bg-brand-solid_hover focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2"
      >
        <MessageChatCircle className="size-6 [.uui-chat-open_&]:hidden" aria-hidden="true" />
        <XClose className="hidden size-6 [.uui-chat-open_&]:block" aria-hidden="true" />
      </button>
    </div>
  )
}

<script>
  import { hasConsent } from "../../legal-hu/lib/consent";

  type FaqItem = { q: string; a: string; keywords: string[] };

  const root = document.getElementById("uui-chat-root");
  const panel = document.getElementById("uui-chat-panel");
  const launcher = document.getElementById("uui-chat-launcher");

  if (root && panel && launcher) {
    const mode = root.dataset.uuiChatMode; // "script" | "crisp" | "faq"
    const isThirdParty = mode === "script" || mode === "crisp";
    const crispId = root.dataset.uuiCrispId || "";
    const scriptSrc = root.dataset.uuiChatSrc || "";
    const waHref = root.dataset.uuiChatWa || "";
    const log = document.getElementById("uui-chat-log");

    // A faqItems-et a markupból nem olvassuk vissza JSON-ként; a "faq" logika a
    // gyors-chipek data-attribútumaiból és a beírt szövegből dolgozik, a
    // válaszszöveget pedig a chiphez tartozó kérdéshez kötjük. Ezért a teljes
    // faq listát itt is megadjuk (egyetlen forrásnak az .astro frontmatter
    // számít; ha bővíted, mindkét helyen frissítsd, vagy emeld ki configba).
    const faqItems: FaqItem[] = [
      {
        q: "Mikor vagytok nyitva?",
        a: "Hétköznap 9:00 és 17:00 között állunk rendelkezésedre. Hétvégén előzetes egyeztetéssel.",
        keywords: ["nyitva", "nyitvatartás", "óra", "mikor", "időpont"],
      },
      {
        q: "Hol találom a műhelyt?",
        a: "Címünk: 1075 Budapest, Liget utca 14. A térkép alapján könnyen ide találsz.",
        keywords: ["hol", "cím", "térkép", "megközelítés", "parkolás"],
      },
      {
        q: "Hogyan kérhetek ajánlatot?",
        a: "Írj nekünk a kapcsolati űrlapon vagy WhatsAppon, és 24 órán belül válaszolunk.",
        keywords: ["ajánlat", "árajánlat", "ár", "költség", "kapcsolat", "email"],
      },
      {
        q: "Mennyi a szállítási idő?",
        a: "A legtöbb megrendelést 3–5 munkanapon belül teljesítjük. Egyedi munkák esetén külön egyeztetünk.",
        keywords: ["szállítás", "szállítási", "idő", "mennyi", "kiszállítás", "csomag"],
      },
    ];

    let lastFocused: HTMLElement | null = null;
    let scriptInjected = false;

    const setOpen = (open: boolean) => {
      panel.hidden = !open;
      launcher.setAttribute("aria-expanded", String(open));
      launcher.setAttribute("aria-label", open ? "Chat bezárása" : "Chat megnyitása");
      root.classList.toggle("uui-chat-open", open);
      if (open) {
        lastFocused = document.activeElement as HTMLElement | null;
        document.addEventListener("keydown", onKeydown);
        if (isThirdParty) maybeInjectScript();
        // fókusz az első értelmes elemre
        const focusTarget =
          panel.querySelector<HTMLElement>("#uui-chat-input") ||
          panel.querySelector<HTMLElement>("[data-uui-chat-action='close']");
        focusTarget?.focus();
      } else {
        document.removeEventListener("keydown", onKeydown);
        (lastFocused ?? launcher).focus();
      }
    };

    // ----- "script" mód: harmadik-fél widget lusta + consent-gated injektálása -----
    const injectScript = () => {
      if (scriptInjected || !scriptSrc) return;
      scriptInjected = true;
      if (mode === "crisp") {
        // Crisp inline init: a parancssor a loader előtt kell, hogy létezzen.
        const w = window as unknown as { $crisp?: unknown[][]; CRISP_WEBSITE_ID?: string };
        w.$crisp = w.$crisp || [];
        w.CRISP_WEBSITE_ID = crispId;
        w.$crisp.push(["safe", true]);
        w.$crisp.push(["do", "chat:open"]);
        // Betöltés után a Crisp saját buborékja a launcher; a UU facade eltűnik.
        w.$crisp.push([
          "on",
          "session:loaded",
          () => {
            setOpen(false);
            root.hidden = true;
          },
        ]);
      }
      const s = document.createElement("script");
      s.src = scriptSrc;
      s.async = true;
      document.head.appendChild(s);
      // a betöltő-felirat látszik, a consent-kártya eltűnik
      panel
        .querySelector<HTMLElement>("[data-uui-chat-script-loading]")
        ?.removeAttribute("hidden");
      panel.querySelector<HTMLElement>("[data-uui-chat-consent]")?.setAttribute("hidden", "");
    };

    const maybeInjectScript = () => {
      // 3rd-party widget = marketing kategória → CSAK elfogadás után tölt.
      if (hasConsent("marketing")) {
        injectScript();
      } else {
        panel.querySelector<HTMLElement>("[data-uui-chat-consent]")?.removeAttribute("hidden");
        panel.querySelector<HTMLElement>("[data-uui-chat-script-loading]")?.setAttribute("hidden", "");
      }
    };

    // ha menet közben elfogadja a marketing sütiket, és a panel nyitva van → tölt
    window.addEventListener("uui:consent-change", () => {
      if (isThirdParty && !panel.hidden) maybeInjectScript();
    });

    // ----- "faq" mód: kliensoldali kulcsszó-kereső -----
    const appendUser = (text: string) => {
      if (!log) return;
      const row = document.createElement("div");
      row.className = "flex justify-end";
      const bubble = document.createElement("p");
      bubble.className =
        "max-w-[80%] rounded-2xl rounded-br-sm bg-brand-solid px-3.5 py-2 text-sm text-white";
      bubble.textContent = text;
      row.appendChild(bubble);
      log.appendChild(row);
      log.scrollTop = log.scrollHeight;
    };

    const appendBot = (text: string) => {
      if (!log) return;
      const row = document.createElement("div");
      row.className = "flex items-end gap-2";
      const av = document.createElement("span");
      av.className =
        "flex size-7 shrink-0 items-center justify-center rounded-full bg-brand-section text-brand-secondary text-xs font-semibold";
      av.setAttribute("aria-hidden", "true");
      av.textContent = "AI";
      const bubble = document.createElement("p");
      bubble.className =
        "max-w-[80%] rounded-2xl rounded-bl-sm bg-secondary px-3.5 py-2 text-sm text-primary";
      bubble.textContent = text;
      row.append(av, bubble);
      log.appendChild(row);
      log.scrollTop = log.scrollHeight;
    };

    const normalize = (s: string) => s.toLowerCase().trim();

    const findAnswer = (query: string): string | null => {
      const q = normalize(query);
      if (!q) return null;
      // 1) pontos / részleges kérdés-egyezés
      const byQuestion = faqItems.find((it) => normalize(it.q).includes(q) || q.includes(normalize(it.q)));
      if (byQuestion) return byQuestion.a;
      // 2) kulcsszó-egyezés
      const byKeyword = faqItems.find((it) => it.keywords.some((k) => q.includes(normalize(k))));
      if (byKeyword) return byKeyword.a;
      return null;
    };

    const respond = (query: string) => {
      appendUser(query);
      const answer = findAnswer(query);
      if (answer) {
        appendBot(answer);
      } else {
        const fallback = waHref
          ? "Erre nem találtam kész választ. Írj nekünk WhatsAppon a panel alján, és személyesen segítünk!"
          : "Erre nem találtam kész választ. Írj nekünk a kapcsolati űrlapon, és hamarosan válaszolunk!";
        appendBot(fallback);
      }
    };

    // gyors-chipek
    panel.querySelectorAll<HTMLButtonElement>("[data-uui-chat-quick-index]").forEach((btn) => {
      btn.addEventListener("click", () => {
        const i = Number(btn.dataset.uuiChatQuickIndex);
        const item = faqItems[i];
        if (item) respond(item.q);
      });
    });

    // beviteli forma
    const form = document.getElementById("uui-chat-form") as HTMLFormElement | null;
    const input = document.getElementById("uui-chat-input") as HTMLInputElement | null;
    form?.addEventListener("submit", (e) => {
      e.preventDefault();
      const val = input?.value ?? "";
      if (!val.trim()) return;
      respond(val.trim());
      if (input) input.value = "";
      input?.focus();
    });

    // ----- billentyűzet: Esc zár, egyszerű focus-trap -----
    const onKeydown = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        setOpen(false);
        return;
      }
      if (e.key !== "Tab") return;
      const focusable = panel.querySelectorAll<HTMLElement>(
        "button, [href], input:not([disabled]), [tabindex]:not([tabindex='-1'])",
      );
      if (focusable.length === 0) return;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };

    // ----- akciók -----
    root.addEventListener("click", (e) => {
      const el = (e.target as HTMLElement)?.closest<HTMLElement>("[data-uui-chat-action]");
      if (!el) return;
      const action = el.getAttribute("data-uui-chat-action");
      if (action === "toggle") setOpen(panel.hidden);
      else if (action === "close") setOpen(false);
    });
  }
</script>