Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Notifications #161

Merged
merged 13 commits into from
Jan 12, 2025
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ jobs:
echo "LIVEBLOCKS_SECRET=sk_test" >> apps/app/.env.local
echo "BASEHUB_TOKEN=${{ secrets.BASEHUB_TOKEN }}" >> apps/app/.env.local
echo "VERCEL_PROJECT_PRODUCTION_URL=http://localhost:3002" >> apps/app/.env.local
echo "KNOCK_API_KEY=test" >> apps/app/.env.local
echo "KNOCK_FEED_CHANNEL_ID=test" >> apps/app/.env.local

echo "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_JA==" >> apps/app/.env.local
echo "NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in" >> apps/app/.env.local
Expand Down
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ SVIX_TOKEN=""
LIVEBLOCKS_SECRET=""
BASEHUB_TOKEN=""
VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3002"
KNOCK_API_KEY=""
KNOCK_FEED_CHANNEL_ID=""

# Client
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
Expand Down
5 changes: 4 additions & 1 deletion apps/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ SVIX_TOKEN=""
LIVEBLOCKS_SECRET=""
BASEHUB_TOKEN=""
VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3000"
KNOCK_API_KEY=""
KNOCK_FEED_CHANNEL_ID=""
KNOCK_SECRET_API_KEY=""

# Client
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
Expand All @@ -26,4 +29,4 @@ NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST=""
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_WEB_URL="http://localhost:3001"
NEXT_PUBLIC_DOCS_URL="http://localhost:3004"
NEXT_PUBLIC_DOCS_URL="http://localhost:3004"
12 changes: 11 additions & 1 deletion apps/app/app/(authenticated)/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { OrganizationSwitcher, UserButton } from '@repo/auth/client';
import { ModeToggle } from '@repo/design-system/components/mode-toggle';
import { Button } from '@repo/design-system/components/ui/button';
import {
Collapsible,
CollapsibleContent,
Expand Down Expand Up @@ -33,8 +34,10 @@ import {
useSidebar,
} from '@repo/design-system/components/ui/sidebar';
import { cn } from '@repo/design-system/lib/utils';
import { NotificationsTrigger } from '@repo/notifications/components/trigger';
import {
AnchorIcon,
BellIcon,
BookOpenIcon,
BotIcon,
ChevronRightIcon,
Expand Down Expand Up @@ -331,7 +334,14 @@ export const GlobalSidebar = ({ children }: GlobalSidebarProperties) => {
},
}}
/>
<ModeToggle />
<div className="flex shrink-0 items-center gap-px">
<ModeToggle />
<NotificationsTrigger>
<Button variant="ghost" size="icon" className="shrink-0">
<BellIcon size={16} className="text-muted-foreground" />
</Button>
</NotificationsTrigger>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
Expand Down
27 changes: 15 additions & 12 deletions apps/app/app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { env } from '@/env';
import { auth, currentUser } from '@repo/auth/server';
import { SidebarProvider } from '@repo/design-system/components/ui/sidebar';
import { showBetaFeature } from '@repo/feature-flags';
import { NotificationsProvider } from '@repo/notifications/components/provider';
import { secure } from '@repo/security';
import type { ReactNode } from 'react';
import { PostHogIdentifier } from './components/posthog-identifier';
Expand All @@ -21,21 +22,23 @@ const AppLayout = async ({ children }: AppLayoutProperties) => {
const betaFeature = await showBetaFeature();

if (!user) {
redirectToSignIn();
return redirectToSignIn();
}

return (
<SidebarProvider>
<GlobalSidebar>
{betaFeature && (
<div className="m-4 rounded-full bg-success p-1.5 text-center text-sm text-success-foreground">
Beta feature now available
</div>
)}
{children}
</GlobalSidebar>
<PostHogIdentifier />
</SidebarProvider>
<NotificationsProvider userId={user.id}>
<SidebarProvider>
<GlobalSidebar>
{betaFeature && (
<div className="m-4 rounded-full bg-success p-1.5 text-center text-sm text-success-foreground">
Beta feature now available
</div>
)}
{children}
</GlobalSidebar>
<PostHogIdentifier />
</SidebarProvider>
</NotificationsProvider>
);
};

