diff --git a/apps/next/next.config.js b/apps/next/next.config.js
index 06c7dac..39297d4 100644
--- a/apps/next/next.config.js
+++ b/apps/next/next.config.js
@@ -3,12 +3,16 @@ import { createJiti } from 'jiti';
import { withPlausibleProxy } from 'next-plausible';
const jiti = createJiti(import.meta.url);
-await jiti.import('./src/env');
+const { env } = /** @type {typeof import('./src/env')} */ (
+ await jiti.import('./src/env')
+);
+const { posthogScriptAliases } =
+ /** @type {typeof import('./src/lib/posthog-scripts')} */ (
+ await jiti.import('./src/lib/posthog-scripts')
+ );
/** @type {import("next").NextConfig} */
-const config = withPlausibleProxy({
- customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
-})({
+const baseConfig = {
output: 'standalone',
images: {
remotePatterns: [
@@ -23,11 +27,54 @@ const config = withPlausibleProxy({
serverActions: {
bodySizeLimit: '10mb',
},
+ // Resolves `import { Button } from '@gib/ui'` to the button module alone,
+ // so marketing pages never pull in admin-only Radix widgets or Recharts.
+ // radix-ui is the umbrella package the shadcn components import from.
+ // lucide-react, date-fns and @tabler/icons-react are on Next's default list.
+ optimizePackageImports: ['@gib/ui', 'radix-ui'],
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: ['@gib/backend', '@gib/ui'],
typescript: { ignoreBuildErrors: true },
-});
+ // PostHog's capture paths end in a slash (/e/, /i/v0/e/), and Next's
+ // built-in trailing-slash redirect would answer those proxied POSTs with a
+ // redirect. It is turned off here and replaced by the redirect below, which
+ // strips trailing slashes everywhere except /ingest.
+ skipTrailingSlashRedirect: true,
+ redirects: async () => [
+ {
+ source: '/:path((?!ingest(?:/|$)).+)/',
+ destination: '/:path',
+ permanent: true,
+ },
+ ],
+ // Same-origin proxy for PostHog, so blocklists that match the PostHog host
+ // never see the request. The aliased extension scripts (blocked by file
+ // name, see src/lib/posthog-scripts.ts) map back to their real files first.
+ rewrites: async () => {
+ const host = env.NEXT_PUBLIC_POSTHOG_HOST;
+ if (!host) return [];
+ return [
+ ...Object.entries(posthogScriptAliases).map(([name, alias]) => ({
+ source: `/ingest/static/${alias}.js`,
+ destination: `${host}/static/${name}.js`,
+ })),
+ { source: '/ingest/:path*', destination: `${host}/:path*` },
+ ];
+ },
+};
+
+// Serves Plausible's per-site script from this origin so ad blockers see a
+// first-party request. next-plausible 4 takes the full script URL. Its default
+// event path is /api/event, which sits inside Payload's /api routes, so both
+// paths move under /pa.
+const config = env.NEXT_PUBLIC_PLAUSIBLE_SRC
+ ? withPlausibleProxy({
+ src: env.NEXT_PUBLIC_PLAUSIBLE_SRC,
+ scriptPath: '/pa/script.js',
+ apiPath: '/pa/event',
+ })(baseConfig)
+ : baseConfig;
const sentryConfig = {
// For all available options, see:
diff --git a/apps/next/src/app/global-error.tsx b/apps/next/src/app/global-error.tsx
index fae61df..c586145 100644
--- a/apps/next/src/app/global-error.tsx
+++ b/apps/next/src/app/global-error.tsx
@@ -10,8 +10,8 @@ import { useEffect } from 'react';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import { ConvexClientProvider } from '@/components/providers';
-import { env } from '@/env';
import { generateMetadata } from '@/lib/metadata';
+import { publicEnv } from '@/publicEnv';
import * as Sentry from '@sentry/nextjs';
import PlausibleProvider from 'next-plausible';
@@ -44,39 +44,41 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
- return (
-
-
-
-
-
-
-
-
- {reset !== undefined && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {reset !== undefined && (
+
+ )}
+
+
+
+
+
+
+
+
+ );
+ return (
+
+
+ {publicEnv.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
+ {page}
+ ) : (
+ page
+ )}
+
+
);
};
export default GlobalError;
diff --git a/apps/next/src/app/layout.tsx b/apps/next/src/app/layout.tsx
index f8ff122..abda573 100644
--- a/apps/next/src/app/layout.tsx
+++ b/apps/next/src/app/layout.tsx
@@ -36,34 +36,38 @@ const RootLayout = ({
}: Readonly<{
children: React.ReactNode;
}>) => {
+ const app = (
+
+
+
+
+ {children}
+
+
+
+
+
+ );
return (
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
+
+
+ {/* Plausible loads its per-site script through the same-origin
+ proxy in next.config.js; without that config it stays off. */}
+ {env.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
+ {app}
+ ) : (
+ app
+ )}
+
+
);
};
diff --git a/apps/next/src/components/layout/auth/profile/sign-out.tsx b/apps/next/src/components/layout/auth/profile/sign-out.tsx
index 0cad885..edb10d5 100644
--- a/apps/next/src/components/layout/auth/profile/sign-out.tsx
+++ b/apps/next/src/components/layout/auth/profile/sign-out.tsx
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
+import { resetAnalyticsIdentity } from '@/lib/analytics';
import { useAuthActions } from '@convex-dev/auth/react';
import { LogOut } from 'lucide-react';
@@ -22,6 +23,7 @@ export const SignOutForm = () => {
setIsSigningOut(true);
try {
await signOut();
+ resetAnalyticsIdentity();
router.push('/');
} catch (error) {
console.error('Sign out error:', error);
diff --git a/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx b/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx
index c3dbd1e..ca07c86 100644
--- a/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx
+++ b/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
+import { resetAnalyticsIdentity, useAnalyticsIdentity } from '@/lib/analytics';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
@@ -23,6 +24,7 @@ export const AvatarDropdown = () => {
const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, {});
+ useAnalyticsIdentity(user?._id);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
@@ -77,6 +79,7 @@ export const AvatarDropdown = () => {