From 12e084b36b2a08b2fa19918d5c2fe795e1560a82 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 8 Jan 2026 12:38:15 +0200 Subject: [PATCH 1/5] Re-enable nudges Signed-off-by: Vasilica Olariu --- package.json | 4 +- src/lib/config/profile-toasts.config.ts | 2 +- src/lib/functions/load-nudge-app.ts | 12 ++ src/lib/functions/profile-nudges.ts | 67 ++++++++ src/lib/nudge-app/Nudge.module.scss | 43 +++++ src/lib/nudge-app/Nudge.svelte | 35 ++++ .../components/Animation.module.scss | 12 ++ src/lib/nudge-app/components/Animation.svelte | 53 ++++++ .../nudge-app/components/Toastr.module.scss | 158 ++++++++++++++++++ src/lib/nudge-app/components/Toastr.svelte | 72 ++++++++ src/lib/nudge-app/toast-manager.ts | 42 +++++ src/out/nudge.ts | 1 + .../src/lib/config/profile-toasts.config.d.ts | 2 +- types/src/lib/functions/load-nudge-app.d.ts | 1 + types/src/lib/functions/profile-nudges.d.ts | 18 ++ types/src/lib/nudge-app/toast-manager.d.ts | 12 ++ types/src/out/nudge.d.ts | 2 + 17 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 src/lib/functions/load-nudge-app.ts create mode 100644 src/lib/functions/profile-nudges.ts create mode 100644 src/lib/nudge-app/Nudge.module.scss create mode 100644 src/lib/nudge-app/Nudge.svelte create mode 100644 src/lib/nudge-app/components/Animation.module.scss create mode 100644 src/lib/nudge-app/components/Animation.svelte create mode 100644 src/lib/nudge-app/components/Toastr.module.scss create mode 100644 src/lib/nudge-app/components/Toastr.svelte create mode 100644 src/lib/nudge-app/toast-manager.ts create mode 100644 src/out/nudge.ts create mode 100644 types/src/lib/functions/load-nudge-app.d.ts create mode 100644 types/src/lib/functions/profile-nudges.d.ts create mode 100644 types/src/lib/nudge-app/toast-manager.d.ts create mode 100644 types/src/out/nudge.d.ts diff --git a/package.json b/package.json index 34701f30..3779817d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "types": "./types/src/main.d.ts", "repository": "https://github.com/topcoder-platform/universal-navigation.git", "scripts": { - "build": "vite build --mode=production && npx rimraf ./types && npm run types", + "build:nudge": "APP_BUILD_TARGET=nudge vite build --mode=production", + "build:base": "vite build --mode=production && npx rimraf ./types && npm run types", + "build": "npm run build:base && npm run build:nudge", "check": "svelte-check --tsconfig ./tsconfig.json", "demo:dist": "npx http-server --cors --host local.topcoder-dev.com --port 8083 ./dist", "demo:marketing": "npx http-server --host local.topcoder-dev.com --port 8081 ./demo/marketing -P http://local.topcoder-dev.com:8081? -o /", diff --git a/src/lib/config/profile-toasts.config.ts b/src/lib/config/profile-toasts.config.ts index ee0dc3be..05f4e42d 100644 --- a/src/lib/config/profile-toasts.config.ts +++ b/src/lib/config/profile-toasts.config.ts @@ -14,7 +14,7 @@ export const CUSTOMER_HOSTS = [ WORK_MANAGER_HOST, ] -export const DISABLE_NUDGES = true; +export const DISABLE_NUDGES = false; export const NUDGES_DISABLED_HOSTS = [ ...CUSTOMER_HOSTS, diff --git a/src/lib/functions/load-nudge-app.ts b/src/lib/functions/load-nudge-app.ts new file mode 100644 index 00000000..e804da02 --- /dev/null +++ b/src/lib/functions/load-nudge-app.ts @@ -0,0 +1,12 @@ +const modulePath = BUILD_IS_PROD ? './nudge.js' : '../nudge-app/Nudge.svelte'; + +const loadModule = () => { + return import(/* @vite-ignore */ modulePath).then(d => d.default) +} + +export const loadNudgeApp = async (ctx: any, targetEl: Element): Promise => { + const NudgeApp = await loadModule(); + + // instantiate the nudge app + new NudgeApp({ target: targetEl, context: ctx }); +} diff --git a/src/lib/functions/profile-nudges.ts b/src/lib/functions/profile-nudges.ts new file mode 100644 index 00000000..b0361bf3 --- /dev/null +++ b/src/lib/functions/profile-nudges.ts @@ -0,0 +1,67 @@ +import { TC_API_HOST } from "lib/config"; +import type { AuthUser } from "lib/app-context"; +import { DISABLE_NUDGES, NUDGES_DISABLED_HOSTS } from "lib/config/profile-toasts.config"; + +import { getRequestAuthHeaders } from "./auth-jwt"; + +/** + * Check if we're on a domain that should not show the profile nudges + * @returns Boolean + */ +export function dismissNudgesBasedOnHost(): 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')) + ))); +} + +// store fetched data in a local cache (de-duplicate immediate api calls) +const localCache: Record | undefined> = {}; + +export interface ProfileCompletednessResponse { + handle: string + showToast: string + data: { + percentComplete: number + } +} + +/** + * Fetches the user profile completedness + * @returns Promise + */ +export const fetchUserProfileCompletedness = async (user: AuthUser, force = false): Promise => { + const userHandle = user?.handle; + + if (!userHandle) { + return undefined; + } + + const cacheKey = `${userHandle}-completedness`; + if (!force && localCache[cacheKey]) { + return await localCache[cacheKey]; + } + + + let resolve!: (value: ProfileCompletednessResponse) => void; + localCache[cacheKey] = new Promise((r) => { resolve = r }); + + // for QA purpose only + const toastOverrideFlagParam = (window?.location.search.match(/[?&]+toast=(\w+)/i) ?? [])[1]; + const toastOverrideFlag = toastOverrideFlagParam ? `?toast=${toastOverrideFlagParam}` : ''; + const requestUrl: string = `${TC_API_HOST}/members/${userHandle}/profileCompleteness${toastOverrideFlag}`; + const request = fetch(requestUrl, {headers: {...getRequestAuthHeaders()}}); + + const response = await (await request).json(); + resolve({ + ...response, + data: { + ...response.data, + percentComplete: (response?.data?.percentComplete ?? 0) * 100, + }, + }); + + return localCache[cacheKey]; +} diff --git a/src/lib/nudge-app/Nudge.module.scss b/src/lib/nudge-app/Nudge.module.scss new file mode 100644 index 00000000..12c2a90b --- /dev/null +++ b/src/lib/nudge-app/Nudge.module.scss @@ -0,0 +1,43 @@ +@use 'lib/styles/mixins.scss' as *; + +.nudgeOuter { + position: absolute; + top: calc(var(--uninav-header-height) + var(--top-offset)); + left: 0; + z-index: 19; + + height: 0; + width: 100%; + @include mobile { + --top-offset: 2px!important; + } +} + +.nudgeWrap { + position: absolute; + display: flex; + width: 100%; + margin: 0 auto; + @include maxViewWidth; + left: 50%; + transform: translateX(-50%); +} + +.nudgeInner { + position: absolute; + right: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + + padding: 8px 32px; + + @include tablet { + padding: 8px 16px; + } + + @include mobile { + padding: 0 2px; + width: 100%; + } +} diff --git a/src/lib/nudge-app/Nudge.svelte b/src/lib/nudge-app/Nudge.svelte new file mode 100644 index 00000000..35a0f9d0 --- /dev/null +++ b/src/lib/nudge-app/Nudge.svelte @@ -0,0 +1,35 @@ + + +{#if toast} + +
+
+ +
+
+
+{/if} diff --git a/src/lib/nudge-app/components/Animation.module.scss b/src/lib/nudge-app/components/Animation.module.scss new file mode 100644 index 00000000..ed3207c8 --- /dev/null +++ b/src/lib/nudge-app/components/Animation.module.scss @@ -0,0 +1,12 @@ +.animation { + width: 100%; + height: 100%; + + svg { + transform: none!important; + } + + img { + object-fit: contain; + } +} diff --git a/src/lib/nudge-app/components/Animation.svelte b/src/lib/nudge-app/components/Animation.svelte new file mode 100644 index 00000000..bdd4f07e --- /dev/null +++ b/src/lib/nudge-app/components/Animation.svelte @@ -0,0 +1,53 @@ + + +
+ {#if cover} + icon + {/if} +
diff --git a/src/lib/nudge-app/components/Toastr.module.scss b/src/lib/nudge-app/components/Toastr.module.scss new file mode 100644 index 00000000..0ebf2e67 --- /dev/null +++ b/src/lib/nudge-app/components/Toastr.module.scss @@ -0,0 +1,158 @@ +@use 'lib/styles/fonts.scss' as *; +@use 'lib/styles/palette.scss' as *; +@use 'lib/styles/mixins.scss' as *; + +.toastr { + display: flex; + min-height: 105px; + width: 407px; + padding: 6px 9px; + gap: 6px; + border-radius: 11px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + background: linear-gradient(265.38deg, #E8735A 1.99%, #66108F 98.19%); + + &:global(.hidden) { + opacity: 0; + // visibility: hidden; + pointer-events: none; + // move up and fade in when shown + transform: translate(0, -30px); + transition: transform 0.3s ease-in-out, opacity 0.18s ease-in-out 0.1s, visibility 0.01ms 0.3s; + } + + &:not(:global(.hidden)) { + opacity: 1; + // visibility: visible; + pointer-events: all; + // move down and fade out when hidden + transform: translate(0, 0); + transition: transform 0.3s ease-in-out, opacity 0.18s ease-in-out, visibility 0.01ms; + } + + &:global(.bio-theme) { + background: linear-gradient(273.53deg, #070318 0%, #00ABFF 78.46%); + } + + &:global(.education-theme) { + background: linear-gradient(265.38deg, #E8735A 1.99%, #66108F 98.19%); + } + + &:global(.gigAvailability-theme) { + background: linear-gradient(265.38deg, #30178C 1.99%, #5CB3DE 98.19%); + } + + &:global(.profilePicture-theme) { + background: linear-gradient(265.38deg, #EF476F 1.99%, #9D41C9 98.19%); + + } + + &:global(.skills-theme) { + background: linear-gradient(265.38deg, #EF476F 1.99%, #9D41C9 98.19%); + } + + &:global(.verified-theme) { + background: linear-gradient(265.38deg, #E8735A 1.99%, #66108F 98.19%); + } + + &:global(.workHistory-theme) { + background: linear-gradient(265.38deg, #30178C 1.99%, #5CB3DE 98.19%); + } + + @include mobile { + width: 100%; + max-width: 520px; + } +} + +.icon { + flex: 1 1 52%; + position: relative; + pointer-events: none; + max-width: 200px; + + .animation { + width: 200px; + height: 190px; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + .toastr:global(.bio-theme) & { + + } + .toastr:global(.gigAvailability-theme) & { + height: 190px; + } + .toastr:global(.profilePicture-theme) & { + height: 170px; + transform: translate(-50%,-58%); + } + .toastr:global(.skills-theme) & { + height: 200px; + } + .toastr:global(.verified-theme) & { + height: 190px; + } + .toastr:global(.workHistory-theme) & { + transform: translate(-50%,-58%); + } + } + + img { + display: block; + width: 100%; + height: 100%; + } +} + +.contents { + flex: 1 1 48%; + + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + + font-family: $roboto; + color: #fff; + + .title { + font-size: 16px; + font-weight: 700; + line-height: 20px; + letter-spacing: 0.5px; + } + + .message { + font-size: 14px; + line-height: 16px; + } + + .ctaBtn { + display: block; + height: 30px; + padding: 5px 15px; + border-radius: 25px; + + background: tc-color(black, 100); + + font-size: 16px; + font-weight: 700; + line-height: 20px; + letter-spacing: 0.5px; + transition: 0.15s ease-in-out; + + &:hover { + background: tc-color(black, tc); + } + } +} + +.close { + padding-top: 3px; + flex: 0 0 16px; + + cursor: pointer; +} diff --git a/src/lib/nudge-app/components/Toastr.svelte b/src/lib/nudge-app/components/Toastr.svelte new file mode 100644 index 00000000..5ea92d1d --- /dev/null +++ b/src/lib/nudge-app/components/Toastr.svelte @@ -0,0 +1,72 @@ + + +
+
+
+ {#if toast.type === 'animated'} + + {:else if toast.type === 'static'} + icon + {/if} +
+
+
+
{toast.title}
+
{toast.message}
+ + + {toast.ctaText} + +
+
+ close +
+
diff --git a/src/lib/nudge-app/toast-manager.ts b/src/lib/nudge-app/toast-manager.ts new file mode 100644 index 00000000..42f6c0b9 --- /dev/null +++ b/src/lib/nudge-app/toast-manager.ts @@ -0,0 +1,42 @@ +import type { ProfileCompletionData } from 'lib/app-context/profile-completion.model'; +import { toastsMeta } from 'lib/config/profile-toasts.config'; +import { checkCookie, getCookieValue, setCookie } from '../utils/cookies'; +import { dismissNudgesBasedOnHost } from '../functions/profile-nudges'; + +const TOAST_COOKIE = 'uni-toast-shown'; +const TOAST_COOKIE_ACTIVE_PERIOD_DAYS = 7; + +function isToastDismissed() { + return checkCookie(TOAST_COOKIE, 'hidden'); +} + +function getLastSeenToast() { + return getCookieValue(TOAST_COOKIE); +} + +/** + * Get the toast to show based on the completedness data + * If the user has completed their profile, return undefined + * If the user has already seen and dismissed the toast, return undefined + * If the user has seen a toast type but did not dismiss it, return the same toast type + * If the user has not seen the toast, return the toast to show + * @param completednessData + * @returns + */ +export const getToast = (completednessData: ProfileCompletionData) => { + if (dismissNudgesBasedOnHost() || !completednessData || completednessData.completed || isToastDismissed()) { + return; + } + + const lastToastSeen = getLastSeenToast(); + if (!lastToastSeen) { + setCookie(TOAST_COOKIE, completednessData.showToast, TOAST_COOKIE_ACTIVE_PERIOD_DAYS); + } + + const toastToShow = lastToastSeen || completednessData.showToast; + return toastsMeta[toastToShow]; +} + +export const hideToast = () => { + setCookie(TOAST_COOKIE, 'hidden', TOAST_COOKIE_ACTIVE_PERIOD_DAYS); +} diff --git a/src/out/nudge.ts b/src/out/nudge.ts new file mode 100644 index 00000000..8ba1e77c --- /dev/null +++ b/src/out/nudge.ts @@ -0,0 +1 @@ +export default import('../lib/nudge-app/Nudge.svelte').then(d => d.default); diff --git a/types/src/lib/config/profile-toasts.config.d.ts b/types/src/lib/config/profile-toasts.config.d.ts index 43dc4f00..788f1a48 100644 --- a/types/src/lib/config/profile-toasts.config.d.ts +++ b/types/src/lib/config/profile-toasts.config.d.ts @@ -1,5 +1,5 @@ export declare const CUSTOMER_HOSTS: string[]; -export declare const DISABLE_NUDGES = true; +export declare const DISABLE_NUDGES = false; export declare const NUDGES_DISABLED_HOSTS: string[]; export interface ToastType { theme: 'bio' | 'education' | 'gigAvailability' | 'profilePicture' | 'skills' | 'verified' | 'workHistory'; diff --git a/types/src/lib/functions/load-nudge-app.d.ts b/types/src/lib/functions/load-nudge-app.d.ts new file mode 100644 index 00000000..59cab762 --- /dev/null +++ b/types/src/lib/functions/load-nudge-app.d.ts @@ -0,0 +1 @@ +export declare const loadNudgeApp: (ctx: any, targetEl: Element) => Promise; diff --git a/types/src/lib/functions/profile-nudges.d.ts b/types/src/lib/functions/profile-nudges.d.ts new file mode 100644 index 00000000..092da7a5 --- /dev/null +++ b/types/src/lib/functions/profile-nudges.d.ts @@ -0,0 +1,18 @@ +import type { AuthUser } from "lib/app-context"; +/** + * Check if we're on a domain that should not show the profile nudges + * @returns Boolean + */ +export declare function dismissNudgesBasedOnHost(): boolean; +export interface ProfileCompletednessResponse { + handle: string; + showToast: string; + data: { + percentComplete: number; + }; +} +/** + * Fetches the user profile completedness + * @returns Promise + */ +export declare const fetchUserProfileCompletedness: (user: AuthUser, force?: boolean) => Promise; diff --git a/types/src/lib/nudge-app/toast-manager.d.ts b/types/src/lib/nudge-app/toast-manager.d.ts new file mode 100644 index 00000000..b5b1585e --- /dev/null +++ b/types/src/lib/nudge-app/toast-manager.d.ts @@ -0,0 +1,12 @@ +import type { ProfileCompletionData } from 'lib/app-context/profile-completion.model'; +/** + * Get the toast to show based on the completedness data + * If the user has completed their profile, return undefined + * If the user has already seen and dismissed the toast, return undefined + * If the user has seen a toast type but did not dismiss it, return the same toast type + * If the user has not seen the toast, return the toast to show + * @param completednessData + * @returns + */ +export declare const getToast: (completednessData: ProfileCompletionData) => import("lib/config/profile-toasts.config").ToastType | undefined; +export declare const hideToast: () => void; diff --git a/types/src/out/nudge.d.ts b/types/src/out/nudge.d.ts new file mode 100644 index 00000000..7370346c --- /dev/null +++ b/types/src/out/nudge.d.ts @@ -0,0 +1,2 @@ +declare const _default: Promise; +export default _default; From 8fec7f26149c41d52d23ef26ad79793b7a403427 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 8 Jan 2026 12:39:06 +0200 Subject: [PATCH 2/5] deploy to dev --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 561def1a..369d43b1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -222,6 +222,7 @@ workflows: - TOP-2044_show-signin-modal - maintenance - review-app + - re-enable-nudges - deployProd: context: org-global From 7dead44fc60bcac7c146e92457d395f07c34d56f Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 8 Jan 2026 12:50:08 +0200 Subject: [PATCH 3/5] Fetch profile data --- src/lib/components/user-area/UserArea.svelte | 35 +++++++++++++++----- src/lib/nudge-app/Nudge.svelte | 4 +-- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/lib/components/user-area/UserArea.svelte b/src/lib/components/user-area/UserArea.svelte index e9be684b..fd9a4070 100644 --- a/src/lib/components/user-area/UserArea.svelte +++ b/src/lib/components/user-area/UserArea.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { getAppContext } from 'lib/app-context'; import { checkUserAppRole, fetchUserProfile } from 'lib/functions/user-profile.provider'; + import { fetchUserProfileCompletedness } from 'lib/functions/profile-nudges'; import { AUTH0_AUTHENTICATOR_URL } from 'lib/config'; import { AUTH_USER_ROLE } from 'lib/config/auth'; import { DISABLE_NUDGES } from "lib/config/profile-toasts.config"; @@ -38,15 +39,31 @@ debounce = user.handle; - $ctx.auth = { - ...$ctx.auth, - profileCompletionData: { - completed: true, - handle: user?.handle, - percentComplete: 0, - showToast: "", - }, - }; + 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: "", + }, + }; + } setTimeout(() => debounce = '', 100); } diff --git a/src/lib/nudge-app/Nudge.svelte b/src/lib/nudge-app/Nudge.svelte index 35a0f9d0..dd6d2ca7 100644 --- a/src/lib/nudge-app/Nudge.svelte +++ b/src/lib/nudge-app/Nudge.svelte @@ -14,14 +14,14 @@ profileCompletionData, } = $ctx.auth) - let toast: ToastType; + let toast: ToastType | undefined; function dismissToast() { toast = undefined; hideToast(); } - $: toast = isReady && getToast(profileCompletionData); + $: toast = isReady ? getToast(profileCompletionData) : undefined; {#if toast} From eab68b5f052efd7aa5c377d445e6ac5672c1543b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 8 Jan 2026 12:58:28 +0200 Subject: [PATCH 4/5] load the nudge app --- src/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main.ts b/src/main.ts index 603af735..84e8b038 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { writable } from 'svelte/store' import type { Writable } from 'svelte/store' import { buildContext, type AuthUser, type NavigationHandler, type SupportMeta } from './lib/app-context' +import { loadNudgeApp } from './lib/functions/load-nudge-app' import { PubSub } from './lib/utils/pubsub'; import { initializeUtmCookieHandler } from './lib/functions/utm-cookies.handler'; @@ -137,6 +138,10 @@ async function init( if (typeof readyCallback === 'function') { readyCallback(); } + + if (navType === 'tool' || navType === 'marketing') { + loadNudgeApp(ctx, targetEl.querySelector('.tc-universal-nav-wrap')); + } } /** From 01b7b6d2f2990c2e847aa86cfe63877573ae256e Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 8 Jan 2026 13:04:30 +0200 Subject: [PATCH 5/5] typefix --- src/lib/nudge-app/Nudge.svelte | 3 ++- src/main.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/nudge-app/Nudge.svelte b/src/lib/nudge-app/Nudge.svelte index dd6d2ca7..ffc95d71 100644 --- a/src/lib/nudge-app/Nudge.svelte +++ b/src/lib/nudge-app/Nudge.svelte @@ -5,6 +5,7 @@ import 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'; const ctx = getAppContext(); @@ -21,7 +22,7 @@ hideToast(); } - $: toast = isReady ? getToast(profileCompletionData) : undefined; + $: toast = isReady ? getToast(profileCompletionData as ProfileCompletionData) : undefined; {#if toast} diff --git a/src/main.ts b/src/main.ts index 84e8b038..32e4659d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -140,7 +140,7 @@ async function init( } if (navType === 'tool' || navType === 'marketing') { - loadNudgeApp(ctx, targetEl.querySelector('.tc-universal-nav-wrap')); + loadNudgeApp(ctx, targetEl.querySelector('.tc-universal-nav-wrap') as Element); } }