+
{
+ t.mock.timers.enable({
+ apis: ['Date', 'setInterval'],
+ now: new Date('2026-11-30T23:59:58Z'),
+ })
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ t.after(() => disposePinia(pinia))
+ const promotion = useBlackFridayStore()
+ const offerVisible = computed(() => promotion.active)
+ const links = ['pro', 'enterprise'].map((product) =>
+ computed(
+ () =>
+ new URL(
+ getBlackFridayCheckoutUrl(
+ new URL(`https://store.lemonsqueezy.com/buy/${product}`),
+ 'free',
+ promotion.now,
+ ),
+ ),
+ ),
+ )
+
+ assert.equal(offerVisible.value, true)
+ for (const link of links) {
+ assert.equal(
+ link.value.searchParams.get('checkout[discount_code]'),
+ 'BF2026',
+ )
+ }
+ t.mock.timers.tick(1000)
+ assert.equal(promotion.countdown, '0 Days 00:00:01')
+ t.mock.timers.tick(1000)
+ assert.equal(promotion.countdown, null)
+ assert.equal(offerVisible.value, false)
+ for (const link of links) {
+ assert.equal(link.value.searchParams.has('checkout[discount_code]'), false)
+ }
+ const expiredAt = promotion.now
+ t.mock.timers.tick(10_000)
+ assert.equal(promotion.now, expiredAt)
+})
+
+test('stops the shared clock when the store is disposed', (t) => {
+ t.mock.timers.enable({
+ apis: ['Date', 'setInterval'],
+ now: new Date('2026-11-30T23:59:58Z'),
+ })
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ t.after(() => disposePinia(pinia))
+ const promotion = useBlackFridayStore()
+ const initialTime = promotion.now
+
+ promotion.$dispose()
+ t.mock.timers.tick(2000)
+
+ assert.equal(promotion.now, initialTime)
+})
diff --git a/web/app/stores/blackFriday.ts b/web/app/stores/blackFriday.ts
new file mode 100644
index 000000000..b93325c41
--- /dev/null
+++ b/web/app/stores/blackFriday.ts
@@ -0,0 +1,24 @@
+import { defineStore } from 'pinia'
+import { computed, onScopeDispose, ref } from 'vue'
+import {
+ BLACK_FRIDAY_END,
+ getBlackFridayCountdown,
+} from '../utils/black-friday'
+
+export const useBlackFridayStore = defineStore('blackFriday', () => {
+ const now = ref(Date.now())
+ const active = computed(() => now.value < BLACK_FRIDAY_END)
+ const countdown = computed(() => getBlackFridayCountdown(now.value))
+ let timer: ReturnType | undefined
+
+ if (active.value) {
+ timer = setInterval(() => {
+ now.value = Date.now()
+ if (!active.value) clearInterval(timer)
+ }, 1000)
+ }
+
+ onScopeDispose(() => clearInterval(timer))
+
+ return { now, active, countdown }
+})
diff --git a/web/app/utils/black-friday.test.ts b/web/app/utils/black-friday.test.ts
new file mode 100644
index 000000000..1fe5f6da0
--- /dev/null
+++ b/web/app/utils/black-friday.test.ts
@@ -0,0 +1,112 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import {
+ getBlackFridayCheckoutUrl,
+ getBlackFridayCountdown,
+ isBlackFridayEligible,
+} from './black-friday'
+
+test('counts down to the UTC deadline regardless of the input timezone', () => {
+ for (const now of [
+ '2026-11-29T22:57:56Z',
+ '2026-11-30T00:57:56+02:00',
+ '2026-11-29T17:57:56-05:00',
+ ]) {
+ assert.equal(getBlackFridayCountdown(Date.parse(now)), '1 Day 01:02:04')
+ }
+})
+
+test('spells out plural days in the countdown', () => {
+ assert.equal(
+ getBlackFridayCountdown(Date.parse('2026-11-15T12:00:00Z')),
+ '15 Days 12:00:00',
+ )
+})
+
+test('keeps the final partial second visible and expires exactly at the deadline', () => {
+ assert.equal(
+ getBlackFridayCountdown(Date.parse('2026-11-30T23:59:59.500Z')),
+ '0 Days 00:00:01',
+ )
+ assert.equal(
+ getBlackFridayCountdown(Date.parse('2026-12-01T00:00:00Z')),
+ null,
+ )
+ assert.equal(
+ getBlackFridayCountdown(Date.parse('2026-12-02T00:00:00Z')),
+ null,
+ )
+})
+
+test('shows the landing promotion only after confirming the visitor is signed out', () => {
+ assert.equal(isBlackFridayEligible('index', true, false), true)
+ assert.equal(isBlackFridayEligible('index', false, false), false)
+ assert.equal(isBlackFridayEligible('index', true, true, 'free'), false)
+ assert.equal(isBlackFridayEligible('blog', true, false), false)
+})
+
+test('shows the promotion across app pages only for confirmed signed-in free users', () => {
+ for (const page of [
+ 'billing',
+ 'threads',
+ 'threads-id',
+ 'settings',
+ 'bulk-messages',
+ 'search-messages',
+ 'phone-api-keys',
+ 'heartbeats-id',
+ ]) {
+ assert.equal(isBlackFridayEligible(page, true, true, 'free'), true)
+ assert.equal(isBlackFridayEligible(page, true, true), false)
+ assert.equal(isBlackFridayEligible(page, true, false, 'free'), false)
+ assert.equal(isBlackFridayEligible(page, false, true, 'free'), false)
+ for (const plan of [
+ 'pro-monthly',
+ 'pro-yearly',
+ 'pro-lifetime',
+ 'unknown',
+ ]) {
+ assert.equal(isBlackFridayEligible(page, true, true, plan), false)
+ }
+ }
+})
+
+test('prefills the active promotion at checkout without losing customer details', () => {
+ const url = new URL('https://store.lemonsqueezy.com/buy/pro?embed=1')
+ url.searchParams.set('checkout[custom][user_id]', 'test-user')
+ url.searchParams.set('checkout[email]', 'buyer@example.test')
+ const checkout = new URL(
+ getBlackFridayCheckoutUrl(url, 'free', Date.parse('2026-11-30T23:59:59Z')),
+ )
+
+ assert.equal(checkout.searchParams.get('checkout[discount_code]'), 'BF2026')
+ assert.equal(
+ checkout.searchParams.get('checkout[custom][user_id]'),
+ 'test-user',
+ )
+ assert.equal(
+ checkout.searchParams.get('checkout[email]'),
+ 'buyer@example.test',
+ )
+ assert.equal(checkout.searchParams.get('embed'), '1')
+})
+
+test('does not prefill the promotion for paid or unconfirmed users', () => {
+ for (const plan of [undefined, 'pro-yearly', 'pro-lifetime', 'unknown']) {
+ const url = new URL('https://store.lemonsqueezy.com/buy/pro')
+ assert.equal(
+ getBlackFridayCheckoutUrl(url, plan, Date.parse('2026-11-15T12:00:00Z')),
+ 'https://store.lemonsqueezy.com/buy/pro',
+ )
+ }
+})
+
+test('stops prefilling the discount at the UTC promotion deadline', () => {
+ const url = new URL('https://store.lemonsqueezy.com/buy/pro')
+ for (const now of ['2026-12-01T00:00:00Z', '2026-12-02T00:00:00Z']) {
+ assert.equal(
+ getBlackFridayCheckoutUrl(url, 'free', Date.parse(now)),
+ 'https://store.lemonsqueezy.com/buy/pro',
+ )
+ }
+})
diff --git a/web/app/utils/black-friday.ts b/web/app/utils/black-friday.ts
new file mode 100644
index 000000000..ea837a604
--- /dev/null
+++ b/web/app/utils/black-friday.ts
@@ -0,0 +1,36 @@
+export const BLACK_FRIDAY_END = Date.parse('2026-12-01T00:00:00Z')
+
+export function getBlackFridayCountdown(now: number): string | null {
+ const seconds = Math.ceil((BLACK_FRIDAY_END - now) / 1000)
+ if (seconds <= 0) return null
+
+ const days = Math.floor(seconds / 86400)
+ const hours = Math.floor((seconds % 86400) / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const time = [hours, minutes, seconds % 60]
+ .map((value) => String(value).padStart(2, '0'))
+ .join(':')
+ return `${days} ${days === 1 ? 'Day' : 'Days'} ${time}`
+}
+
+export function isBlackFridayEligible(
+ page: string,
+ authReady: boolean,
+ signedIn: boolean,
+ subscriptionName?: string,
+): boolean {
+ if (!authReady) return false
+ if (page === 'index') return !signedIn
+ return signedIn && subscriptionName === 'free'
+}
+
+export function getBlackFridayCheckoutUrl(
+ url: URL,
+ subscriptionName?: string,
+ now = Date.now(),
+): string {
+ if (subscriptionName === 'free' && now < BLACK_FRIDAY_END) {
+ url.searchParams.set('checkout[discount_code]', 'BF2026')
+ }
+ return url.toString()
+}