diff --git a/AGENTS.md b/AGENTS.md index 37de005..c9b0ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,12 @@ read-only `spoon-ci` machine identity (Gitea secrets `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET`), and hands the result to the scripts as `CI_ENV_FILE`. `scripts/export-env` refuses to run in CI. +- Plausible (`NEXT_PUBLIC_PLAUSIBLE_SRC`, the per-site script) loads through a + same-origin `/pa` proxy and PostHog (`NEXT_PUBLIC_POSTHOG_KEY`/`_HOST`) through + `/ingest`, after the page is idle. Both stay off when unset. Session replay comes + from PostHog; Sentry carries errors and traces only. Client code reads public env + from `@/publicEnv`, and `packages/ui/src/index.tsx` holds re-exports only so + `optimizePackageImports` can load just the modules a page imports. - A push to `main` builds the images, syncs the Convex env, deploys Convex, pushes the images, and asks the VPS to redeploy `podman-spoon.service` (`ssh gib@192.168.2.2 spoon`). A push to `main` is a production deploy. diff --git a/README.md b/README.md index 1f10691..3816660 100644 --- a/README.md +++ b/README.md @@ -549,7 +549,9 @@ not call Infisical. | `NEXT_PUBLIC_CONVEX_URL` | Convex client URL | | `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | Browser-facing worker WS base for the terminal (**build-time** var) | | `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed | -| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint | +| `NEXT_PUBLIC_PLAUSIBLE_SRC` | Plausible per-site script URL (optional) | +| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog project key (optional) | +| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host (optional) | | `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN | | `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL | | `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization | diff --git a/apps/next/next.config.js b/apps/next/next.config.js index 4d9bb7a..fe17f3c 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 '@spoon/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: ['@spoon/ui', 'radix-ui'], }, /** Enables hot reloading for local packages without a build step */ transpilePackages: ['@spoon/backend', '@spoon/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/package.json b/apps/next/package.json index c54c446..db40d1f 100644 --- a/apps/next/package.json +++ b/apps/next/package.json @@ -34,8 +34,9 @@ "monaco-editor": "~0.55.1", "monaco-vim": "~0.4.4", "next": "^16.2.1", - "next-plausible": "^3.12.5", + "next-plausible": "^4.0.0", "next-themes": "^0.4.6", + "posthog-js": "^1.435.6", "react": "catalog:react19", "react-dom": "catalog:react19", "require-in-the-middle": "^7.5.2", diff --git a/apps/next/src/app/global-error.tsx b/apps/next/src/app/global-error.tsx index 0570801..6515e22 100644 --- a/apps/next/src/app/global-error.tsx +++ b/apps/next/src/app/global-error.tsx @@ -7,8 +7,8 @@ import { Geist, Geist_Mono } from 'next/font/google'; import '@/app/styles.css'; import { useEffect } from 'react'; -import { env } from '@/env'; import { generateMetadata } from '@/lib/metadata'; +import { publicEnv } from '@/publicEnv'; import * as Sentry from '@sentry/nextjs'; import PlausibleProvider from 'next-plausible'; @@ -41,32 +41,34 @@ 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 4afb928..71d55b3 100644 --- a/apps/next/src/app/layout.tsx +++ b/apps/next/src/app/layout.tsx @@ -43,34 +43,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 62ee9d0..7fc94b1 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 21c598e..9e9da9e 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 { isRemoteImageUrl } from '@/lib/avatar'; 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 remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null; const currentImageUrl = useQuery( api.files.getImageUrl, @@ -83,6 +85,7 @@ export const AvatarDropdown = () => {