+
{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 = {