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
3 changes: 3 additions & 0 deletions public/assets/icon-confirm.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/assets/icon-external.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/assets/nudge/icon-circle-dismiss.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/lib/app-context/profile-completion.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export interface ProfileCompletionData {
handle: string
percentComplete: number
showToast: string
dateFields?: [string, Date][]
Comment thread
vas3a marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion src/lib/components/modals/Modal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
border: 1px solid;
border-radius: 1px;
color: #e9e9e9;
margin: 24px 0;
margin: 12px 0;
width: 100%;
display: block;
}
Expand Down
8 changes: 7 additions & 1 deletion src/lib/components/sticky/Sticky.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
let className = '';
export { className as class };
export let yOffset = 0;
export let delayYOffset = 0;

let elRef: HTMLElement | undefined;
let placeholderRef: HTMLElement | undefined;
let elYOffset = 0;

function handleScroll() {
Expand All @@ -15,7 +17,10 @@
}
const { scrollY } = window;

const isFixed = (scrollY + yOffset - elYOffset) >= 0;
const isFixed = (scrollY + yOffset - elYOffset - delayYOffset) >= 0;
if (placeholderRef) {
Object.assign(placeholderRef.style, {height: isFixed ? `${elRef.offsetHeight}px` : 0});
Comment thread
vas3a marked this conversation as resolved.
}
elRef.classList.toggle(styles.sticky, isFixed);
}

Expand All @@ -31,6 +36,7 @@
})
</script>

<div bind:this={placeholderRef}></div>
<div bind:this={elRef} class={className} style="--top-offset: {yOffset}px">
<slot />
</div>
37 changes: 13 additions & 24 deletions src/lib/components/user-area/UserArea.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,20 @@

debounce = user.handle;

if (!DISABLE_NUDGES) {
const completednessData = await fetchUserProfileCompletedness(user, true);
if (!completednessData) {
return;
}
$ctx.auth = {
...$ctx.auth,
profileCompletionData: {
completed: completednessData.data?.percentComplete === 100,
handle: completednessData.handle,
percentComplete: completednessData.data?.percentComplete,
showToast: completednessData.showToast,
},
};
} else {
$ctx.auth = {
...$ctx.auth,
profileCompletionData: {
completed: true,
handle: user?.handle,
percentComplete: 0,
showToast: "",
},
};
const completednessData = await fetchUserProfileCompletedness(user, true);
if (!completednessData) {
return;
}
$ctx.auth = {
...$ctx.auth,
profileCompletionData: {
completed: completednessData.data?.percentComplete === 100,
handle: completednessData.handle,
percentComplete: completednessData.data?.percentComplete,
showToast: DISABLE_NUDGES ? '' : completednessData.showToast,
Comment thread
vas3a marked this conversation as resolved.
dateFields: completednessData.data?.dateFields,
Comment thread
vas3a marked this conversation as resolved.
},
};

setTimeout(() => debounce = '', 100);
}
Expand Down
33 changes: 25 additions & 8 deletions src/lib/functions/profile-nudges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import { DISABLE_NUDGES, NUDGES_DISABLED_HOSTS } from "lib/config/profile-toasts

import { getRequestAuthHeaders } from "./auth-jwt";

export function isOnHost(host: string): boolean {
const locationHostname = window?.location.hostname ?? ''
return !!host.match(new RegExp(`^https?:\/\/${locationHostname}`, 'i'));
Comment thread
vas3a marked this conversation as resolved.
}

/**
* Check if we're on a domain that should not show the profile nudges
* @returns Boolean
*/
export function dismissNudgesBasedOnHost(): boolean {
export function dismissNudgesBasedOnHost(exceptHost?: string): boolean {
// Ue the new flag to disable the profile nudges completely (PS-267)
const locationHostname = window?.location.hostname ?? ''
return DISABLE_NUDGES ||
(!!NUDGES_DISABLED_HOSTS.find(host => (
host.match(new RegExp(`^https?:\/\/${locationHostname}`, 'i'))
)));
return DISABLE_NUDGES || NUDGES_DISABLED_HOSTS.filter(h => !exceptHost || h !== exceptHost).some(isOnHost);
Comment thread
vas3a marked this conversation as resolved.
}

// store fetched data in a local cache (de-duplicate immediate api calls)
Expand All @@ -25,7 +26,7 @@ export interface ProfileCompletednessResponse {
showToast: string
data: {
percentComplete: number
}
} & {[key: string]: any}
}

