diff --git a/packages/shared/src/features/profile/components/experience/ProfileUserExperiences.tsx b/packages/shared/src/features/profile/components/experience/ProfileUserExperiences.tsx new file mode 100644 index 0000000000..d3166579b1 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/ProfileUserExperiences.tsx @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../../lib/query'; +import { useAuthContext } from '../../../../contexts/AuthContext'; +import type { PublicProfile } from '../../../../lib/user'; +import { getUserProfileExperiences } from '../../../../graphql/user/profile'; +import { UserExperienceList } from './UserExperiencesList'; + +interface ProfileUserExperiencesProps { + user: PublicProfile; +} + +export function ProfileUserExperiences({ + user, +}: ProfileUserExperiencesProps): ReactElement { + const { user: loggedUser } = useAuthContext(); + const { data } = useQuery({ + queryKey: generateQueryKey( + RequestKey.UserExperience, + loggedUser, + 'profile', + ), + queryFn: () => getUserProfileExperiences(user.id), + staleTime: StaleTime.Default, + }); + + const list = useMemo( + () => ({ + work: data?.work?.edges?.map(({ node }) => node), + education: data?.education?.edges?.map(({ node }) => node), + cert: data?.certification?.edges?.map(({ node }) => node), + project: data?.project?.edges?.map(({ node }) => node), + }), + [data], + ); + + return ( + <> + + + + + + ); +} diff --git a/packages/shared/src/features/profile/components/experience/UserExperienceItem.tsx b/packages/shared/src/features/profile/components/experience/UserExperienceItem.tsx new file mode 100644 index 0000000000..128091fe97 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/UserExperienceItem.tsx @@ -0,0 +1,142 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { + UserExperience, + UserExperienceCertification, + UserExperienceProject, + UserExperienceWork, +} from '../../../../graphql/user/profile'; +import { Image, ImageType } from '../../../../components/image/Image'; +import { Pill, PillSize } from '../../../../components/Pill'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../components/typography/Typography'; +import { formatDate, TimeFormatType } from '../../../../lib/dateFormat'; +import { anchorDefaultRel, concatStrings } from '../../../../lib/strings'; +import { currrentPill } from './common'; +import { Button } from '../../../../components/buttons/Button'; + +interface UserExperienceItemProps { + experience: UserExperience; + grouped?: { + isLastItem?: boolean; + }; +} + +export function UserExperienceItem({ + experience, + grouped, +}: UserExperienceItemProps): ReactElement { + const { company, title, description, startedAt, endedAt, subtitle } = + experience; + const { skills, verified } = experience as UserExperienceWork; + const { url } = experience as UserExperienceProject; + const { externalReferenceId } = experience as UserExperienceCertification; + + return ( +
  • + {grouped ? ( +
    +
    + {!grouped.isLastItem && ( +
    + )} +
    + ) : ( + + )} +
    +
    + + {title} + {!grouped && !endedAt && currrentPill} + {url && ( +
    + + {description} + + {skills?.length > 0 && ( +
    + {skills.map((skill) => ( + + ))} +
    + )} +
    +
  • + ); +} diff --git a/packages/shared/src/features/profile/components/experience/UserExperiencesGroupedList.tsx b/packages/shared/src/features/profile/components/experience/UserExperiencesGroupedList.tsx new file mode 100644 index 0000000000..174ad08ad9 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/UserExperiencesGroupedList.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { UserExperience } from '../../../../graphql/user/profile'; +import { Image, ImageType } from '../../../../components/image/Image'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../components/typography/Typography'; +import { UserExperienceItem } from './UserExperienceItem'; +import { formatDate, TimeFormatType } from '../../../../lib/dateFormat'; +import { currrentPill } from './common'; + +interface UserExperiencesGroupedListProps { + company: string; + experiences: UserExperience[]; +} + +export function UserExperiencesGroupedList({ + company, + experiences, +}: UserExperiencesGroupedListProps): ReactElement { + const [first] = experiences; + const last = experiences[experiences.length - 1]; + const duration = formatDate({ + value: new Date(last.startedAt), + type: TimeFormatType.Experience, + now: first.endedAt ? new Date(first.endedAt) : new Date(), + }); + + return ( + <> +
  • + +
    + + {company} + {!first.endedAt && currrentPill} + + + {duration} + +
    +
  • +
      + {experiences.map((experience, index) => ( + + ))} +
    + + ); +} diff --git a/packages/shared/src/features/profile/components/experience/UserExperiencesList.tsx b/packages/shared/src/features/profile/components/experience/UserExperiencesList.tsx new file mode 100644 index 0000000000..483e35bcc1 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/UserExperiencesList.tsx @@ -0,0 +1,69 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { UserExperience } from '../../../../graphql/user/profile'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import { UserExperienceItem } from './UserExperienceItem'; +import { UserExperiencesGroupedList } from './UserExperiencesGroupedList'; + +interface UserExperienceListProps { + experiences: T[]; + title: string; +} + +const groupListByCompany = ( + experiences: T[], +): [string, T[]][] => { + if (!experiences?.length) { + return []; + } + + const grouped = experiences.reduce((acc, node) => { + const name = node.customCompanyName || node.company?.name; + if (!acc[name]) { + acc[name] = []; + } + acc[name].push(node); + return acc; + }, {} as Record); + + return Object.entries(grouped); +}; + +export function UserExperienceList({ + experiences, + title, +}: UserExperienceListProps): ReactElement { + const groupedByCompany: [string, T[]][] = useMemo( + () => groupListByCompany(experiences), + [experiences], + ); + + if (!experiences?.length) { + return <>; + } + + return ( +
    + + {title} + +
      + {groupedByCompany?.map(([company, list]) => + list.length === 1 ? ( + + ) : ( + + ), + )} +
    +
    + ); +} diff --git a/packages/shared/src/features/profile/components/experience/common.tsx b/packages/shared/src/features/profile/components/experience/common.tsx new file mode 100644 index 0000000000..bd70028c19 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/common.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { Pill, PillSize } from '../../../../components/Pill'; + +export const currrentPill = ( + +); diff --git a/packages/shared/src/graphql/user/profile.ts b/packages/shared/src/graphql/user/profile.ts new file mode 100644 index 0000000000..6c5ff52b74 --- /dev/null +++ b/packages/shared/src/graphql/user/profile.ts @@ -0,0 +1,138 @@ +import { gql } from 'graphql-request'; +import type { Connection } from '../common'; +import { gqlClient } from '../common'; +import type { TLocation } from '../autocomplete'; +import type { Company } from '../../lib/userCompany'; + +const USER_EXPERIENCE_FRAGMENT = gql` + fragment UserExperienceFragment on UserExperience { + id + type + title + description + createdAt + startedAt + endedAt + customCompanyName + company { + id + name + image + } + } +`; + +const getExperiencesProps = (props: string) => ` + edges { + node { + ...UserExperienceFragment + ${props} + } + } + pageInfo { + hasNextPage + endCursor + } +`; + +const workProps = ` + employmentType + locationType + location { + id + city + subdivision + country + } + skills { + value + } +`; + +const USER_PROFILE_EXPERIENCES_QUERY = gql` + query UserExperiences($userId: ID!) { + work: userExperiences(userId: $userId, type: work, first: 3) { + ${getExperiencesProps(workProps)} + } + education: userExperiences(userId: $userId, type: education, first: 3) { + ${getExperiencesProps(`subtitle`)} + } + project: userExperiences(userId: $userId, type: project, first: 3) { + ${getExperiencesProps(`url`)} + } + certification: userExperiences(userId: $userId, type: certification, first: 3) { + ${getExperiencesProps(` + externalReferenceId + url + `)} + } + } + ${USER_EXPERIENCE_FRAGMENT} +`; + +export enum UserExperienceType { + Work = 'work', + Education = 'education', + Project = 'project', + Certification = 'certification', +} + +export interface UserExperience { + id: string; + type: UserExperienceType; + title: string; + description: string | null; + createdAt: string; + startedAt: string | null; + endedAt: string | null; + company: Company | null; + customCompanyName: string | null; + subtitle?: string | null; +} + +interface UserSkill { + value: string; +} + +export interface UserExperienceWork extends UserExperience { + type: UserExperienceType.Work; + employmentType: number | null; + locationType: number | null; + location: TLocation | null; + skills: UserSkill[]; + verified: boolean | null; +} + +export interface UserExperienceEducation extends UserExperience { + type: UserExperienceType.Education; + subtitle: string | null; +} + +export interface UserExperienceProject extends UserExperience { + type: UserExperienceType.Project; + url: string | null; +} + +export interface UserExperienceCertification extends UserExperience { + type: UserExperienceType.Certification; + externalReferenceId: string | null; + url: string | null; +} + +export interface UserProfileExperienceData { + work: Connection; + education: Connection; + project: Connection; + certification: Connection; +} + +export const getUserProfileExperiences = async ( + userId: string, +): Promise => { + const result = await gqlClient.request( + USER_PROFILE_EXPERIENCES_QUERY, + { userId }, + ); + + return result; +}; diff --git a/packages/shared/src/lib/dateFormat.ts b/packages/shared/src/lib/dateFormat.ts index 2e412c72b2..08b3eae94e 100644 --- a/packages/shared/src/lib/dateFormat.ts +++ b/packages/shared/src/lib/dateFormat.ts @@ -6,11 +6,13 @@ import { isSameYear, subDays, } from 'date-fns'; +import { pluralize } from './strings'; export const oneMinute = 60; export const oneHour = 3600; export const oneDay = 86400; const oneWeek = 7 * oneDay; +const oneMonth = 30 * oneDay; export const oneYear = oneDay * 365; export const publishTimeRelativeShort = ( @@ -76,6 +78,7 @@ export enum TimeFormatType { Transaction = 'transaction', LastActivity = 'lastActivity', LiveTimer = 'liveTimer', + Experience = 'experience', } export function postDateFormat( @@ -228,6 +231,28 @@ export const getLastActivityDateFormat = ( }); }; +const publishExperienceTime = (start: Date, end: Date): string => { + const difference = + new Date(end || Date.now()).getTime() - new Date(start).getTime(); + const differenceInMonths = Math.floor(difference / oneMonth); + const years = Math.floor(differenceInMonths / 12); + const months = differenceInMonths % 12; + + if (years > 0) { + const yearCopy = `${years} ${pluralize('year', years)}`; + + if (months === 0) { + return yearCopy; + } + + return `${yearCopy} ${months} ${pluralize('month', months)}`; + } + + return months > 0 + ? `${months} ${pluralize('month', months)}` + : 'Less than a month'; +}; + export const getTodayTz = (timeZone: string, now = new Date()): Date => { const timeZonedToday = now.toLocaleString('en', { timeZone }); return new Date(timeZonedToday); @@ -276,6 +301,10 @@ export const formatDate = ({ value, type, now }: FormatDateProps): string => { return publishTimeLiveTimer(date, now); } + if (type === TimeFormatType.Experience) { + return publishExperienceTime(date, now); + } + return postDateFormat(date); }; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index a444d1a93d..e33a0565f1 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -218,6 +218,7 @@ export enum RequestKey { UserCandidatePreferences = 'user_candidate_preferences', KeywordAutocomplete = 'keyword_autocomplete', Autocomplete = 'autocomplete', + UserExperience = 'user_experience', } export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id]; diff --git a/packages/shared/src/lib/strings.ts b/packages/shared/src/lib/strings.ts index 500c1d9ba4..50916db94e 100644 --- a/packages/shared/src/lib/strings.ts +++ b/packages/shared/src/lib/strings.ts @@ -26,3 +26,10 @@ export const escapeRegexCharacters = (str: string): string => { export const pluralize = (word: string, count: number, append = 's') => `${word}${count === 1 ? '' : append}`; + +export const concatStrings = ( + strings: Array, + separator = ', ', +): string => { + return strings.filter(Boolean).join(separator); +}; diff --git a/packages/webapp/pages/[userId]/index.tsx b/packages/webapp/pages/[userId]/index.tsx index 8562c42f96..2b51f00bd5 100644 --- a/packages/webapp/pages/[userId]/index.tsx +++ b/packages/webapp/pages/[userId]/index.tsx @@ -7,6 +7,7 @@ import { NextSeo } from 'next-seo'; import type { NextSeoProps } from 'next-seo/lib/types'; import ProfileHeader from '@dailydotdev/shared/src/components/profile/ProfileHeader'; import { AutofillProfileBanner } from '@dailydotdev/shared/src/features/profile/components/AutofillProfileBanner'; +import { ProfileUserExperiences } from '@dailydotdev/shared/src/features/profile/components/experience/ProfileUserExperiences'; import { useUploadCv } from '@dailydotdev/shared/src/features/profile/hooks/useUploadCv'; import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; import { ProfileWidgets } from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets'; @@ -72,7 +73,7 @@ const ProfilePage = ({ )}
    -
    +
    {shouldShowBanner && (
    +
    );