diff --git a/packages/shared/src/components/archive/ArchiveCopyLinkButton.tsx b/packages/shared/src/components/archive/ArchiveCopyLinkButton.tsx new file mode 100644 index 00000000000..123a5a93ceb --- /dev/null +++ b/packages/shared/src/components/archive/ArchiveCopyLinkButton.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ArchiveScopeType } from '../../graphql/archive'; +import type { ArchiveScopeInfo } from '../../lib/archive'; +import { CopyLinkButton } from '../share/CopyLinkButton'; +import { LogEvent, Origin } from '../../lib/log'; +import { ReferralCampaignKey } from '../../lib/referral'; + +interface ArchiveCopyLinkButtonProps { + scopeType: ArchiveScopeInfo['scopeType']; + scopeId?: string; + text: string; +} + +const shareByScope: Record< + ArchiveScopeInfo['scopeType'], + { event: LogEvent; cid: ReferralCampaignKey } +> = { + [ArchiveScopeType.Global]: { + event: LogEvent.ShareArchive, + cid: ReferralCampaignKey.Generic, + }, + [ArchiveScopeType.Tag]: { + event: LogEvent.ShareTag, + cid: ReferralCampaignKey.ShareTag, + }, + [ArchiveScopeType.Source]: { + event: LogEvent.ShareSource, + cid: ReferralCampaignKey.ShareSource, + }, +}; + +export const ArchiveCopyLinkButton = ({ + scopeType, + scopeId, + text, +}: ArchiveCopyLinkButtonProps): ReactElement => { + const { event, cid } = shareByScope[scopeType]; + + return ( + ({ + event_name: event, + target_id: scopeId, + }), + }} + /> + ); +}; diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx new file mode 100644 index 00000000000..3e4a57559ec --- /dev/null +++ b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import { ArchiveFeedPage } from './ArchiveFeedPage'; +import { ArchivePeriodType, ArchiveScopeType } from '../../graphql/archive'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +it('logs a copy link on a monthly best-of page as an archive share', () => { + const logEvent = jest.fn(); + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareArchive, + target_id: undefined, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.ArchiveIndex, + }), + }); +}); diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.tsx index d9d00076de9..b745b27ce96 100644 --- a/packages/shared/src/components/archive/ArchiveFeedPage.tsx +++ b/packages/shared/src/components/archive/ArchiveFeedPage.tsx @@ -4,13 +4,14 @@ import classNames from 'classnames'; import type { Archive, ArchiveItem } from '../../graphql/archive'; import { ArchivePeriodType } from '../../graphql/archive'; import type { ArchiveScopeInfo } from '../../lib/archive'; -import { getArchiveTitle, getArchiveIndexUrl } from '../../lib/archive'; +import { getArchiveIndexUrl, getArchivePeriodLabel } from '../../lib/archive'; import { ArchiveNavigation } from './ArchiveNavigation'; import { ArchivePostItem } from './ArchivePostItem'; import { ElementPlaceholder } from '../ElementPlaceholder'; import Link from '../utilities/Link'; import { ArrowIcon } from '../icons'; import { IconSize } from '../Icon'; +import { ArchiveCopyLinkButton } from './ArchiveCopyLinkButton'; interface ArchiveFeedPageProps { scopeType: ArchiveScopeInfo['scopeType']; @@ -83,7 +84,7 @@ export function ArchiveFeedPage({ isLoading, className, }: ArchiveFeedPageProps): ReactElement { - const title = getArchiveTitle({ + const periodLabel = getArchivePeriodLabel({ periodType, periodStart: periodType === ArchivePeriodType.Month && month @@ -104,9 +105,16 @@ export function ArchiveFeedPage({ )} > {/* Header */} -

- Best of {scopeName} — {title.replace('Best of ', '')} -

+
+

+ Best of {scopeName} — {periodLabel} +

+ +
{/* Top navigation */} {/* Header */} -

- Best of {scopeName} — Archive -

+
+

+ Best of {scopeName} — Archive +

+ +
{/* Archive grid by year */} + {/* Hover-revealed only where hover exists; always there on touch. */} + ({ + event_name: LogEvent.ShareSource, + target_id: item.id, + }), + }} + /> ))} diff --git a/packages/shared/src/components/cards/squad/SquadGrid.tsx b/packages/shared/src/components/cards/squad/SquadGrid.tsx index 3d99843ee8c..53a7047bac9 100644 --- a/packages/shared/src/components/cards/squad/SquadGrid.tsx +++ b/packages/shared/src/components/cards/squad/SquadGrid.tsx @@ -9,8 +9,8 @@ import { Image, ImageType } from '../../image/Image'; import { cloudinarySquadsDirectoryCardBannerDefault } from '../../../lib/image'; import type { UnFeaturedSquadCardProps } from './common/types'; import { SquadActionButton } from '../../squads/SquadActionButton'; -import { Origin } from '../../../lib/log'; -import { ButtonVariant } from '../../buttons/common'; +import { LogEvent, Origin } from '../../../lib/log'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; import { anchorDefaultRel } from '../../../lib/strings'; import { useCampaignById } from '../../../graphql/campaigns'; import { Tooltip } from '../../tooltip/Tooltip'; @@ -27,6 +27,8 @@ import { import { useSquadsDirectoryLogging } from './common/useSquadsDirectoryLogging'; import { AdViewability } from '../ad/common/AdViewability'; import { useScrambler } from '../../../hooks/useScrambler'; +import { CopyLinkButton } from '../../share/CopyLinkButton'; +import { ReferralCampaignKey } from '../../../lib/referral'; export enum SourceCardBorderColor { Avocado = 'avocado', @@ -87,6 +89,15 @@ export const SquadGrid = ({ }); const borderColor = border || color || SourceCardBorderColor.Avocado; const { ref, onClickAd, onViewableAd } = useSquadsDirectoryLogging(ad); + const shareProps = { + text: `Check out the ${name} squad on daily.dev`, + link: permalink, + cid: ReferralCampaignKey.ShareSource, + logObject: () => ({ + event_name: LogEvent.ShareSource, + target_id: source.id, + }), + }; const promotedText = useScrambler('Promoted'); const promotedByTooltip = useScrambler( campaign ? `Promoted by @${campaign.user.username}` : null, @@ -158,13 +169,24 @@ export const SquadGrid = ({ )} - +
+
+ +
+ +
{children} diff --git a/packages/shared/src/components/cards/squad/SquadList.spec.tsx b/packages/shared/src/components/cards/squad/SquadList.spec.tsx index bcf0976129c..3acce269e6b 100644 --- a/packages/shared/src/components/cards/squad/SquadList.spec.tsx +++ b/packages/shared/src/components/cards/squad/SquadList.spec.tsx @@ -1,5 +1,5 @@ import type { RenderResult } from '@testing-library/react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import nock from 'nock'; @@ -21,6 +21,9 @@ import { CONTENT_PREFERENCE_STATUS_QUERY, ContentPreferenceType, } from '../../../graphql/contentPreference'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { LogEvent, Origin } from '../../../lib/log'; +import { ShareProvider } from '../../../lib/share'; const squadsList = [generateTestSquad()]; const members = generateMembersList(); @@ -81,6 +84,40 @@ it('should render the component and member count when members are provided', () expect(memberCount.innerHTML).toEqual(`${length} members`); }); +it('copies the squad link from the row without following the row link', () => { + const writeText = jest.fn().mockResolvedValue(undefined); + const logEvent = jest.fn(); + const onRowLinkClick = jest.fn(); + Object.assign(navigator, { clipboard: { writeText } }); + render( + + + , + ); + screen + .getByTitle(admin.source.name) + .addEventListener('click', onRowLinkClick); + + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(writeText).toHaveBeenCalledWith( + `${admin.source.permalink}?userid=${loggedUser.id}&cid=share_source`, + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareSource, + target_id: admin.source.id, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.SquadDirectory, + }), + }); + expect(onRowLinkClick).not.toHaveBeenCalled(); +}); + it('should render the component with a view squad button', async () => { mockGraphQL({ request: { diff --git a/packages/shared/src/components/cards/squad/SquadList.tsx b/packages/shared/src/components/cards/squad/SquadList.tsx index a5f4ced5283..c301a8e5456 100644 --- a/packages/shared/src/components/cards/squad/SquadList.tsx +++ b/packages/shared/src/components/cards/squad/SquadList.tsx @@ -11,13 +11,15 @@ import { Separator } from '../common/common'; import { largeNumberFormat } from '../../../lib'; import { CardLink } from '../common/Card'; import { SquadActionButton } from '../../squads/SquadActionButton'; -import { Origin } from '../../../lib/log'; +import { LogEvent, Origin } from '../../../lib/log'; import { Image, ImageType } from '../../image/Image'; -import { ButtonVariant } from '../../buttons/common'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; import type { Ad } from '../../../graphql/posts'; import { useSquadsDirectoryLogging } from './common/useSquadsDirectoryLogging'; import { AdViewability } from '../ad/common/AdViewability'; import { useScrambler } from '../../../hooks/useScrambler'; +import { CopyLinkButton } from '../../share/CopyLinkButton'; +import { ReferralCampaignKey } from '../../../lib/referral'; interface SquadListProps extends ComponentProps<'div'> { squad: Squad; @@ -37,6 +39,15 @@ export const SquadList = ({ const campaignId = ad?.data?.source?.flags?.campaignId; const { ref, onClickAd, onViewableAd } = useSquadsDirectoryLogging(ad); const promotedText = useScrambler('Promoted'); + const shareProps = { + text: `Check out the ${name} squad on daily.dev`, + link: permalink, + cid: ReferralCampaignKey.ShareSource, + logObject: () => ({ + event_name: LogEvent.ShareSource, + target_id: squad.id, + }), + }; return (
-
+
{name} @@ -80,14 +91,23 @@ export const SquadList = ({ )}
- +
+ + +
{children} {!!ad && }
diff --git a/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx b/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx index ef4f41d2b9d..5e72a91ed78 100644 --- a/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx +++ b/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx @@ -8,22 +8,38 @@ import { TypographyTag, TypographyType, } from '../../typography/Typography'; -import { largeNumberFormat } from '../../../lib'; +import { largeNumberFormat } from '../../../lib/numberFormat'; import { Separator } from '../common/common'; import type { UnFeaturedSquadCardProps } from './common/types'; -import { Origin } from '../../../lib/log'; +import { LogEvent, Origin } from '../../../lib/log'; import { SquadActionButton } from '../../squads/SquadActionButton'; -import { ButtonVariant } from '../../buttons/common'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; import { Image, ImageType } from '../../image/Image'; +import { CopyLinkButton } from '../../share/CopyLinkButton'; +import { ReferralCampaignKey } from '../../../lib/referral'; export const UnfeaturedSquadGrid = ({ source, className, }: UnFeaturedSquadCardProps): ReactElement => { const title = source.name; + const shareProps = { + text: `Check out the ${title} squad on daily.dev`, + link: source.permalink, + cid: ReferralCampaignKey.ShareSource, + logObject: () => ({ + event_name: LogEvent.ShareSource, + target_id: source.id, + }), + }; return ( - + - +
+ + +
{ + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +const renderComponent = () => + render( + + ({ + event_name: LogEvent.ShareSource, + target_id: 'theverge', + }), + }} + /> + , + ); + +it('writes the tracked link within the click, before the shortener answers', () => { + renderComponent(); + + // No await: Safari refuses a clipboard write once the click task has ended. + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(writeText).toHaveBeenCalledWith( + 'https://app.daily.dev/sources/theverge?userid=u1&cid=share_source', + ); +}); + +it('logs the share with its provider and placement', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareSource, + target_id: 'theverge', + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.SourcePage, + }), + }); +}); diff --git a/packages/shared/src/components/share/CopyLinkButton.tsx b/packages/shared/src/components/share/CopyLinkButton.tsx new file mode 100644 index 00000000000..3d7b2e088e8 --- /dev/null +++ b/packages/shared/src/components/share/CopyLinkButton.tsx @@ -0,0 +1,52 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { LinkIcon } from '../icons/Link'; +import { CopyStateIcon } from './CopyStateIcon'; +import { Tooltip } from '../tooltip/Tooltip'; +import type { UseShareOrCopyLinkProps } from '../../hooks/useShareOrCopyLink'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import type { Origin } from '../../lib/log'; + +interface CopyLinkButtonProps { + shareProps: UseShareOrCopyLinkProps & + Required>; + origin: Origin; + className?: string; + size?: ButtonSize; + variant?: ButtonVariant; +} + +const LABEL = 'Copy link'; +const COPIED_LABEL = 'Copied!'; + +export const CopyLinkButton = ({ + shareProps, + origin, + className, + size = ButtonSize.Small, + variant = ButtonVariant.Float, +}: CopyLinkButtonProps): ReactElement => { + // Stays false on the native-share path, where the OS sheet is the feedback. + const [copied, onShareOrCopyLink] = useShareOrCopyLink({ + ...shareProps, + logObject: (provider) => ({ + ...shareProps.logObject(provider), + extra: JSON.stringify({ provider, origin }), + }), + }); + + return ( + + )} + push( @@ -471,15 +484,7 @@ export const TagTopicPage = ({ feedId, }) } - shareProps={{ - text: `Check out the ${tag} tag on daily.dev`, - link: globalThis?.location?.href, - cid: ReferralCampaignKey.ShareTag, - logObject: () => ({ - event_name: LogEvent.ShareTag, - target_id: tag, - }), - }} + shareProps={shareProps} /> {/* SEO crawl paths preserved from the legacy tag page. */} diff --git a/packages/shared/src/hooks/integrations/useSourceIntegrationQuery.ts b/packages/shared/src/hooks/integrations/useSourceIntegrationQuery.ts index 72d56cc5be8..7c6199e127a 100644 --- a/packages/shared/src/hooks/integrations/useSourceIntegrationQuery.ts +++ b/packages/shared/src/hooks/integrations/useSourceIntegrationQuery.ts @@ -12,7 +12,7 @@ import { useAuthContext } from '../../contexts/AuthContext'; export type UseSourceIntegrationQueryProps = { userIntegrationType: UserIntegrationType; - sourceId: string; + sourceId?: string; queryOptions?: UseQueryOptions; }; diff --git a/packages/shared/src/hooks/useCoresFeature.ts b/packages/shared/src/hooks/useCoresFeature.ts index f09913e56e8..0720e87ef28 100644 --- a/packages/shared/src/hooks/useCoresFeature.ts +++ b/packages/shared/src/hooks/useCoresFeature.ts @@ -46,7 +46,7 @@ export const useCanAwardUser = (props: UseCanAwardUserProps): boolean => { }; interface UseGetSquadAwardAdminProps { - sendingUser: LoggedUser; + sendingUser?: LoggedUser; squad: Squad; } @@ -57,18 +57,21 @@ export const useGetSquadAwardAdmin = ( if ( props.squad.currentMember?.role === SourceMemberRole.Admin || - !hasAccess + !hasAccess || + !props.sendingUser ) { return null; } + const { sendingUser } = props; + // Return the first user that's eligible for cores return props.squad?.privilegedMembers?.find((receivingUser: SourceMember) => { if (receivingUser.role !== SourceMemberRole.Admin) { return false; } return canAwardUser({ - sendingUser: props.sendingUser, + sendingUser, receivingUser: receivingUser.user as unknown as LoggedUser, }); })?.user; diff --git a/packages/shared/src/hooks/useShareOrCopyLink.ts b/packages/shared/src/hooks/useShareOrCopyLink.ts index 0c635e392dd..16afe36c36f 100644 --- a/packages/shared/src/hooks/useShareOrCopyLink.ts +++ b/packages/shared/src/hooks/useShareOrCopyLink.ts @@ -4,7 +4,7 @@ import { useCopyLink } from './useCopy'; import { ShareProvider } from '../lib/share'; import type { LogEvent } from './log/useLogQueue'; import { useGetShortUrl } from './utils/useGetShortUrl'; -import type { ReferralCampaignKey } from '../lib'; +import type { ReferralCampaignKey } from '../lib/referral'; import { shouldUseNativeShare } from '../lib/func'; export interface UseShareOrCopyLinkProps { @@ -22,10 +22,9 @@ export function useShareOrCopyLink({ }: UseShareOrCopyLinkProps): ReturnType { const { logEvent } = useLogContext(); const [copying, copyLink] = useCopyLink(); - const { getShortUrl } = useGetShortUrl(); + const { getShortUrl, getTrackedUrl } = useGetShortUrl(); const onShareOrCopy: CopyNotifyFunction = async () => { - const shortLink = cid ? await getShortUrl(link, cid) : link; const logShareEvent = (provider: ShareProvider): void => { if (!logObject) { return; @@ -35,6 +34,8 @@ export function useShareOrCopyLink({ }; if (shouldUseNativeShare()) { + const shortLink = cid ? await getShortUrl(link, cid) : link; + try { await navigator.share({ text: `${text}\n${shortLink}`, @@ -45,7 +46,10 @@ export function useShareOrCopyLink({ } } else { logShareEvent(ShareProvider.CopyLink); - copyLink({ link: shortLink }); + copyLink({ + link: cid ? getTrackedUrl(link, cid) : link, + shorten: !!cid, + }); } }; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index bf644bf2bb5..aa2f72ad92c 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -23,6 +23,8 @@ export enum Origin { TagPage = 'tag page', ToolPage = 'tool page', ToolsDirectory = 'tools directory', + SourceDirectory = 'source directory', + ArchiveIndex = 'archive index', Profile = 'profile', PostTags = 'post tags', // squads - start @@ -361,6 +363,7 @@ export enum LogEvent { ShareLog = 'share log', ShareWorld = 'share world', ShareTool = 'share tool', + ShareArchive = 'share archive', // End Share /* Start World `world view` is the denominator and fires whatever happens next, so the diff --git a/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx deleted file mode 100644 index 042cb0bf4f4..00000000000 --- a/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - BellIcon, - BlockIcon, - MenuIcon, -} from '@dailydotdev/shared/src/components/icons'; -import type { DeviceName } from '../surfaceChrome'; -import { - AVATAR, - Category, - Control, - Device, - Rail, - SurfacePage, - Variant, -} from '../surfaceChrome'; - -/** CustomFeedOptionsMenu — the same two items on tags, sources and profiles. */ - -const Options = () => ( - - - - - - - -); - -/* ---------------------------------------------------------------- sources */ - -/** The source page is left-aligned, and its actions sit below the title. */ -const SourceScreen = ({ device }: { device: DeviceName }) => ( - -
- - Sources / XDA Developers - - -
- -

- XDA Developers -

-
- -
- -
- -

- News and reviews for developers, by developers. -

- -
- {['#android', '#hardware', '#reviews'].map((tag) => ( - - {tag} - - ))} -
-
-
-); - -/* ----------------------------------------------------------------- squads */ - -/** SquadEntityCard: w-80, image and actions on one row, body under it. */ -const SquadCard = () => ( -
-
- -
- - - {/* SquadHeaderMenu — `invisible group-hover/menu:visible`. */} -
-
-
- - Frontend Fans - -

- Everything CSS, React and the browser. Ship it and show it. -

- - 2.4K Members - · - 12K Upvotes - -
-
-); - -const SquadScreen = ({ device }: { device: DeviceName }) => ( - -
-

Squads

-
- -
-
-
-); - -/* ---------------------------------------------------------------- archive */ - -/** ArchiveIndexPage: a month grid, no posts and no controls. */ -const ArchiveScreen = ({ device }: { device: DeviceName }) => ( - -
- - Sources / XDA Developers / Best of - -
-

- Best of XDA Developers — Archive -

- -
- -
- {['2026', '2025'].map((year) => ( -
-

- {year} -

-
- {['January', 'February', 'March', 'April'].map((month) => ( - - - {month} - - - 24 posts - - - ))} -
-
- ))} -
-
-
-); - -/* -------------------------------------------------------------------- page */ - -const Rails = ({ - Screen, -}: { - Screen: React.ComponentType<{ device: DeviceName }>; -}) => ( - - - - - -); - -const Directories = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Topic & directory pages', - component: Directories, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx index f6ed7e190c9..4b3b7e4d2f1 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx @@ -96,8 +96,8 @@ const PAGES: React.ReactNode[][] = [ ], [ 'Topic & directory pages', - '#6357 #6363 #6364 #6359', - 'Tags, sources, squads, collections — sharing beside a primary CTA without competing with it', + '#6566', + 'Shipped: the live tag, source, squad and best-of pages are the reference, so they have no mockup here', ], [ 'Invite & feed export', diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index b3efe367405..a02dbfc4473 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -300,7 +300,7 @@ const SourcePage = ({

{source.name}

- +
{source?.description && (

{source?.description}