Skip to content

Commit

Permalink
feat(dashboard): changelog news widget (#7345)
Browse files Browse the repository at this point in the history
  • Loading branch information
scopsy authored Dec 25, 2024
1 parent 5b221a8 commit 5980073
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 12 deletions.
179 changes: 179 additions & 0 deletions apps/dashboard/src/components/side-navigation/changelog-cards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { RiCloseLine } from 'react-icons/ri';
import { useQuery } from '@tanstack/react-query';
import { useTelemetry } from '@/hooks/use-telemetry';
import { TelemetryEvent } from '@/utils/telemetry';
import { useUser } from '@clerk/clerk-react';

type Changelog = {
id: string;
date: string;
title: string;
notes?: string;
version: number;
imageUrl?: string;
published: boolean;
};

declare global {
interface UserUnsafeMetadata {
dismissed_changelogs?: string[];
}
}

const CONSTANTS = {
CHANGELOG_API_URL: 'https://productlane.com/api/v1/changelogs/f13f1996-c9b0-4fea-8ee7-2c3faf6a832d',
NUMBER_OF_CARDS: 3,
CARD_OFFSET: 10,
SCALE_FACTOR: 0.06,
MAX_DISMISSED_IDS: 15,
MONTHS_TO_SHOW: 2,
} as const;

export function ChangelogStack() {
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
const track = useTelemetry();
const { user } = useUser();

const getDismissedChangelogs = (): string[] => {
return user?.unsafeMetadata?.dismissed_changelogs ?? [];
};

const updateDismissedChangelogs = async (changelogId: string) => {
if (!user) return;

const currentDismissed = getDismissedChangelogs();
const updatedDismissed = [...currentDismissed, changelogId].slice(-CONSTANTS.MAX_DISMISSED_IDS);

await user.update({
unsafeMetadata: {
...user.unsafeMetadata,
dismissed_changelogs: updatedDismissed,
},
});
};

const fetchChangelogs = async (): Promise<Changelog[]> => {
const response = await fetch(CONSTANTS.CHANGELOG_API_URL);
const rawData: Changelog[] = await response.json();

return filterChangelogs(rawData, getDismissedChangelogs());
};

const { data: fetchedChangelogs } = useQuery({
queryKey: ['changelogs'],
queryFn: fetchChangelogs,
// Refetch every hour to ensure users see new changelogs
staleTime: 60 * 60 * 1000,
});

useEffect(() => {
if (fetchedChangelogs) {
setChangelogs(fetchedChangelogs);
}
}, [fetchedChangelogs]);

const handleChangelogClick = async (changelog: Changelog) => {
track(TelemetryEvent.CHANGELOG_ITEM_CLICKED, { title: changelog.title });
window.open('https://roadmap.novu.co/changelog/' + changelog.id, '_blank');

await updateDismissedChangelogs(changelog.id);
setChangelogs((prev) => prev.filter((log) => log.id !== changelog.id));
};

const handleDismiss = async (e: React.MouseEvent, changelog: Changelog) => {
e.stopPropagation();
track(TelemetryEvent.CHANGELOG_ITEM_DISMISSED, { title: changelog.title });

await updateDismissedChangelogs(changelog.id);
setChangelogs((prev) => prev.filter((log) => log.id !== changelog.id));
};

return (
<div className="absolute bottom-10 w-full">
<div className="m-full relative mb-4 h-[190px]">
{changelogs.map((changelog, index) => (
<ChangelogCard
key={changelog.id}
changelog={changelog}
index={index}
totalCards={changelogs.length}
onDismiss={handleDismiss}
onClick={handleChangelogClick}
/>
))}
</div>
</div>
);
}

function filterChangelogs(changelogs: Changelog[], dismissedIds: string[]): Changelog[] {
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - CONSTANTS.MONTHS_TO_SHOW);

return changelogs
.filter((item) => {
const changelogDate = new Date(item.date);
return item.published && item.imageUrl && changelogDate >= cutoffDate;
})
.slice(0, CONSTANTS.NUMBER_OF_CARDS)
.filter((item) => !dismissedIds.includes(item.id));
}

