11'use client' ;
22
33import { Button , Tooltip , TooltipContent , TooltipTrigger } from '@/components/ui' ;
4+ import { ChangelogEntry } from '@/lib/changelog' ;
5+ import { getPublicEnvVar } from '@/lib/env' ;
46import { cn } from '@/lib/utils' ;
57import { checkVersion , VersionCheckResult } from '@/lib/version-check' ;
68import { BookOpenIcon , CircleNotchIcon , ClockClockwiseIcon , LightbulbIcon , XIcon } from '@phosphor-icons/react' ;
9+ import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises' ;
710import React , { createContext , useCallback , useContext , useEffect , useRef , useState } from 'react' ;
811import packageJson from '../../package.json' ;
912import { FeedbackForm } from './feedback-form' ;
1013import { ChangelogWidget } from './stack-companion/changelog-widget' ;
1114import { FeatureRequestBoard } from './stack-companion/feature-request-board' ;
1215import { UnifiedDocsWidget } from './stack-companion/unified-docs-widget' ;
1316
17+ /**
18+ * Compare two CalVer versions in YYYY.MM.DD format
19+ * Returns true if version1 is newer than version2
20+ */
21+ function isNewerCalVer ( version1 : string , version2 : string ) : boolean {
22+ const parseCalVer = ( version : string ) : Date | null => {
23+ const match = version . match ( / ^ ( \d { 4 } ) \. ( \d { 2 } ) \. ( \d { 2 } ) $ / ) ;
24+ if ( ! match ) return null ;
25+ const [ , year , month , day ] = match ;
26+ return new Date ( parseInt ( year ) , parseInt ( month ) - 1 , parseInt ( day ) ) ;
27+ } ;
28+
29+ const date1 = parseCalVer ( version1 ) ;
30+ const date2 = parseCalVer ( version2 ) ;
31+
32+ if ( ! date1 || ! date2 ) {
33+ // Fallback to string comparison if parsing fails
34+ return version1 > version2 ;
35+ }
36+
37+ return date1 . getTime ( ) > date2 . getTime ( ) ;
38+ }
39+
40+ /**
41+ * Sanitize a string value for use in a cookie
42+ * Removes or encodes characters that could break cookie parsing
43+ */
44+ function sanitizeCookieValue ( value : string ) : string {
45+ // Remove or encode special characters that break cookie parsing
46+ return encodeURIComponent ( value ) ;
47+ }
48+
1449type SidebarItem = {
1550 id : string ,
1651 label : string ,
@@ -73,6 +108,7 @@ export function useStackCompanion() {
73108 return useContext ( StackCompanionContext ) ;
74109}
75110
111+
76112export function StackCompanion ( { className } : { className ?: string } ) {
77113 const [ activeItem , setActiveItem ] = useState < string | null > ( null ) ;
78114 const [ mounted , setMounted ] = useState ( false ) ;
@@ -82,6 +118,9 @@ export function StackCompanion({ className }: { className?: string }) {
82118 const [ isAnimating , setIsAnimating ] = useState ( false ) ;
83119 const [ isDragging , setIsDragging ] = useState ( false ) ;
84120 const [ isSplitScreenMode , setIsSplitScreenMode ] = useState ( false ) ;
121+ const [ changelogData , setChangelogData ] = useState < ChangelogEntry [ ] | undefined > ( undefined ) ;
122+ const [ hasNewVersions , setHasNewVersions ] = useState ( false ) ;
123+ const [ lastSeenVersion , setLastSeenVersion ] = useState ( '' ) ;
85124
86125 const startXRef = useRef ( 0 ) ;
87126 const startWidthRef = useRef ( 0 ) ;
@@ -125,6 +164,84 @@ export function StackCompanion({ className }: { className?: string }) {
125164 return cleanup ;
126165 } , [ ] ) ;
127166
167+ // Fetch changelog data on mount and check for new versions
168+ useEffect ( ( ) => {
169+ runAsynchronously ( async ( ) => {
170+ const baseUrl = getPublicEnvVar ( 'NEXT_PUBLIC_STACK_API_URL' ) || '' ;
171+ const response = await fetch ( `${ baseUrl } /api/latest/internal/changelog` ) ;
172+ if ( ! response . ok ) {
173+ return ;
174+ }
175+
176+ const payload = await response . json ( ) ;
177+ const entries = payload . entries || [ ] ;
178+ setChangelogData ( entries ) ;
179+
180+ // Check for new versions
181+ const lastSeenRaw = document . cookie
182+ . split ( '; ' )
183+ . find ( row => row . startsWith ( 'stack-last-seen-changelog-version=' ) )
184+ ?. split ( '=' ) [ 1 ] || '' ;
185+
186+ const lastSeen = lastSeenRaw ? decodeURIComponent ( lastSeenRaw ) : '' ;
187+ setLastSeenVersion ( lastSeen ) ;
188+
189+ if ( entries . length > 0 ) {
190+ // If no lastSeen cookie, user hasn't seen any changelog yet - show bell
191+ if ( ! lastSeen ) {
192+ setHasNewVersions ( true ) ;
193+ } else {
194+ const hasNewer = entries . some ( ( entry : ChangelogEntry ) => {
195+ if ( entry . isUnreleased ) return false ;
196+ return isNewerCalVer ( entry . version , lastSeen ) ;
197+ } ) ;
198+ setHasNewVersions ( hasNewer ) ;
199+ }
200+ }
201+ } ) ;
202+ } , [ ] ) ;
203+
204+ // Re-check for new versions when changelog is opened/closed
205+ useEffect ( ( ) => {
206+ if ( activeItem === 'changelog' ) {
207+ // When changelog is opened, mark the latest released version as seen
208+ // Skip unreleased versions to avoid breaking version comparison
209+ if ( changelogData && changelogData . length > 0 ) {
210+ const latestReleasedEntry = changelogData . find ( entry => ! entry . isUnreleased ) ;
211+ if ( latestReleasedEntry ) {
212+ document . cookie = `stack-last-seen-changelog-version=${ sanitizeCookieValue ( latestReleasedEntry . version ) } ; path=/; max-age=31536000` ; // 1 year
213+ setLastSeenVersion ( latestReleasedEntry . version ) ;
214+ }
215+ }
216+ // Clear the notification badge immediately
217+ setHasNewVersions ( false ) ;
218+ } else if ( activeItem === null ) {
219+ // When closed, re-check if there are new versions
220+ const lastSeenRaw = document . cookie
221+ . split ( '; ' )
222+ . find ( row => row . startsWith ( 'stack-last-seen-changelog-version=' ) )
223+ ?. split ( '=' ) [ 1 ] || '' ;
224+
225+ const lastSeen = lastSeenRaw ? decodeURIComponent ( lastSeenRaw ) : '' ;
226+
227+ if ( changelogData && changelogData . length > 0 ) {
228+ // If no lastSeen cookie, user hasn't seen any changelog yet - show bell
229+ if ( ! lastSeen ) {
230+ setHasNewVersions ( true ) ;
231+ } else {
232+ const hasNewer = changelogData . some ( ( entry : ChangelogEntry ) => {
233+ if ( entry . isUnreleased ) return false ;
234+ return isNewerCalVer ( entry . version , lastSeen ) ;
235+ } ) ;
236+ setHasNewVersions ( hasNewer ) ;
237+ }
238+ } else {
239+ setHasNewVersions ( false ) ;
240+ }
241+ }
242+ } , [ activeItem , changelogData ] ) ;
243+
244+
128245 const openDrawer = useCallback ( ( itemId : string ) => {
129246 setActiveItem ( itemId ) ;
130247 setIsAnimating ( true ) ;
@@ -304,7 +421,7 @@ export function StackCompanion({ className }: { className?: string }) {
304421 < div className = "flex-1 overflow-y-auto p-5 overflow-x-hidden no-drag cursor-auto" >
305422 { activeItem === 'docs' && < UnifiedDocsWidget isActive = { true } /> }
306423 { activeItem === 'feedback' && < FeatureRequestBoard isActive = { true } /> }
307- { activeItem === 'changelog' && < ChangelogWidget isActive = { true } /> }
424+ { activeItem === 'changelog' && < ChangelogWidget isActive = { true } initialData = { changelogData } /> }
308425 { activeItem === 'support' && < FeedbackForm /> }
309426 </ div >
310427 </ div >
@@ -338,18 +455,26 @@ export function StackCompanion({ className }: { className?: string }) {
338455 className = { cn (
339456 "h-10 w-10 p-0 text-muted-foreground transition-all duration-[50ms] rounded-xl relative group" ,
340457 item . hoverBg ,
341- activeItem === item . id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5"
458+ activeItem === item . id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5" ,
459+ // Glow effect for changelog with new updates
460+ item . id === 'changelog' && hasNewVersions && "ring-2 ring-green-500/30 bg-green-500/10"
342461 ) }
343462 onClick = { ( e ) => {
344463 e . stopPropagation ( ) ;
345464 handleItemClick ( item . id ) ;
346465 } }
347466 >
348467 < item . icon className = { cn ( "h-5 w-5 transition-transform duration-[50ms] group-hover:scale-110" , item . color ) } />
468+ { item . id === 'changelog' && hasNewVersions && (
469+ < span className = "absolute -top-1 -right-1 flex h-3 w-3" >
470+ < span className = "animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
471+ < span className = "relative inline-flex rounded-full h-3 w-3 bg-green-500" />
472+ </ span >
473+ ) }
349474 </ Button >
350475 </ TooltipTrigger >
351476 < TooltipContent side = "left" className = "z-[60] mr-2" >
352- { item . label }
477+ { item . id === 'changelog' && hasNewVersions ? ` ${ item . label } (New updates available!)` : item . label }
353478 </ TooltipContent >
354479 </ Tooltip >
355480 ) ) }
0 commit comments