Skip to content

Commit ce9bab3

Browse files
committed
add whats new indicator to nav
1 parent 50f2f36 commit ce9bab3

File tree

4 files changed

+200
-0
lines changed

4 files changed

+200
-0
lines changed

packages/web/src/app/[domain]/components/navigationMenu.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
1515
import { env } from "@/env.mjs";
1616
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
1717
import { auth } from "@/auth";
18+
import WhatsNewIndicator from "./whatsNewIndicator";
1819

1920
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
2021
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@@ -105,6 +106,7 @@ export const NavigationMenu = async ({
105106
<WarningNavIndicator />
106107
<ErrorNavIndicator />
107108
<TrialNavIndicator subscription={subscription} />
109+
<WhatsNewIndicator />
108110
<form
109111
action={async () => {
110112
"use server";
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"use client"
2+
3+
import type React from "react"
4+
5+
import { useState, useEffect } from "react"
6+
import { HelpCircle, Mail, MailOpen } from "lucide-react"
7+
import { Button } from "@/components/ui/button"
8+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
9+
import { Badge } from "@/components/ui/badge"
10+
import { NewsItem } from "@/lib/types"
11+
import { newsData } from "@/lib/newsData"
12+
13+
interface WhatsNewProps {
14+
newsItems?: NewsItem[]
15+
autoMarkAsRead?: boolean
16+
}
17+
18+
const COOKIE_NAME = "whats-new-read-items"
19+
20+
const getReadItems = (): string[] => {
21+
if (typeof document === "undefined") return []
22+
23+
const cookies = document.cookie.split(';').map(cookie => cookie.trim())
24+
const targetCookie = cookies.find(cookie => cookie.startsWith(`${COOKIE_NAME}=`))
25+
26+
if (!targetCookie) return []
27+
28+
try {
29+
const cookieValue = targetCookie.substring(`${COOKIE_NAME}=`.length)
30+
return JSON.parse(decodeURIComponent(cookieValue))
31+
} catch (error) {
32+
console.warn('Failed to parse whats-new cookie:', error)
33+
return []
34+
}
35+
}
36+
37+
const setReadItems = (readItems: string[]) => {
38+
if (typeof document === "undefined") return
39+
40+
try {
41+
const expires = new Date()
42+
expires.setFullYear(expires.getFullYear() + 1)
43+
const cookieValue = encodeURIComponent(JSON.stringify(readItems))
44+
45+
document.cookie = `${COOKIE_NAME}=${cookieValue}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`
46+
} catch (error) {
47+
console.warn('Failed to set whats-new cookie:', error)
48+
}
49+
}
50+
51+
export default function WhatsNewIndicator({ newsItems = newsData, autoMarkAsRead = true }: WhatsNewProps) {
52+
const [isOpen, setIsOpen] = useState(false)
53+
const [readItems, setReadItemsState] = useState<string[]>([])
54+
const [isInitialized, setIsInitialized] = useState(false)
55+
56+
useEffect(() => {
57+
const items = getReadItems()
58+
setReadItemsState(items)
59+
setIsInitialized(true)
60+
}, [])
61+
62+
useEffect(() => {
63+
if (isInitialized) {
64+
setReadItems(readItems)
65+
}
66+
}, [readItems, isInitialized])
67+
68+
const newsItemsWithReadState = newsItems.map((item) => ({
69+
...item,
70+
read: readItems.includes(item.unique_id),
71+
}))
72+
73+
const unreadCount = newsItemsWithReadState.filter((item) => !item.read).length
74+
75+
const markAsRead = (itemId: string) => {
76+
setReadItemsState((prev) => {
77+
if (!prev.includes(itemId)) {
78+
return [...prev, itemId]
79+
}
80+
return prev
81+
})
82+
}
83+
84+
const markAllAsRead = () => {
85+
const allIds = newsItems.map((item) => item.unique_id)
86+
setReadItemsState(allIds)
87+
}
88+
89+
const handleNewsItemClick = (item: NewsItem) => {
90+
window.open(item.url, "_blank", "noopener,noreferrer")
91+
92+
if (autoMarkAsRead && !item.read) {
93+
markAsRead(item.unique_id)
94+
}
95+
}
96+
97+
return (
98+
<Popover open={isOpen} onOpenChange={setIsOpen}>
99+
<PopoverTrigger asChild>
100+
<Button
101+
variant="ghost"
102+
size="icon"
103+
className="relative h-9 w-9 rounded-full hover:bg-muted"
104+
aria-label={`What's new${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
105+
>
106+
<HelpCircle className="h-4 w-4" />
107+
{isInitialized && unreadCount > 0 && (
108+
<Badge
109+
variant="destructive"
110+
className="absolute -top-1 -right-1 h-5 w-5 p-0 text-[10px] flex items-center justify-center"
111+
>
112+
{unreadCount > 9 ? "9+" : unreadCount}
113+
<span className="sr-only">{unreadCount} unread updates</span>
114+
</Badge>
115+
)}
116+
</Button>
117+
</PopoverTrigger>
118+
<PopoverContent className="w-80 p-0" align="end" sideOffset={8}>
119+
<div className="border-b p-4">
120+
<div className="flex items-center justify-between">
121+
<div>
122+
<h3 className="font-semibold text-sm">{"What's New"}</h3>
123+
<p className="text-xs text-muted-foreground mt-1">
124+
{unreadCount > 0 ? `${unreadCount} unread update${unreadCount === 1 ? "" : "s"}` : "All caught up!"}
125+
</p>
126+
</div>
127+
{unreadCount > 0 && (
128+
<Button variant="ghost" size="sm" onClick={markAllAsRead} className="text-xs h-7">
129+
Mark all read
130+
</Button>
131+
)}
132+
</div>
133+
</div>
134+
<div className="max-h-[32rem] overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent">
135+
{newsItemsWithReadState.length === 0 ? (
136+
<div className="p-4 text-center text-sm text-muted-foreground">No recent updates</div>
137+
) : (
138+
<div className="space-y-1 p-2">
139+
{newsItemsWithReadState.map((item, index) => (
140+
<div
141+
key={item.unique_id}
142+
className={`relative rounded-md transition-colors ${item.read ? "opacity-60" : ""} ${
143+
index !== newsItemsWithReadState.length - 1 ? "border-b border-border/50" : ""
144+
}`}
145+
>
146+
{!item.read && <div className="absolute left-2 top-3 h-2 w-2 bg-blue-500 rounded-full"></div>}
147+
<button
148+
onClick={() => handleNewsItemClick(item)}
149+
className="w-full text-left p-3 pl-6 rounded-md hover:bg-muted transition-colors group"
150+
>
151+
<div className="flex items-start justify-between gap-2">
152+
<div className="flex-1 min-w-0">
153+
<h4
154+
className={`font-medium text-sm leading-tight group-hover:text-primary ${
155+
item.read ? "text-muted-foreground" : ""
156+
}`}
157+
>
158+
{item.header}
159+
</h4>
160+
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.sub_header}</p>
161+
</div>
162+
<div className="flex items-center gap-1 flex-shrink-0">
163+
{item.read ? (
164+
<MailOpen className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
165+
) : (
166+
<Mail className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
167+
)}
168+
</div>
169+
</div>
170+
</button>
171+
</div>
172+
))}
173+
</div>
174+
)}
175+
</div>
176+
</PopoverContent>
177+
</Popover>
178+
)
179+
}

packages/web/src/lib/newsData.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { NewsItem } from "./types";
2+
3+
// Sample news data - replace with your actual data source
4+
export const newsData: NewsItem[] = [
5+
{
6+
unique_id: "1",
7+
header: "Code navigation",
8+
sub_header: "Built in go-to definition and find references",
9+
url: "https://docs.sourcebot.dev", // TODO: link to code nav docs
10+
}
11+
];

packages/web/src/lib/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,13 @@ export type ApiKeyPayload = {
1616
domain: string;
1717
};
1818

19+
export type NewsItem = {
20+
unique_id: string;
21+
header: string;
22+
sub_header: string;
23+
url: string;
24+
read?: boolean;
25+
}
26+
1927
export type TenancyMode = z.infer<typeof tenancyModeSchema>;
2028
export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>;

0 commit comments

Comments
 (0)