feat: add PostHog, move Plausible to v4, and drop Sentry session replay
Build and Deploy Spoon / quality (push) Successful in 1m40s
Build and Deploy Spoon / build-images (push) Successful in 12m23s

PostHog loads after the page is idle through a same-origin /ingest proxy and follows the signed-in Convex user. Plausible uses the per-site script through a /pa proxy. Sentry keeps errors and traces only. Pages load only the @spoon/ui modules they import, and client code reads public env from publicEnv.ts so zod stays out of the browser. Both analytics integrations stay off until their env is set in Infisical prod.
This commit is contained in:
Gabriel Brown committed 2026-10-01 17:17:21 -04:00
1 parent 587fa0fdcd
commit 348c2d3d62
22 files changed
+336 -76

No files matched your search

+6
View File
@@ -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 [email protected] spoon`). A push to `main` is a production deploy.
+3 -1
View File
@@ -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 |
+52 -5
View File
@@ -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:
+2 -1
View File
@@ -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",
+13 -11
View File
@@ -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,15 +41,7 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<PlausibleProvider
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
const page = (
<ThemeProvider
attribute='class'
defaultTheme='system'
@@ -64,9 +56,19 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
<Toaster />
</main>
</ThemeProvider>
);
return (
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{publicEnv.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
<PlausibleProvider>{page}</PlausibleProvider>
) : (
page
)}
</body>
</html>
</PlausibleProvider>
);
};
export default GlobalError;
+15 -11
View File
@@ -43,16 +43,7 @@ const RootLayout = ({
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} ${victorMono.variable} antialiased`}
>
const app = (
<ThemeProvider
attribute='class'
defaultTheme='system'
@@ -68,9 +59,22 @@ const RootLayout = ({
<Toaster />
</ConvexClientProvider>
</ThemeProvider>
);
return (
<ConvexAuthNextjsServerProvider>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} ${victorMono.variable} antialiased`}
>
{/* 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 ? (
<PlausibleProvider>{app}</PlausibleProvider>
) : (
app
)}
</body>
</html>
</PlausibleProvider>
</ConvexAuthNextjsServerProvider>
);
};
@@ -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);
@@ -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 = () => {
<button
onClick={() =>
void signOut().then(() => {
resetAnalyticsIdentity();
router.push('/');
})
}
@@ -1,11 +1,11 @@
'use client';
import type { ReactNode } from 'react';
import { env } from '@/env';
import { publicEnv } from '@/publicEnv';
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
import { ConvexReactClient } from 'convex/react';
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
const convex = new ConvexReactClient(publicEnv.NEXT_PUBLIC_CONVEX_URL);
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
<ConvexAuthNextjsProvider client={convex}>
+7 -2
View File
@@ -34,7 +34,10 @@ export const env = createEnv({
client: {
NEXT_PUBLIC_SITE_URL: z.url(),
NEXT_PUBLIC_CONVEX_URL: z.url(),
NEXT_PUBLIC_PLAUSIBLE_URL: z.url(),
// Analytics are optional: absent values leave the integration off.
NEXT_PUBLIC_PLAUSIBLE_SRC: z.url().optional(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.url().optional(),
NEXT_PUBLIC_SENTRY_DSN: z.string(),
NEXT_PUBLIC_SENTRY_URL: z.string(),
NEXT_PUBLIC_SENTRY_ORG: z.string(),
@@ -71,7 +74,9 @@ export const env = createEnv({
process.env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
NEXT_PUBLIC_PLAUSIBLE_SRC: process.env.NEXT_PUBLIC_PLAUSIBLE_SRC,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
+24 -16
View File
@@ -1,27 +1,35 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import { env } from '@/env';
import { startAnalytics } from '@/lib/analytics';
import { publicEnv } from '@/publicEnv';
import * as Sentry from '@sentry/nextjs';
// Session replay is left to PostHog, so Sentry carries no replay integration.
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
Sentry.feedbackIntegration({
colorScheme: 'system',
}),
],
dsn: publicEnv.NEXT_PUBLIC_SENTRY_DSN,
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
sendDefaultPii: false,
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
tracesSampleRate: 1,
// A tenth of page loads is plenty for performance trends and keeps the
// self-hosted Sentry light.
tracesSampleRate: 0.1,
enableLogs: true,
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.5,
replaysOnErrorSampleRate: 1.0,
debug: false,
});
// The feedback widget is a floating button nobody needs during the first
// paint, so its code is split into its own chunk and added once the page has
// loaded.
const addFeedbackWidget = async () => {
const { feedbackIntegration } = await import('@sentry/nextjs');
Sentry.addIntegration(feedbackIntegration({ colorScheme: 'system' }));
};
if (document.readyState === 'complete') void addFeedbackWidget();
else
window.addEventListener('load', () => void addFeedbackWidget(), {
once: true,
});
startAnalytics();
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
+86
View File
@@ -0,0 +1,86 @@
import type { PostHog } from 'posthog-js';
import { useEffect } from 'react';
import { aliasPosthogScript } from '@/lib/posthog-scripts';
import { publicEnv } from '@/publicEnv';
// PostHog stays off until a project key and host are configured. Calling
// posthog-js before init logs an error on every call, so every use goes
// through this flag.
export const analyticsEnabled = Boolean(
publicEnv.NEXT_PUBLIC_POSTHOG_KEY && publicEnv.NEXT_PUBLIC_POSTHOG_HOST,
);
let client: PostHog | undefined;
// Identity calls made before PostHog has loaded, replayed in order once it has.
const pending: ((posthog: PostHog) => void)[] = [];
const withPosthog = (call: (posthog: PostHog) => void) => {
if (client) call(client);
else pending.push(call);
};
// Runs once the page has loaded and the main thread is idle, so analytics
// never competes with the first paint or hydration.
const afterPageIsIdle = (run: () => void) => {
const schedule = () => {
if ('requestIdleCallback' in window)
window.requestIdleCallback(run, { timeout: 4_000 });
else setTimeout(run, 0);
};
if (document.readyState === 'complete') schedule();
else window.addEventListener('load', schedule, { once: true });
};
const initPosthog = async (key: string) => {
const { default: posthog } = await import('posthog-js');
posthog.init(key, {
// Events go through the same-origin /ingest rewrite in next.config.js
// rather than straight to the PostHog host, whose name and paths ad
// blockers match on. ui_host keeps toolbar links on the real host.
api_host: '/ingest',
ui_host: publicEnv.NEXT_PUBLIC_POSTHOG_HOST,
// Makes pageviews follow App Router client-side navigation.
defaults: '2026-08-30',
// Autocapture, session replay (typed input masked), heatmaps and web
// vitals come from these defaults plus the project settings in PostHog.
capture_exceptions: true,
// Two extension script names are ad-blocked by name; see
// lib/posthog-scripts.ts for the aliasing.
strict_script_versioning: false,
prepare_external_dependency_script: (script) => {
script.src = aliasPosthogScript(script.src);
return script;
},
});
// Keeps local runs separate from production in the same project.
posthog.register({
environment: new URL(publicEnv.NEXT_PUBLIC_SITE_URL).hostname,
});
client = posthog;
for (const call of pending.splice(0)) call(posthog);
};
// Called once from instrumentation-client. posthog-js is downloaded and
// initialised only after the page is idle, which keeps it out of the first
// load; identity calls made in the meantime are queued above.
export const startAnalytics = () => {
const key = publicEnv.NEXT_PUBLIC_POSTHOG_KEY;
if (!analyticsEnabled || !key) return;
afterPageIsIdle(() => void initPosthog(key));
};
// Ties this browser's PostHog person to the signed-in Convex user. The id is
// opaque, so no name or email leaves the app. Mounted by the header avatar,
// which renders on every page while someone is signed in.
export const useAnalyticsIdentity = (userId: string | undefined) => {
useEffect(() => {
if (!analyticsEnabled || !userId) return;
withPosthog((posthog) => posthog.identify(userId));
}, [userId]);
};
// Starts a fresh anonymous id on sign-out so the next person on this browser
// is never merged into the last one.
export const resetAnalyticsIdentity = () => {
if (analyticsEnabled) withPosthog((posthog) => posthog.reset());
};
+24
View File
@@ -0,0 +1,24 @@
// posthog-js loads optional features (session replay, dead clicks) as extra
// scripts from <api_host>/static/<name>.js. Two of those file names sit on the
// default ad-blocker lists by name alone (EasyPrivacy carries the literal
// substrings /posthog-recorder.js and /dead-clicks-autocapture.js), so the
// same-origin proxy does not save them: the browser refuses the request before
// it leaves. The client asks for the alias instead, and next.config.js rewrites
// the alias back to the real file on the way out. Nothing here changes what is
// loaded, only what it is called on the wire. The aliases are deliberately
// meaningless so a broader rule added later has nothing to match.
export const posthogScriptAliases: Record<string, string> = {
'posthog-recorder': 'ext-a',
'dead-clicks-autocapture': 'ext-b',
};
const staticScript = /\/static\/([a-z0-9-]+)\.js(?=\?|$)/;
// Rewrites a PostHog extension script URL to its alias, or returns it
// unchanged when the file is not on the list.
export const aliasPosthogScript = (src: string) =>
src.replace(staticScript, (match, name: string) =>
name in posthogScriptAliases
? `/static/${posthogScriptAliases[name]}.js`
: match,
);
+30
View File
@@ -0,0 +1,30 @@
import type { env } from '@/env';
type PublicEnv = Pick<
typeof env,
| 'NEXT_PUBLIC_SITE_URL'
| 'NEXT_PUBLIC_CONVEX_URL'
| 'NEXT_PUBLIC_PLAUSIBLE_SRC'
| 'NEXT_PUBLIC_POSTHOG_KEY'
| 'NEXT_PUBLIC_POSTHOG_HOST'
| 'NEXT_PUBLIC_SENTRY_DSN'
>;
// Empty strings count as unset, matching `emptyStringAsUndefined` in env.ts.
const read = (value: string | undefined) => (value === '' ? undefined : value);
// The browser-side NEXT_PUBLIC_* values for client components and
// instrumentation-client. Next inlines each literal `process.env.X` at build
// time, and env.ts has already validated them by then (next.config.js imports
// it), so reading them here keeps zod and the env schema out of every
// visitor's first download. Server code keeps importing `@/env`.
/* eslint-disable no-restricted-properties -- the one sanctioned client-side reader, see AGENTS.md */
export const publicEnv = {
NEXT_PUBLIC_SITE_URL: read(process.env.NEXT_PUBLIC_SITE_URL),
NEXT_PUBLIC_CONVEX_URL: read(process.env.NEXT_PUBLIC_CONVEX_URL),
NEXT_PUBLIC_PLAUSIBLE_SRC: read(process.env.NEXT_PUBLIC_PLAUSIBLE_SRC),
NEXT_PUBLIC_POSTHOG_KEY: read(process.env.NEXT_PUBLIC_POSTHOG_KEY),
NEXT_PUBLIC_POSTHOG_HOST: read(process.env.NEXT_PUBLIC_POSTHOG_HOST),
NEXT_PUBLIC_SENTRY_DSN: read(process.env.NEXT_PUBLIC_SENTRY_DSN),
} as PublicEnv;
/* eslint-enable no-restricted-properties */
+2 -1
View File
@@ -3,7 +3,8 @@ import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,
// One in ten requests keeps traces useful without loading the home server.
tracesSampleRate: 0.1,
enableLogs: true,
debug: false,
});
+26 -3
View File
@@ -114,8 +114,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",
@@ -1068,6 +1069,12 @@
"@panva/hkdf": ["@panva/[email protected]", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@posthog/browser-common": ["@posthog/[email protected]", "", { "dependencies": { "@posthog/core": "^1.55.3", "@posthog/types": "^1.413.0" } }, "sha512-Dl8uV2OZAxfHfWyC7K0Y3HtT7ld+WGLbXIwgCgAGziOMGDIa/bepCRrCxy7IxsEzqY1HOrq/VDdcoNN7hBTSQQ=="],
"@posthog/core": ["@posthog/[email protected]", "", { "dependencies": { "@posthog/types": "^1.413.0" } }, "sha512-XnmG9jo7WlxTeN4oCNWH4ESLQHR6ZPZ+mcq+8JxU62k5i1BAe3BSrybnmXHjqKp7e3rUvLK2bOzGZGSlfHvlNA=="],
"@posthog/types": ["@posthog/[email protected]", "", {}, "sha512-tYAVzNojWckuG/wxBB0fwE70sFruQQq4xbhkci7eH2ARU3mMfUTkvpN1HwBMpm0AtbxidLiH0IFvagmdJ8opPw=="],
"@prisma/instrumentation": ["@prisma/[email protected]", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg=="],
"@protobufjs/aspromise": ["@protobufjs/[email protected]", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
@@ -1952,6 +1959,8 @@
"copy-anything": ["[email protected]", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"core-js": ["[email protected]", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"core-js-compat": ["[email protected]", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
"core-util-is": ["[email protected]", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
@@ -2264,6 +2273,8 @@
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["[email protected]", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="],
"figures": ["[email protected]", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -2752,7 +2763,7 @@
"next": ["[email protected]", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="],
"next-plausible": ["next-plausible@3.12.5", "", { "peerDependencies": { "next": "^11.1.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 ", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l1YMuTI9akb2u7z4hyTuxXpudy8KfSteRNXCYpWpnhAoBjaWQlv6sITai1TwcR7wWvVW8DFbLubvMQAsirAjcA=="],
"next-plausible": ["next-plausible@4.0.0", "", { "peerDependencies": { "next": "^11.1.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 ", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tC48VscREZ4fEvas9T4oj5qJwnpPlms0Wih1Unbgi/ozG08yN1w0IAPGp+/cHB8n6qzEAL5J0MlAS0FOr132jA=="],
"next-themes": ["[email protected]", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -2880,7 +2891,9 @@
"postgres-interval": ["[email protected]", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"posthog-js": ["[email protected]", "", { "dependencies": { "@posthog/browser-common": "^0.9.1", "@posthog/core": "^1.55.3", "@posthog/types": "^1.413.0", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^6.2.1", "web-vitals-soft-navs": "npm:[email protected]" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-PdBZw1Np4Ch2ZzvQ/Ij0CTBrJSkSeEmDJsyJfbaipTYH8XJKJk45BYLtNgL4z9DlevRcYJxhwb15VrDzbw7shA=="],
"preact": ["[email protected]", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
"preact-render-to-string": ["[email protected]", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
@@ -2918,6 +2931,8 @@
"qrcode-terminal": ["[email protected]", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
"query-selector-shadow-dom": ["[email protected]", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="],
"query-string": ["[email protected]", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
"queue": ["[email protected]", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],
@@ -3384,6 +3399,10 @@
"wcwidth": ["[email protected]", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"web-vitals": ["[email protected]", "", {}, "sha512-oto5x6dLEgrRqfcWed+pZEUb2q6ikbFmqF54CRDhI/QGbn+qxC49b4C4OkbH+kb9C3a8shpFD3SgR9kRcs80ZQ=="],
"web-vitals-soft-navs": ["[email protected]", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="],
"webidl-conversions": ["[email protected]", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"webpack": ["[email protected]", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ=="],
@@ -3456,6 +3475,8 @@
"zod-validation-error": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@auth/core/preact": ["[email protected]", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"@babel/core/@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/core/@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
@@ -4118,6 +4139,8 @@
"postcss/nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ=="],
"posthog-js/dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="],
"pretty-format/react-is": ["[email protected]", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"prop-types/react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+6 -2
View File
@@ -10,7 +10,9 @@ ARG SENTRY_AUTH_TOKEN
ARG SENTRY_DISABLE_AUTO_UPLOAD=false
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_CONVEX_URL
ARG NEXT_PUBLIC_PLAUSIBLE_URL
ARG NEXT_PUBLIC_PLAUSIBLE_SRC
ARG NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_SENTRY_URL
ARG NEXT_PUBLIC_SENTRY_ORG
@@ -21,7 +23,9 @@ ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_CONVEX_URL=$NEXT_PUBLIC_CONVEX_URL
ENV NEXT_PUBLIC_PLAUSIBLE_URL=$NEXT_PUBLIC_PLAUSIBLE_URL
ENV NEXT_PUBLIC_PLAUSIBLE_SRC=$NEXT_PUBLIC_PLAUSIBLE_SRC
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_SENTRY_URL=$NEXT_PUBLIC_SENTRY_URL
ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG
+6 -2
View File
@@ -12,7 +12,9 @@ services:
SENTRY_DISABLE_AUTO_UPLOAD: ${SENTRY_DISABLE_AUTO_UPLOAD:-false}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
NEXT_PUBLIC_CONVEX_URL: ${NEXT_PUBLIC_CONVEX_URL}
NEXT_PUBLIC_PLAUSIBLE_URL: ${NEXT_PUBLIC_PLAUSIBLE_URL}
NEXT_PUBLIC_PLAUSIBLE_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SRC:-}
NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-}
NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN}
NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL}
NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG}
@@ -27,7 +29,9 @@ services:
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_PLAUSIBLE_SRC=${NEXT_PUBLIC_PLAUSIBLE_SRC:-}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY:-}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
+3 -1
View File
@@ -38,7 +38,9 @@ services:
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_PLAUSIBLE_SRC=${NEXT_PUBLIC_PLAUSIBLE_SRC:-}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY:-}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
+5 -17
View File
@@ -1,20 +1,8 @@
import { cx } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: Parameters<typeof cx>) => twMerge(cx(inputs));
export const ccn = ({
context,
className,
on = '',
off = '',
}: {
context: boolean;
className: string;
on: string;
off: string;
}) => twMerge(className, context ? on : off);
// Re-exports only. The Next app lists @spoon/ui in `optimizePackageImports`,
// which resolves each named import straight to its own module so a page that
// uses Button never loads Chart (and Recharts). That only works while nothing
// is declared in this file.
export { cn, ccn } from './utils';
export {
Accordion,
AccordionItem,
+16
View File
@@ -0,0 +1,16 @@
import { cx } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: Parameters<typeof cx>) => twMerge(cx(inputs));
export const ccn = ({
context,
className,
on = '',
off = '',
}: {
context: boolean;
className: string;
on: string;
off: string;
}) => twMerge(className, context ? on : off);
+3 -1
View File
@@ -8,7 +8,9 @@
"SENTRY_DISABLE_AUTO_UPLOAD",
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_CONVEX_URL",
"NEXT_PUBLIC_PLAUSIBLE_URL",
"NEXT_PUBLIC_PLAUSIBLE_SRC",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_SENTRY_DSN",
"NEXT_PUBLIC_SENTRY_URL",
"NEXT_PUBLIC_SENTRY_ORG",