mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
1 parent
07c75e84fc
commit
ed692f6905
8 files changed
+221
-57
No files matched your search
@@ -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(<AnnouncementBanner />)
|
||||
|
||||
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(<AnnouncementBanner />)
|
||||
|
||||
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(<AnnouncementBanner />)
|
||||
|
||||
@@ -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 (
|
||||
<Announcement show announcementKey={SELECT_26_WWW_DISMISSAL_KEY}>
|
||||
<Select26Banner />
|
||||
<Announcement key={dismissalKey} show announcementKey={dismissalKey}>
|
||||
<Select26Banner phase={phase} />
|
||||
</Announcement>
|
||||
)
|
||||
}
|
||||
@@ -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<Select26PromotionPhase, 'ended'> }) => (
|
||||
<div className="relative isolate flex min-h-14 w-full items-center overflow-hidden border-b border-[#00482f]/15 bg-[#f8f3ef] px-4 py-2.5 pr-12 text-base font-medium text-[#001a10] dark:border-white/10 dark:bg-[#0b0e0d] dark:text-[#f8f3ef] sm:px-6 sm:pr-14">
|
||||
<Select26Field
|
||||
cols={WWW_FIELD_BASE_COLS}
|
||||
@@ -26,10 +33,10 @@ export const Select26Banner = () => (
|
||||
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]"
|
||||
/>
|
||||
<div className="relative z-10 ml-0 mr-auto flex w-fit max-w-full flex-wrap items-center justify-start gap-x-2 gap-y-1.5 rounded-md bg-[#f8f3ef] px-2.5 py-1 sm:mx-auto sm:flex-nowrap sm:justify-center sm:gap-x-2.5 sm:px-3.5 dark:bg-[#0b0e0d]">
|
||||
<div className="relative z-10 ml-0 mr-auto flex w-fit max-w-full flex-wrap items-center justify-start gap-x-2 rounded-md bg-[#f8f3ef] px-2.5 py-1 sm:mx-auto sm:flex-nowrap sm:justify-center sm:gap-x-2.5 sm:px-3.5 dark:bg-[#0b0e0d]">
|
||||
<p className="text-left leading-5 sm:text-center">
|
||||
{SELECT_26_TITLE}
|
||||
<span className="hidden sm:inline"> is coming October 2</span>
|
||||
{phase === 'waitlist' && <span className="hidden sm:inline"> is coming October 2</span>}
|
||||
</p>
|
||||
<span aria-hidden className="text-[#00482f]/20 dark:text-white/15">
|
||||
▪
|
||||
@@ -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}
|
||||
<ArrowUpRight className="size-4 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5 motion-reduce:transform-none" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<Select26PromotionPhase>(() => getSelect26PromotionPhase())
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
let timeoutId: ReturnType<typeof setTimeout> | 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 = {
|
||||
|
||||
Reference in new issue
Block a user