diff --git a/.env.example b/.env.example index 6d71646..531c8dc 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,8 @@ APPLE_CLIENT_ID= NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com GA_MEASUREMENT_ID= +OPENAI_ADS_PIXEL_ID= +OPENAI_ADS_CONVERSIONS_API_KEY= # Optional runtime branding COMPANY_NAME=Knowhere AI diff --git a/README.md b/README.md index fced721..c0566d7 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Optional: | --- | --- | | `NEXT_PUBLIC_POSTHOG_KEY`, `NEXT_PUBLIC_POSTHOG_HOST` | PostHog analytics. | | `GA_MEASUREMENT_ID` | Google Analytics measurement ID. | +| `OPENAI_ADS_PIXEL_ID` | OpenAI Ads Measurement Pixel ID. Set to `JDvSf6KLL8Y3e8QJhCmFF3` for the current pixel. | +| `OPENAI_ADS_CONVERSIONS_API_KEY` | Optional OpenAI Ads Conversions API key for server-side conversion delivery. | | `BILLING_ENABLED` | Set to `true` only when the API billing endpoints and payment configuration are available. Defaults to disabled for open-source self-hosted deployments. | | `PASSWORD_LOGIN_ENABLED` | Set to `true` to show the login page's password-login button. Defaults to hidden. | | `AUTH_COOKIE_DOMAIN` | Optional Better Auth parent cookie domain for cross-subdomain sessions. Leave unset for host-only cookies. | diff --git a/app/(dashboard)/billing/page.tsx b/app/(dashboard)/billing/page.tsx index e0fbfc2..5f5b889 100644 --- a/app/(dashboard)/billing/page.tsx +++ b/app/(dashboard)/billing/page.tsx @@ -6,7 +6,7 @@ import { Button } from "@components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@components/ui/card"; import { Skeleton } from "@components/ui/skeleton"; import { useCredits } from "@hooks/use-credits"; -import { trackPaymentRedirectFromSearchParams } from "@lib/posthog-checkout"; +import { trackPaymentRedirectFromSearchParams } from "@lib/analytics/payment-redirect"; import type { Subscription } from "@server/external-api/subscriptions"; import { CheckCircle2, CreditCard, XCircle } from "lucide-react"; import Link from "next/link"; diff --git a/app/(landing)/_components/hero-playground.tsx b/app/(landing)/_components/hero-playground.tsx index 5bdc8a9..526bac3 100644 --- a/app/(landing)/_components/hero-playground.tsx +++ b/app/(landing)/_components/hero-playground.tsx @@ -5,7 +5,7 @@ import { trackLandingInteraction, } from "@app/(landing)/_components/landing-tracked-link"; import { Dialog, DialogContent, DialogTitle } from "@components/ui/dialog"; -import { mirrorPlaygroundParseStarted } from "@lib/google-analytics"; +import { trackAnalyticsEvent } from "@lib/analytics"; import { cn } from "@lib/utils"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { @@ -1663,7 +1663,11 @@ export const HeroPlayground = () => { file_name: sample.cardLabel, sample_id: sampleId, }); - mirrorPlaygroundParseStarted(sample.cardLabel); + trackAnalyticsEvent({ + fileName: sample.cardLabel, + name: "playground.parse_started", + timestamp: new Date().toISOString(), + }); setActiveSampleId(sampleId); setParsedSampleId(sampleId); diff --git a/app/api/acquisition-attribution/bind/route.ts b/app/api/acquisition-attribution/bind/route.ts new file mode 100644 index 0000000..95561cd --- /dev/null +++ b/app/api/acquisition-attribution/bind/route.ts @@ -0,0 +1,39 @@ +import { ACQUISITION_ATTRIBUTION_COOKIE_NAME } from "@lib/acquisition-attribution/core"; +import { auth } from "@lib/auth"; +import { bindAcquisitionSessionToUser } from "@server/acquisition-attribution"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const bindRequestSchema = z.object({ + userId: z.string().min(1), +}); + +export async function POST(request: Request): Promise { + const sessionData = await auth.api.getSession({ headers: request.headers }); + if (!sessionData?.user?.id) { + return NextResponse.json({ message: "Authentication required" }, { status: 401 }); + } + + const requestBody = await request.json().catch(() => null); + const parsedRequest = bindRequestSchema.safeParse(requestBody); + if (!parsedRequest.success) { + return NextResponse.json({ message: "Invalid acquisition bind payload" }, { status: 400 }); + } + + if (parsedRequest.data.userId !== sessionData.user.id) { + return NextResponse.json( + { message: "Cannot bind attribution to another user" }, + { status: 403 } + ); + } + + const cookieStore = await cookies(); + const sessionId = cookieStore.get(ACQUISITION_ATTRIBUTION_COOKIE_NAME)?.value ?? null; + const result = await bindAcquisitionSessionToUser({ + sessionId, + userId: sessionData.user.id, + }); + + return NextResponse.json(result); +} diff --git a/app/api/acquisition-attribution/session/route.ts b/app/api/acquisition-attribution/session/route.ts new file mode 100644 index 0000000..01392d4 --- /dev/null +++ b/app/api/acquisition-attribution/session/route.ts @@ -0,0 +1,43 @@ +import { + ACQUISITION_ATTRIBUTION_COOKIE_NAME, + ACQUISITION_ATTRIBUTION_TTL_SECONDS, +} from "@lib/acquisition-attribution/core"; +import { captureAcquisitionSession } from "@server/acquisition-attribution"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const captureRequestSchema = z.object({ + landingUrl: z.url(), + referrer: z.string().optional(), +}); + +export async function POST(request: Request): Promise { + const requestBody = await request.json().catch(() => null); + const parsedRequest = captureRequestSchema.safeParse(requestBody); + + if (!parsedRequest.success) { + return NextResponse.json({ message: "Invalid acquisition payload" }, { status: 400 }); + } + + const cookieStore = await cookies(); + const existingSessionId = cookieStore.get(ACQUISITION_ATTRIBUTION_COOKIE_NAME)?.value ?? null; + const result = await captureAcquisitionSession({ + existingSessionId, + landingUrl: parsedRequest.data.landingUrl, + referrer: parsedRequest.data.referrer, + }); + const response = NextResponse.json(result); + + if (result.sessionId) { + response.cookies.set(ACQUISITION_ATTRIBUTION_COOKIE_NAME, result.sessionId, { + httpOnly: true, + maxAge: ACQUISITION_ATTRIBUTION_TTL_SECONDS, + path: "/", + sameSite: "lax", + secure: new URL(request.url).protocol === "https:", + }); + } + + return response; +} diff --git a/app/api/openai-ads/conversions/route.ts b/app/api/openai-ads/conversions/route.ts new file mode 100644 index 0000000..369503f --- /dev/null +++ b/app/api/openai-ads/conversions/route.ts @@ -0,0 +1,59 @@ +import { sendOpenAIAdsConversionEvent } from "@server/openai-ads-conversions"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const openAIAdsEventNameSchema = z.enum([ + "checkout_started", + "lead_created", + "order_created", + "registration_completed", + "subscription_created", +]); + +const openAIAdsConversionDataSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("customer_action"), + }) + .strict(), + z + .object({ + amount: z.number().int().nonnegative().optional(), + currency: z.literal("USD").optional(), + type: z.literal("contents"), + }) + .strict(), + z + .object({ + amount: z.number().int().nonnegative().optional(), + currency: z.literal("USD").optional(), + plan_id: z.string().min(1).optional(), + type: z.literal("plan_enrollment"), + }) + .strict(), +]); + +const openAIAdsConversionRequestSchema = z.object({ + data: openAIAdsConversionDataSchema, + eventId: z.string().min(1), + eventName: openAIAdsEventNameSchema, + sourceUrl: z.url().optional(), + timestamp: z.iso.datetime(), +}); + +export async function POST(request: Request): Promise { + const requestBody = await request.json().catch(() => null); + const parsedRequest = openAIAdsConversionRequestSchema.safeParse(requestBody); + + if (!parsedRequest.success) { + return NextResponse.json({ message: "Invalid OpenAI Ads conversion payload" }, { status: 400 }); + } + + try { + const result = await sendOpenAIAdsConversionEvent(parsedRequest.data); + return NextResponse.json(result, { status: 202 }); + } catch (error: unknown) { + console.error("[openai-ads] failed to send conversion event", error); + return NextResponse.json({ message: "OpenAI Ads conversion delivery failed" }, { status: 502 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 9bd3c99..ba99049 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,9 +6,8 @@ import "./globals.css"; import { ThemeProvider } from "@components/theme-provider"; import { appMetadata } from "@lib/app-metadata"; import { getDefaultConfig } from "@lib/config"; +import { AnalyticsProvider } from "@providers/analytics-provider"; import { ConfigProvider } from "@providers/config-provider"; -import { GoogleAnalyticsProvider } from "@providers/google-analytics-provider"; -import PostHogProvider from "@providers/posthog-provider"; import { Providers } from "@providers/providers"; import { NextIntlClientProvider } from "next-intl"; import { getMessages } from "next-intl/server"; @@ -36,23 +35,22 @@ export default async function RootLayout({ children }: { children: React.ReactNo // 在服务端读取环境变量(运行时配置,不带NEXT_PUBLIC_前缀) const appConfig = getDefaultConfig(); const gaMeasurementId = appConfig.gaMeasurementId; + const openAIAdsPixelId = appConfig.openAIAdsPixelId; // 获取翻译消息 const messages = await getMessages(); return ( - + - - - -
{children}
-
-
-
+ + +
{children}
+
+
@@ -72,6 +70,23 @@ export default async function RootLayout({ children }: { children: React.ReactNo ) : null} + {openAIAdsPixelId ? ( + <> + +