Expand Down
2 changes: 2 additions & 0 deletions apps/app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { keys as database } from '@repo/database/keys';
import { keys as email } from '@repo/email/keys';
import { keys as flags } from '@repo/feature-flags/keys';
import { keys as core } from '@repo/next-config/keys';
import { keys as notifications } from '@repo/notifications/keys';
import { keys as observability } from '@repo/observability/keys';
import { keys as security } from '@repo/security/keys';
import { keys as webhooks } from '@repo/webhooks/keys';
Expand All @@ -19,6 +20,7 @@ export const env = createEnv({
database(),
email(),
flags(),
notifications(),
observability(),
security(),
webhooks(),
Expand Down
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@repo/design-system": "workspace:*",
"@repo/feature-flags": "workspace:*",
"@repo/next-config": "workspace:*",
"@repo/notifications": "workspace:*",
"@repo/observability": "workspace:*",
"@repo/security": "workspace:*",
"@repo/seo": "workspace:*",
Expand Down
4 changes: 3 additions & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ ARCJET_KEY=""
SVIX_TOKEN=""
LIVEBLOCKS_SECRET=""
BASEHUB_TOKEN=""
# VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3001"
VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3001"
KNOCK_API_KEY=""
KNOCK_FEED_CHANNEL_ID=""

# Client
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
Expand Down
Binary file added docs/images/authors/knock/j_everhart383.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/authors/knock/logo.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/mint.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"packages/next-config/bundle-analysis"
]
},
"packages/notifications",
"packages/payments",
{
"group": "Security",
Expand Down
72 changes: 72 additions & 0 deletions docs/packages/notifications.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
title: Notifications
description: In-app notifications for your users.
---

import { Authors } from '/snippets/authors.mdx';

<Authors data={[{
user: {
name: 'Hayden Bleasel',
id: 'haydenbleasel',
},
company: {
name: 'next-forge',
id: 'next-forge',
},
}, {
user: {
name: 'Jeff Everhart',
id: 'j_everhart383',
},
company: {
name: 'Knock',
id: 'knock',
},
}]} />

next-forge offers a notifications package that allows you to send in-app notifications to your users. By default, it uses [Knock](https://knock.app/), a cross-channel notification platform that supports in-app, email, SMS, push, and chat notifications. Knock allows you to centralize your notification logic and templates in one place and [orchestrate complex workflows](https://docs.knock.app/designing-workflows/overview) with things like branching, batching, throttling, delays, and conditional sending.

## Setup

To use the notifications package, you need to add the required environment variables to your project, as specified in the `packages/notifications/keys.ts` file.

## In-app notifications feed

To render an in-app notifications feed, import the `NotificationsTrigger` component from the `@repo/notifications` package and use it in your app. We've already added this to the sidebar in the example app:

```tsx apps/app/app/(authenticated)/components/sidebar.tsx
import { NotificationsTrigger } from '@repo/notifications/components/trigger';

<NotificationsTrigger>
<Button variant="ghost" size="icon" className="shrink-0">
<BellIcon size={16} className="text-muted-foreground" />
</Button>
</NotificationsTrigger>
```

Pressing the button will open the in-app notifications feed, which displays all of the notifications for the current user.

## Send a notification

Knock sends notifications using workflows. To send an in-app notification, create a new [workflow](https://docs.knock.app/concepts/workflows) in the Knock dashboard that uses the [`in-app` channel provider](https://docs.knock.app/integrations/in-app/knock) and create a corresponding message template.

Then you can [trigger that workflow](https://docs.knock.app/send-notifications/triggering-workflows) for a particular user in your app, passing in the necessary data to populate the message template:

```tsx notify.ts
import { notifications } from '@repo/notifications';

await notifications.workflows.trigger('workflow-key', {
recipients: [{
id: 'user-id',
email: 'user-email',
}],
data: {
message: 'Hello, world!',
},
});
```

## Multi-channel notifications

Using Knock, you can add additional channel providers to your workflow to send notifications via email, SMS, push, or chat. To do this, create a new [channel provider](https://docs.knock.app/integrations) in the Knock dashboard, follow any configuration instructions for that provider, and add it to your workflow as a channel step.
2 changes: 1 addition & 1 deletion packages/design-system/components/mode-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const ModeToggle = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
variant="ghost"
size="icon"
className="shrink-0 text-foreground"
>
Expand Down
4 changes: 2 additions & 2 deletions packages/design-system/components/ui/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ function Calendar({
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
IconLeft: ({ className, children, ...props }) => (
<ChevronLeftIcon className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
IconRight: ({ className, children, ...props }) => (
<ChevronRightIcon className={cn("h-4 w-4", className)} {...props} />
),
}}
Expand Down
30 changes: 30 additions & 0 deletions packages/notifications/components/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client';

import { KnockFeedProvider, KnockProvider } from '@knocklabs/react';
import type { ReactNode } from 'react';
import { keys } from '../keys';

const knockApiKey = keys().NEXT_PUBLIC_KNOCK_API_KEY;
const knockFeedChannelId = keys().NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID;

type NotificationsProviderProps = {
children: ReactNode;
userId: string;
};

export const NotificationsProvider = ({
children,
userId,
}: NotificationsProviderProps) => {
if (!knockApiKey || !knockFeedChannelId) {
return children;
}

return (
<KnockProvider apiKey={knockApiKey} userId={userId}>
<KnockFeedProvider feedId={knockFeedChannelId}>
{children}
</KnockFeedProvider>
</KnockProvider>
);
};
48 changes: 48 additions & 0 deletions packages/notifications/components/trigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import { NotificationFeedPopover } from '@knocklabs/react';
import { useRef, useState } from 'react';
import type { ReactElement, RefObject } from 'react';
import { keys } from '../keys';

// Required CSS import, unless you're overriding the styling
import '@knocklabs/react/dist/index.css';

type NotificationsTriggerProperties = {
children: ReactElement;
};

export const NotificationsTrigger = ({
children,
}: NotificationsTriggerProperties) => {
const [isVisible, setIsVisible] = useState(false);
const notifButtonRef = useRef<HTMLDivElement>(null);

if (!keys().NEXT_PUBLIC_KNOCK_API_KEY) {
return null;
}

return (
<>
{/* biome-ignore lint/nursery/noStaticElementInteractions: "avoid nested buttons" */}
<div
onClick={() => setIsVisible(!isVisible)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
setIsVisible(!isVisible);
}
}}
ref={notifButtonRef}
>
{children}
</div>
{notifButtonRef.current && (
<NotificationFeedPopover
buttonRef={notifButtonRef as RefObject<HTMLElement>}
isVisible={isVisible}
onClose={() => setIsVisible(false)}
/>
)}
</>
);
};
6 changes: 6 additions & 0 deletions packages/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Knock } from '@knocklabs/node';
import { keys } from './keys';

const key = keys().KNOCK_SECRET_API_KEY;

export const notifications = new Knock(key);
19 changes: 19 additions & 0 deletions packages/notifications/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const keys = () =>
createEnv({
server: {
KNOCK_SECRET_API_KEY: z.string().min(1).optional(),
},
client: {
NEXT_PUBLIC_KNOCK_API_KEY: z.string().min(1).optional(),
NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID: z.string().min(1).optional(),
},
runtimeEnv: {
NEXT_PUBLIC_KNOCK_API_KEY: process.env.NEXT_PUBLIC_KNOCK_API_KEY,
NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID:
process.env.NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID,
KNOCK_SECRET_API_KEY: process.env.KNOCK_SECRET_API_KEY,
},
});
23 changes: 23 additions & 0 deletions packages/notifications/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@repo/notifications",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "git clean -xdf .cache .turbo dist node_modules",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@knocklabs/node": "^0.6.13",
"@knocklabs/react": "^0.2.29",
"@t3-oss/env-nextjs": "^0.11.1",
"react": "^19.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"typescript": "^5.7.2",
"@types/node": "22.10.5",
"@types/react": "19.0.2",
"@types/react-dom": "^19.0.2"
}
}
8 changes: 8 additions & 0 deletions packages/notifications/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Loading
Loading