diff --git a/src/lib/components/bottomModalAlert.svelte b/src/lib/components/bottomModalAlert.svelte new file mode 100644 index 000000000..1ad42dda7 --- /dev/null +++ b/src/lib/components/bottomModalAlert.svelte @@ -0,0 +1,384 @@ + + +{#if filteredModalAlerts.length > 0 && currentModalAlert} + {@const shouldShowUpgrade = showUpgrade()} +
+
+
+ {#key currentModalAlert.id} + + +
+ {#if $app.themeInUse === 'dark'} + {currentModalAlert.title} + {:else} + {currentModalAlert.title} + {/if} + + {#if filteredModalAlerts.length > 1} +
+ + Feature {currentIndex + 1} of {filteredModalAlerts.length} + + +
+
+
+ {/if} + +
+

{currentModalAlert.title}

+ + + {#if currentModalAlert.isHtml} + {@html currentModalAlert.message} + {:else} + {currentModalAlert.message} + {/if} + +
+ +
+ + + {#if currentModalAlert.learnMore && currentModalAlert.learnMore.link} + + {/if} +
+
+ {/key} +
+
+
+ +
+ {#if openModalOnMobile} +
+
+ {#key currentModalAlert.id} + + +
+ {#if $app.themeInUse === 'dark'} + {currentModalAlert.title} + {:else} + {currentModalAlert.title} + {/if} + + {#if filteredModalAlerts.length > 1} +
+ + Feature {currentIndex + 1} of {filteredModalAlerts.length} + + +
+
+
+ {/if} + +
+

{currentModalAlert.title}

+ + + {#if currentModalAlert.isHtml} + {@html currentModalAlert.message} + {:else} + {currentModalAlert.message} + {/if} + +
+ +
+ + + {#if currentModalAlert.learnMore && currentModalAlert.learnMore.link} + + {/if} +
+
+ {/key} +
+
+ {:else} + +
+ + + Opt-in to experiments, new features, and more. + + + + {/if} + +{/if} + + diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index be9f955e9..265d605c1 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -75,3 +75,4 @@ export { default as ModalSideCol } from './modalSideCol.svelte'; export { default as EmptyCardImageCloud } from './emptyCardImageCloud.svelte'; export { default as ImagePreview } from './imagePreview.svelte'; export { default as MfaChallengeFormList } from './mfaChallengeFormList.svelte'; +export { default as BottomModalAlert } from './bottomModalAlert.svelte'; diff --git a/src/lib/helpers/notifications.ts b/src/lib/helpers/notifications.ts new file mode 100644 index 000000000..306a80995 --- /dev/null +++ b/src/lib/helpers/notifications.ts @@ -0,0 +1,108 @@ +import { sdk } from '$lib/stores/sdk'; +import { get } from 'svelte/store'; +import { user } from '$lib/stores/user'; + +export type NotificationPrefItem = { + expiry: number; + hideCount: number; + state: 'hidden' | 'shown' | undefined; +}; + +export type NotificationCoolOffOptions = { + coolOffPeriod?: number; + exponentialBackoff?: boolean; + exponentialBackoffFactor?: number; +}; + +const userPreferences = () => get(user).prefs; + +const notificationPrefs = (): Record => { + const prefs = userPreferences(); + return prefs.notificationPrefs ? prefs.notificationPrefs : {}; +}; + +function updateNotificationPrefs(parsedPrefs: Record) { + const currentPrefs = userPreferences(); + + const newPrefs = { + ...currentPrefs, + notificationPrefs: parsedPrefs + }; + + sdk.forConsole.account.updatePrefs(newPrefs); +} + +/** + * Hides the notification banner by marking it as 'hidden' and setting an expiry time. + * Supports normal cool-off periods or exponential backoff based on the options passed. + * + * @param {string} id - The ID of the notification. + * @param {NotificationCoolOffOptions} [options] - Configuration for cool-off behavior. + * @param {number} [options.coolOffPeriod=24] - Cool-off period in hours, defaults to 24 hours. + * @param {boolean} [options.exponentialBackoff=false] - If true, the cool-off period doubles with each hide. Consider using a smaller `coolOffPeriod` when this option is enabled. + * @param {number} [options.exponentialBackoffFactor=2] - The factor by which the cool-off period is multiplied with each hide. Default is 2. + */ +export function hideNotification(id: string, options: NotificationCoolOffOptions = {}) { + const { + coolOffPeriod = 24, + exponentialBackoff = false, + exponentialBackoffFactor = 2 + } = options; + + const parsedBannerPrefs = notificationPrefs(); + + let expiryTime = Date.now() + coolOffPeriod * 3600000; + + let hideCount = parsedBannerPrefs[id]?.hideCount || 0; + + if (exponentialBackoff) { + hideCount += 1; + expiryTime = + Date.now() + coolOffPeriod * 3600000 * exponentialBackoffFactor ** (hideCount - 1); + } + + parsedBannerPrefs[id] = { + hideCount, + state: 'hidden', + expiry: expiryTime + }; + + updateNotificationPrefs(parsedBannerPrefs); +} + +/** + * Removes the notification preference for the given ID from the user's preferences. + * + * @param {string} id - The ID of the notification to remove from preferences. + */ +export function removeNotificationFromPrefs(id: string) { + const parsedBannerPrefs = notificationPrefs(); + + if (!parsedBannerPrefs[id]) return; + + delete parsedBannerPrefs[id]; + + updateNotificationPrefs(parsedBannerPrefs); +} + +/** + * Checks if the notification banner should be shown based on the expiry time. + * + * @param {string} id - The ID of the notification. + * @returns {boolean} - Returns true if the banner should be shown, false otherwise. + */ +export function shouldShowNotification(id: string): boolean { + const parsedBannerPrefs = notificationPrefs(); + + const notificationPref = parsedBannerPrefs[id]; + + if (!notificationPref) return true; + + if (Date.now() >= notificationPref.expiry) { + notificationPref.state = 'shown'; + updateNotificationPrefs(parsedBannerPrefs); + return true; + } + + return false; +} diff --git a/src/lib/stores/bottom-alerts.ts b/src/lib/stores/bottom-alerts.ts new file mode 100644 index 000000000..331057456 --- /dev/null +++ b/src/lib/stores/bottom-alerts.ts @@ -0,0 +1,43 @@ +import { writable } from 'svelte/store'; +import type { NotificationCoolOffOptions } from '$lib/helpers/notifications'; + +export type BottomModalAlertItem = { + id: string; + title: string; + message: string; + + src: Record<'dark' | 'light', string>; + cta: Record<'text' | 'link', string>; + plan: 'free' | 'pro' | 'scale' /*| 'enterprise'*/; + learnMore?: Partial>; + + show?: boolean; + isHtml?: boolean; + importance?: number; + + closed?: () => void; + notificationHideOptions?: NotificationCoolOffOptions; +}; + +export const bottomModalAlerts = writable([]); + +export const hideAllModalAlerts = () => { + bottomModalAlerts.update((all) => all.map((t) => ({ ...t, show: false }))); +}; + +export const dismissBottomModalAlert = (id: string) => { + bottomModalAlerts.update((all) => all.filter((t) => t.id !== id)); +}; + +export const showBottomModalAlert = (notification: BottomModalAlertItem) => { + const defaults: Partial = { + show: true, + importance: 5, + isHtml: false, + ...notification + }; + + bottomModalAlerts.update((all) => { + return [...all, defaults as BottomModalAlertItem]; + }); +}; diff --git a/src/lib/stores/user.ts b/src/lib/stores/user.ts index 6dbb23e99..21f0eb636 100644 --- a/src/lib/stores/user.ts +++ b/src/lib/stores/user.ts @@ -2,8 +2,14 @@ import { page } from '$app/stores'; import { derived } from 'svelte/store'; import type { Models } from '@appwrite.io/console'; import { browser } from '$app/environment'; +import type { NotificationPrefItem } from '$lib/helpers/notifications'; -export type Account = Models.User<{ organization?: string } & Record>; +export type Account = Models.User< + { + organization?: string; + notificationPrefs: Record; + } & Record +>; export const user = derived(page, ($page) => { if (browser) sessionStorage.setItem('account', JSON.stringify($page.data.account)); diff --git a/src/routes/(console)/project-[project]/+layout.svelte b/src/routes/(console)/project-[project]/+layout.svelte index 5e7398aff..70ba9dd1e 100644 --- a/src/routes/(console)/project-[project]/+layout.svelte +++ b/src/routes/(console)/project-[project]/+layout.svelte @@ -1,5 +1,5 @@