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
152 changes: 152 additions & 0 deletions src/api/ebbApi/__tests__/notificationApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest'
import { NotificationApi } from '@/api/ebbApi/notificationApi'
import { License } from '@/api/ebbApi/licenseApi'
import { DateTime } from 'luxon'

describe('NotificationApi', () => {
describe('getLicenseExpirationNotification', () => {
const currentDate = DateTime.fromISO('2024-01-15T12:00:00.000Z')

const createLicense = (
licenseType: 'perpetual' | 'subscription' | 'free_trial',
expirationDate: Date
): License => ({
id: '123',
userId: '456',
status: 'active',
licenseType,
purchaseDate: new Date(),
expirationDate,
createdAt: new Date(),
updatedAt: new Date(),
})

describe('perpetual licenses', () => {
it('should return null for perpetual licenses', () => {
const license = createLicense('perpetual', new Date('2024-01-20'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)
expect(result).toBeNull()
})
})

describe('free trial licenses', () => {
it('should return trial_expired notification when trial has expired', () => {
const license = createLicense('free_trial', new Date('2024-01-14T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toEqual({
sentId: 'trial_expired',
content: 'Your free trial has ended. Upgrade to Ebb Pro to continue.',
subType: 'warning',
})
})

it('should return trial_expired notification when trial expired exactly at current time', () => {
const license = createLicense('free_trial', new Date('2024-01-15T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toEqual({
sentId: 'trial_expired',
content: 'Your free trial has ended. Upgrade to Ebb Pro to continue.',
subType: 'warning',
})
})

it('should return trial_expiring_3_days notification when trial expires in 3 days', () => {
const license = createLicense('free_trial', new Date('2024-01-18T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toEqual({
sentId: 'trial_expiring_3_days',
content: 'Your free trial ends in less than 3 days on Jan 18, 2024. Upgrade to keep your pro features.',
subType: 'info',
})
})

it('should return trial_expiring_3_days notification when trial expires in 1 day', () => {
const license = createLicense('free_trial', new Date('2024-01-16T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toEqual({
sentId: 'trial_expiring_3_days',
content: 'Your free trial ends in less than 3 days on Jan 16, 2024. Upgrade to keep your pro features.',
subType: 'info',
})
})

it('should return null when trial expires in more than 3 days', () => {
const license = createLicense('free_trial', new Date('2024-01-19T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toBeNull()
})

it('should return null when trial expires in 10 days', () => {
const license = createLicense('free_trial', new Date('2024-01-25T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toBeNull()
})
})

describe('subscription licenses', () => {
it('should return paid_expired notification when subscription has expired', () => {
const license = createLicense('subscription', new Date('2024-01-14T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toEqual({
sentId: 'paid_expired',
content: 'Your subscription has expired. Renew to restore your pro features.',
subType: 'warning',
})
})

it('should return paid_expired notification when subscription expired exactly at current time', () => {
const license = createLicense('subscription', new Date('2024-01-15T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toEqual({
sentId: 'paid_expired',
content: 'Your subscription has expired. Renew to restore your pro features.',
subType: 'warning',
})
})

it('should return paid_expiring_3_days notification when subscription expires in 3 days', () => {
const license = createLicense('subscription', new Date('2024-01-18T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toEqual({
sentId: 'paid_expiring_3_days',
content: 'Your subscription expires in less than 3 days on Jan 18, 2024. Renew to keep your access.',
subType: 'info',
})
})

it('should return paid_expiring_3_days notification when subscription expires in 1 day', () => {
const license = createLicense('subscription', new Date('2024-01-16T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toEqual({
sentId: 'paid_expiring_3_days',
content: 'Your subscription expires in less than 3 days on Jan 16, 2024. Renew to keep your access.',
subType: 'info',
})
})

it('should return null when subscription expires in more than 3 days', () => {
const license = createLicense('subscription', new Date('2024-01-19T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toBeNull()
})

it('should return null when subscription expires in 30 days', () => {
const license = createLicense('subscription', new Date('2024-02-14T12:00:00.000Z'))
const result = NotificationApi.getLicenseExpirationNotification(license, currentDate)

expect(result).toBeNull()
})
})
})
})
67 changes: 67 additions & 0 deletions src/api/ebbApi/notificationApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { NotificationRepo, Notification, CreateNotificationSchema } from '@/db/ebb/notificationRepo'
import { License } from './licenseApi'
import { DateTime } from 'luxon'

const getLatestActiveNotifications = async (): Promise<Notification | null> => {
const [notification] = await NotificationRepo.getAppNotifications({ dismissed: false })
Expand All @@ -24,11 +26,76 @@ const getNotificationBySentId = async (sentId: string): Promise<Notification | n
return notification || null
}

type NotificationSentId = 'trial_expiring_3_days' | 'trial_expired' | 'paid_expiring_3_days' | 'paid_expired'

export interface LicenseExpirationNotificationConfig {
sentId: NotificationSentId
content: string
subType: 'warning' | 'info'
}

/**
* Determines which license expiration notification to show based on license data
* @param license - The user's license
* @param currentDate - The current date (for testing purposes)
* @returns NotificationConfig if a notification should be shown, null otherwise
*/
const getLicenseExpirationNotification = (
license: License,
currentDate: DateTime = DateTime.now()
): LicenseExpirationNotificationConfig | null => {
const expirationDate = DateTime.fromJSDate(license.expirationDate)
const daysUntilExpiration = expirationDate.diff(currentDate, 'days').days

// No notifications for perpetual licenses
if (license.licenseType === 'perpetual') {
return null
}

// License has expired
if (daysUntilExpiration <= 0) {
if (license.licenseType === 'free_trial') {
return {
sentId: 'trial_expired',
content: 'Your free trial has ended. Upgrade to Ebb Pro to continue.',
subType: 'warning',
}
} else if (license.licenseType === 'subscription') {
return {
sentId: 'paid_expired',
content: 'Your subscription has expired. Renew to restore your pro features.',
subType: 'warning',
}
}
}

// License expires in 3 days or less (but not yet expired)
if (daysUntilExpiration <= 3 && daysUntilExpiration > 0) {
if (license.licenseType === 'free_trial') {
return {
sentId: 'trial_expiring_3_days',
content: 'Your free trial ends in less than 3 days on ' + expirationDate.toFormat('MMM d, yyyy') + '. Upgrade to keep your pro features.',
subType: 'info',
}
} else if (license.licenseType === 'subscription') {
return {
sentId: 'paid_expiring_3_days',
content: 'Your subscription expires in less than 3 days on ' + expirationDate.toFormat('MMM d, yyyy') + '. Renew to keep your access.',
subType: 'info',
}
}
}

// No notification needed (more than 3 days until expiration)
return null
}

export const NotificationApi = {
getLatestActiveNotifications,
createAppNotification,
updateAppNotificationStatus,
getNotificationBySentId,
getLicenseExpirationNotification,
}

// Re-export types that components/hooks need
Expand Down
3 changes: 3 additions & 0 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { Sidebar } from './Sidebar'
import { TopNav } from './TopNav'
import { NotificationBanner } from './NotificationBanner'
import { useLicenseExpirationNotification } from '@/hooks/useLicenseExpirationNotification'

interface LayoutProps {
children: React.ReactNode
}

export function Layout({ children }: LayoutProps) {
useLicenseExpirationNotification()

return (
<div className="h-screen flex flex-col">
<div className="w-full">
Expand Down
35 changes: 29 additions & 6 deletions src/components/NotificationBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useGetActiveNotification, useUpdateNotificationStatus } from '@/api/hoo
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { AnalyticsButton } from './ui/analytics-button'
import { usePaywall } from '@/hooks/usePaywall'

interface NotificationAction {
label: string
Expand All @@ -14,6 +15,17 @@ export const NotificationBanner: React.FC = () => {
const { mutate: markAsRead } = useUpdateNotificationStatus()
const { mutate: dismiss } = useUpdateNotificationStatus()
const navigate = useNavigate()
const { openPaywall } = usePaywall()

// Helper to wrap actions with dismiss logic
const createActionWithDismiss = (actionFn: () => void): (() => void) => {
return () => {
actionFn()
if (activeNotification?.id) {
dismiss({ id: activeNotification.id, options: { dismissed: true } })
}
}
}

// Action mapping based on notification_sent_id
const getNotificationAction = (notificationSentId: string | null | undefined): NotificationAction | null => {
Expand All @@ -22,12 +34,23 @@ export const NotificationBanner: React.FC = () => {
const actionMap: Record<string, NotificationAction> = {
'focus_schedule_feature_intro': {
label: 'Try It',
action: () => {
navigate('/focus-schedule')
if (activeNotification?.id) {
dismiss({ id: activeNotification.id, options: { dismissed: true } })
}
}
action: createActionWithDismiss(() => navigate('/focus-schedule'))
},
'trial_expiring_3_days': {
label: 'Upgrade',
action: createActionWithDismiss(() => openPaywall())
},
'trial_expired': {
label: 'Upgrade',
action: createActionWithDismiss(() => openPaywall())
},
'paid_expiring_3_days': {
label: 'Renew',
action: createActionWithDismiss(() => openPaywall())
},
'paid_expired': {
label: 'Renew',
action: createActionWithDismiss(() => openPaywall())
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/db/ebb/notificationRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface NotificationSchema {
content: string
notification_type: 'app'
notification_sub_type: 'warning' | 'info'
notification_sent_id: 'firefox_not_supported' | 'focus_schedule_feature_intro'
notification_sent_id: 'firefox_not_supported' | 'focus_schedule_feature_intro' | 'trial_expiring_3_days' | 'trial_expired' | 'paid_expiring_3_days' | 'paid_expired'
read: number
dismissed: number
notification_sent_at: string
Expand Down
43 changes: 43 additions & 0 deletions src/hooks/useLicenseExpirationNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect } from 'react'
import { useLicenseWithDevices } from '@/api/hooks/useLicense'
import { useAuth } from '@/hooks/useAuth'
import { NotificationApi } from '@/api/ebbApi/notificationApi'
import { toSqlBool } from '@/lib/utils/sql.util'
import { useCreateNotification, useGetNotificationBySentId } from '@/api/hooks/useNotifications'

/**
* Hook that automatically creates license expiration notifications
* - Shows notification 3 days before expiration
* - Shows notification when license has expired
* - Different messages for trial vs paid licenses
* - Prevents duplicate notifications by checking sentId
*/
export const useLicenseExpirationNotification = () => {
const { user } = useAuth()
const { data: licenseData, isLoading: isLicenseLoading } = useLicenseWithDevices(user?.id || null)
const createNotification = useCreateNotification()

const notificationConfig = licenseData?.license
? NotificationApi.getLicenseExpirationNotification(licenseData.license)
: null

const { data: existingNotification, isLoading: isNotificationLoading } = useGetNotificationBySentId(
notificationConfig?.sentId
)

useEffect(() => {
if (isLicenseLoading || isNotificationLoading) return
if (!notificationConfig) return
if (existingNotification) return

// Create notification
createNotification.mutate({
content: notificationConfig.content,
notification_sub_type: notificationConfig.subType,
notification_sent_id: notificationConfig.sentId,
read: toSqlBool(false),
dismissed: toSqlBool(false),
notification_type: 'app',
})
}, [isLicenseLoading, isNotificationLoading, notificationConfig?.sentId, existingNotification])
}