Add PostHog, move Plausible to v4, and drop Sentry session replay

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, clear of Payload's /api routes. Sentry keeps errors and traces only. Pages load only the @gib/ui modules they import, and client code reads public env from publicEnv.ts so zod stays out of the browser.
This commit is contained in:
Gabriel Brown committed 2026-10-01 14:42:29 -04:00
1 parent 1af6b43b1a
commit f39376a887
14 files changed
+318 -102

No files matched your search

+52 -5
View File
@@ -4,12 +4,16 @@ import { createJiti } from 'jiti';
import { withPlausibleProxy } from 'next-plausible'; import { withPlausibleProxy } from 'next-plausible';
const jiti = createJiti(import.meta.url); 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} */ /** @type {import("next").NextConfig} */
const config = withPlausibleProxy({ const baseConfig = {
customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
})({
output: 'standalone', output: 'standalone',
images: { images: {
remotePatterns: [ remotePatterns: [
@@ -24,11 +28,54 @@ const config = withPlausibleProxy({
serverActions: { serverActions: {
bodySizeLimit: '10mb', 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 */ /** Enables hot reloading for local packages without a build step */
transpilePackages: ['@gib/backend', '@gib/ui'], transpilePackages: ['@gib/backend', '@gib/ui'],
typescript: { ignoreBuildErrors: true }, 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 = { const sentryConfig = {
// For all available options, see: // For all available options, see:
+35 -33
View File
@@ -10,8 +10,8 @@ import { useEffect } from 'react';
import Footer from '@/components/layout/footer'; import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header'; import Header from '@/components/layout/header';
import { ConvexClientProvider } from '@/components/providers'; import { ConvexClientProvider } from '@/components/providers';
import { env } from '@/env';
import { generateMetadata } from '@/lib/metadata'; import { generateMetadata } from '@/lib/metadata';
import { publicEnv } from '@/publicEnv';
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import PlausibleProvider from 'next-plausible'; import PlausibleProvider from 'next-plausible';
@@ -44,39 +44,41 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => { useEffect(() => {
Sentry.captureException(error); Sentry.captureException(error);
}, [error]); }, [error]);
return ( const page = (
<PlausibleProvider <ThemeProvider
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')} attribute='class'
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL} defaultTheme='system'
enableSystem
disableTransitionOnChange
> >
<html lang='en' suppressHydrationWarning> <ConvexClientProvider>
<body <main className='flex min-h-screen flex-col items-center'>
className={`${geistSans.variable} ${geistMono.variable} antialiased`} <Header />
> <NextError statusCode={0} />
<ThemeProvider {reset !== undefined && (
attribute='class' <Button onClick={() => reset()}>Try Again</Button>
defaultTheme='system' )}
enableSystem <Toaster />
disableTransitionOnChange <Footer />
> </main>
<ConvexClientProvider> <main className='flex min-h-[90vh] flex-col items-center'>
<main className='flex min-h-screen flex-col items-center'> <Toaster />
<Header /> </main>
<NextError statusCode={0} /> </ConvexClientProvider>
{reset !== undefined && ( </ThemeProvider>
<Button onClick={() => reset()}>Try Again</Button> );
)} return (
<Toaster /> <html lang='en' suppressHydrationWarning>
<Footer /> <body
</main> className={`${geistSans.variable} ${geistMono.variable} antialiased`}
<main className='flex min-h-[90vh] flex-col items-center'> >
<Toaster /> {publicEnv.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
</main> <PlausibleProvider>{page}</PlausibleProvider>
</ConvexClientProvider> ) : (
</ThemeProvider> page
</body> )}
</html> </body>
</PlausibleProvider> </html>
); );
}; };
export default GlobalError; export default GlobalError;
+30 -26
View File
@@ -36,34 +36,38 @@ const RootLayout = ({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) => { }>) => {
const app = (
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<div className='flex min-h-screen flex-col'>
<Header />
{children}
<Footer />
</div>
<Toaster />
</ConvexClientProvider>
</ThemeProvider>
);
return ( return (
<ConvexAuthNextjsServerProvider> <ConvexAuthNextjsServerProvider>
<PlausibleProvider <html lang='en' suppressHydrationWarning>
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')} <body
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<html lang='en' suppressHydrationWarning> {/* Plausible loads its per-site script through the same-origin
<body proxy in next.config.js; without that config it stays off. */}
className={`${geistSans.variable} ${geistMono.variable} antialiased`} {env.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
> <PlausibleProvider>{app}</PlausibleProvider>
<ThemeProvider ) : (
attribute='class' app
defaultTheme='system' )}
enableSystem </body>
disableTransitionOnChange </html>
>
<ConvexClientProvider>
<div className='flex min-h-screen flex-col'>
<Header />
{children}
<Footer />
</div>
<Toaster />
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
</ConvexAuthNextjsServerProvider> </ConvexAuthNextjsServerProvider>
); );
}; };
@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { resetAnalyticsIdentity } from '@/lib/analytics';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import { LogOut } from 'lucide-react'; import { LogOut } from 'lucide-react';
@@ -22,6 +23,7 @@ export const SignOutForm = () => {
setIsSigningOut(true); setIsSigningOut(true);
try { try {
await signOut(); await signOut();
resetAnalyticsIdentity();
router.push('/'); router.push('/');
} catch (error) { } catch (error) {
console.error('Sign out error:', error); console.error('Sign out error:', error);
@@ -2,6 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { resetAnalyticsIdentity, useAnalyticsIdentity } from '@/lib/analytics';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react'; import { useConvexAuth, useQuery } from 'convex/react';
@@ -23,6 +24,7 @@ export const AvatarDropdown = () => {
const { isLoading, isAuthenticated } = useConvexAuth(); const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions(); const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, {}); const user = useQuery(api.auth.getUser, {});
useAnalyticsIdentity(user?._id);
const currentImageUrl = useQuery( const currentImageUrl = useQuery(
api.files.getImageUrl, api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip', user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
@@ -77,6 +79,7 @@ export const AvatarDropdown = () => {
<button <button
onClick={() => onClick={() =>
void signOut().then(() => { void signOut().then(() => {
resetAnalyticsIdentity();
router.push('/'); router.push('/');
}) })
} }
@@ -1,11 +1,11 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { env } from '@/env'; import { publicEnv } from '@/publicEnv';
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs'; import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
import { ConvexReactClient } from 'convex/react'; 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 }) => ( export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
<ConvexAuthNextjsProvider client={convex}> <ConvexAuthNextjsProvider client={convex}>
+7 -2
View File
@@ -20,7 +20,10 @@ export const env = createEnv({
client: { client: {
NEXT_PUBLIC_SITE_URL: z.url(), NEXT_PUBLIC_SITE_URL: z.url(),
NEXT_PUBLIC_CONVEX_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_DSN: z.string(),
NEXT_PUBLIC_SENTRY_URL: z.string(), NEXT_PUBLIC_SENTRY_URL: z.string(),
NEXT_PUBLIC_SENTRY_ORG: z.string(), NEXT_PUBLIC_SENTRY_ORG: z.string(),
@@ -38,7 +41,9 @@ export const env = createEnv({
PAYLOAD_DB_URL: process.env.PAYLOAD_DB_URL, PAYLOAD_DB_URL: process.env.PAYLOAD_DB_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_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_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL, NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG, 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/ // 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'; import * as Sentry from '@sentry/nextjs';
// Session replay is left to PostHog, so Sentry carries no replay integration.
Sentry.init({ Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN, dsn: publicEnv.NEXT_PUBLIC_SENTRY_DSN,
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
Sentry.feedbackIntegration({
colorScheme: 'system',
}),
],
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii // 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 // 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, enableLogs: true,
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.5,
replaysOnErrorSampleRate: 1.0,
debug: false, 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 // `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; 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({ Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN, 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, enableLogs: true,
debug: false, debug: false,
}); });
+5 -17
View File
@@ -1,20 +1,8 @@
import { cx } from 'class-variance-authority'; // Re-exports only. The Next app lists @gib/ui in `optimizePackageImports`,
import { twMerge } from 'tailwind-merge'; // 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
export const cn = (...inputs: Parameters<typeof cx>) => twMerge(cx(inputs)); // is declared in this file.
export { cn, ccn } from './utils';
export const ccn = ({
context,
className,
on = '',
off = '',
}: {
context: boolean;
className: string;
on: string;
off: string;
}) => twMerge(className, context ? on : off);
export { export {
Accordion, Accordion,
AccordionItem, 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);