diff --git a/static/js/managers/BannerService.js b/static/js/managers/BannerService.js index 2261e267..ce8022b7 100644 --- a/static/js/managers/BannerService.js +++ b/static/js/managers/BannerService.js @@ -12,7 +12,6 @@ const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version'; // Increment this version to reset the banner schedule after significant updates const COMMUNITY_SUPPORT_STATE_VERSION = 'v2'; -const COMMUNITY_SUPPORT_SHOWN_KEY_LEGACY = 'community_support_banner_shown'; const KO_FI_URL = 'https://ko-fi.com/pixelpawsai'; const AFDIAN_URL = 'https://afdian.com/a/pixelpawsai'; const BANNER_HISTORY_KEY = 'banner_history'; @@ -28,8 +27,6 @@ class BannerService { this.banners = new Map(); this.container = null; this.initialized = false; - this.communitySupportBannerTimer = null; - this.communitySupportBannerRegistered = false; this.recentHistory = this.loadBannerHistory(); this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt(); @@ -114,7 +111,9 @@ class BannerService { */ dismissBanner(bannerId) { const dismissedBanners = getStorageItem('dismissed_banners', []); - if (!dismissedBanners.includes(bannerId)) { + let bannerAlreadyDismissed = dismissedBanners.includes(bannerId); + + if (!bannerAlreadyDismissed) { dismissedBanners.push(bannerId); setStorageItem('dismissed_banners', dismissedBanners); } @@ -135,7 +134,9 @@ class BannerService { }, 300); } - this.markBannerDismissed(bannerId); + if (!bannerAlreadyDismissed) { + this.markBannerDismissed(bannerId); + } } /** @@ -232,11 +233,6 @@ class BannerService { } prepareCommunitySupportBanner() { - if (this.communitySupportBannerTimer) { - clearTimeout(this.communitySupportBannerTimer); - this.communitySupportBannerTimer = null; - } - if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) { return; } @@ -250,29 +246,17 @@ class BannerService { } const availableAt = firstSeenAt + COMMUNITY_SUPPORT_BANNER_DELAY_MS; - const delay = Math.max(availableAt - now, 0); - - if (delay === 0) { + + if (now >= availableAt) { this.registerCommunitySupportBanner(); - } else { - this.communitySupportBannerTimer = setTimeout(() => { - this.registerCommunitySupportBanner(); - }, delay); } } registerCommunitySupportBanner() { - if (this.communitySupportBannerRegistered || this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) { + if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) { return; } - if (this.communitySupportBannerTimer) { - clearTimeout(this.communitySupportBannerTimer); - this.communitySupportBannerTimer = null; - } - - this.communitySupportBannerRegistered = true; - // Determine support URL based on user language const currentLanguage = state.global.settings.language; const supportUrl = currentLanguage === 'zh-CN' ? AFDIAN_URL : KO_FI_URL; @@ -330,7 +314,6 @@ class BannerService { setStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, COMMUNITY_SUPPORT_STATE_VERSION); setStorageItem(COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY, Date.now()); - removeStorageItem(COMMUNITY_SUPPORT_SHOWN_KEY_LEGACY); } loadBannerHistory() { diff --git a/tests/frontend/managers/BannerService.test.js b/tests/frontend/managers/BannerService.test.js new file mode 100644 index 00000000..94956dad --- /dev/null +++ b/tests/frontend/managers/BannerService.test.js @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { bannerService } from '../../../static/js/managers/BannerService.js'; +import * as storageHelpers from '../../../static/js/utils/storageHelpers.js'; +import * as i18nHelpers from '../../../static/js/utils/i18nHelpers.js'; +import { state } from '../../../static/js/state/index.js'; + +// Mock storage helpers +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + getStorageItem: vi.fn(), + setStorageItem: vi.fn(), + removeStorageItem: vi.fn() +})); + +// Mock i18n helpers +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: vi.fn((key, params, defaultValue) => defaultValue || key) +})); + +// Mock state +vi.mock('../../../static/js/state/index.js', () => ({ + state: { + global: { + settings: { + language: 'en' + } + } + } +})); + +describe('BannerService', () => { + beforeEach(() => { + // Clear all mocks + vi.clearAllMocks(); + + // Reset banner service state + bannerService.banners.clear(); + bannerService.initialized = false; + + // Clear DOM + document.body.innerHTML = ''; + }); + + describe('Community Support Banner', () => { + const COMMUNITY_SUPPORT_BANNER_ID = 'community-support'; + const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at'; + const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version'; + + beforeEach(() => { + // Mock the version check to avoid resetting state + storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => { + if (key === COMMUNITY_SUPPORT_VERSION_KEY) { + return 'v2'; // Current version + } + return defaultValue; + }); + + // Initialize the banner service + bannerService.initializeCommunitySupportState(); + }); + + it('should not show community support banner before 5 days have passed', () => { + const now = Date.now(); + const firstSeenAt = now - (3 * 24 * 60 * 60 * 1000); // 3 days ago + + storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => { + if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) { + return firstSeenAt; + } + if (key === COMMUNITY_SUPPORT_VERSION_KEY) { + return 'v2'; + } + if (key === 'dismissed_banners') { + return []; + } + return defaultValue; + }); + + // Mock Date.now to control time + const originalNow = Date.now; + global.Date.now = vi.fn(() => now); + + try { + bannerService.prepareCommunitySupportBanner(); + + // Banner should not be registered yet + expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(false); + } finally { + global.Date.now = originalNow; + } + }); + + it('should show community support banner after 5 days have passed', () => { + const now = Date.now(); + const firstSeenAt = now - (6 * 24 * 60 * 60 * 1000); // 6 days ago + + storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => { + if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) { + return firstSeenAt; + } + if (key === COMMUNITY_SUPPORT_VERSION_KEY) { + return 'v2'; + } + if (key === 'dismissed_banners') { + return []; + } + return defaultValue; + }); + + // Mock Date.now to control time + const originalNow = Date.now; + global.Date.now = vi.fn(() => now); + + try { + bannerService.prepareCommunitySupportBanner(); + + // Banner should be registered + expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(true); + } finally { + global.Date.now = originalNow; + } + }); + + it('should not show community support banner if it has been dismissed', () => { + const now = Date.now(); + const firstSeenAt = now - (6 * 24 * 60 * 60 * 1000); // 6 days ago + + storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => { + if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) { + return firstSeenAt; + } + if (key === COMMUNITY_SUPPORT_VERSION_KEY) { + return 'v2'; + } + if (key === 'dismissed_banners') { + return [COMMUNITY_SUPPORT_BANNER_ID]; // Dismissed + } + return defaultValue; + }); + + // Mock Date.now to control time + const originalNow = Date.now; + global.Date.now = vi.fn(() => now); + + try { + bannerService.prepareCommunitySupportBanner(); + + // Banner should not be registered because it's dismissed + expect(bannerService.banners.has(COMMUNITY_SUPPORT_BANNER_ID)).toBe(false); + } finally { + global.Date.now = originalNow; + } + }); + + it('should set first seen time if not already set', () => { + const now = Date.now(); + + storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => { + if (key === COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY) { + return null; // Not set + } + if (key === COMMUNITY_SUPPORT_VERSION_KEY) { + return 'v2'; + } + if (key === 'dismissed_banners') { + return []; + } + return defaultValue; + }); + + // Mock Date.now to control time + const originalNow = Date.now; + global.Date.now = vi.fn(() => now); + + try { + bannerService.prepareCommunitySupportBanner(); + + // Should have set the first seen time + expect(storageHelpers.setStorageItem).toHaveBeenCalledWith( + COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY, + now + ); + } finally { + global.Date.now = originalNow; + } + }); + }); + + describe('Banner Dismissal', () => { + it('should add banner to dismissed_banners array when dismissed', () => { + storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => { + if (key === 'dismissed_banners') { + return []; + } + return defaultValue; + }); + + bannerService.dismissBanner('test-banner'); + + expect(storageHelpers.setStorageItem).toHaveBeenCalledWith( + 'dismissed_banners', + ['test-banner'] + ); + }); + + it('should not add duplicate banner IDs to dismissed_banners array', () => { + storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => { + if (key === 'dismissed_banners') { + return ['test-banner']; + } + return defaultValue; + }); + + bannerService.dismissBanner('test-banner'); + + // Should not have been called again since it's already dismissed + expect(storageHelpers.setStorageItem).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file