diff --git a/public/assets/icon-confirm.svg b/public/assets/icon-confirm.svg new file mode 100644 index 00000000..b4e6e1f6 --- /dev/null +++ b/public/assets/icon-confirm.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icon-external.svg b/public/assets/icon-external.svg new file mode 100644 index 00000000..22e79c95 --- /dev/null +++ b/public/assets/icon-external.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/nudge/icon-circle-dismiss.svg b/public/assets/nudge/icon-circle-dismiss.svg new file mode 100644 index 00000000..6ea3e714 --- /dev/null +++ b/public/assets/nudge/icon-circle-dismiss.svg @@ -0,0 +1 @@ + diff --git a/public/assets/tools/gigs.svg b/public/assets/tools/gigs.svg index 7b2214e3..ade9b2b6 100644 --- a/public/assets/tools/gigs.svg +++ b/public/assets/tools/gigs.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/public/assets/tools/sprite.svg b/public/assets/tools/sprite.svg index 481b19a9..dcd72689 100644 --- a/public/assets/tools/sprite.svg +++ b/public/assets/tools/sprite.svg @@ -1,7 +1,7 @@ - + diff --git a/src/lib/app-context/profile-completion.model.ts b/src/lib/app-context/profile-completion.model.ts index 26153bc9..dd2d0640 100644 --- a/src/lib/app-context/profile-completion.model.ts +++ b/src/lib/app-context/profile-completion.model.ts @@ -3,4 +3,5 @@ export interface ProfileCompletionData { handle: string percentComplete: number showToast: string + dateFields?: [string, Date][] } diff --git a/src/lib/components/modals/Modal.module.scss b/src/lib/components/modals/Modal.module.scss index da96bdec..aaee3629 100644 --- a/src/lib/components/modals/Modal.module.scss +++ b/src/lib/components/modals/Modal.module.scss @@ -55,7 +55,7 @@ border: 1px solid; border-radius: 1px; color: #e9e9e9; - margin: 24px 0; + margin: 12px 0; width: 100%; display: block; } diff --git a/src/lib/components/sticky/Sticky.svelte b/src/lib/components/sticky/Sticky.svelte index 4b9f2be9..9e023b82 100644 --- a/src/lib/components/sticky/Sticky.svelte +++ b/src/lib/components/sticky/Sticky.svelte @@ -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() { @@ -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}); + } elRef.classList.toggle(styles.sticky, isFixed); } @@ -31,6 +36,7 @@ }) + diff --git a/src/lib/components/tool-selector/ToolMenu.module.scss b/src/lib/components/tool-selector/ToolMenu.module.scss index 1e787c88..e8f19bdc 100644 --- a/src/lib/components/tool-selector/ToolMenu.module.scss +++ b/src/lib/components/tool-selector/ToolMenu.module.scss @@ -62,7 +62,7 @@ .toolSection.talent & { flex-direction: column; - max-height: 380px; + max-height: 420px; } @include mobile { diff --git a/src/lib/components/user-area/UserArea.svelte b/src/lib/components/user-area/UserArea.svelte index fd9a4070..e0cbd830 100644 --- a/src/lib/components/user-area/UserArea.svelte +++ b/src/lib/components/user-area/UserArea.svelte @@ -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, + dateFields: completednessData.data?.dateFields, + }, + }; setTimeout(() => debounce = '', 100); } diff --git a/src/lib/config/hosts.ts b/src/lib/config/hosts.ts index 9e1d02e1..8ebdae49 100644 --- a/src/lib/config/hosts.ts +++ b/src/lib/config/hosts.ts @@ -35,3 +35,4 @@ export const TALENT_SEARCH_HOST: string = `https://talent-search.${TC_DOMAIN}`; export const ACCOUNT_SETTINGS_HOST: string = `https://account-settings.${TC_DOMAIN}`; export const WALLETAPP_HOST: string = `https://wallet.${TC_DOMAIN}`; export const COPILOT_PORTAL_HOST: string = `https://copilots.${TC_DOMAIN}`; +export const ENGAGEMENT_PORTAL_HOST: string = `https://engagements.${TC_DOMAIN}`; diff --git a/src/lib/config/nav-menu/all-nav-items.config.ts b/src/lib/config/nav-menu/all-nav-items.config.ts index d02a2d90..24e461a5 100644 --- a/src/lib/config/nav-menu/all-nav-items.config.ts +++ b/src/lib/config/nav-menu/all-nav-items.config.ts @@ -13,6 +13,7 @@ import { WALLETAPP_HOST, WORK_MANAGER_HOST, AUTH0_AUTHENTICATOR_URL, + ENGAGEMENT_PORTAL_HOST, } from '..'; export const allNavItems: {[key: string]: NavMenuItem} = { @@ -111,6 +112,12 @@ export const allNavItems: {[key: string]: NavMenuItem} = { icon: 'challenges', description: 'Compete and earn money', }, + engagementsApp: { + label: 'Engagement Portal', + url: ENGAGEMENT_PORTAL_HOST, + icon: 'gigs', + description: 'Work directly with clients', + }, discordApp: { label: 'Discord', url: 'https://discord.com/invite/topcoder', diff --git a/src/lib/config/nav-menu/tool-selector-nav-items.ts b/src/lib/config/nav-menu/tool-selector-nav-items.ts index b9ec2934..9aceab55 100644 --- a/src/lib/config/nav-menu/tool-selector-nav-items.ts +++ b/src/lib/config/nav-menu/tool-selector-nav-items.ts @@ -39,6 +39,7 @@ export const toolSelectorNavItems: NavMenuItem = { groupOrder: 2, children: [ allNavItems.challengesApp, + allNavItems.engagementsApp, allNavItems.review, allNavItems.payments, allNavItems.copilotPortal, diff --git a/src/lib/functions/profile-nudges.ts b/src/lib/functions/profile-nudges.ts index b0361bf3..3129c20f 100644 --- a/src/lib/functions/profile-nudges.ts +++ b/src/lib/functions/profile-nudges.ts @@ -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')); +} + /** * 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); } // store fetched data in a local cache (de-duplicate immediate api calls) @@ -25,7 +26,7 @@ export interface ProfileCompletednessResponse { showToast: string data: { percentComplete: number - } + } & {[key: string]: any} } /** @@ -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') + .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()}}); + + const response = await (await request).json(); + + return response.data; +} diff --git a/src/lib/nudge-app/Nudge.module.scss b/src/lib/nudge-app/Nudge.module.scss index 12c2a90b..24759207 100644 --- a/src/lib/nudge-app/Nudge.module.scss +++ b/src/lib/nudge-app/Nudge.module.scss @@ -1,5 +1,6 @@ @use 'lib/styles/mixins.scss' as *; +.bannerOuter, .nudgeOuter { position: absolute; top: calc(var(--uninav-header-height) + var(--top-offset)); @@ -13,6 +14,12 @@ } } +.bannerOuter { + position: relative; + top: 0; + height: min-content; +} + .nudgeWrap { position: absolute; display: flex; @@ -41,3 +48,26 @@ width: 100%; } } + +.bannerWrap { + display: flex; + width: 100%; + background: #60267d; + 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%; + } +} diff --git a/src/lib/nudge-app/Nudge.svelte b/src/lib/nudge-app/Nudge.svelte index ffc95d71..fa1a2a73 100644 --- a/src/lib/nudge-app/Nudge.svelte +++ b/src/lib/nudge-app/Nudge.svelte @@ -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(); @@ -16,20 +18,41 @@ } = $ctx.auth) let toast: ToastType | undefined; + let banner: {key: string, date: Date} | undefined; function dismissToast() { toast = undefined; hideToast(); } + function dismissBanner() { + banner = undefined; + hideBanner(); + } + $: toast = isReady ? getToast(profileCompletionData as ProfileCompletionData) : undefined; + $: banner = isReady ? getBanner(profileCompletionData as ProfileCompletionData) : undefined; +{#if banner} + + + + + + + +{/if} + {#if toast} - + diff --git a/src/lib/nudge-app/banner-manager.ts b/src/lib/nudge-app/banner-manager.ts new file mode 100644 index 00000000..a980596d --- /dev/null +++ b/src/lib/nudge-app/banner-manager.ts @@ -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 => { + 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]; + if (!lastUpdate) { + return; + } + + const lastUpdateOrCofirmDate = lastProfileConfirmationDate ? Math.max(lastUpdate, +lastProfileConfirmationDate) : lastUpdate; + 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); +} + +export const hide = () => { + setCookie(COOKIE_NAME, 'hidden', COOKIE_ACTIVE_PERIOD_DAYS); +} diff --git a/src/lib/nudge-app/components/Banner.module.scss b/src/lib/nudge-app/components/Banner.module.scss new file mode 100644 index 00000000..ebc40df1 --- /dev/null +++ b/src/lib/nudge-app/components/Banner.module.scss @@ -0,0 +1,126 @@ +@use 'lib/styles/fonts.scss' as *; +@use 'lib/styles/palette.scss' as *; +@use 'lib/styles/mixins.scss' as *; +$btnSize: 20px; + +.banner { + display: flex; + align-items: center; + justify-content: center; + font-family: $nunito; + font-size: 14px; + line-height: 20px; + + &: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; + } + + @include tablet { + flex-wrap: wrap; + padding: 4px 12px 8px; + .spacer { + width: 100%; + height: 0; + margin-top: 6px; + } + } +} + +.contents { + flex: 1 1 auto; + text-align: center; + justify-content: center; + + &, a { + display: flex; + align-items: center; + gap: 6px; + } + + a { + &:hover { + text-decoration: underline !important; + } + } +} + +.close { + flex: 0 0 16px; + cursor: pointer; +} + +.externalIcon { + margin-top: -2px; + width: 16px; + height: 16px; + display: block; +} + + +.inlineBtn { + height: $btnSize; + cursor: pointer; + font-size: 16px; + font-family: $roboto; + border: 0; + + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background: transparent; + padding: 0; + border-radius: 0.25rem; + + transition: background 0.15s ease; + cursor: pointer; + text-decoration: underline; + &:hover { + background: rgb(122 74 146); + } + &:active { + background: rgb(69 27 90); + } +} + +.confirmBtn { + cursor: pointer; + font-family: $roboto; + border: 1px solid #fff; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + color: #fff; + background: transparent; + padding: 2px 8px; + margin: -2px 0; + margin-right: 8px; + border-radius: 0.25rem; + + transition: background 0.15s ease; + &:hover { + background: rgb(122 74 146); + } + &:active { + background: rgb(69 27 90); + } +} + +.modalBtns { + margin-top: 24px; +} diff --git a/src/lib/nudge-app/components/Banner.svelte b/src/lib/nudge-app/components/Banner.svelte new file mode 100644 index 00000000..7379f9aa --- /dev/null +++ b/src/lib/nudge-app/components/Banner.svelte @@ -0,0 +1,131 @@ + + + + {#if isOnProfiles} + + If your profile is up to date, you can + handleCloseKeydown(ev, true)} + > + click here + + to confirm. + + {:else} + + + Hey there! Looks like you haven't updated your profile in a long time. + You last updated your + {toStartCase(banner.key)} + on + {' '} + {fmDate(banner.date)} + + + + + handleCloseKeydown(ev, true)} + > + Update + + + showConfirmModal = true} + on:keydown={(ev) => handleCloseKeydown(ev, true)} + > + Confirm + + {/if} + + + + + + + + Please make sure your profile is up to date. + + + + diff --git a/src/lib/styles/main.scss b/src/lib/styles/main.scss index 9cade287..7493c41b 100644 --- a/src/lib/styles/main.scss +++ b/src/lib/styles/main.scss @@ -1,5 +1,5 @@ html { - --uninav-header-height: 60px; + --uninav-header-height: 58px; @media (max-width: 767px) { --uninav-header-height: 48px; diff --git a/src/lib/tool-navigation/tool-nav-separator/ToolNavSeparator.module.scss b/src/lib/tool-navigation/tool-nav-separator/ToolNavSeparator.module.scss index 050c1ec8..6c2fb4b4 100644 --- a/src/lib/tool-navigation/tool-nav-separator/ToolNavSeparator.module.scss +++ b/src/lib/tool-navigation/tool-nav-separator/ToolNavSeparator.module.scss @@ -2,7 +2,7 @@ position: relative; background: #D4D4D4; width: 1px; - height: 60px; + height: var(--uninav-header-height); margin: -18px 0; margin-left: 0; z-index: 1; diff --git a/src/main.ts b/src/main.ts index 32e4659d..2aa50223 100644 --- a/src/main.ts +++ b/src/main.ts @@ -129,6 +129,9 @@ async function init( instancesContextStore[targetId] = ctx; + if (navType === 'tool' || navType === 'marketing') { + await loadNudgeApp(ctx, targetEl as Element); + } // load the navigation component const Navigation = await loadNavigationFn(); // instantiate the navigation component @@ -138,10 +141,6 @@ 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/types/src/lib/app-context/profile-completion.model.d.ts b/types/src/lib/app-context/profile-completion.model.d.ts index f1de542b..b3062f0c 100644 --- a/types/src/lib/app-context/profile-completion.model.d.ts +++ b/types/src/lib/app-context/profile-completion.model.d.ts @@ -3,4 +3,5 @@ export interface ProfileCompletionData { handle: string; percentComplete: number; showToast: string; + dateFields?: [string, Date][]; } diff --git a/types/src/lib/config/hosts.d.ts b/types/src/lib/config/hosts.d.ts index 761ec153..0c5526f8 100644 --- a/types/src/lib/config/hosts.d.ts +++ b/types/src/lib/config/hosts.d.ts @@ -18,3 +18,4 @@ export declare const TALENT_SEARCH_HOST: string; export declare const ACCOUNT_SETTINGS_HOST: string; export declare const WALLETAPP_HOST: string; export declare const COPILOT_PORTAL_HOST: string; +export declare const ENGAGEMENT_PORTAL_HOST: string; diff --git a/types/src/lib/functions/profile-nudges.d.ts b/types/src/lib/functions/profile-nudges.d.ts index 092da7a5..d0905b4c 100644 --- a/types/src/lib/functions/profile-nudges.d.ts +++ b/types/src/lib/functions/profile-nudges.d.ts @@ -1,14 +1,17 @@ import type { AuthUser } from "lib/app-context"; +export declare function isOnHost(host: string): boolean; /** * Check if we're on a domain that should not show the profile nudges * @returns Boolean */ -export declare function dismissNudgesBasedOnHost(): boolean; +export declare function dismissNudgesBasedOnHost(exceptHost?: string): boolean; export interface ProfileCompletednessResponse { handle: string; showToast: string; data: { percentComplete: number; + } & { + [key: string]: any; }; } /** @@ -16,3 +19,4 @@ export interface ProfileCompletednessResponse { * @returns Promise */ export declare const fetchUserProfileCompletedness: (user: AuthUser, force?: boolean) => Promise; +export declare const confirmProfileData: (userHandle: string) => Promise; diff --git a/types/src/lib/nudge-app/banner-manager.d.ts b/types/src/lib/nudge-app/banner-manager.d.ts new file mode 100644 index 00000000..98a9ac99 --- /dev/null +++ b/types/src/lib/nudge-app/banner-manager.d.ts @@ -0,0 +1,7 @@ +import type { ProfileCompletionData } from 'lib/app-context/profile-completion.model'; +/** + * @param completednessData + * @returns + */ +export declare const getBanner: (completednessData: ProfileCompletionData) => any; +export declare const hide: () => void;