feat(select): schedule livestream banners (#51006)

## 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` |
| --- |
| <img width="736" height="136" alt="CleanShot 2026-09-29 at 16 27
38@2x"
src="https://github.com/user-attachments/assets/1281bcf0-ece4-41fc-99d2-1f9ab6e1b3f5"
/> |
| _Until Friday 8am PT_ |
| <img width="734" height="138" alt="CleanShot 2026-09-29 at 16 28
06@2x"
src="https://github.com/user-attachments/assets/05b8779a-9961-4886-ad7d-96ba31c8b4f7"
/> |
| _Friday 8am to 5:30pm PT_ |

| `studio` |
| --- |
| <img width="612" height="454" alt="CleanShot 2026-09-29 at 16 26
51@2x"
src="https://github.com/user-attachments/assets/0d9a8bbb-c636-41dc-aa76-381196c149c2"
/> |
| _Until Friday 8am PT_ |
| <img width="616" height="440" alt="CleanShot 2026-09-29 at 16 27
09@2x"
src="https://github.com/user-attachments/assets/458afdb9-6576-4699-9419-947ca5312bcd"
/> |
| _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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-09-30 09:09:12 +10:00
1 parent 07c75e84fc
commit ed692f6905
8 files changed
+221 -57

No files matched your search

@@ -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,
])
@@ -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(<BannerSelect2026 />)
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()
})
})
@@ -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 (
<BannerCard
onDismiss={dismiss}
@@ -45,7 +57,7 @@ export const BannerSelect2026 = () => {
<div className="relative z-[2] flex flex-col gap-y-1 mb-2">
<p className="text-sm font-medium text-balance">{SELECT_26_TITLE}</p>
<p className="text-xs text-foreground-lighter text-balance dark:text-[#f8f3ef]/65">
{SELECT_26_DESCRIPTION}
{phase === 'livestream' ? SELECT_26_LIVESTREAM_DESCRIPTION : SELECT_26_DESCRIPTION}
</p>
</div>
<Button
@@ -55,7 +67,7 @@ export const BannerSelect2026 = () => {
iconRight={<ArrowUpRight size={14} strokeWidth={1.5} />}
>
<Link href={SELECT_26_URL} target="_blank" rel="noopener noreferrer" onClick={dismiss}>
{SELECT_26_CTA}
{phase === 'livestream' ? SELECT_26_LIVESTREAM_STUDIO_CTA : SELECT_26_CTA}
</Link>
</Button>
</div>