diff --git a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx
index f9dca357e7..183c870437 100644
--- a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx
+++ b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import type { ReactElement } from 'react';
import { ProfileSection } from '../ProfileSection';
-import { CoinIcon, DevCardIcon, UserIcon } from '../../icons';
+import { AnalyticsIcon, CoinIcon, DevCardIcon, UserIcon } from '../../icons';
import { settingsUrl, walletUrl, webappUrl } from '../../../lib/constants';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useHasAccessToCores } from '../../../hooks/useCoresFeature';
@@ -29,6 +29,11 @@ export const MainSection = (): ReactElement => {
href: `${settingsUrl}/customization/devcard`,
icon: DevCardIcon,
},
+ {
+ title: 'Analytics',
+ href: `${webappUrl}analytics`,
+ icon: AnalyticsIcon,
+ },
].filter(Boolean)}
/>
);
diff --git a/packages/shared/src/components/analytics/CombinedImpressionsChart.tsx b/packages/shared/src/components/analytics/CombinedImpressionsChart.tsx
new file mode 100644
index 0000000000..8ed1ee7279
--- /dev/null
+++ b/packages/shared/src/components/analytics/CombinedImpressionsChart.tsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import type { ReactElement } from 'react';
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import type { TickProp } from 'recharts/types/util/types';
+import { largeNumberFormat } from '../../lib';
+
+type ImpressionNode = {
+ name: string;
+ value: number;
+ isBoosted: boolean;
+};
+
+export interface CombinedImpressionsChartProps {
+ data: ImpressionNode[] | undefined;
+}
+
+const tickProp: TickProp = {
+ fill: 'var(--theme-text-tertiary)',
+ fontSize: '0.6875rem',
+};
+
+export const CombinedImpressionsChart = ({
+ data,
+}: CombinedImpressionsChartProps): ReactElement => {
+ return (
+
+ {!!data && (
+
+
+
+
+ largeNumberFormat(value)}
+ tickLine={false}
+ tick={tickProp}
+ interval={1}
+ />
+ (
+
+ {largeNumberFormat(payload?.[0]?.value || 0)} {' '}
+ impressions on {label}
+
+ )}
+ />
+
+ {data.map((entry) => {
+ return (
+ |
+ );
+ })}
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts
index a846ebabd2..5e4ae1dac6 100644
--- a/packages/shared/src/graphql/users.ts
+++ b/packages/shared/src/graphql/users.ts
@@ -593,6 +593,41 @@ export interface UserProfileAnalyticsHistory {
updatedAt: Date;
}
+export interface UserPostsAnalytics {
+ id: string;
+ impressions: number;
+ reach: number;
+ upvotes: number;
+ comments: number;
+ bookmarks: number;
+ awards: number;
+ profileViews: number;
+ followers: number;
+ reputation: number;
+ coresEarned: number;
+ shares: number;
+ clicks: number;
+ upvotesRatio: number;
+}
+
+export interface UserPostsAnalyticsHistoryNode {
+ date: string;
+ impressions: number;
+ impressionsAds: number;
+}
+
+export interface UserPostWithAnalytics {
+ id: string;
+ title: string | null;
+ image: string | null;
+ createdAt: string;
+ impressions: number;
+ upvotes: number;
+ reputation: number;
+ isBoosted: boolean;
+ commentsPermalink: string;
+}
+
export const getReadingStreak = async (): Promise => {
const res = await gqlClient.request(USER_STREAK_QUERY);
@@ -892,3 +927,59 @@ export const updateNotificationSettings = async (
notificationFlags,
});
};
+
+export const USER_POSTS_ANALYTICS_QUERY = gql`
+ query UserPostsAnalytics {
+ userPostsAnalytics {
+ id
+ impressions
+ reach
+ upvotes
+ comments
+ bookmarks
+ awards
+ profileViews
+ followers
+ reputation
+ coresEarned
+ shares
+ clicks
+ upvotesRatio
+ }
+ }
+`;
+
+export const USER_POSTS_ANALYTICS_HISTORY_QUERY = gql`
+ query UserPostsAnalyticsHistory {
+ userPostsAnalyticsHistory {
+ date
+ impressions
+ impressionsAds
+ }
+ }
+`;
+
+export const USER_POSTS_WITH_ANALYTICS_QUERY = gql`
+ query UserPostsWithAnalytics($after: String, $first: Int) {
+ userPostsWithAnalytics(after: $after, first: $first) {
+ edges {
+ cursor
+ node {
+ id
+ title
+ image
+ createdAt
+ impressions
+ upvotes
+ reputation
+ isBoosted
+ commentsPermalink
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+`;
diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts
index bf95982f7c..f4e40979d5 100644
--- a/packages/shared/src/lib/query.ts
+++ b/packages/shared/src/lib/query.ts
@@ -214,6 +214,9 @@ export enum RequestKey {
PostAnalyticsHistory = 'post_analytics_history',
ProfileAnalytics = 'profile_analytics',
ProfileAnalyticsHistory = 'profile_analytics_history',
+ UserPostsAnalytics = 'user_posts_analytics',
+ UserPostsAnalyticsHistory = 'user_posts_analytics_history',
+ UserPostsWithAnalytics = 'user_posts_with_analytics',
CheckLocation = 'check_location',
GenerateBrief = 'generate_brief',
Opportunity = 'opportunity',
diff --git a/packages/webapp/components/analytics/AnalyticsEmptyState.tsx b/packages/webapp/components/analytics/AnalyticsEmptyState.tsx
new file mode 100644
index 0000000000..9549f88fc1
--- /dev/null
+++ b/packages/webapp/components/analytics/AnalyticsEmptyState.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import type { ReactElement } from 'react';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import {
+ Button,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { PlusIcon } from '@dailydotdev/shared/src/components/icons';
+import { link } from '@dailydotdev/shared/src/lib/links';
+import Link from '@dailydotdev/shared/src/components/utilities/Link';
+
+export const AnalyticsEmptyState = (): ReactElement => {
+ return (
+
+
+
+ It's never too late to start posting
+
+
+ Hardest part of being a developer? Where do we start... it's
+ everything. Go on, share with us your best rant.
+
+
+
+
} tag="a">
+ New post
+
+
+
+ );
+};
diff --git a/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx b/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx
new file mode 100644
index 0000000000..2c2d7979d5
--- /dev/null
+++ b/packages/webapp/components/analytics/UserPostsAnalyticsTable.tsx
@@ -0,0 +1,191 @@
+import React from 'react';
+import type { ReactElement } from 'react';
+import Link from '@dailydotdev/shared/src/components/utilities/Link';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import {
+ Button,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { BoostIcon } from '@dailydotdev/shared/src/components/icons/Boost';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import { largeNumberFormat } from '@dailydotdev/shared/src/lib';
+import {
+ TimeFormatType,
+ formatDate,
+} from '@dailydotdev/shared/src/lib/dateFormat';
+import type { UserPostWithAnalytics } from '@dailydotdev/shared/src/graphql/users';
+import { webappUrl } from '@dailydotdev/shared/src/lib/constants';
+import { LazyImage } from '@dailydotdev/shared/src/components/LazyImage';
+import { cloudinaryPostImageCoverPlaceholder } from '@dailydotdev/shared/src/lib/image';
+
+export interface UserPostsAnalyticsTableProps {
+ posts: UserPostWithAnalytics[];
+ isLoading: boolean;
+ hasNextPage: boolean | undefined;
+ isFetchingNextPage: boolean;
+ fetchNextPage: () => void;
+}
+
+export const UserPostsAnalyticsTable = ({
+ posts,
+ isLoading,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+}: UserPostsAnalyticsTableProps): ReactElement => {
+ if (isLoading) {
+ return (
+
+
+ Loading posts...
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Post
+
+
+
+
+ Date
+
+
+
+
+ Reputation
+
+
+
+
+ Impressions
+
+
+
+
+ Upvotes
+
+
+
+
+
+ {posts.map((post) => (
+
+
+
+
+
+
+
+
+ {post.title || 'Untitled'}
+
+ {post.isBoosted && (
+
+ )}
+
+
+
+
+
+
+
+ {formatDate({
+ value: post.createdAt,
+ type: TimeFormatType.Post,
+ })}
+
+
+
+
+ {largeNumberFormat(post.reputation)}
+
+
+
+
+ {largeNumberFormat(post.impressions)}
+
+
+
+
+ {largeNumberFormat(post.upvotes)}
+
+
+
+ ))}
+
+
+
+ {hasNextPage && (
+
fetchNextPage()}
+ loading={isFetchingNextPage}
+ className="mx-auto"
+ >
+ Load more
+
+ )}
+
+ );
+};
diff --git a/packages/webapp/pages/analytics/index.tsx b/packages/webapp/pages/analytics/index.tsx
new file mode 100644
index 0000000000..23aaa6fef0
--- /dev/null
+++ b/packages/webapp/pages/analytics/index.tsx
@@ -0,0 +1,362 @@
+import type { ReactElement } from 'react';
+import React, { useCallback, useMemo } from 'react';
+import type { NextSeoProps } from 'next-seo';
+import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
+import { addDays, subDays } from 'date-fns';
+import {
+ ResponsivePageContainer,
+ Divider,
+ pageBorders,
+} from '@dailydotdev/shared/src/components/utilities';
+import { LayoutHeader } from '@dailydotdev/shared/src/components/layout/common';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import { DataTile } from '@dailydotdev/shared/src/components/DataTile';
+import {
+ AddUserIcon,
+ ReputationIcon,
+ UpvoteIcon,
+ EyeIcon,
+} from '@dailydotdev/shared/src/components/icons';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import classed from '@dailydotdev/shared/src/lib/classed';
+import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext';
+import {
+ generateQueryKey,
+ RequestKey,
+ StaleTime,
+ getNextPageParam,
+} from '@dailydotdev/shared/src/lib/query';
+import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
+import {
+ USER_POSTS_ANALYTICS_QUERY,
+ USER_POSTS_ANALYTICS_HISTORY_QUERY,
+ USER_POSTS_WITH_ANALYTICS_QUERY,
+} from '@dailydotdev/shared/src/graphql/users';
+import type {
+ UserPostsAnalytics,
+ UserPostsAnalyticsHistoryNode,
+ UserPostWithAnalytics,
+} from '@dailydotdev/shared/src/graphql/users';
+import type { Connection } from '@dailydotdev/shared/src/graphql/common';
+import {
+ dateFormatInTimezone,
+ DEFAULT_TIMEZONE,
+} from '@dailydotdev/shared/src/lib/timezones';
+import dynamic from 'next/dynamic';
+import ProtectedPage from '../../components/ProtectedPage';
+import { getLayout } from '../../components/layouts/MainLayout';
+import { UserPostsAnalyticsTable } from '../../components/analytics/UserPostsAnalyticsTable';
+import { AnalyticsEmptyState } from '../../components/analytics/AnalyticsEmptyState';
+
+const CombinedImpressionsChart = dynamic(
+ () =>
+ import(
+ '@dailydotdev/shared/src/components/analytics/CombinedImpressionsChart'
+ ).then((mod) => mod.CombinedImpressionsChart),
+ {
+ loading: () =>
,
+ },
+);
+
+const dividerClassName = 'bg-border-subtlest-tertiary';
+const SectionContainer = classed('div', 'flex flex-col gap-4');
+const SectionHeader = ({
+ children,
+}: {
+ children: React.ReactNode;
+}): ReactElement => {
+ return (
+
+ {children}
+
+ );
+};
+
+const POST_ANALYTICS_HISTORY_LIMIT = 45;
+
+type ImpressionNode = {
+ name: string;
+ value: number;
+ isBoosted: boolean;
+};
+
+const Analytics = (): ReactElement => {
+ const { user } = useAuthContext();
+ const userTimezone = user?.timezone || DEFAULT_TIMEZONE;
+
+ const analyticsQueryKey = generateQueryKey(
+ RequestKey.UserPostsAnalytics,
+ user,
+ );
+
+ const historyQueryKey = generateQueryKey(
+ RequestKey.UserPostsAnalyticsHistory,
+ user,
+ );
+
+ const postsQueryKey = generateQueryKey(
+ RequestKey.UserPostsWithAnalytics,
+ user,
+ );
+
+ const { data: analytics } = useQuery({
+ queryKey: analyticsQueryKey,
+ queryFn: async () => {
+ const result = await gqlClient.request<{
+ userPostsAnalytics: UserPostsAnalytics;
+ }>(USER_POSTS_ANALYTICS_QUERY);
+ return result.userPostsAnalytics;
+ },
+ staleTime: StaleTime.Default,
+ enabled: !!user,
+ });
+
+ const { data: historyData } = useQuery({
+ queryKey: historyQueryKey,
+ queryFn: async () => {
+ const result = await gqlClient.request<{
+ userPostsAnalyticsHistory: UserPostsAnalyticsHistoryNode[];
+ }>(USER_POSTS_ANALYTICS_HISTORY_QUERY);
+ return result.userPostsAnalyticsHistory;
+ },
+ staleTime: StaleTime.Default,
+ enabled: !!user,
+ select: useCallback(
+ (data: UserPostsAnalyticsHistoryNode[]): ImpressionNode[] => {
+ if (!data) {
+ return [];
+ }
+
+ const impressionsMap = data.reduce((acc, item) => {
+ const date = dateFormatInTimezone(
+ new Date(item.date),
+ 'yyyy-MM-dd',
+ userTimezone,
+ );
+
+ acc[date] = {
+ name: new Date(item.date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ }),
+ value: item.impressions,
+ isBoosted: item.impressionsAds > 0,
+ };
+
+ return acc;
+ }, {} as Record);
+
+ const historyCutOffDate = subDays(
+ new Date(),
+ POST_ANALYTICS_HISTORY_LIMIT - 1,
+ );
+
+ const impressionsData: ImpressionNode[] = [];
+
+ for (let i = 0; i < POST_ANALYTICS_HISTORY_LIMIT; i += 1) {
+ const paddedDate = addDays(historyCutOffDate, i);
+ const date = dateFormatInTimezone(
+ paddedDate,
+ 'yyyy-MM-dd',
+ userTimezone,
+ );
+
+ if (impressionsMap[date]) {
+ impressionsData.push(impressionsMap[date]);
+ } else {
+ impressionsData.push({
+ name: paddedDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ }),
+ value: 0,
+ isBoosted: false,
+ });
+ }
+ }
+
+ return impressionsData;
+ },
+ [userTimezone],
+ ),
+ });
+
+ const {
+ data: postsData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading: isLoadingPosts,
+ } = useInfiniteQuery({
+ queryKey: postsQueryKey,
+ queryFn: async ({ pageParam }) => {
+ const result = await gqlClient.request<{
+ userPostsWithAnalytics: Connection;
+ }>(USER_POSTS_WITH_ANALYTICS_QUERY, {
+ first: 20,
+ after: pageParam,
+ });
+ return result.userPostsWithAnalytics;
+ },
+ initialPageParam: null as string | null,
+ getNextPageParam: (lastPage) => getNextPageParam(lastPage?.pageInfo),
+ staleTime: StaleTime.Default,
+ enabled: !!user,
+ });
+
+ const posts = useMemo(
+ () =>
+ postsData?.pages.flatMap((page) => page.edges.map((e) => e.node)) ?? [],
+ [postsData],
+ );
+
+ const hasNoPosts = !isLoadingPosts && posts.length === 0;
+
+ const hasChartData = useMemo(() => {
+ if (!historyData || historyData.length === 0) {
+ return false;
+ }
+ return historyData.some((item) => item.value > 0);
+ }, [historyData]);
+
+ return (
+
+
+
+
+ Analytics
+
+
+
+
+ Overview
+
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+
+
+
+
+
+ Impressions in the last 45 days
+
+ {hasChartData && (
+
+ )}
+
+ {hasChartData ? (
+
+ ) : (
+
+
+ No impression data yet. Check back after your posts get some
+ views.
+
+
+ )}
+
+
+
+ Posts
+ {hasNoPosts ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
+
+const seo: NextSeoProps = { title: 'Analytics', nofollow: true, noindex: true };
+
+Analytics.getLayout = getLayout;
+Analytics.layoutProps = { seo };
+
+export default Analytics;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3f6ae2a760..59d14e2491 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15885,11 +15885,7 @@ snapshots:
pretty-format: 26.6.2
throat: 5.0.0
transitivePeerDependencies:
- - bufferutil
- - canvas
- supports-color
- - ts-node
- - utf-8-validate
jest-junit@12.3.0:
dependencies: