É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.
Élő chat
Munkatársunk itt, az oldalon válaszol. Az üzenetedet akkor is megkapjuk, ha épp nem vagyunk gépnél, és e-mailben jelentkezünk.
Általában percek alatt válaszolunk
Élő chat
Ez a tartalom külső szolgáltatótól töltődik. Engedélyezd a marketing sütiket a megjelenítéshez.
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.
toolkit/integrations/components/Chatbot.astro---
/**
* 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>