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:
2026-05-27 19:28:50 +03:00
parent c7e59b90e0
commit 1787607be5
11 changed files with 194 additions and 1 deletions

View File

@@ -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]);

View File

@@ -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">