diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 5a51cf97ce5..e0047aa6cf5 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -3,8 +3,9 @@ import dayjs from 'dayjs' import { usePathname } from 'next/navigation' import { PropsWithChildren, useEffect, useRef, useState } from 'react' import { + SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY, SELECT_26_STUDIO_DISMISSAL_KEY, - useSelect26PromotionActive, + useSelect26PromotionPhase, } from 'ui-patterns/Banners/Select26Promotion' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' @@ -45,16 +46,22 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const [isSelect26BannerDismissed, , { isSuccess: isSelect26DismissalLoaded }] = useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false) - const isSelect26PromotionActive = useSelect26PromotionActive() + const [isSelect26LivestreamDismissed, , { isSuccess: isSelect26LivestreamDismissalLoaded }] = + useLocalStorageQuery(SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY, false) + const select26PromotionPhase = useSelect26PromotionPhase() useEffect(() => { - if (!isSelect26DismissalLoaded) return + const isLivestream = select26PromotionPhase === 'livestream' + const dismissalLoaded = isLivestream + ? isSelect26LivestreamDismissalLoaded + : isSelect26DismissalLoaded + if (!dismissalLoaded) return const shouldShow = shouldShowSelect26Banner({ isPlatform: IS_PLATFORM, - dismissalLoaded: isSelect26DismissalLoaded, - isActive: isSelect26PromotionActive, - isDismissed: isSelect26BannerDismissed, + dismissalLoaded, + isActive: select26PromotionPhase !== 'ended', + isDismissed: isLivestream ? isSelect26LivestreamDismissed : isSelect26BannerDismissed, }) if (shouldShow) { @@ -69,8 +76,10 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { } }, [ isSelect26DismissalLoaded, - isSelect26PromotionActive, + isSelect26LivestreamDismissalLoaded, + select26PromotionPhase, isSelect26BannerDismissed, + isSelect26LivestreamDismissed, addBanner, dismissBanner, ]) diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.test.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.test.tsx new file mode 100644 index 00000000000..286654319a3 --- /dev/null +++ b/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.test.tsx @@ -0,0 +1,60 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { BannerSelect2026 } from './BannerSelect2026' + +const { dismissBanner, setWaitlistDismissed, setLivestreamDismissed } = vi.hoisted(() => ({ + dismissBanner: vi.fn(), + setWaitlistDismissed: vi.fn(), + setLivestreamDismissed: vi.fn(), +})) + +vi.mock('../BannerStackProvider', () => ({ + BANNER_ID: { SELECT_26: 'select-2026-banner' }, + useBannerStack: () => ({ dismissBanner }), +})) + +vi.mock('@/hooks/misc/useLocalStorage', () => ({ + useLocalStorageQuery: (key: string) => [ + false, + key === 'select-2026-livestream-dismissed' ? setLivestreamDismissed : setWaitlistDismissed, + ], +})) + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-10-02T07:59:59.999-07:00')) + vi.clearAllMocks() +}) + +afterEach(() => vi.useRealTimers()) + +describe('BannerSelect2026', () => { + it('switches copy and dismissal key at 8am, then disappears at 5:30pm', () => { + render() + + expect(screen.getByRole('link', { name: 'Apply to attend' })).toHaveAttribute( + 'href', + 'https://select.supabase.com/' + ) + + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByRole('link', { name: 'Watch livestream' })).toHaveAttribute( + 'href', + 'https://select.supabase.com/' + ) + expect(screen.getByText('Supabase Select 2026')).toBeVisible() + expect( + screen.getByText('Keynote, main stage, and build stage, streamed all day.') + ).toBeVisible() + + fireEvent.click(screen.getByRole('button', { name: 'Close banner' })) + expect(setLivestreamDismissed).toHaveBeenCalledWith(true) + expect(setWaitlistDismissed).not.toHaveBeenCalled() + expect(dismissBanner).toHaveBeenCalledWith('select-2026-banner') + + vi.setSystemTime(new Date('2026-10-02T17:30:00-07:00')) + act(() => document.dispatchEvent(new Event('visibilitychange'))) + expect(screen.queryByRole('link', { name: 'Watch livestream' })).toBeNull() + }) +}) diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.tsx index 0c8ed7f9492..c9f732bf0c4 100644 --- a/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.tsx +++ b/apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.tsx @@ -4,10 +4,14 @@ import { Button } from 'ui' import { SELECT_26_CTA, SELECT_26_DESCRIPTION, + SELECT_26_LIVESTREAM_DESCRIPTION, + SELECT_26_LIVESTREAM_STUDIO_CTA, + SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY, SELECT_26_STUDIO_DISMISSAL_KEY, SELECT_26_TITLE, SELECT_26_URL, Select26Field, + useSelect26PromotionPhase, } from 'ui-patterns/Banners/Select26Promotion' import { BannerCard } from '../BannerCard' @@ -15,14 +19,22 @@ import { BANNER_ID, useBannerStack } from '../BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' export const BannerSelect2026 = () => { + const phase = useSelect26PromotionPhase() const { dismissBanner } = useBannerStack() - const [, setIsDismissed] = useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false) + const [, setIsWaitlistDismissed] = useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false) + const [, setIsLivestreamDismissed] = useLocalStorageQuery( + SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY, + false + ) const dismiss = () => { - setIsDismissed(true) + if (phase === 'livestream') setIsLivestreamDismissed(true) + else setIsWaitlistDismissed(true) dismissBanner(BANNER_ID.SELECT_26) } + if (phase === 'ended') return null + return ( {

{SELECT_26_TITLE}

- {SELECT_26_DESCRIPTION} + {phase === 'livestream' ? SELECT_26_LIVESTREAM_DESCRIPTION : SELECT_26_DESCRIPTION}

diff --git a/packages/ui-patterns/src/Banners/AnnouncementBanner.test.tsx b/packages/ui-patterns/src/Banners/AnnouncementBanner.test.tsx index 45c70b6f74e..89af0fc2636 100644 --- a/packages/ui-patterns/src/Banners/AnnouncementBanner.test.tsx +++ b/packages/ui-patterns/src/Banners/AnnouncementBanner.test.tsx @@ -1,9 +1,11 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AnnouncementBanner } from './AnnouncementBanner' import { SELECT_26_CTA, + SELECT_26_LIVESTREAM_CTA, + SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY, SELECT_26_MESSAGE, SELECT_26_TITLE, SELECT_26_URL, @@ -69,6 +71,33 @@ describe('AnnouncementBanner', () => { expect(screen.queryByText(SELECT_26_TITLE)).not.toBeInTheDocument() }) + it('shows the livestream even after the waitlist banner was dismissed', async () => { + window.localStorage.setItem(SELECT_26_WWW_DISMISSAL_KEY, 'hidden') + vi.setSystemTime(new Date('2026-10-02T08:00:00-07:00')) + + render() + + expect( + await screen.findByRole('link', { name: new RegExp(SELECT_26_LIVESTREAM_CTA) }) + ).toBeVisible() + expect( + screen.getByRole('link', { name: new RegExp(SELECT_26_LIVESTREAM_CTA) }) + ).toHaveAttribute('href', SELECT_26_URL) + fireEvent.click(screen.getByRole('button', { name: 'Dismiss announcement' })) + expect(window.localStorage.getItem(SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY)).toBe('hidden') + }) + + it('removes the livestream at 5:30pm', async () => { + vi.setSystemTime(new Date('2026-10-02T17:29:00-07:00')) + render() + + expect( + await screen.findByRole('link', { name: new RegExp(SELECT_26_LIVESTREAM_CTA) }) + ).toBeVisible() + act(() => vi.advanceTimersByTime(60 * 1000)) + expect(screen.queryByRole('link', { name: new RegExp(SELECT_26_LIVESTREAM_CTA) })).toBeNull() + }) + it('remains dismissible on launch-week routes', async () => { mockUsePathname.mockReturnValue('/launch-week') render() diff --git a/packages/ui-patterns/src/Banners/AnnouncementBanner.tsx b/packages/ui-patterns/src/Banners/AnnouncementBanner.tsx index 3ebf3a7615a..1f530acf22d 100644 --- a/packages/ui-patterns/src/Banners/AnnouncementBanner.tsx +++ b/packages/ui-patterns/src/Banners/AnnouncementBanner.tsx @@ -3,18 +3,25 @@ import { Announcement } from './Announcement' import announcementJSON from './data.json' import { Select26Banner } from './Select26Banner' -import { SELECT_26_WWW_DISMISSAL_KEY, useSelect26PromotionActive } from './Select26Promotion' +import { + SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY, + SELECT_26_WWW_DISMISSAL_KEY, + useSelect26PromotionPhase, +} from './Select26Promotion' export const announcement = announcementJSON export const AnnouncementBanner = () => { - const isActive = useSelect26PromotionActive() + const phase = useSelect26PromotionPhase() - if (!isActive) return null + if (phase === 'ended') return null + + const dismissalKey = + phase === 'livestream' ? SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY : SELECT_26_WWW_DISMISSAL_KEY return ( - - + + ) } diff --git a/packages/ui-patterns/src/Banners/Select26Banner.tsx b/packages/ui-patterns/src/Banners/Select26Banner.tsx index 9d52a16040e..b0c0cbfc09b 100644 --- a/packages/ui-patterns/src/Banners/Select26Banner.tsx +++ b/packages/ui-patterns/src/Banners/Select26Banner.tsx @@ -3,13 +3,20 @@ import { ArrowUpRight } from 'lucide-react' import Link from 'next/link' -import { SELECT_26_CTA, SELECT_26_TITLE, SELECT_26_URL, Select26Field } from './Select26Promotion' +import { + SELECT_26_CTA, + SELECT_26_LIVESTREAM_CTA, + SELECT_26_TITLE, + SELECT_26_URL, + Select26Field, + type Select26PromotionPhase, +} from './Select26Promotion' /** Three visible rows; top/bottom reach further inward than the middle row. */ const WWW_FIELD_BASE_COLS = 24 const WWW_FIELD_ROW_WIDTHS = [26, 24, 28] -export const Select26Banner = () => ( +export const Select26Banner = ({ phase }: { phase: Exclude }) => (
( mirror className="absolute right-0 top-1/2 -z-10 -translate-y-1/2 text-sm opacity-80 dark:opacity-70 md:text-base xl:text-[0.95rem]" /> -
+

{SELECT_26_TITLE} - is coming October 2 + {phase === 'waitlist' && is coming October 2}

▪ @@ -40,7 +47,7 @@ export const Select26Banner = () => ( rel="noopener noreferrer" className="group inline-flex shrink-0 items-center gap-1 text-[#00482f] underline decoration-[#00482f]/35 underline-offset-4 transition-colors hover:decoration-[#00482f] dark:text-[#94e6b7] dark:decoration-[#94e6b7]/40 dark:hover:decoration-[#94e6b7]" > - {SELECT_26_CTA} + {phase === 'livestream' ? SELECT_26_LIVESTREAM_CTA : SELECT_26_CTA}
diff --git a/packages/ui-patterns/src/Banners/Select26Promotion.test.ts b/packages/ui-patterns/src/Banners/Select26Promotion.test.ts index d43d9ad7152..09491db8dc3 100644 --- a/packages/ui-patterns/src/Banners/Select26Promotion.test.ts +++ b/packages/ui-patterns/src/Banners/Select26Promotion.test.ts @@ -1,36 +1,48 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { isSelect26PromotionActive, useSelect26PromotionActive } from './Select26Promotion' +import { getSelect26PromotionPhase, useSelect26PromotionPhase } from './Select26Promotion' afterEach(() => vi.useRealTimers()) -describe('isSelect26PromotionActive', () => { - it('is active immediately before the campaign expiry', () => { - expect(isSelect26PromotionActive(new Date('2026-10-02T23:59:59.999-07:00').getTime())).toBe( - true - ) +describe('getSelect26PromotionPhase', () => { + it.each([ + ['2026-10-02T07:59:59.999-07:00', 'waitlist'], + ['2026-10-02T08:00:00-07:00', 'livestream'], + ['2026-10-02T17:29:59.999-07:00', 'livestream'], + ['2026-10-02T17:30:00-07:00', 'ended'], + ['2026-10-02T17:30:00.001-07:00', 'ended'], + ] as const)('returns %s as %s', (time, phase) => { + expect(getSelect26PromotionPhase(new Date(time).getTime())).toBe(phase) }) +}) - it('is inactive at the campaign expiry', () => { - expect(isSelect26PromotionActive(new Date('2026-10-03T00:00:00-07:00').getTime())).toBe(false) - }) +describe('useSelect26PromotionPhase', () => { + it('updates an open page at both boundaries', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-10-02T07:59:59.999-07:00')) + const { result } = renderHook(() => useSelect26PromotionPhase()) + + expect(result.current).toBe('waitlist') + + act(() => vi.advanceTimersByTime(1)) + expect(result.current).toBe('livestream') - it('is inactive after the campaign expiry', () => { - expect(isSelect26PromotionActive(new Date('2026-10-03T00:00:00.001-07:00').getTime())).toBe( - false - ) + act(() => vi.advanceTimersByTime(9.5 * 60 * 60 * 1000)) + expect(result.current).toBe('ended') }) - it('becomes inactive when an open page reaches the expiry', () => { + it('refreshes after a background tab resumes', () => { vi.useFakeTimers() - vi.setSystemTime(new Date('2026-10-02T23:59:59.999-07:00')) - const { result } = renderHook(() => useSelect26PromotionActive()) - - expect(result.current).toBe(true) + vi.setSystemTime(new Date('2026-10-02T07:00:00-07:00')) + const { result } = renderHook(() => useSelect26PromotionPhase()) - act(() => vi.advanceTimersByTime(1)) + vi.setSystemTime(new Date('2026-10-02T08:30:00-07:00')) + act(() => document.dispatchEvent(new Event('visibilitychange'))) + expect(result.current).toBe('livestream') - expect(result.current).toBe(false) + vi.setSystemTime(new Date('2026-10-02T17:31:00-07:00')) + act(() => document.dispatchEvent(new Event('visibilitychange'))) + expect(result.current).toBe('ended') }) }) diff --git a/packages/ui-patterns/src/Banners/Select26Promotion.tsx b/packages/ui-patterns/src/Banners/Select26Promotion.tsx index 6e3ed2a5e74..4acbb008942 100644 --- a/packages/ui-patterns/src/Banners/Select26Promotion.tsx +++ b/packages/ui-patterns/src/Banners/Select26Promotion.tsx @@ -13,10 +13,18 @@ export const SELECT_26_MESSAGE = 'Supabase Select 2026 is coming October 2' export const SELECT_26_DESCRIPTION = 'A curated day of talks by the industry’s best builders. Join us on October 2nd in San Francisco.' export const SELECT_26_CTA = 'Apply to attend' -export const SELECT_26_EXPIRY = '2026-10-03T00:00:00-07:00' +export const SELECT_26_LIVESTREAM_DESCRIPTION = + 'Keynote, main stage, and build stage, streamed all day.' +export const SELECT_26_LIVESTREAM_CTA = 'Watch the livestream' +export const SELECT_26_LIVESTREAM_STUDIO_CTA = 'Watch livestream' +export const SELECT_26_LIVESTREAM_START = '2026-10-02T08:00:00-07:00' +export const SELECT_26_EXPIRY = '2026-10-02T17:30:00-07:00' export const SELECT_26_WWW_DISMISSAL_KEY = 'announcement_select_26_08' export const SELECT_26_STUDIO_DISMISSAL_KEY = 'select-2026-promotion-dismissed' +export const SELECT_26_LIVESTREAM_WWW_DISMISSAL_KEY = 'announcement_select_26_livestream' +export const SELECT_26_LIVESTREAM_STUDIO_DISMISSAL_KEY = 'select-2026-livestream-dismissed' +const SELECT_26_LIVESTREAM_START_MS = new Date(SELECT_26_LIVESTREAM_START).getTime() const SELECT_26_EXPIRY_MS = new Date(SELECT_26_EXPIRY).getTime() const MAX_TIMEOUT_MS = 2_147_483_647 @@ -36,27 +44,47 @@ const FRAME_INTERVAL_MS = 70 const positiveModulo = (value: number, modulo: number) => ((value % modulo) + modulo) % modulo -export const isSelect26PromotionActive = (now = Date.now()) => now < SELECT_26_EXPIRY_MS +export type Select26PromotionPhase = 'waitlist' | 'livestream' | 'ended' -export const useSelect26PromotionActive = () => { - const [isActive, setIsActive] = useState(() => isSelect26PromotionActive()) +export const getSelect26PromotionPhase = (now = Date.now()): Select26PromotionPhase => { + if (now < SELECT_26_LIVESTREAM_START_MS) return 'waitlist' + if (now < SELECT_26_EXPIRY_MS) return 'livestream' + return 'ended' +} + +export const useSelect26PromotionPhase = () => { + const [phase, setPhase] = useState(() => getSelect26PromotionPhase()) useEffect(() => { - if (!isActive) return let timeoutId: ReturnType | undefined - const armExpiryTimer = () => { - const remainingMs = SELECT_26_EXPIRY_MS - Date.now() - if (remainingMs <= 0) { - setIsActive(false) - return + const refreshPhase = () => { + clearTimeout(timeoutId) + const now = Date.now() + const currentPhase = getSelect26PromotionPhase(now) + setPhase(currentPhase) + let nextBoundary: number | null = null + if (currentPhase === 'waitlist') nextBoundary = SELECT_26_LIVESTREAM_START_MS + if (currentPhase === 'livestream') nextBoundary = SELECT_26_EXPIRY_MS + if (nextBoundary !== null) { + timeoutId = setTimeout(refreshPhase, Math.min(nextBoundary - now, MAX_TIMEOUT_MS)) } - timeoutId = setTimeout(armExpiryTimer, Math.min(remainingMs, MAX_TIMEOUT_MS)) } - armExpiryTimer() - return () => clearTimeout(timeoutId) - }, [isActive]) + const onVisibilityChange = () => { + if (!document.hidden) refreshPhase() + } + refreshPhase() + document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('focus', refreshPhase) + window.addEventListener('pageshow', refreshPhase) + return () => { + clearTimeout(timeoutId) + document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('focus', refreshPhase) + window.removeEventListener('pageshow', refreshPhase) + } + }, []) - return isActive + return phase } type FieldCell = {