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 && (
+
+ )}
+
+ {!!subtitle && (
+
+ {subtitle}
+
+ )}
+ {!grouped && (
+
+ {company?.name}
+ {!!verified && (
+ Verified
+ )}
+
+ )}
+
+ {concatStrings(
+ [
+ formatDate({
+ value: startedAt,
+ type: TimeFormatType.TopReaderBadge,
+ }),
+ !!endedAt &&
+ formatDate({
+ value: endedAt,
+ type: TimeFormatType.TopReaderBadge,
+ }),
+ ],
+ ' - ',
+ )}
+
+ {!!externalReferenceId && (
+
+ {externalReferenceId}
+
+ )}
+
+
+ {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 = ({
)}
-
);