Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions web/app/components/BlackFridayBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { mdiChevronRight, mdiClose } from '@mdi/js'
import { BLACK_FRIDAY_END, isBlackFridayEligible } from '~/utils/black-friday'

const route = useRoute()
const authStore = useAuthStore()
const promotion = useBlackFridayStore()
const dismissed = useCookie<boolean>('httpsms_bf2026_dismissed', {
default: () => false,
expires: new Date(BLACK_FRIDAY_END),
sameSite: 'lax',
path: '/',
})
const visible = computed(
() =>
!dismissed.value &&
promotion.active &&
isBlackFridayEligible(
String(route.name),
authStore.authStateChanged,
authStore.authUser !== null,
authStore.user?.subscription_name,
),
)
const bannerElement = useTemplateRef<HTMLElement>('bannerElement')
const bannerHeight = ref(0)
const contentColumns = computed(() => {
switch (route.name) {
case 'settings':
case 'billing':
case 'phone-api-keys':
return { md: 9, offsetMd: 1, xl: 8, offsetXl: 2 }
case 'bulk-messages':
return { md: 10, offsetMd: 1, xxl: 8, offsetXxl: 2 }
case 'messages':
return { md: 8, offsetMd: 2, xl: 6, offsetXl: 3 }
default:
return {}
}
})
const hasPagePadding = computed(() =>
['settings', 'messages'].includes(String(route.name)),
)
let resizeObserver: ResizeObserver | undefined

watch(bannerElement, (element) => {
resizeObserver?.disconnect()
if (!element) return

const updateHeight = () => {
bannerHeight.value = element.getBoundingClientRect().height
}
updateHeight()
resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(element)
})

onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>

<template>
<v-layout-item
v-if="visible"
position="top"
:order="-1"
:size="bannerHeight"
model-value
>
<div ref="bannerElement">
<v-toolbar
class="black-friday-banner"
color="transparent"
tag="aside"
aria-label="Black Friday promotion"
flat
>
<v-container fluid class="py-0" :class="{ 'px-0': !hasPagePadding }">
<v-container :class="route.name === 'settings' ? 'pa-0' : 'py-0'">
<v-row class="my-0">
<v-col cols="12" v-bind="contentColumns" class="py-3">
<div class="d-flex align-center ga-3">
<div
class="black-friday-banner-offer d-flex align-center ga-3"
>
<strong class="text-warning text-uppercase"
>Black Friday</strong
>
<span>
Get <strong>40% off</strong> the yearly plans with
discount code
<strong class="text-warning">BF2026</strong> at checkout.
</span>
<v-btn
to="/billing"
class="d-none d-sm-inline-flex"
color="warning"
size="small"
:append-icon="mdiChevronRight"
>
Use now
</v-btn>
</div>
<div
class="black-friday-banner-timer d-none d-sm-block text-right"
>
<strong
class="text-no-wrap"
role="timer"
aria-label="Time remaining"
aria-live="off"
>
{{ promotion.countdown }}
</strong>
</div>
<v-btn
:icon="mdiClose"
class="align-self-start align-self-sm-center flex-shrink-0"
color="error"
density="compact"
size="small"
variant="text"
aria-label="Dismiss Black Friday promotion"
@click="dismissed = true"
/>
</div>
</v-col>
</v-row>
</v-container>
</v-container>
</v-toolbar>
</div>
</v-layout-item>
</template>

