From ed692f690543893c2e41d09c6d240bd63120d0c7 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:09:12 +1000 Subject: [PATCH] feat(select): schedule livestream banners (#51006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The Select banners on www and Studio still promote attendance. They need to keep serving the waitlist until the event, promote the livestream during the requested window, and disappear after the event. ## Solution Use a shared three-phase clock: existing promotion before 8:00am PT on 2 October, livestream promotion from 8:00am to 5:30pm PT, and no banner afterwards. Both livestream CTAs lead to `select.supabase.com`, where viewers can choose a stage. Separate dismissal keys let someone who dismissed the earlier promotion see the livestream. Open pages set a timer for the next boundary and recheck on visibility, focus, or page restore. | `www` | | --- | | CleanShot 2026-09-29 at 16 27
38@2x | | _Until Friday 8am PT_ | | CleanShot 2026-09-29 at 16 28
06@2x | | _Friday 8am to 5:30pm PT_ | | `studio` | | --- | | CleanShot 2026-09-29 at 16 26
51@2x | | _Until Friday 8am PT_ | | CleanShot 2026-09-29 at 16 27
09@2x | | _Friday 8am to 5:30pm PT_ | ## Review instructions 1. Open the [www homepage](https://zone-www-dot-com-git-dnywh-select-2026-livestre-93dab1-supabase.vercel.app/) and a [hosted Studio dashboard](https://studio-staging-git-dnywh-select-2026-livestream-05b822-supabase.vercel.app/) in separate tabs. If the Privacy Policy update card is in front in Studio, close it (only) to reveal the Select banner. 2. In each tab's DevTools console, paste this helper: ```js window.selectRealNow = Date.now window.showSelectAt = (iso) => { Date.now = () => new Date(iso).getTime() window.dispatchEvent(new Event('focus')) } ``` 3. Run `showSelectAt('2026-10-02T07:59:00-07:00')`: both banners keep the existing “Apply to attend” CTA. 4. Run `showSelectAt('2026-10-02T08:00:00-07:00')`: www shows “Supabase Select 2026” and “Watch the livestream”; Studio shows the same title, the description “Keynote, main stage, and build stage, streamed all day.” and “Watch livestream”. Both CTAs link to `https://select.supabase.com/`. 5. Run `showSelectAt('2026-10-02T17:30:00-07:00')`: neither banner is visible. Restore the clock with `Date.now = window.selectRealNow; window.dispatchEvent(new Event('focus'))`. The console clock override affects only the current tab and is lost on reload. ## Summary by CodeRabbit - **New Features** - The Select 2026 banner switches from waitlist messaging to livestream details and a “Watch livestream” link during the livestream period. - Livestream banner dismissals are tracked separately from waitlist banner dismissals. - Promotion banners end at 5:30 p.m. Pacific on October 2, 2026, and update when the promotion changes phase, including after returning to an open tab. --- .../interfaces/App/AppBannerWrapper.tsx | 23 ++++--- .../Banners/BannerSelect2026.test.tsx | 60 +++++++++++++++++++ .../BannerStack/Banners/BannerSelect2026.tsx | 20 +++++-- .../src/Banners/AnnouncementBanner.test.tsx | 31 +++++++++- .../src/Banners/AnnouncementBanner.tsx | 17 ++++-- .../src/Banners/Select26Banner.tsx | 17 ++++-- .../src/Banners/Select26Promotion.test.ts | 52 +++++++++------- .../src/Banners/Select26Promotion.tsx | 58 +++++++++++++----- 8 files changed, 221 insertions(+), 57 deletions(-) create mode 100644 apps/studio/components/ui/BannerStack/Banners/BannerSelect2026.test.tsx 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 = {