diff --git a/src/api/ebbApi/__tests__/notificationApi.test.ts b/src/api/ebbApi/__tests__/notificationApi.test.ts new file mode 100644 index 00000000..2ca13360 --- /dev/null +++ b/src/api/ebbApi/__tests__/notificationApi.test.ts @@ -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() + }) + }) + }) +}) diff --git a/src/api/ebbApi/notificationApi.ts b/src/api/ebbApi/notificationApi.ts index c8dbce40..85ff922a 100644 --- a/src/api/ebbApi/notificationApi.ts +++ b/src/api/ebbApi/notificationApi.ts @@ -1,4 +1,6 @@ import { NotificationRepo, Notification, CreateNotificationSchema } from '@/db/ebb/notificationRepo' +import { License } from './licenseApi' +import { DateTime } from 'luxon' const getLatestActiveNotifications = async (): Promise => { const [notification] = await NotificationRepo.getAppNotifications({ dismissed: false }) @@ -24,11 +26,76 @@ const getNotificationBySentId = async (sentId: string): Promise { + 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 diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 89a1b0b0..c8568834 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 (
diff --git a/src/components/NotificationBanner.tsx b/src/components/NotificationBanner.tsx index d8ba9fa2..eb1b0a0f 100644 --- a/src/components/NotificationBanner.tsx +++ b/src/components/NotificationBanner.tsx @@ -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 @@ -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 => { @@ -22,12 +34,23 @@ export const NotificationBanner: React.FC = () => { const actionMap: Record = { '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()) } } diff --git a/src/db/ebb/notificationRepo.ts b/src/db/ebb/notificationRepo.ts index 1274441e..0adb51c8 100644 --- a/src/db/ebb/notificationRepo.ts +++ b/src/db/ebb/notificationRepo.ts @@ -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 diff --git a/src/hooks/useLicenseExpirationNotification.ts b/src/hooks/useLicenseExpirationNotification.ts new file mode 100644 index 00000000..c666e66b --- /dev/null +++ b/src/hooks/useLicenseExpirationNotification.ts @@ -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]) +}