feat: embed Chatwoot live-chat widget (destek.sase.tr)
Site-wide live-chat widget served from the self-hosted Chatwoot at destek.sase.tr, with verified user identity and vehicle context. - apps/web: lib/chatwoot.ts loads the SDK lazily (mirrors the PostHog init pattern), init in main.tsx, identify logged-in users in __root via a server-computed HMAC, and attach the viewed vehicle (VIN/brand/ model) as contact custom attributes on the vehicle detail page. - apps/api: GET /api/chatwoot/identity (AuthGuard-protected) returns HMAC-SHA256(user.id) so the widget can use verified identity. - env: VITE_CHATWOOT_BASE_URL + VITE_CHATWOOT_WEBSITE_TOKEN (build-time, wired through docker-compose.coolify.yml build args + Dockerfile ARG) and CHATWOOT_HMAC_TOKEN (api runtime). All optional — widget and endpoint no-op when unset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { BrandsModule } from "./brands/brands.module";
|
||||
import { CatalogModule } from "./catalog/catalog.module";
|
||||
import { CategoriesModule } from "./categories/categories.module";
|
||||
import { ChangelogModule } from "./changelog/changelog.module";
|
||||
import { ChatwootModule } from "./chatwoot/chatwoot.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { AuthGuard } from "./common/guards/auth.guard";
|
||||
import { ImpersonationReadonlyGuard } from "./common/guards/impersonation-readonly.guard";
|
||||
@@ -89,6 +90,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
AnalyticsModule,
|
||||
CatalogModule,
|
||||
ChangelogModule,
|
||||
ChatwootModule,
|
||||
BlogModule,
|
||||
ContactModule,
|
||||
PostHogModule,
|
||||
|
||||
23
apps/api/src/chatwoot/chatwoot.controller.ts
Normal file
23
apps/api/src/chatwoot/chatwoot.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("chatwoot")
|
||||
export class ChatwootController {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
// HMAC-SHA256 of the authenticated user's id, consumed by the Chatwoot
|
||||
// live-chat widget as `identifier_hash` for verified identity (setUser).
|
||||
// The global AuthGuard protects this route — only logged-in users reach it,
|
||||
// so a visitor can never forge another user's hash.
|
||||
@Get("identity")
|
||||
getIdentity(@CurrentUser("id") userId: string) {
|
||||
const token = this.configService.get<string>("chatwoot.hmacToken");
|
||||
if (!token) {
|
||||
throw new ServiceUnavailableException("Chatwoot identity not configured");
|
||||
}
|
||||
const identifierHash = createHmac("sha256", token).update(userId).digest("hex");
|
||||
return { identifier: userId, identifierHash };
|
||||
}
|
||||
}
|
||||
7
apps/api/src/chatwoot/chatwoot.module.ts
Normal file
7
apps/api/src/chatwoot/chatwoot.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ChatwootController } from "./chatwoot.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [ChatwootController],
|
||||
})
|
||||
export class ChatwootModule {}
|
||||
@@ -66,6 +66,9 @@ export default () => ({
|
||||
mailtrack: {
|
||||
secret: process.env.MAILTRACK_SECRET,
|
||||
},
|
||||
chatwoot: {
|
||||
hmacToken: process.env.CHATWOOT_HMAC_TOKEN,
|
||||
},
|
||||
otel: {
|
||||
enabled: process.env.OTEL_ENABLED === "true",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
|
||||
119
apps/web/src/lib/chatwoot.ts
Normal file
119
apps/web/src/lib/chatwoot.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
// Chatwoot live-chat widget — script injected lazily so it stays out of the
|
||||
// initial bundle. All exported functions are fire-and-forget; if the widget
|
||||
// fails to load or isn't configured, the site keeps working without chat.
|
||||
|
||||
interface ChatwootUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string;
|
||||
referralCode?: string | null;
|
||||
}
|
||||
|
||||
interface ChatwootApi {
|
||||
setUser: (
|
||||
identifier: string,
|
||||
attributes: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
identifier_hash?: string;
|
||||
},
|
||||
) => void;
|
||||
setCustomAttributes: (attrs: Record<string, string | number | boolean>) => void;
|
||||
deleteCustomAttribute: (key: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
chatwootSDK?: { run: (opts: { websiteToken: string; baseUrl: string }) => void };
|
||||
$chatwoot?: ChatwootApi;
|
||||
chatwootSettings?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_CHATWOOT_BASE_URL;
|
||||
const WEBSITE_TOKEN = import.meta.env.VITE_CHATWOOT_WEBSITE_TOKEN;
|
||||
|
||||
// The SDK exposes window.$chatwoot only after it fires `chatwoot:ready`; queue
|
||||
// any calls made before then and flush once ready.
|
||||
let _ready = false;
|
||||
let _pending: Array<() => void> = [];
|
||||
|
||||
function whenReady(fn: () => void): void {
|
||||
if (_ready && window.$chatwoot) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
_pending.push(fn);
|
||||
}
|
||||
|
||||
export function initChatwoot(): void {
|
||||
if (!BASE_URL || !WEBSITE_TOKEN) return;
|
||||
if (typeof document === "undefined" || document.getElementById("chatwoot-sdk")) return;
|
||||
|
||||
window.chatwootSettings = {
|
||||
locale: "tr",
|
||||
position: "right",
|
||||
type: "expanded_bubble",
|
||||
launcherTitle: "Yardım",
|
||||
};
|
||||
|
||||
window.addEventListener("chatwoot:ready", () => {
|
||||
_ready = true;
|
||||
for (const fn of _pending) fn();
|
||||
_pending = [];
|
||||
});
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.id = "chatwoot-sdk";
|
||||
script.src = `${BASE_URL}/packs/js/sdk.js`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.chatwootSDK?.run({ websiteToken: WEBSITE_TOKEN, baseUrl: BASE_URL });
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// Identify a logged-in user. `identifierHash` is the HMAC computed server-side
|
||||
// (GET /api/chatwoot/identity); required because the inbox enforces identity
|
||||
// validation. Without it the widget stays anonymous.
|
||||
export function setChatwootUser(user: ChatwootUser, identifierHash: string): void {
|
||||
whenReady(() => {
|
||||
window.$chatwoot?.setUser(user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatar_url: user.image ?? undefined,
|
||||
identifier_hash: identifierHash,
|
||||
});
|
||||
const attrs: Record<string, string> = {};
|
||||
if (user.role) attrs.rol = user.role;
|
||||
if (user.referralCode) attrs.referans_kodu = user.referralCode;
|
||||
if (Object.keys(attrs).length > 0) window.$chatwoot?.setCustomAttributes(attrs);
|
||||
});
|
||||
}
|
||||
|
||||
export function resetChatwootUser(): void {
|
||||
whenReady(() => window.$chatwoot?.reset());
|
||||
}
|
||||
|
||||
// Attach the vehicle the user is currently viewing as contact custom attributes
|
||||
// so the support agent sees the car/VIN context (pre-sales part compatibility).
|
||||
export function setChatwootVehicle(vehicle: {
|
||||
id: string;
|
||||
vin?: string | null;
|
||||
brandName?: string | null;
|
||||
model?: string | null;
|
||||
year?: number | null;
|
||||
}): void {
|
||||
const label = [vehicle.brandName, vehicle.model, vehicle.year].filter(Boolean).join(" ");
|
||||
whenReady(() => {
|
||||
const attrs: Record<string, string | number> = { son_arac_id: vehicle.id };
|
||||
if (vehicle.vin) attrs.son_arac_vin = vehicle.vin;
|
||||
if (label) attrs.son_arac = label;
|
||||
window.$chatwoot?.setCustomAttributes(attrs);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { initChatwoot } from "./lib/chatwoot";
|
||||
import { initFaro } from "./lib/faro";
|
||||
import { initMetaPixel } from "./lib/meta-pixel";
|
||||
import { initPostHog } from "./lib/posthog";
|
||||
@@ -17,6 +18,9 @@ initPostHog();
|
||||
// Initialize Meta Pixel (ad attribution)
|
||||
initMetaPixel();
|
||||
|
||||
// Initialize Chatwoot live-chat widget
|
||||
initChatwoot();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
||||
import { trackPageView as trackMetaPageView } from "@/lib/meta-pixel";
|
||||
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
|
||||
import { Toaster } from "@/lib/toast";
|
||||
@@ -106,8 +108,15 @@ function RootComponent() {
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
});
|
||||
// Verified Chatwoot identity: fetch the server-computed HMAC, then set the
|
||||
// widget user. On failure the widget stays anonymous (chat still works).
|
||||
api
|
||||
.get<{ identifier: string; identifierHash: string }>("/chatwoot/identity")
|
||||
.then((res) => setChatwootUser(user, res.identifierHash))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
resetUser();
|
||||
resetChatwootUser();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { setChatwootVehicle } from "@/lib/chatwoot";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { cleanModelName } from "@/lib/vehicle";
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ import { Button } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
||||
@@ -81,6 +82,20 @@ function VehicleDetailPage() {
|
||||
? `${vehicle.brandName}${cleanModelName(vehicle?.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
|
||||
: "Araç";
|
||||
|
||||
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
|
||||
// so agents have the car context for part-compatibility questions.
|
||||
useEffect(() => {
|
||||
if (vehicle) {
|
||||
setChatwootVehicle({
|
||||
id: vehicle.id ?? id,
|
||||
vin: vehicle.vin,
|
||||
brandName: vehicle.brandName,
|
||||
model: vehicle.model,
|
||||
year: vehicle.year,
|
||||
});
|
||||
}
|
||||
}, [vehicle, id]);
|
||||
|
||||
if (vehicleLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
Reference in New Issue
Block a user