function ChangelogCard({
changelog,
index,
totalCards,
onDismiss,
onClick,
}: {
changelog: Changelog;
index: number;
totalCards: number;
onDismiss: (e: React.MouseEvent, changelog: Changelog) => void;
onClick: (changelog: Changelog) => void;
}) {
return (
<motion.div
key={changelog.id}
className="border-stroke-soft rounded-8 group absolute flex h-[175px] w-full cursor-pointer flex-col justify-between overflow-hidden border bg-white p-3 shadow-xl shadow-black/[0.1] transition-[height] duration-200 dark:border-white/[0.1] dark:bg-black dark:shadow-white/[0.05]"
style={{ transformOrigin: 'top center' }}
animate={{
top: index * -CONSTANTS.CARD_OFFSET,
scale: 1 - index * CONSTANTS.SCALE_FACTOR,
zIndex: totalCards - index,
}}
whileHover={{
scale: (1 - index * CONSTANTS.SCALE_FACTOR) * 1.01,
y: -2,
transition: { duration: 0.2, ease: 'easeOut' },
}}
onClick={() => onClick(changelog)}
>
<div>
<div className="relative">
<div className="text-text-soft text-subheading-2xs">WHAT'S NEW</div>
<button
onClick={(e) => onDismiss(e, changelog)}
className="absolute right-[-8px] top-[-8px] p-1 text-neutral-500 opacity-0 transition-opacity duration-200 hover:text-neutral-900 group-hover:opacity-100 dark:hover:text-white"
>
<RiCloseLine size={16} />
</button>
<div className="mb-2 flex items-center justify-between">
<h5 className="text-label-sm text-text-strong mt-0 line-clamp-1 dark:text-white">{changelog.title}</h5>
</div>
{changelog.imageUrl && (
<div className="relative h-[110px] w-full">
<img
src={changelog.imageUrl}
alt={changelog.title}
className="h-full w-full rounded-[6px] object-cover"
/>
</div>
)}
</div>
</div>
</motion.div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, TooltipArrow } from '../primit
import { LEGACY_ROUTES, ROUTES } from '@/utils/routes';
import { Link } from 'react-router-dom';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { FeatureFlagsKeysEnum } from '@novu/shared';
import { useFetchSubscription } from '@/hooks/use-fetch-subscription';
import { FeatureFlagsKeysEnum, GetSubscriptionDto } from '@novu/shared';

const transition = 'transition-all duration-300 ease-out';

Expand Down Expand Up @@ -68,16 +67,11 @@ const CardContent = ({
</>
);

export const FreeTrialCard = () => {
const { subscription, daysLeft, isLoading } = useFetchSubscription();
export const FreeTrialCard = ({ subscription, daysLeft }: { subscription?: GetSubscriptionDto; daysLeft: number }) => {
const daysTotal = subscription && subscription.trial.daysTotal > 0 ? subscription.trial.daysTotal : 100;
const pluralizedDays = pluralizeDaysLeft(daysLeft);
const isV2BillingEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_DASHBOARD_BILLING_ENABLED);

if (isLoading || !subscription || !subscription.trial.isActive || subscription?.hasPaymentMethod) {
return null;
}

const pluralizedDays = pluralizeDaysLeft(daysLeft);
const cardClassName =
'bg-background group relative left-2 mb-2 flex w-[calc(100%-1rem)] cursor-pointer flex-col gap-2 rounded-lg p-3 shadow';

Expand Down
14 changes: 11 additions & 3 deletions apps/dashboard/src/components/side-navigation/side-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { SubscribersStayTunedModal } from './subscribers-stay-tuned-modal';
import { SidebarContent } from '@/components/side-navigation/sidebar';
import { NavigationLink } from './navigation-link';
import { GettingStartedMenuItem } from './getting-started-menu-item';
import { ChangelogStack } from './changelog-cards';
import { useFetchSubscription } from '../../hooks/use-fetch-subscription';
import * as Sentry from '@sentry/react';

const NavigationGroup = ({ children, label }: { children: ReactNode; label?: string }) => {
Expand All @@ -34,11 +36,15 @@ const NavigationGroup = ({ children, label }: { children: ReactNode; label?: str
};

export const SideNavigation = () => {
const { subscription, daysLeft, isLoading: isLoadingSubscription } = useFetchSubscription();
const isFreeTrialActive = subscription?.trial.isActive || subscription?.hasPaymentMethod;

const { currentEnvironment, environments, switchEnvironment } = useEnvironment();
const track = useTelemetry();
const isNewActivityFeedEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_NEW_DASHBOARD_ACTIVITY_FEED_ENABLED, false);
const isNewIntegrationStoreEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_INTEGRATION_STORE_ENABLED, false);
const environmentNames = useMemo(() => environments?.map((env) => env.name), [environments]);

const onEnvironmentChange = (value: string) => {
const environment = environments?.find((env) => env.name === value);
switchEnvironment(environment?.slug);
Expand Down Expand Up @@ -113,9 +119,11 @@ export const SideNavigation = () => {
</NavigationGroup>
</div>

<div className="mt-auto gap-8 pt-4">
<FreeTrialCard />

<div className="relative mt-auto gap-8 pt-4">
{!isFreeTrialActive && !isLoadingSubscription && <ChangelogStack />}{' '}
{isFreeTrialActive && !isLoadingSubscription && (
<FreeTrialCard subscription={subscription} daysLeft={daysLeft} />
)}
<NavigationGroup>
<button onClick={showPlainLiveChat} className="w-full">
<NavigationLink>
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/utils/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,7 @@ export enum TelemetryEvent {
WORKFLOW_PREFERENCES_OVERRIDE_USED = 'Workflow preferences override used',
EXPORT_TO_CODE_BANNER_REACTION = 'Export to Code banner reaction - [Promotional]',
EXTERNAL_LINK_CLICKED = 'External link clicked',
CHANGELOG_ITEM_CLICKED = 'Changelog item clicked',
CHANGELOG_ITEM_DISMISSED = 'Changelog item dismissed',
SHARE_FEEDBACK_LINK_CLICKED = 'Share feedback link clicked',
}
7 changes: 7 additions & 0 deletions apps/dashboard/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,13 @@ export default {
soft: 'hsl(var(--stroke-soft))',
white: 'hsl(var(--stroke-white))',
},
text: {
strong: 'hsl(var(--text-strong))',
sub: 'hsl(var(--text-sub))',
soft: 'hsl(var(--text-soft))',
disabled: 'hsl(var(--text-disabled))',
white: 'hsl(var(--text-white))',
},
faded: {
dark: 'hsl(var(--faded-dark))',
base: 'hsl(var(--faded-base))',
Expand Down

0 comments on commit 5980073

Please sign in to comment.