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 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/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/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..ffc95d71 --- /dev/null +++ b/src/lib/nudge-app/Nudge.svelte @@ -0,0 +1,36 @@ + + +{#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/main.ts b/src/main.ts index 603af735..32e4659d 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') as Element); + } } /** 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;