/**
Expand Down Expand Up @@ -55,13 +56,29 @@ export const fetchUserProfileCompletedness = async (user: AuthUser, force = fals
const request = fetch(requestUrl, {headers: {...getRequestAuthHeaders()}});

const response = await (await request).json();

const responseData = response.data ?? {};
const dateFields = Object.keys(responseData)
.filter(k => k.endsWith('LastUpdateDate') || k === 'lastProfileConfirmationDate')
Comment thread
vas3a marked this conversation as resolved.
.map((key) => [key, new Date(responseData[key])]);

resolve({
...response,
data: {
...response.data,
...responseData,
dateFields,
percentComplete: (response?.data?.percentComplete ?? 0) * 100,
},
});

return localCache[cacheKey];
}

export const confirmProfileData = async (userHandle: string) => {
const requestUrl: string = `${TC_API_HOST}/members/${userHandle}/confirmProfile`;
const request = fetch(requestUrl, {method: 'POST', headers: {...getRequestAuthHeaders()}});
Comment thread
vas3a marked this conversation as resolved.

const response = await (await request).json();

return response.data;
}
30 changes: 30 additions & 0 deletions src/lib/nudge-app/Nudge.module.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@use 'lib/styles/mixins.scss' as *;

.bannerOuter,
.nudgeOuter {
position: absolute;
top: calc(var(--uninav-header-height) + var(--top-offset));
Expand All @@ -13,6 +14,12 @@
}
}

.bannerOuter {
position: relative;
top: 0;
height: min-content;
}

.nudgeWrap {
position: absolute;
display: flex;
Expand Down Expand Up @@ -41,3 +48,26 @@
width: 100%;
}
}

.bannerWrap {
display: flex;
width: 100%;
background: #60267d;
Comment thread
vas3a marked this conversation as resolved.
color: #fff;
}

.bannerInner {
width: 100%;
margin: 0 auto;
@include maxViewWidth;
padding: 6px 32px;

@include tablet {
padding: 6px 16px;
}

@include mobile {
padding: 0 2px;
width: 100%;
}
}
28 changes: 26 additions & 2 deletions src/lib/nudge-app/Nudge.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import { getAppContext } from 'lib/app-context';
import Toastr from './components/Toastr.svelte';
import styles from './Nudge.module.scss';
import type { ToastType } from 'lib/config/profile-toasts.config';
import { toastsMeta, type ToastType } from 'lib/config/profile-toasts.config';
import { getToast, hideToast } from './toast-manager';
import Sticky from 'lib/components/sticky/Sticky.svelte';
import type { ProfileCompletionData } from 'lib/app-context/profile-completion.model';
import { getBanner, hide as hideBanner } from './banner-manager';
import Banner from './components/Banner.svelte';

const ctx = getAppContext();

Expand All @@ -16,20 +18,42 @@
} = $ctx.auth)

let toast: ToastType | undefined;
let banner: {key: string, date: Date} | undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
Consider defining a type for the banner object to improve type safety and maintainability. This will make it easier to understand the structure of the banner object and prevent potential errors.


function dismissToast() {
toast = undefined;
hideToast();
}

function dismissBanner() {
banner = undefined;
hideBanner();
}

$: toast = isReady ? getToast(profileCompletionData as ProfileCompletionData) : undefined;
$: banner = isReady ? getBanner(profileCompletionData as ProfileCompletionData) : undefined;
</script>

{#if banner}
<Sticky class={styles.bannerOuter} delayYOffset={92}>
<div class={styles.bannerWrap}>
<div class={styles.bannerInner}>
<Banner
userHandle={user?.handle ?? ''}
banner={banner}
redirect={toastsMeta[banner.key].ctaLink(user?.handle ?? '')}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
Ensure that toastsMeta[banner.key] is always defined before accessing ctaLink. If banner.key is not present in toastsMeta, this could lead to a runtime error.

on:dismiss={dismissBanner}
/>
</div>
</div>
</Sticky>
{/if}

{#if toast}
<Sticky class={styles.nudgeOuter} yOffset={10}>
<div class={styles.nudgeWrap}>
<div class={styles.nudgeInner}>
<Toastr userhandle={user.handle} toast={toast} on:dismiss={dismissToast} />
<Toastr userhandle={user?.handle ?? ''} toast={toast} on:dismiss={dismissToast} />
</div>
</div>
</Sticky>
Expand Down
72 changes: 72 additions & 0 deletions src/lib/nudge-app/banner-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ProfileCompletionData } from 'lib/app-context/profile-completion.model';
import { checkCookie, getCookieValue, setCookie } from '../utils/cookies';
import { dismissNudgesBasedOnHost } from '../functions/profile-nudges';
import { PROFILE_HOST } from 'lib/config';

const COOKIE_NAME = 'uni-profilereminder-banner-shown';
const COOKIE_ACTIVE_PERIOD_DAYS = 3;
const PROFILE_UPDATE_REMINDER_PERIOD_DAYS = 3*30;

const DAY = 24 * 3600 * 1000;
const _30DAYS = 30 * DAY;

function isDismissed() {
return checkCookie(COOKIE_NAME, 'hidden');
}

function getLastSeen() {
return getCookieValue(COOKIE_NAME);
}

const isOlderThanTreshold = (date: Date | number, treshold: number): boolean => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
The function name isOlderThanTreshold contains a typo in Treshold. It should be Threshold for clarity and consistency.

const diffInDays = Math.floor((Date.now() - +date) / DAY);
return diffInDays >= treshold;
}

/**
* @param completednessData
* @returns
*/
export const getBanner = (completednessData: ProfileCompletionData) => {
if (dismissNudgesBasedOnHost(PROFILE_HOST) || !completednessData || isDismissed()) {
return;
}

const fields = completednessData.dateFields ?? [];
const updatableProfileFields: [string, Date][] = fields
.filter(([k]) => k.endsWith('LastUpdateDate'))
.map(([k, d]) => [k.replace(/LastUpdateDate$/, ''), d]);
const lastProfileConfirmationDate = fields.find(([k]) => k === 'lastProfileConfirmationDate')?.[1];

const sorted = updatableProfileFields
.sort((a, b) => +a[1] - +b[1]);


const lastUpdate = +sorted[sorted.length - 1][1];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
Accessing sorted[sorted.length - 1] without checking if sorted is empty could lead to an error. Consider adding a check to ensure sorted has elements before accessing.

if (!lastUpdate) {
return;
}

const lastUpdateOrCofirmDate = lastProfileConfirmationDate ? Math.max(lastUpdate, +lastProfileConfirmationDate) : lastUpdate;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 readability]
The variable name lastUpdateOrCofirmDate contains a typo in Cofirm. It should be Confirm for clarity and consistency.

if (!lastUpdateOrCofirmDate || !isOlderThanTreshold(lastUpdateOrCofirmDate, PROFILE_UPDATE_REMINDER_PERIOD_DAYS)) {
setCookie(COOKIE_NAME, '', 0);
return;
}

let lastSeen = getLastSeen();
if (!lastSeen) {
const oldFields = updatableProfileFields.filter(([,d]) => isOlderThanTreshold(d, PROFILE_UPDATE_REMINDER_PERIOD_DAYS)).map(([k]) => k);
const fieldKey = oldFields.length ? oldFields[0] : undefined;
const field = updatableProfileFields.find(f => f[0] === fieldKey) ?? [];
lastSeen = JSON.stringify({key: field[0], date: field[1]});
if(lastSeen) {
setCookie(COOKIE_NAME, lastSeen, COOKIE_ACTIVE_PERIOD_DAYS);
}
}

return JSON.parse(lastSeen);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The function getBanner returns the result of JSON.parse(lastSeen) without checking if lastSeen is a valid JSON string. Consider adding error handling for JSON.parse to prevent runtime errors.

}

export const hide = () => {
setCookie(COOKIE_NAME, 'hidden', COOKIE_ACTIVE_PERIOD_DAYS);
}
Loading
Loading