<style scoped>
.black-friday-banner {
background: linear-gradient(110deg, #090909 40%, #10545b);
}

.black-friday-banner :deep([class~='v-toolbar__content']) {
height: auto !important;
min-height: 64px;
}

.black-friday-banner-offer {
flex: 1;
flex-wrap: wrap;
min-width: 0;
}

.black-friday-banner-timer {
font-variant-numeric: tabular-nums;
}
</style>
5 changes: 3 additions & 2 deletions web/app/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ function startPoller() {

<template>
<v-app>
<BlackFridayBanner />
<v-navigation-drawer v-if="lgAndUp && hasDrawer" :width="400" permanent>
<template #prepend>
<template #default>
<div class="message-thread-sidebar d-flex flex-column">
<div v-if="appStore.isLocal" class="py-2 bg-warning" />
<MessageThreadHeader />
Expand Down Expand Up @@ -99,7 +100,7 @@ function startPoller() {
}
}
.message-thread-sidebar {
height: 100dvh;
height: 100%;
}
.v-navigation-drawer__message-thread {
flex: 1;
Expand Down
28 changes: 25 additions & 3 deletions web/app/pages/billing/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {

import { countries, getStateOptions } from '~/utils/countries'
import { getApiErrorMessage } from '~/utils/api-error'
import { getBlackFridayCheckoutUrl } from '~/utils/black-friday'

type SubscriptionPayment =
ResponsesUserSubscriptionPaymentsResponse['data'][number]
Expand All @@ -30,6 +31,7 @@ useHead({
const config = useRuntimeConfig()
const { lgAndUp } = useVDisplay()
const authStore = useAuthStore()
const promotion = useBlackFridayStore()
const billingStore = useBillingStore()
const notificationsStore = useNotificationsStore()
const { formatDecimal, formatTimestamp } = useFilters()
Expand Down Expand Up @@ -127,6 +129,9 @@ const plan = computed<PaymentPlan>(() => {
})

const isOnFreePlan = computed(() => plan.value.id === 'free')
const blackFridayDiscountAvailable = computed(
() => authStore.user?.subscription_name === 'free' && promotion.active,
)
Comment thread
AchoArnold marked this conversation as resolved.
Resolved
const isOnLifetimePlan = computed(() => plan.value.id === 'pro-lifetime')
const subscriptionIsCancelled = computed(
() => authStore.user?.subscription_status === 'cancelled',
Expand Down Expand Up @@ -156,7 +161,11 @@ const checkoutURL = computed(() => {
url.searchParams.append('checkout[name]', user.displayName)
}
}
return url.toString()
return getBlackFridayCheckoutUrl(
url,
authStore.user?.subscription_name,
promotion.now,
)
})

const enterpriseCheckoutURL = computed(() => {
Expand All @@ -171,7 +180,11 @@ const enterpriseCheckoutURL = computed(() => {
url.searchParams.append('checkout[name]', user.displayName)
}
}
return url.toString()
return getBlackFridayCheckoutUrl(
url,
authStore.user?.subscription_name,
promotion.now,
)
})

async function loadData() {
Expand Down Expand Up @@ -421,7 +434,9 @@ onMounted(async () => {

<!-- Upgrade Plan (only for free users) -->
<template v-if="isOnFreePlan">
<h2 class="text-headline-large mt-4 mb-2">Upgrade Plan</h2>
<h2 id="upgrade-plan" class="text-headline-large mt-4 mb-2">
Upgrade Plan
</h2>
<VRow>
<VCol cols="12" md="6">
<VCard :href="checkoutURL" link>
Expand All @@ -436,6 +451,13 @@ onMounted(async () => {
<p class="text-medium-emphasis">
Send and receive 5,000 to 20,000 messages per month
</p>
<p
v-if="blackFridayDiscountAvailable"
class="mb-0 text-warning"
>
Or <strong>$60/year</strong> with code
<strong>BF2026</strong>.
</p>
</VCol>
<VCol class="flex-grow-0 flex-shrink-0 text-center">
<span class="text-headline-medium">$10</span>/month
Expand Down
1 change: 1 addition & 0 deletions web/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const planYearlyMonthlyPrice = computed(

<template>
<div>
<BlackFridayBanner />
<VContainer>
<VRow :class="{ 'py-4': lgAndUp }">
<VCol
Expand Down
66 changes: 66 additions & 0 deletions web/app/stores/blackFriday.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createPinia, disposePinia, setActivePinia } from 'pinia'
import { computed } from 'vue'
import { getBlackFridayCheckoutUrl } from '../utils/black-friday'
import { useBlackFridayStore } from './blackFriday'

test('expires the countdown, cached offer, and checkout links together on an open page', (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 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)
})
24 changes: 24 additions & 0 deletions web/app/stores/blackFriday.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | undefined

if (active.value) {
timer = setInterval(() => {
now.value = Date.now()
if (!active.value) clearInterval(timer)
}, 1000)
}

onScopeDispose(() => clearInterval(timer))

return { now, active, countdown }
})
Loading
Loading