Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/blocks/DeployPlans.astro
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const four = plans.length === 4;
<h3 class="plan__name">{p.name}</h3>
<p class="plan__tagline">{p.tagline}</p>
<p class="plan__price"><span class="plan__amount">{p.price}</span><span class="plan__period">{p.period}</span></p>
<a class:list={["btn", p.highlight || single ? "btn--primary" : "btn--ink", "plan__cta"]} href={p.cta.href}>{p.cta.label}<Icon name="arrow-right" class="arrow" /></a>
<a class:list={["btn", p.highlight || single ? "btn--primary" : "btn--ink", "plan__cta"]} href={p.cta.href} data-track-plan={p.id}>{p.cta.label}<Icon name="arrow-right" class="arrow" /></a>
</div>
<div class="plan__lists">
<div>
Expand Down
1 change: 1 addition & 0 deletions src/components/blocks/company/lead-form-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function wire(root: HTMLElement) {
});
const data = (await res.json().catch(() => ({}))) as LeadResponse;
if (res.ok && data.ok) {
(window as unknown as { posthog?: { capture: (e: string, p?: object) => void } }).posthog?.capture("lead_submitted", { form: form.getAttribute("action") });
form.reset();
root.dataset.state = "done";
donePanel?.focus();
Expand Down
2 changes: 2 additions & 0 deletions src/components/chat/chat-widget-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ export function initChatWidget() {
if (!clean) return;
render({ role: "user", text: clean, at: Date.now() });
suggest.hidden = true;
// "Thinking" dots right away; the server's typing/delta/message frames take it from here.
showTyping();
send({ type: "message", text: clean });
};

Expand Down
8 changes: 5 additions & 3 deletions src/components/layout/CookieBadge.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
/**
* Cookie notice. Essential storage needs no consent; analytics (Google Tag Manager, production
* only) stays in cookieless Consent Mode until the visitor presses "Accept". The choice is kept
* Cookie notice. Essential storage needs no consent; analytics (Google Tag Manager and PostHog,
* production only) stays in cookieless Consent Mode until the visitor presses "Accept". The choice is kept
* in `dewee.cookies` ("all" or "essential") and the notice does not come back once answered.
*/
import { localePath, type Locale } from "~/i18n/config";
Expand Down Expand Up @@ -65,8 +65,10 @@ const { locale } = Astro.props;
const choice = btn.dataset.cookieChoice as Choice;
try { localStorage.setItem(KEY, choice); } catch { /* still hide for this page */ }
if (choice === "all") {
const w = window as unknown as { gtag?: (...args: unknown[]) => void };
const w = window as unknown as { gtag?: (...args: unknown[]) => void; posthog?: { set_config: (c: Record<string, unknown>) => void } };
w.gtag?.("consent", "update", { analytics_storage: "granted", ad_storage: "granted", ad_user_data: "granted", ad_personalization: "granted" });
// PostHog ran in memory until now; keep the visitor's id from here on.
w.posthog?.set_config({ persistence: "localStorage+cookie" });
}
removeEventListener("scroll", onScroll);
el.hidden = true;
Expand Down
57 changes: 57 additions & 0 deletions src/components/layout/PosthogAnalytics.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
/**
* PostHog product analytics through the first-party /ingest proxy (see posthog-proxy). Loads
* on production only, like Google Tag Manager, so staging and local traffic stay out of the
* project. Until the visitor accepts cookies, PostHog keeps its id in memory only (nothing is
* stored); CookieBadge switches it to persistent storage on "Accept".
* Events: pageviews, plan CTAs, app/contact/outbound clicks, theme toggles, chat opens, leads.
*/
import { SITE } from "~/content/site";
import type { Locale } from "~/i18n/config";
interface Props { locale: Locale }
const { locale } = Astro.props;
const enabled = (Astro.site?.hostname ?? "") === SITE.domain;
const config = {
key: "phc_x3SRpJyREJK2UfZhvQGaqAxax94u6Aj7YLnQ3Xo7kp7m",
props: { site: "dewee-web-v2", locale },
};
---

{enabled && (
<script is:inline define:vars={{ config }}>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog && window.posthog.__loaded)||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}p||((p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",p.onerror=function(){p=null},(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r));var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],Object.defineProperty(u,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e}}),Object.defineProperty(u.people,"toString",{configurable:!0,enumerable:!0,writable:!0,value:function(){return u.toString(1)+".people (stub)"}}),o="mu yu bu Su init Vu Gu zu Uu Ku il Wu Yu ju rh oh ah uh hh dh capture getExtension Zu pu gh calculateEventProperties ph register register_once register_for_session unregister unregister_for_session Hu mh getFeatureFlag getFeatureFlagPayload getFeatureFlagResult getAllFeatureFlags isFeatureEnabled reloadFeatureFlags updateFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey displaySurvey cancelPendingSurvey canRenderSurvey canRenderSurveyAsync wh identify setPersonProperties unsetPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset kh shutdown setIdentity clearIdentity get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException addExceptionStep captureLog startExceptionAutocapture stopExceptionAutocapture loadToolbar get_property getSessionProperty yh ih createPersonProfile setInternalOrTestUser bh xu Cu opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing get_explicit_consent_status is_capturing clear_opt_in_out_capturing th debug nl Os getPageViewId captureTraceFeedback captureTraceMetric Du".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
var consented = false;
try { consented = localStorage.getItem("dewee.cookies") === "all"; } catch (e) {}
posthog.init(config.key, {
api_host: location.origin + "/ingest",
ui_host: "https://us.posthog.com",
defaults: "2026-05-30",
person_profiles: "identified_only",
persistence: consented ? "localStorage+cookie" : "memory",
});
posthog.register(config.props);

// Delegated click tracking for conversion-relevant links and controls.
document.addEventListener("click", function (event) {
var target = event.target instanceof Element ? event.target : null;
if (!target) return;
if (target.closest("[data-theme-toggle]")) {
setTimeout(function () { posthog.capture("theme_toggled", { theme: document.documentElement.dataset.theme }); }, 0);
return;
}
if (target.closest("[data-chat-toggle], [data-open-chat]")) { posthog.capture("chat_opened"); }
var link = target.closest("a[href]");
if (!link) return;
var url = new URL(link.href, location.href);
var props = {
href: url.href,
text: (link.textContent || "").trim().slice(0, 80),
location: link.closest("header") ? "nav" : link.closest("footer") ? "footer" : (link.closest("section") || {}).id || "main",
};
if (link.dataset.trackPlan) posthog.capture("pricing_plan_cta_clicked", Object.assign({ plan: link.dataset.trackPlan }, props));
if (url.hostname === "app.dewee.sh") posthog.capture("app_cta_clicked", props);
else if (url.protocol === "mailto:") posthog.capture("contact_email_clicked", props);
else if (url.origin !== location.origin && url.protocol.indexOf("http") === 0) posthog.capture("outbound_link_clicked", props);
}, { capture: true });
</script>
)}
2 changes: 2 additions & 0 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import SiteHeader from "~/components/layout/SiteHeader.astro";
import SiteFooter from "~/components/layout/SiteFooter.astro";
import CookieBadge from "~/components/layout/CookieBadge.astro";
import GoogleTagManager from "~/components/layout/GoogleTagManager.astro";
import PosthogAnalytics from "~/components/layout/PosthogAnalytics.astro";
import ChatLauncher from "~/components/chat/ChatLauncher.astro";

interface Props {
Expand Down Expand Up @@ -52,6 +53,7 @@ const isStaging = (Astro.site?.hostname ?? "").startsWith("staging.");
<meta name="theme-color" content="#fbfaff" />
<meta name="generator" content={Astro.generator} />
<GoogleTagManager part="head" />
<PosthogAnalytics locale={locale} />
<!-- Apply the saved theme before first paint so the page never flashes the wrong paper. -->
<script is:inline>
(function () {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/server/chat-faq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,24 @@ const ENTRIES: Entry[] = [
},
];

/** A bare greeting or thanks: answer warmly instead of handing off. */
const SMALL_TALK = /^\s*(hi+|hello|hey|yo|hola|good (morning|afternoon|evening)|thanks?|thank you|ok(ay)?|chào( bạn| shop| em| anh| chị)?|xin chào|alo|hello bạn|cảm ơn|cám ơn|ok nhé)[\s!.?~]*$/i;

const HUMAN = /human|person|someone|sales|contact|call|demo|người thật|nhân viên|tư vấn|liên hệ|gọi/i;

/**
* FAQ answer for a visitor message. Without an email on file the answer asks for one (and the
* widget shows the email form); once the visitor has left it, the answer says the team has it.
*/
export function answerFromFaq(text: string, locale: Locale, hasEmail = false): { text: string; askEmail: boolean } {
if (SMALL_TALK.test(text)) {
return {
text: locale === "vi"
? "Chào bạn! Mình là dewee. Bạn muốn tìm hiểu tính năng, bảng giá hay cách triển khai?"
: "Hi there! I'm dewee. Want to know about features, pricing or how to deploy?",
askEmail: false,
};
}
const hit = ENTRIES.find((e) => e.match.test(text));
if (hit && !HUMAN.test(text)) return { text: hit[locale], askEmail: false };
const vi = locale === "vi";
Expand Down
9 changes: 3 additions & 6 deletions src/lib/server/chat-room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,19 @@ export class ChatRoom extends DurableObject<Env> {
return;
}
const msg = this.insert("user", text);
// Show "thinking" at once; recording the message and pinging the team can take a moment.
ws.send(JSON.stringify({ type: "typing" }));
const first = this.count("user") === 1;
this.broadcast("agent", { type: "message", msg });
await recordVisitorMessage(this.env, this.getMeta("sid"), this.locale(), text, first);
await this.handoff(ws, text);

if (this.ctx.getWebSockets("agent").length) {
ws.send(JSON.stringify({ type: "typing" }));
return;
}
if (this.ctx.getWebSockets("agent").length) return;
if (this.env.CHAT_AGENT_WEBHOOK_URL) {
ws.send(JSON.stringify({ type: "typing" }));
const ok = await forwardToWebhook(this.env.CHAT_AGENT_WEBHOOK_URL, { sid: this.getMeta("sid"), locale: this.locale(), text, history: this.history(20) });
if (ok) return;
}
if (!agentConfig(this.env)) return this.answerFromFaq(ws, text);
ws.send(JSON.stringify({ type: "typing" }));
const answer = await agentReply(this.env, this.history(HISTORY_LINES), this.locale(), this.getMeta("sid") ?? "", (textSoFar) => {
try { ws.send(JSON.stringify({ type: "delta", text: textSoFar })); } catch { /* visitor left */ }
});
Expand Down
4 changes: 4 additions & 0 deletions src/lib/server/edge-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* - brand links (/discord, /facebook, /x, /github) → 302 to the social profile
* - /api/chat/session → a signed chat session (Turnstile + per-IP limit), see chat-session-route
* - /api/chat/ws → the visitor's ChatRoom Durable Object (WebSocket, same origin, signed session)
* - /ingest/* → PostHog (first-party analytics proxy), see posthog-proxy
* - /media/<key> → R2 bucket MEDIA
* - <any page>.md → the page's Markdown twin
* - /_seo/* (build manifest for the discovery routes) → 404
Expand All @@ -12,6 +13,7 @@ import { BRAND_REDIRECTS } from "../../content/site";
import { chatSessionRoute, sameOrigin, SID_RE } from "./chat-session-route";
import { clientIp, hashIp, verifyChatSession } from "./chat-session-token";
import { markdownTwin } from "./markdown-twin";
import { POSTHOG_PROXY_PREFIX, proxyPosthog } from "./posthog-proxy";

type Render = (req: Request) => Promise<Response>;

Expand All @@ -36,6 +38,8 @@ export async function handleEdge(request: Request, env: Env, _ctx: ExecutionCont
if (url.pathname === "/api/chat/session") return withSecurityHeaders(await chatSessionRoute(request, env, url), env);
if (url.pathname === "/api/chat/ws") return chatSocket(request, env, url);

if (url.pathname.startsWith(`${POSTHOG_PROXY_PREFIX}/`)) return proxyPosthog(request);

if (url.pathname.startsWith("/media/") && (request.method === "GET" || request.method === "HEAD")) {
return withSecurityHeaders(await serveMedia(request, env, decodeURIComponent(url.pathname.slice("/media/".length))), env);
}
Expand Down
38 changes: 38 additions & 0 deletions src/lib/server/posthog-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* First-party reverse proxy for PostHog US Cloud under /ingest, so analytics stays on the
* dewee.sh origin and survives tracker blocklists. Static SDK files come from the asset host,
* everything else from the ingestion host. Cookies and auth never leave the site.
*/
export const POSTHOG_PROXY_PREFIX = "/ingest";

const API_HOST = "us.i.posthog.com";
const ASSET_HOST = "us-assets.i.posthog.com";

export function posthogUpstreamUrl(requestUrl: string): URL {
const url = new URL(requestUrl);
const path = url.pathname.slice(POSTHOG_PROXY_PREFIX.length) || "/";
const upstream = new URL(`https://${path.startsWith("/static/") ? ASSET_HOST : API_HOST}`);
upstream.pathname = path;
upstream.search = url.search;
return upstream;
}

export async function proxyPosthog(request: Request): Promise<Response> {
const upstream = posthogUpstreamUrl(request.url);
const headers = new Headers(request.headers);
headers.delete("cookie");
headers.delete("authorization");
headers.set("host", upstream.host);
const ip = request.headers.get("cf-connecting-ip");
if (ip) headers.set("x-forwarded-for", ip);
const hasBody = request.method !== "GET" && request.method !== "HEAD";
const response = await fetch(upstream, {
method: request.method,
headers,
body: hasBody ? await request.arrayBuffer() : null,
redirect: "manual",
});
const out = new Headers(response.headers);
out.delete("set-cookie");
return new Response(response.body, { status: response.status, headers: out });
}
9 changes: 9 additions & 0 deletions tests/chat-faq.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ describe("answerFromFaq", () => {
}
});
});

describe("answerFromFaq small talk", () => {
it("greets back instead of handing off", () => {
for (const text of ["hi", "Hello!", "xin chào", "cảm ơn"]) {
expect(answerFromFaq(text, "en").askEmail).toBe(false);
}
expect(answerFromFaq("hi, I need a human", "en").askEmail).toBe(true);
});
});
12 changes: 12 additions & 0 deletions tests/posthog-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { posthogUpstreamUrl } from "../src/lib/server/posthog-proxy";

describe("posthogUpstreamUrl", () => {
it("routes static assets to the PostHog asset host", () => {
expect(posthogUpstreamUrl("https://dewee.sh/ingest/static/array.js").href).toBe("https://us-assets.i.posthog.com/static/array.js");
});

it("routes API calls to the ingestion host and keeps the query", () => {
expect(posthogUpstreamUrl("https://dewee.sh/ingest/i/v0/e/?ip=0&ver=1").href).toBe("https://us.i.posthog.com/i/v0/e/?ip=0&ver=1");
});
});
4 changes: 2 additions & 2 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"CHAT_AGENT_ENABLED": "true",
"CHAT_AGENT_DAILY_BUDGET": "500",
"DEWEE_CHAT_AGENT_URL": "https://dewee.zuey.me",
"DEWEE_CHAT_AGENT_ID": "dewee-web-advisor",
"DEWEE_CHAT_AGENT_ID": "01a0d84d-87a5-748b-addf-548ff26ac409",
"TURNSTILE_SITE_KEY": "0x4AAAAAAFDSsllNUJs_W1u_"
},
"d1_databases": [
Expand Down Expand Up @@ -59,7 +59,7 @@
"CHAT_AGENT_ENABLED": "true",
"CHAT_AGENT_DAILY_BUDGET": "500",
"DEWEE_CHAT_AGENT_URL": "https://dewee.zuey.me",
"DEWEE_CHAT_AGENT_ID": "dewee-web-advisor",
"DEWEE_CHAT_AGENT_ID": "01a0d84d-87a5-748b-addf-548ff26ac409",
"TURNSTILE_SITE_KEY": "0x4AAAAAAFDSsllNUJs_W1u_"
},
"d1_databases": [
Expand Down
Loading