diff --git a/packages/playwright/exports/explore-full-2026-03-25T21-08-30-518Z.pdf b/packages/playwright/exports/explore-full-2026-03-25T21-08-30-518Z.pdf new file mode 100644 index 00000000000..f82cc4bef00 Binary files /dev/null and b/packages/playwright/exports/explore-full-2026-03-25T21-08-30-518Z.pdf differ diff --git a/packages/playwright/exports/explore-full-2026-03-25T21-08-30-518Z.png b/packages/playwright/exports/explore-full-2026-03-25T21-08-30-518Z.png new file mode 100644 index 00000000000..e26661523cc Binary files /dev/null and b/packages/playwright/exports/explore-full-2026-03-25T21-08-30-518Z.png differ diff --git a/packages/shared/src/components/cards/brief/BriefCard/BriefCard.tsx b/packages/shared/src/components/cards/brief/BriefCard/BriefCard.tsx index ba228d7fe10..13fa3a15687 100644 --- a/packages/shared/src/components/cards/brief/BriefCard/BriefCard.tsx +++ b/packages/shared/src/components/cards/brief/BriefCard/BriefCard.tsx @@ -29,6 +29,8 @@ export type BriefCardProps = { container: string; card: string; }>; + showCloseButton?: boolean; + showBorder?: boolean; animationSrc?: string; progressPercentage?: number; headnote?: ReactNode; @@ -248,6 +250,7 @@ export const BriefCardInternal = ( We sent an AI agent to read the entire internet. Every release, every hot take, and every unreadable blog post from the past week. It's diff --git a/packages/shared/src/components/cards/brief/BriefCard/BriefCardDefault.tsx b/packages/shared/src/components/cards/brief/BriefCard/BriefCardDefault.tsx index d87c57cf27a..c3a02548f86 100644 --- a/packages/shared/src/components/cards/brief/BriefCard/BriefCardDefault.tsx +++ b/packages/shared/src/components/cards/brief/BriefCard/BriefCardDefault.tsx @@ -9,12 +9,8 @@ import { } from '../../../typography/Typography'; import { briefButtonBg, - briefCardBg, - briefCardBorder, } from '../../../../styles/custom'; import type { BriefCardProps } from './BriefCard'; -import { BriefGradientIcon } from '../../../icons'; -import { IconSize } from '../../../Icon'; import { Button, ButtonSize, ButtonVariant } from '../../../buttons/Button'; import { BriefingType, @@ -32,15 +28,12 @@ import { LogEvent, TargetType } from '../../../../lib/log'; export type BriefCardDefaultProps = BriefCardProps; -const rootStyle = { - border: briefCardBorder, - background: briefCardBg, -}; - export const BriefCardDefault = ({ className, title, children, + showCloseButton = true, + showBorder = true, }: BriefCardDefaultProps): ReactElement => { const briefContext = useBriefContext(); const { displayToast } = useToastNotification(); @@ -98,23 +91,32 @@ export const BriefCardDefault = ({ return (
- + )} + - {title} @@ -123,7 +125,7 @@ export const BriefCardDefault = ({ style={{ background: briefButtonBg, }} - className="mt-auto w-full text-black" + className="brief-card-cta-gradient mt-auto w-full text-black" tag="a" type="button" variant={ButtonVariant.Primary} diff --git a/packages/shared/src/components/cards/brief/BriefCard/BriefCardFeed.tsx b/packages/shared/src/components/cards/brief/BriefCard/BriefCardFeed.tsx index 9724ce93273..441f55c6984 100644 --- a/packages/shared/src/components/cards/brief/BriefCard/BriefCardFeed.tsx +++ b/packages/shared/src/components/cards/brief/BriefCard/BriefCardFeed.tsx @@ -4,7 +4,10 @@ import type { BriefCardProps } from './BriefCard'; import { BriefCard } from './BriefCard'; export const BriefCardFeed = ( - props: Pick, + props: Pick< + BriefCardProps, + 'targetId' | 'className' | 'showCloseButton' | 'showBorder' + >, ): ReactElement => { return ; }; diff --git a/packages/shared/src/components/cards/brief/BriefContext.tsx b/packages/shared/src/components/cards/brief/BriefContext.tsx index f45ce21dfa3..22dcef0d9a4 100644 --- a/packages/shared/src/components/cards/brief/BriefContext.tsx +++ b/packages/shared/src/components/cards/brief/BriefContext.tsx @@ -15,9 +15,10 @@ type BriefContext = { const [BriefContextProvider, useBriefContext] = createContextProvider( (): BriefContext => { const { user } = useAuthContext(); + const persistentBriefKey = `brief_card_${user?.id ?? 'anonymous'}_v3`; const [brief, setBrief] = usePersistentState( - `brief_card_${user.id}_v3`, + persistentBriefKey, undefined, ); diff --git a/packages/shared/src/components/sources/SourceActions/SourceActionsNotify.tsx b/packages/shared/src/components/sources/SourceActions/SourceActionsNotify.tsx index dfbec3ffcdb..08f00e79b03 100644 --- a/packages/shared/src/components/sources/SourceActions/SourceActionsNotify.tsx +++ b/packages/shared/src/components/sources/SourceActions/SourceActionsNotify.tsx @@ -9,26 +9,31 @@ interface SourceActionsNotifyProps { haveNotificationsOn: boolean; onClick: (e: React.MouseEvent) => void; disabled?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; + className?: string; } const SourceActionsNotify = (props: SourceActionsNotifyProps): ReactElement => { - const { haveNotificationsOn, onClick, disabled } = props; + const { haveNotificationsOn, onClick, disabled, size, variant, className } = + props; const icon = haveNotificationsOn ? : ; const label = `${haveNotificationsOn ? 'Disable' : 'Enable'} notifications`; - const variant = haveNotificationsOn - ? ButtonVariant.Subtle - : ButtonVariant.Secondary; + const buttonVariant = + variant ?? + (haveNotificationsOn ? ButtonVariant.Subtle : ButtonVariant.Secondary); return ( diff --git a/packages/shared/src/features/agents/arena/ArenaRankings.tsx b/packages/shared/src/features/agents/arena/ArenaRankings.tsx index 585ccab5467..0d57d07e55c 100644 --- a/packages/shared/src/features/agents/arena/ArenaRankings.tsx +++ b/packages/shared/src/features/agents/arena/ArenaRankings.tsx @@ -470,7 +470,7 @@ export const ArenaRankings = ({ 'overflow-hidden', compact ? 'bg-background-default' - : 'rounded-16 border border-border-subtlest-tertiary bg-background-subtle', + : 'min-w-[42rem] rounded-16 border border-border-subtlest-tertiary', )} > {/* Header */} diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index 8c5493aedff..301fbeb1947 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -33,6 +33,10 @@ export const slackIntegration = 'https://r.daily.dev/slack'; export const statusPage = 'https://r.daily.dev/status'; export const businessWebsiteUrl = 'https://r.daily.dev/business'; export const appsUrl = 'https://daily.dev/apps'; +export const androidAppStoreUrl = + 'https://play.google.com/store/apps/details?id=dev.daily'; +export const iosAppStoreUrl = + 'https://apps.apple.com/app/daily-dev/id6740634400'; export const timezoneSettingsUrl = 'https://r.daily.dev/timezone'; export const isDevelopment = process.env.NODE_ENV === 'development'; export const isProductionAPI = @@ -43,6 +47,9 @@ export const isTesting = export const isGBDevMode = process.env.NEXT_PUBLIC_GB_DEV_MODE === 'true'; export const isBrave = (): boolean => { + if (typeof window === 'undefined') { + return false; + } if (!window.Promise) { return false; } diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index cf25dbb2af2..12f835b90ff 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -82,6 +82,7 @@ export enum Origin { HotTakeList = 'hot take list', HotAndCold = 'hot and cold', Leaderboard = 'leaderboard', + ExplorePage = 'explore page', } export enum LogEvent { @@ -551,6 +552,7 @@ export enum NotificationCtaPlacement { SquadCard = 'squad-card', PostActions = 'post-actions', SquadShareToast = 'squad-share-toast', + ExploreQuickActions = 'explore-quick-actions', } export enum NotificationCtaKind { @@ -572,6 +574,7 @@ export enum NotificationPromptSource { SquadChecklist = 'squad checklist', SourceSubscribe = 'source subscribe', ReadingReminder = 'reading reminder', + ExplorePage = 'explore page', } export enum ShortcutsSourceType { diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 67b1fce7a25..706c54a34c2 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -110,6 +110,21 @@ animation: slide-down 0.3s ease-out; } +@keyframes explore-hero-slow-zoom { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(1.08); + } +} + +.explore-hero-slow-zoom { + transform-origin: center; + animation: explore-hero-slow-zoom 20s linear infinite alternate; +} + @keyframes crown-icon-pop { 0% { transform: scale(1) rotate(0deg); @@ -625,3 +640,186 @@ .agent-live-radar-sweep { animation: agent-live-radar-sweep 20s linear infinite; } + +@keyframes feed-highlights-title-gradient-shift { + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } +} + +.feed-highlights-title-gradient { + color: transparent; + background-image: linear-gradient( + 120deg, + var(--theme-accent-blueCheese-default), + var(--theme-accent-cheese-default), + var(--theme-accent-avocado-default) + ); + background-size: 200% 200%; + background-clip: text; + -webkit-background-clip: text; + animation: feed-highlights-title-gradient-shift 6s ease-in-out infinite; +} + +.feed-highlights-sponsor-gradient-bg { + background-image: linear-gradient( + 120deg, + var(--theme-accent-blueCheese-default), + var(--theme-accent-cheese-default), + var(--theme-accent-avocado-default) + ); + background-size: 200% 200%; + animation: feed-highlights-title-gradient-shift 6s ease-in-out infinite; +} + +@keyframes brief-card-border-tail-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes brief-card-border-tail-opacity { + 0%, + 100% { + opacity: 0.72; + } + + 50% { + opacity: 1; + } +} + +.brief-card-animated-border { + position: relative; + overflow: hidden; + isolation: isolate; + border: 1px solid var(--theme-border-subtlest-tertiary); +} + +.brief-card-animated-border::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + pointer-events: none; + background: conic-gradient( + from 0deg, + transparent 0deg 292deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 12%, transparent) + 312deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 52%, transparent) + 328deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 100%, transparent) + 340deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 45%, transparent) + 350deg, + transparent 360deg + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + transform-origin: center; + will-change: transform; + filter: drop-shadow( + 0 0 0.45rem + color-mix(in srgb, var(--theme-accent-blueCheese-default) 42%, transparent) + ); + animation: + brief-card-border-tail-spin 2.4s linear infinite, + brief-card-border-tail-opacity 2.4s ease-in-out infinite; +} + +.brief-card-animated-border::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + pointer-events: none; + background: conic-gradient( + from 180deg, + transparent 0deg 318deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 60%, transparent) + 336deg, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 18%, transparent) + 352deg, + transparent 360deg + ); + -webkit-mask: linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + transform-origin: center; + filter: blur(0.2rem); + opacity: 0.8; + animation: brief-card-border-tail-spin 3.6s linear infinite reverse; +} + +.brief-card-cta-gradient { + background-size: 200% 200%; + animation: feed-highlights-title-gradient-shift 6s ease-in-out infinite; +} + +@keyframes brief-card-magic-float { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-0.375rem); + } +} + +.brief-card-magic-float { + animation: brief-card-magic-float 4.8s cubic-bezier(0.42, 0, 0.58, 1) + infinite alternate; +} + +/* Explore page quick actions — static card outline */ +.explore-quick-action-border { + border: 1px solid var(--theme-border-subtlest-tertiary); +} + +@media (prefers-reduced-motion: reduce) { + .feed-highlights-title-gradient { + animation: none; + background-position: 0% 50%; + } + + .feed-highlights-sponsor-gradient-bg { + animation: none; + background-position: 0% 50%; + } + + .brief-card-animated-border { + &::before { + animation: none; + } + + &::after { + animation: none; + } + } + + .brief-card-cta-gradient { + animation: none; + background-position: 0% 50%; + } + + .brief-card-magic-float { + animation: none; + } +} diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index 89a82ff1fcb..313b22d2e3b 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -252,12 +252,31 @@ export default { backgroundColor: 'transparent', }, }, + 'magic-float': { + '0%, 100%': { + transform: 'translateY(0) scale(1) rotate(0deg)', + filter: 'drop-shadow(0 0 8px rgba(168, 85, 247, 0.4))', + }, + '25%': { + transform: 'translateY(-4px) scale(1.02) rotate(-2deg)', + filter: 'drop-shadow(0 0 12px rgba(168, 85, 247, 0.6))', + }, + '50%': { + transform: 'translateY(-8px) scale(1.05) rotate(3deg)', + filter: 'drop-shadow(0 0 20px rgba(168, 85, 247, 0.9))', + }, + '75%': { + transform: 'translateY(-4px) scale(1.02) rotate(-1deg)', + filter: 'drop-shadow(0 0 12px rgba(168, 85, 247, 0.6))', + }, + }, }, animation: { 'scale-down-pulse': 'scale-down-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'fade-slide-up': 'fade-slide-up 0.5s ease-out 1s both', 'highlight-fade': 'highlight-fade 2.5s ease-out forwards', + 'magic-float': 'magic-float 4s ease-in-out infinite', }, }, lineClamp: { diff --git a/packages/webapp/components/agents/AgentsHighlightsSection.tsx b/packages/webapp/components/agents/AgentsHighlightsSection.tsx index 4d75dcf9d44..e8fc27124c8 100644 --- a/packages/webapp/components/agents/AgentsHighlightsSection.tsx +++ b/packages/webapp/components/agents/AgentsHighlightsSection.tsx @@ -36,6 +36,8 @@ const DigestSubscribeButton = ({ return ( { event.preventDefault(); event.stopPropagation(); @@ -69,9 +71,9 @@ interface AgentsHighlightsSectionProps { } const HighlightSkeleton = (): ReactElement => ( -
-
-
+
+
+
); @@ -80,46 +82,55 @@ export const AgentsHighlightsSection = ({ loading, digestSource, }: AgentsHighlightsSectionProps): ReactElement | null => { + const visibleHighlights = highlights.slice(0, 5); + if (!loading && highlights.length === 0) { return null; } return ( -
-
-

+
+
+

Happening Now

{!!digestSource?.id && ( -
+
)}
- {loading ? ( - <> - - - - - ) : ( - highlights.map((highlight) => ( - - - - {highlight.headline} - - - - - )) - )} +
+ {loading ? ( + <> + + + + + ) : ( +
+ {visibleHighlights.map((highlight) => ( + + +

+ {highlight.headline} +

+ +
+ + ))} +
+ )} +
); }; diff --git a/packages/webapp/components/agents/AgentsLeaderboardSection.tsx b/packages/webapp/components/agents/AgentsLeaderboardSection.tsx index 6ad8bc7aa77..c96531ceb85 100644 --- a/packages/webapp/components/agents/AgentsLeaderboardSection.tsx +++ b/packages/webapp/components/agents/AgentsLeaderboardSection.tsx @@ -1,11 +1,19 @@ import type { ReactElement } from 'react'; import React from 'react'; import { ArenaRankings } from '@dailydotdev/shared/src/features/agents/arena/ArenaRankings'; +import { ArenaHighlightsFeed } from '@dailydotdev/shared/src/features/agents/arena/ArenaHighlightsFeed'; import type { ArenaTab, RankedTool, + SentimentHighlightItem, } from '@dailydotdev/shared/src/features/agents/arena/types'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { + Button, + ButtonGroup, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; const LiveIndicator = (): ReactElement => ( @@ -18,33 +26,110 @@ interface AgentsLeaderboardSectionProps { tools: RankedTool[]; loading: boolean; tab: ArenaTab; + onTabChange?: (tab: ArenaTab) => void; + compact?: boolean; + highlightsItems?: SentimentHighlightItem[]; } export const AgentsLeaderboardSection = ({ tools, loading, tab, + onTabChange, + compact = true, + highlightsItems = [], }: AgentsLeaderboardSectionProps): ReactElement => ( -
-
-

Arena

-
- - - View all - +
+ {compact ? ( +
+

Arena

+
+ + + View all + +
+
+ ) : ( +
+
+
+

+ The Arena +

+

+ Where AI tools fight for developer love +

+
+ {!!onTabChange && ( +
+ + + + +
+ )} +
+
+ )} + {compact ? ( + + ) : ( +
+
+ +
+
-
- + )}
); diff --git a/packages/webapp/components/explore/AgenticTopicClusterSection.tsx b/packages/webapp/components/explore/AgenticTopicClusterSection.tsx new file mode 100644 index 00000000000..71eb401747f --- /dev/null +++ b/packages/webapp/components/explore/AgenticTopicClusterSection.tsx @@ -0,0 +1,299 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ChevronRight } from 'lucide-react'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DiscussIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { RelativeTime } from '@dailydotdev/shared/src/components/utilities/RelativeTime'; +import { EXPLORE_CATEGORIES } from './exploreCategories'; +import type { ExploreCategoryId } from './exploreCategories'; +import type { ExploreStory } from './ExploreNewsLayout'; + +interface ClusterStory { + id: string; + publisher: string; + publisherImage?: string; + title: string; + href: string; + publishedAt?: string; + readTimeMinutes?: number | null; + upvotes: number; + comments: number; + image?: string; +} + +type TopicCluster = { + id: string; + topic: string; + topicHref: string; + featured: ClusterStory; + related: ClusterStory[]; +}; + +const TOPIC_CATEGORIES = (() => { + const agenticIndex = EXPLORE_CATEGORIES.findIndex( + (category) => category.id === 'agentic', + ); + + return EXPLORE_CATEGORIES.slice(agenticIndex + 1); +})(); + +const getStoryTitle = (story: ExploreStory): string => + story.title?.trim() || + story.sharedPost?.title?.trim() || + story.summary?.trim() || + 'Untitled story'; + +const getStoryImage = (story: ExploreStory): string | undefined => + story.image || story.sharedPost?.image || undefined; + +const mapToClusterStory = (story: ExploreStory): ClusterStory => ({ + id: story.id, + publisher: + (story.source?.name === 'Community Picks' && story.author?.name) || + story.source?.name || + story.author?.name || + 'Community', + publisherImage: + (story.source?.name === 'Community Picks' + ? story.author?.image + : story.source?.image) || + story.author?.image || + undefined, + title: getStoryTitle(story), + href: story.commentsPermalink, + publishedAt: story.createdAt || undefined, + readTimeMinutes: story.readTime ?? null, + upvotes: story.numUpvotes ?? 0, + comments: story.numComments ?? 0, + image: getStoryImage(story), +}); + +const StoryMeta = ({ + publisher, + publisherImage, + publishedAt, + upvotes, + comments, +}: Pick< + ClusterStory, + 'publisher' | 'publisherImage' | 'publishedAt' | 'upvotes' | 'comments' +>): ReactElement => ( +

+ {publisherImage ? ( + {publisher} + ) : ( + + {publisher.charAt(0)} + + )} + {publisher} + {publishedAt && ( + <> + + + + )} + {(upvotes > 0 || comments > 0) && } + {upvotes > 0 && ( + + + {upvotes} + + )} + {comments > 0 && ( + + + {comments} + + )} +

+); + +const TopicClusterCard = ({ + cluster, +}: { + cluster: TopicCluster; +}): ReactElement => { + return ( +
+
+ +

+ {cluster.topic} +

+
+
+ +
+ ); +}; + +const AgenticTopicClusterSection = ({ + storiesByCategory, +}: { + storiesByCategory?: Partial>; +}): ReactElement => { + const globalFallbackImage = TOPIC_CATEGORIES.reduce( + (foundImage, category) => { + if (foundImage) { + return foundImage; + } + + const categoryStories = storiesByCategory?.[category.id] ?? []; + return categoryStories.map(getStoryImage).find(Boolean); + }, + undefined, + ); + + const topicClusters = TOPIC_CATEGORIES.map((category) => { + const categoryStories = storiesByCategory?.[category.id] ?? []; + const mappedStories = categoryStories.map(mapToClusterStory); + const featuredIndex = + mappedStories.findIndex((story) => !!story.image) >= 0 + ? mappedStories.findIndex((story) => !!story.image) + : 0; + const featuredStory = mappedStories[featuredIndex]; + const featured = featuredStory + ? { + ...featuredStory, + image: featuredStory.image || globalFallbackImage, + } + : { + id: `${category.id}-featured-fallback`, + publisher: `${category.label} Digest`, + title: `Latest ${category.label} stories`, + href: category.path, + publishedAt: undefined, + readTimeMinutes: 5, + upvotes: 0, + comments: 0, + image: globalFallbackImage, + }; + const related = mappedStories + .filter((_, index) => index !== featuredIndex) + .slice(0, 4); + + return { + id: category.id, + topic: category.label, + topicHref: category.path, + featured, + related, + }; + }); + + return ( +
+ {topicClusters.map((cluster) => ( + + ))} +
+ ); +}; + +export default AgenticTopicClusterSection; diff --git a/packages/webapp/components/explore/ExploreNewsLayout.tsx b/packages/webapp/components/explore/ExploreNewsLayout.tsx new file mode 100644 index 00000000000..ae8191662d4 --- /dev/null +++ b/packages/webapp/components/explore/ExploreNewsLayout.tsx @@ -0,0 +1,1003 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { + ArenaTab, + RankedTool, + SentimentHighlightItem, +} from '@dailydotdev/shared/src/features/agents/arena/types'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import type { PostHighlight } from '@dailydotdev/shared/src/graphql/highlights'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { RelativeTime } from '@dailydotdev/shared/src/components/utilities/RelativeTime'; +import { BriefCardFeed } from '@dailydotdev/shared/src/components/cards/brief/BriefCard/BriefCardFeed'; +import { TopHero } from '@dailydotdev/shared/src/components/banners/HeroBottomBanner'; +import { + DiscussIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { TargetId } from '@dailydotdev/shared/src/lib/log'; +import { + BriefContextProvider, + useBriefContext, +} from '@dailydotdev/shared/src/components/cards/brief/BriefContext'; +import { useReadingReminderHero } from '@dailydotdev/shared/src/hooks/notifications/useReadingReminderHero'; +import { AgentsHighlightsSection } from '../agents/AgentsHighlightsSection'; +import { AgentsLeaderboardSection } from '../agents/AgentsLeaderboardSection'; +import { ExploreSocialStrips } from './ExploreSocialStrips'; +import AgenticTopicClusterSection from './AgenticTopicClusterSection'; +import { ExploreQuickActionsSection } from './ExploreQuickActionsSection'; +import type { ExploreCategoryId } from './exploreCategories'; +import { EXPLORE_CATEGORIES } from './exploreCategories'; + +export type ExploreStory = Pick< + Post, + | 'id' + | 'title' + | 'summary' + | 'type' + | 'flags' + | 'sharedPost' + | 'author' + | 'commentsPermalink' + | 'createdAt' + | 'creatorTwitter' + | 'creatorTwitterImage' + | 'creatorTwitterName' + | 'readTime' + | 'image' + | 'source' + | 'numComments' + | 'numUpvotes' +>; + +interface StorySection { + id: string; + title: string; + href: string; + stories: ExploreStory[]; + totalStoriesCount: number; +} + +interface ExploreNewsLayoutProps { + activeTabId: ExploreCategoryId; + highlightsLoading: boolean; + highlights: PostHighlight[]; + digestSource?: Source | null; + latestStories: ExploreStory[]; + popularStories: ExploreStory[]; + upvotedStories: ExploreStory[]; + discussedStories: ExploreStory[]; + videoLatestStories: ExploreStory[]; + videoPopularStories: ExploreStory[]; + videoUpvotedStories: ExploreStory[]; + videoDiscussedStories: ExploreStory[]; + arenaTools: RankedTool[]; + arenaLoading: boolean; + arenaTab: ArenaTab; + onArenaTabChange?: (tab: ArenaTab) => void; + arenaHighlightsItems: SentimentHighlightItem[]; + categoryClusterStories?: Partial>; +} + +const getStoryHeadline = (story: ExploreStory): string => + story.title?.trim() || + story.sharedPost?.title?.trim() || + story.summary?.trim() || + 'Untitled story'; + +const SourceMeta = ({ + source, +}: { + source: ExploreStory['source']; +}): ReactElement | null => { + if (!source?.name) { + return null; + } + + return ( + + {source.image ? ( + {source.name} + ) : ( + + {source.name.charAt(0)} + + )} + {source.name} + + ); +}; + +const getCommunityAuthorMeta = ( + story: ExploreStory, +): { name: string; image?: string | null } | null => { + const name = + story.author?.name || + story.sharedPost?.author?.name || + story.creatorTwitterName || + story.creatorTwitter || + null; + + if (!name) { + return null; + } + + return { + name, + image: + story.author?.image || + story.sharedPost?.author?.image || + story.creatorTwitterImage || + null, + }; +}; + +const StoryOriginMeta = ({ + story, + sourceLabelOverride, + sourceFallbackLabel, +}: { + story: ExploreStory; + sourceLabelOverride?: string; + sourceFallbackLabel?: string; +}): ReactElement | null => { + if (sourceLabelOverride) { + return ( + + {sourceLabelOverride} + + ); + } + + if (story.source?.name === 'Community Picks') { + const communityAuthorMeta = getCommunityAuthorMeta(story); + + if (!communityAuthorMeta) { + return null; + } + + return ( + + {communityAuthorMeta.image ? ( + {communityAuthorMeta.name} + ) : ( + + {communityAuthorMeta.name.charAt(0)} + + )} + {communityAuthorMeta.name} + + ); + } + + if (sourceFallbackLabel) { + return ( + + {sourceFallbackLabel} + + ); + } + + return ; +}; + +const StoryRow = ({ + story, + sourceLabelOverride, + showEngagement = true, + isSponsored = false, + imageOnRight = false, + showEngagementIcons = false, + sourceFallbackLabel, +}: { + story: ExploreStory; + sourceLabelOverride?: string; + showEngagement?: boolean; + isSponsored?: boolean; + imageOnRight?: boolean; + showEngagementIcons?: boolean; + sourceFallbackLabel?: string; +}): ReactElement => { + const hasCommunityAuthorMeta = Boolean(getCommunityAuthorMeta(story)); + const hasSourceMeta = Boolean( + sourceLabelOverride || + (story.source?.name === 'Community Picks' + ? hasCommunityAuthorMeta + : story.source?.name) || + sourceFallbackLabel, + ); + + return ( + + +
+ {!!story.image && ( + {getStoryHeadline(story)} + )} +
+
+

+ {getStoryHeadline(story)} +

+
+ {isSponsored ? ( + <> + + + Sponsored + + ) : ( + <> + + + {hasSourceMeta && !!story.createdAt && ( + + )} + {!!story.createdAt && ( + + )} + + {showEngagementIcons && + showEngagement && + !!story.numUpvotes && ( + <> + + + + {story.numUpvotes} + + + )} + {showEngagementIcons && + showEngagement && + !!story.numComments && ( + <> + {!story.numUpvotes && } + + + {story.numComments} + + + )} + {!showEngagementIcons && + showEngagement && + !!story.numUpvotes && ( + <> + + {story.numUpvotes} upvotes + + )} + {!showEngagementIcons && + showEngagement && + !!story.numComments && ( + <> + + {story.numComments} comments + + )} + + )} +
+
+
+ + ); +}; + +const StorySectionBlock = ({ + section, + sponsoredStory, +}: { + section: StorySection; + sponsoredStory?: ExploreStory | null; +}): ReactElement => { + const isLatestSection = section.id === 'latest'; + const isPopularSection = section.id === 'popular'; + const isSponsoredSlotSection = isLatestSection || isPopularSection; + + let sectionPaddingClass = 'p-3 laptop:p-4'; + if (isLatestSection) { + sectionPaddingClass = + 'pb-3 pl-0 pr-0 pt-0 laptop:pb-4 laptop:pl-0 laptop:pr-0 laptop:pt-0'; + } else if (isPopularSection) { + sectionPaddingClass = + 'pb-3 pl-0 pr-0 pt-0 laptop:pb-4 laptop:pl-0 laptop:pr-0 laptop:pt-0'; + } + + const sectionBorderClass = + isLatestSection || isPopularSection + ? '' + : 'border border-border-subtlest-tertiary'; + const sourceLabelOverride = undefined; + const sourceFallbackLabel = isPopularSection ? 'Top stories' : undefined; + const showEngagement = true; + const storiesWithSponsoredSlot = useMemo(() => { + if (!isSponsoredSlotSection || !sponsoredStory) { + return section.stories; + } + + const nonSponsoredStories = section.stories.filter( + (story) => story.id !== sponsoredStory.id, + ); + + return [ + nonSponsoredStories[0], + sponsoredStory, + ...nonSponsoredStories.slice(1, 6), + ].filter(Boolean) as ExploreStory[]; + }, [isSponsoredSlotSection, section.stories, sponsoredStory]); + const storiesToRender = isSponsoredSlotSection + ? storiesWithSponsoredSlot + : section.stories; + + return ( +
+ {isLatestSection && ( +
+

+ Top stories +

+
+ )} + {section.id !== 'latest' && ( +
+ + +

{section.title}

+
+ +
+ )} + {storiesToRender.length > 0 ? ( + storiesToRender.map((story) => ( + + )) + ) : ( +

No stories yet.

+ )} +
+ ); +}; + +const CompactSectionBlock = ({ + section, +}: { + section: StorySection; +}): ReactElement => { + const isUpvotedSection = section.id === 'upvoted'; + const isDiscussedSection = section.id === 'discussed'; + const isHighlightedCompactSection = + section.id === 'upvoted' || section.id === 'discussed'; + const hasMoreStories = section.totalStoriesCount > section.stories.length; + + return ( +
+
+ + +

{section.title}

+
+ +
+ {section.stories.length > 0 ? ( + section.stories.map((story, index) => ( + + )) + ) : ( +

No stories yet.

+ )} + {hasMoreStories && ( + + + Show all + + + )} +
+ ); +}; + +const MoreStoriesStrip = ({ + stories, +}: { + stories: ExploreStory[]; +}): ReactElement | null => { + if (!stories.length) { + return null; + } + + return ( + + ); +}; + +const ReadingBriefStripInner = (): ReactElement => { + const { brief } = useBriefContext(); + + if (!brief) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Reading brief +

+
+

+ A quick, high-signal recap tailored for you. +

+
+ +
+ + + Open reading brief + + +
+ ); +}; + +const ReadingBriefStrip = (): ReactElement => { + return ( + + + + ); +}; + +export const ExploreNewsLayout = ({ + activeTabId, + highlightsLoading, + highlights, + digestSource, + latestStories, + popularStories, + upvotedStories, + discussedStories, + videoLatestStories, + videoPopularStories, + videoUpvotedStories, + videoDiscussedStories, + arenaTools, + arenaLoading, + arenaTab, + onArenaTabChange, + arenaHighlightsItems, + categoryClusterStories, +}: ExploreNewsLayoutProps): ReactElement => { + const isVideosMode = activeTabId === 'videos'; + const isExplorePage = activeTabId === 'explore'; + const showExploreOnlySections = isExplorePage && !isVideosMode; + const forceShowReadingReminderHero = true; + const { + shouldShow: shouldShowReadingReminderHero, + title: readingReminderTitle, + subtitle: readingReminderSubtitle, + onEnable: onEnableReadingReminder, + onDismiss: onDismissReadingReminder, + } = useReadingReminderHero({ requireMobile: false }); + + const latestStoriesForView = useMemo( + () => (isVideosMode ? videoLatestStories : latestStories), + [isVideosMode, latestStories, videoLatestStories], + ); + const popularStoriesForView = useMemo( + () => (isVideosMode ? videoPopularStories : popularStories), + [isVideosMode, popularStories, videoPopularStories], + ); + const upvotedStoriesForView = useMemo( + () => (isVideosMode ? videoUpvotedStories : upvotedStories), + [isVideosMode, upvotedStories, videoUpvotedStories], + ); + const discussedStoriesForView = useMemo( + () => (isVideosMode ? videoDiscussedStories : discussedStories), + [isVideosMode, discussedStories, videoDiscussedStories], + ); + + const videoHighlights = useMemo(() => { + if (!isVideosMode) { + return []; + } + + const merged = [ + ...latestStoriesForView, + ...popularStoriesForView, + ...upvotedStoriesForView, + ...discussedStoriesForView, + ]; + const uniqueStories = Array.from( + new Map(merged.map((story) => [story.id, story])).values(), + ); + + return uniqueStories.slice(0, 10).map((story) => ({ + channel: 'videos', + headline: getStoryHeadline(story), + highlightedAt: story.createdAt ?? new Date(0).toISOString(), + post: { + id: story.id, + commentsPermalink: story.commentsPermalink, + }, + })); + }, [ + isVideosMode, + latestStoriesForView, + popularStoriesForView, + upvotedStoriesForView, + discussedStoriesForView, + ]); + + const leadStory = useMemo( + () => latestStoriesForView[0] ?? popularStoriesForView[0] ?? null, + [latestStoriesForView, popularStoriesForView], + ); + const leadStoryCommunityAuthorMeta = leadStory + ? getCommunityAuthorMeta(leadStory) + : null; + const leadStoryOriginName = + leadStory?.source?.name === 'Community Picks' + ? leadStoryCommunityAuthorMeta?.name + : leadStory?.source?.name; + const leadStoryOriginImage = + leadStory?.source?.name === 'Community Picks' + ? leadStoryCommunityAuthorMeta?.image + : leadStory?.source?.image; + + const latestSection = useMemo( + () => ({ + id: 'latest', + title: 'Latest', + href: '/posts/latest', + stories: latestStoriesForView + .filter((story) => story.id !== leadStory?.id) + .slice(0, 7), + totalStoriesCount: latestStoriesForView.length, + }), + [latestStoriesForView, leadStory?.id], + ); + const popularSection = useMemo( + () => ({ + id: 'popular', + title: 'More top stories', + href: '/', + stories: popularStoriesForView + .filter((story) => story.id !== leadStory?.id) + .slice(0, 7), + totalStoriesCount: popularStoriesForView.length, + }), + [popularStoriesForView, leadStory?.id], + ); + const upvotedSection = useMemo( + () => ({ + id: 'upvoted', + title: 'Most Upvoted', + href: '/upvoted', + stories: upvotedStoriesForView.slice(0, 6), + totalStoriesCount: upvotedStoriesForView.length, + }), + [upvotedStoriesForView], + ); + const discussedSection = useMemo( + () => ({ + id: 'discussed', + title: 'Best Discussions', + href: '/discussed', + stories: discussedStoriesForView.slice(0, 6), + totalStoriesCount: discussedStoriesForView.length, + }), + [discussedStoriesForView], + ); + const sponsoredStory = useMemo(() => { + const candidates = [ + ...latestStoriesForView, + ...popularStoriesForView, + ...upvotedStoriesForView, + ...discussedStoriesForView, + ]; + const sponsoredCandidate = candidates.find((story) => !!story.flags?.ad); + + if (sponsoredCandidate) { + return sponsoredCandidate; + } + + return candidates.find((story) => story.id !== leadStory?.id) ?? null; + }, [ + leadStory?.id, + latestStoriesForView, + popularStoriesForView, + upvotedStoriesForView, + discussedStoriesForView, + ]); + const sponsoredPopularStory = useMemo(() => { + const sponsoredCandidate = popularStoriesForView.find( + (story) => !!story.flags?.ad, + ); + + if (sponsoredCandidate) { + return sponsoredCandidate; + } + + return ( + popularStoriesForView.find((story) => story.id !== leadStory?.id) ?? null + ); + }, [leadStory?.id, popularStoriesForView]); + const moreStories = useMemo(() => { + const displayedIds = new Set([ + leadStory?.id ?? '', + sponsoredStory?.id ?? '', + sponsoredPopularStory?.id ?? '', + ...latestSection.stories.map((story) => story.id), + ...popularSection.stories.map((story) => story.id), + ...upvotedSection.stories.map((story) => story.id), + ...discussedSection.stories.map((story) => story.id), + ]); + const merged = [ + ...latestStoriesForView, + ...popularStoriesForView, + ...upvotedStoriesForView, + ...discussedStoriesForView, + ]; + const uniqueStories = Array.from( + new Map(merged.map((story) => [story.id, story])).values(), + ); + + return uniqueStories + .filter((story) => !displayedIds.has(story.id)) + .slice(0, 7); + }, [ + leadStory?.id, + latestSection.stories, + popularSection.stories, + upvotedSection.stories, + discussedSection.stories, + sponsoredStory?.id, + sponsoredPopularStory?.id, + latestStoriesForView, + popularStoriesForView, + upvotedStoriesForView, + discussedStoriesForView, + ]); + + return ( +
+
+
+ {EXPLORE_CATEGORIES.map((tab) => ( + + + {tab.label} + + + ))} +
+
+ +
+ +
+ +
+
+ + +
+
+ {showExploreOnlySections && ( +
+
+
+ +
+
+
+ )} +
+
+ + +
+
+
+ +
+ {showExploreOnlySections && ( + <> +
+ +
+
+ +
+ + )} + {isExplorePage && ( +
+
+ + +
+
+ )} + {isExplorePage && } + {showExploreOnlySections && ( +
+ +
+ )} + {isExplorePage && + (shouldShowReadingReminderHero || forceShowReadingReminderHero) && ( +
+ { + void onEnableReadingReminder(); + }} + onClose={() => { + void onDismissReadingReminder(); + }} + /> +
+ )} +
+ ); +}; diff --git a/packages/webapp/components/explore/ExplorePageContent.tsx b/packages/webapp/components/explore/ExplorePageContent.tsx new file mode 100644 index 00000000000..7abcdf0d424 --- /dev/null +++ b/packages/webapp/components/explore/ExplorePageContent.tsx @@ -0,0 +1,320 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import type { QueryClient } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; +import { arenaOptions } from '@dailydotdev/shared/src/features/agents/arena/queries'; +import { computeRankings } from '@dailydotdev/shared/src/features/agents/arena/arenaMetrics'; +import type { ArenaTab } from '@dailydotdev/shared/src/features/agents/arena/types'; +import type { PostHighlight } from '@dailydotdev/shared/src/graphql/highlights'; +import { POST_HIGHLIGHTS_QUERY } from '@dailydotdev/shared/src/graphql/highlights'; +import { + ANONYMOUS_FEED_QUERY, + MOST_DISCUSSED_FEED_QUERY, + MOST_UPVOTED_FEED_QUERY, + RankingAlgorithm, + TAG_FEED_QUERY, + type FeedData, +} from '@dailydotdev/shared/src/graphql/feed'; +import { PostType } from '@dailydotdev/shared/src/types'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { sourceQueryOptions } from '@dailydotdev/shared/src/graphql/sources'; +import { + RequestKey, + StaleTime, + generateQueryKey, +} from '@dailydotdev/shared/src/lib/query'; +import { useScrollRestoration } from '@dailydotdev/shared/src/hooks'; +import { ExploreNewsLayout } from './ExploreNewsLayout'; +import type { ExploreCategoryId } from './exploreCategories'; +import { + EXPLORE_CATEGORIES, + getExploreCategoryById, +} from './exploreCategories'; + +const TOPIC_CLUSTER_CATEGORY_IDS = (() => { + const agenticIndex = EXPLORE_CATEGORIES.findIndex( + (category) => category.id === 'agentic', + ); + + return EXPLORE_CATEGORIES.slice(agenticIndex + 1).map( + (category) => category.id, + ) as ExploreCategoryId[]; +})(); + +const HIGHLIGHTS_CHANNEL = 'vibes'; +const STORIES_PER_SECTION = 8; +const UPVOTED_AND_DISCUSSED_PERIOD = 7; +const DEFAULT_EXPLORE_ARENA_TAB: ArenaTab = 'llms'; +const VIDEO_SUPPORTED_TYPES = [PostType.VideoYouTube]; + +const HIGHLIGHTS_QUERY_KEY = generateQueryKey( + RequestKey.PostHighlights, + undefined, + HIGHLIGHTS_CHANNEL, +); + +const getFeedQueryKey = ( + categoryId: ExploreCategoryId, + section: 'latest' | 'popular' | 'upvoted' | 'discussed', +) => ['explore', categoryId, section] as const; + +const getHighlightsQuery = () => + gqlClient.request<{ postHighlights: PostHighlight[] }>( + POST_HIGHLIGHTS_QUERY, + { + channel: HIGHLIGHTS_CHANNEL, + }, + ); + +const getLatestStoriesQuery = ({ + tag, + supportedTypes, +}: { + tag?: string; + supportedTypes?: PostType[]; +}) => { + if (tag) { + return () => + gqlClient.request(TAG_FEED_QUERY, { + tag, + first: STORIES_PER_SECTION, + ranking: RankingAlgorithm.Time, + supportedTypes, + }); + } + + return () => + gqlClient.request(ANONYMOUS_FEED_QUERY, { + first: STORIES_PER_SECTION, + ranking: RankingAlgorithm.Time, + supportedTypes, + }); +}; + +const getPopularStoriesQuery = ({ + tag, + supportedTypes, +}: { + tag?: string; + supportedTypes?: PostType[]; +}) => { + if (tag) { + return () => + gqlClient.request(TAG_FEED_QUERY, { + tag, + first: STORIES_PER_SECTION, + ranking: RankingAlgorithm.Popularity, + supportedTypes, + }); + } + + return () => + gqlClient.request(ANONYMOUS_FEED_QUERY, { + first: STORIES_PER_SECTION, + ranking: RankingAlgorithm.Popularity, + supportedTypes, + }); +}; + +const getUpvotedStoriesQuery = + ({ tag, supportedTypes }: { tag?: string; supportedTypes?: PostType[] }) => + () => + gqlClient.request(MOST_UPVOTED_FEED_QUERY, { + first: STORIES_PER_SECTION, + period: UPVOTED_AND_DISCUSSED_PERIOD, + tag, + supportedTypes, + }); + +const getDiscussedStoriesQuery = + ({ tag, supportedTypes }: { tag?: string; supportedTypes?: PostType[] }) => + () => + gqlClient.request(MOST_DISCUSSED_FEED_QUERY, { + first: STORIES_PER_SECTION, + period: UPVOTED_AND_DISCUSSED_PERIOD, + tag, + supportedTypes, + }); + +const getFeedQueriesForCategory = (categoryId: ExploreCategoryId) => { + const category = getExploreCategoryById(categoryId); + const isVideosCategory = + !!category && 'isVideos' in category && !!category.isVideos; + const tag = category && 'tag' in category ? category.tag : undefined; + const supportedTypes = isVideosCategory ? VIDEO_SUPPORTED_TYPES : undefined; + + return { + latest: getLatestStoriesQuery({ tag, supportedTypes }), + popular: getPopularStoriesQuery({ tag, supportedTypes }), + upvoted: getUpvotedStoriesQuery({ tag, supportedTypes }), + discussed: getDiscussedStoriesQuery({ tag, supportedTypes }), + }; +}; + +export const prefetchExplorePageData = async ({ + queryClient, + categoryId, +}: { + queryClient: QueryClient; + categoryId: ExploreCategoryId; +}): Promise => { + const feedQueries = getFeedQueriesForCategory(categoryId); + + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: HIGHLIGHTS_QUERY_KEY, + queryFn: getHighlightsQuery, + }), + queryClient.prefetchQuery( + sourceQueryOptions({ sourceId: 'agents_digest' }), + ), + queryClient.prefetchQuery( + arenaOptions({ groupId: DEFAULT_EXPLORE_ARENA_TAB }), + ), + queryClient.prefetchQuery(arenaOptions({ groupId: 'coding-agents' })), + queryClient.prefetchQuery({ + queryKey: getFeedQueryKey(categoryId, 'latest'), + queryFn: feedQueries.latest, + }), + queryClient.prefetchQuery({ + queryKey: getFeedQueryKey(categoryId, 'popular'), + queryFn: feedQueries.popular, + }), + queryClient.prefetchQuery({ + queryKey: getFeedQueryKey(categoryId, 'upvoted'), + queryFn: feedQueries.upvoted, + }), + queryClient.prefetchQuery({ + queryKey: getFeedQueryKey(categoryId, 'discussed'), + queryFn: feedQueries.discussed, + }), + ]); +}; + +export const ExplorePageContent = ({ + activeCategoryId, +}: { + activeCategoryId: ExploreCategoryId; +}): ReactElement => { + useScrollRestoration(); + const [arenaTab, setArenaTab] = useState(DEFAULT_EXPLORE_ARENA_TAB); + const feedQueries = useMemo( + () => getFeedQueriesForCategory(activeCategoryId), + [activeCategoryId], + ); + const isVideosCategory = activeCategoryId === 'videos'; + const isExploreCategory = activeCategoryId === 'explore'; + + const { data: highlightsData, isFetching: isFetchingHighlights } = useQuery({ + queryKey: HIGHLIGHTS_QUERY_KEY, + queryFn: getHighlightsQuery, + staleTime: 0, + refetchOnMount: 'always', + refetchOnWindowFocus: true, + refetchInterval: 60 * 1000, + }); + const { data: digestSource } = useQuery( + sourceQueryOptions({ sourceId: 'agents_digest' }), + ); + const { data: arenaData, isFetching: isFetchingArena } = useQuery( + arenaOptions({ groupId: arenaTab }), + ); + + const arenaRankings = useMemo( + () => + arenaData?.sentimentTimeSeries && arenaData.sentimentGroup + ? computeRankings( + arenaData.sentimentTimeSeries.entities.nodes, + arenaData.sentimentGroup.entities, + arenaData.sentimentTimeSeries.resolutionSeconds, + ) + : [], + [arenaData?.sentimentTimeSeries, arenaData?.sentimentGroup], + ); + const isArenaLoading = isFetchingArena && !arenaData; + + const { data: latestStoriesData } = useQuery({ + queryKey: getFeedQueryKey(activeCategoryId, 'latest'), + queryFn: feedQueries.latest, + staleTime: StaleTime.Default, + }); + const { data: popularStoriesData } = useQuery({ + queryKey: getFeedQueryKey(activeCategoryId, 'popular'), + queryFn: feedQueries.popular, + staleTime: StaleTime.Default, + }); + const { data: upvotedStoriesData } = useQuery({ + queryKey: getFeedQueryKey(activeCategoryId, 'upvoted'), + queryFn: feedQueries.upvoted, + staleTime: StaleTime.Default, + }); + const { data: discussedStoriesData } = useQuery({ + queryKey: getFeedQueryKey(activeCategoryId, 'discussed'), + queryFn: feedQueries.discussed, + staleTime: StaleTime.Default, + }); + const topicClusterStoriesQueries = useQueries({ + queries: TOPIC_CLUSTER_CATEGORY_IDS.map((categoryId) => ({ + queryKey: getFeedQueryKey(categoryId, 'latest'), + queryFn: getFeedQueriesForCategory(categoryId).latest, + staleTime: StaleTime.Default, + enabled: isExploreCategory, + })), + }); + + const latestStories = + latestStoriesData?.page?.edges?.map((edge) => edge.node) ?? []; + const popularStories = + popularStoriesData?.page?.edges?.map((edge) => edge.node) ?? []; + const upvotedStories = + upvotedStoriesData?.page?.edges?.map((edge) => edge.node) ?? []; + const discussedStories = + discussedStoriesData?.page?.edges?.map((edge) => edge.node) ?? []; + const sortedHighlights = useMemo( + () => + [...(highlightsData?.postHighlights ?? [])].sort( + (a, b) => + new Date(b.highlightedAt).getTime() - + new Date(a.highlightedAt).getTime(), + ), + [highlightsData?.postHighlights], + ); + const topicClusterStoriesByCategory = useMemo( + () => + TOPIC_CLUSTER_CATEGORY_IDS.reduce< + Partial> + >((acc, categoryId, index) => { + const categoryStories = + topicClusterStoriesQueries[index]?.data?.page?.edges?.map( + (edge) => edge.node, + ) ?? []; + + acc[categoryId] = categoryStories; + return acc; + }, {}), + [topicClusterStoriesQueries], + ); + + return ( + + ); +}; diff --git a/packages/webapp/components/explore/ExploreQuickActionsSection.tsx b/packages/webapp/components/explore/ExploreQuickActionsSection.tsx new file mode 100644 index 00000000000..a27e939a8c2 --- /dev/null +++ b/packages/webapp/components/explore/ExploreQuickActionsSection.tsx @@ -0,0 +1,273 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import classNames from 'classnames'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + BellIcon, + ChromeIcon, + DocsIcon, + EdgeIcon, + PhoneIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + appsUrl, + downloadBrowserExtension, +} from '@dailydotdev/shared/src/lib/constants'; +import { + fileValidation, + useUploadCv, +} from '@dailydotdev/shared/src/features/profile/hooks/useUploadCv'; +import { useFileInput } from '@dailydotdev/shared/src/features/fileUpload/hooks/useFileInput'; +import { useFileValidation } from '@dailydotdev/shared/src/features/fileUpload/hooks/useFileValidation'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks'; +import { + BrowserName, + checkIsExtension, + getCurrentBrowserName, +} from '@dailydotdev/shared/src/lib/func'; +import { useEnableNotification } from '@dailydotdev/shared/src/hooks/notifications/useEnableNotification'; +import { + LogEvent, + NotificationCtaPlacement, + NotificationPromptSource, + Origin, +} from '@dailydotdev/shared/src/lib/log'; +import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; + +const bellAnimationClass = + 'origin-top motion-safe:[animation:enable-notification-bell-ring_1.1s_ease-in-out_1.5s_infinite]'; + +const tileShellClass = classNames( + 'explore-quick-action-border group block w-full rounded-16 text-left', + 'transition duration-300 ease-out motion-safe:active:translate-y-0', + 'focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-blueCheese-default', +); + +const tileInnerClass = + 'flex min-h-[5.5rem] items-center gap-4 rounded-14 bg-surface-float px-5 py-4'; + +const iconWrapClass = + 'flex h-14 w-14 shrink-0 items-center justify-center rounded-14 bg-surface-hover text-accent-blueCheese-default'; + +interface QuickActionLinkTileProps { + href: string; + rel?: string; + target?: string; + onClick?: () => void; + icon: ReactElement; + title: string; + description: string; +} + +const QuickActionLinkTile = ({ + href, + rel, + target, + onClick, + icon, + title, + description, +}: QuickActionLinkTileProps): ReactElement => ( + + + + {icon} + + + {title} + + {description} + + + + +); + +interface QuickActionButtonTileProps { + onClick: () => void; + disabled?: boolean; + icon: ReactElement; + title: string; + description: string; +} + +const QuickActionButtonTile = ({ + onClick, + disabled = false, + icon, + title, + description, +}: QuickActionButtonTileProps): ReactElement => ( + +); + +export const ExploreQuickActionsSection = (): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); + const browserName = getCurrentBrowserName(); + const isEdge = browserName === BrowserName.Edge; + + const { + shouldShowCta: showNotificationCta, + onEnable: onEnableNotifications, + } = useEnableNotification({ + source: NotificationPromptSource.ExplorePage, + placement: NotificationCtaPlacement.ExploreQuickActions, + }); + + const { onUpload, status, shouldShow: shouldShowCvUpload } = useUploadCv(); + const { validateFiles } = useFileValidation(fileValidation); + + const handleCvFiles = useCallback( + (files: FileList | null) => { + if (!files || files.length === 0) { + return; + } + + const { validFiles, errors } = validateFiles(files); + + if (errors.length > 0) { + const first = errors[0]; + const fileName = first.file ? ` (${first.file.name})` : ''; + displayToast(`${first.message}${fileName}`); + return; + } + + const file = validFiles[0]; + if (!file) { + return; + } + + onUpload(file).catch(() => null); + }, + [displayToast, onUpload, validateFiles], + ); + + const { input: cvFileInput, openFileInput: openCvFileInput } = useFileInput({ + onFiles: handleCvFiles, + accept: fileValidation.acceptedTypes, + disabled: status === 'pending', + }); + + const profilePercent = user?.profileCompletion?.percentage; + const isCvUploading = status === 'pending'; + + const uploadCvTitle = + profilePercent !== undefined + ? `Upload CV (${profilePercent}%)` + : 'Upload CV'; + + const extensionIcon = useMemo( + () => + isEdge ? ( + + ) : ( + + ), + [isEdge], + ); + + return ( +
+ {cvFileInput} +
+ {!checkIsExtension() && ( + + logEvent({ + event_name: LogEvent.DownloadExtension, + origin: Origin.ExplorePage, + }) + } + /> + )} + + {isLoggedIn && showNotificationCta && ( + + } + title="Enable notifications" + description="Get nudges for replies, mentions, and threads you care about." + onClick={() => { + onEnableNotifications().catch(() => null); + }} + /> + )} + + } + title="Get the mobile app" + description="Pick up where you left off on iOS and Android." + /> + + {isLoggedIn && + (shouldShowCvUpload ? ( + + ) : ( + + ) + } + title={uploadCvTitle} + description="Add your résumé so we can match you to better opportunities." + onClick={() => openCvFileInput()} + disabled={isCvUploading} + /> + ) : ( + } + title="Upload your CV" + description="Manage your profile and job preferences in settings." + /> + ))} +
+
+ ); +}; diff --git a/packages/webapp/components/explore/ExploreSocialStrips.tsx b/packages/webapp/components/explore/ExploreSocialStrips.tsx new file mode 100644 index 00000000000..77c0d618bdc --- /dev/null +++ b/packages/webapp/components/explore/ExploreSocialStrips.tsx @@ -0,0 +1,664 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQueries, useQueryClient } from '@tanstack/react-query'; +import type { Squad } from '@dailydotdev/shared/src/graphql/sources'; +import type { UserQuest } from '@dailydotdev/shared/src/graphql/quests'; +import { + QuestRewardType, + QUEST_ROTATION_UPDATE_SUBSCRIPTION, + QUEST_UPDATE_SUBSCRIPTION, +} from '@dailydotdev/shared/src/graphql/quests'; +import { getSquadStaticFields } from '@dailydotdev/shared/src/graphql/squads'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; +import { useQuestDashboard } from '@dailydotdev/shared/src/hooks/useQuestDashboard'; +import useSubscription from '@dailydotdev/shared/src/hooks/useSubscription'; +import { generateQueryKey, RequestKey } from '@dailydotdev/shared/src/lib/query'; +import { GitHubIcon } from '@dailydotdev/shared/src/components/icons/GitHub'; +import { CoreIcon, PlusIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { ExploreCategoryId } from './exploreCategories'; +import { getExploreCategoryById } from './exploreCategories'; + +const DAILY_QUESTS_LIMIT = 4; +const TOP_SQUAD_PLACEHOLDER_IMAGE = + 'https://media.daily.dev/image/upload/v1672041320/squads/squad_placeholder.jpg'; +const TOP_ACTIVE_SQUADS_30D = [ + { name: 'PHP Dev', handle: 'phpdev' }, + { name: 'Machine Learning News', handle: 'mlnews' }, + { name: 'World of Technology', handle: 'thejvmbender' }, + { name: 'Smarter Articles', handle: 'smarterarticles' }, + { name: 'Build With GenAI', handle: 'buildwithgenai' }, + { name: 'Daily Open Source Tools', handle: 'dailyopensourcetools' }, + { name: 'DevOps Daily', handle: 'devopsdaily' }, + { name: 'All Pc Softs', handle: 'allpcsofts' }, + { name: 'Horde', handle: 'horde' }, + { name: 'Grimspin', handle: 'grimspin' }, + { name: 'Devs Together Strong', handle: 'devstogetherstrong' }, + { name: 'Lonely Programmer', handle: 'lonely_programmer' }, + { name: 'Dev World', handle: 'dev_world' }, + { name: 'Just Java', handle: 'justjava' }, + { name: 'Tech GSM Softwares', handle: 'techgsmsoftwares' }, + { name: 'Data Engineering', handle: 'sspdata' }, + { name: 'Zero To Mastery', handle: 'zerotomastery' }, + { name: 'Dev Squad', handle: 'devsquad' }, + { name: 'AI', handle: 'ai' }, + { name: 'Platform & AI', handle: 'platformai' }, +] as const; +const CATEGORY_RELEVANT_SQUADS: Partial< + Record< + ExploreCategoryId, + readonly { + name: string; + handle: string; + }[] + > +> = { + videos: [ + { name: 'Build With GenAI', handle: 'buildwithgenai' }, + { name: 'AI', handle: 'ai' }, + { name: 'Platform & AI', handle: 'platformai' }, + ], + agentic: [ + { name: 'Build With GenAI', handle: 'buildwithgenai' }, + { name: 'AI', handle: 'ai' }, + { name: 'Platform & AI', handle: 'platformai' }, + ], + webdev: [ + { name: 'Zero To Mastery', handle: 'zerotomastery' }, + { name: 'Dev World', handle: 'dev_world' }, + { name: 'Smarter Articles', handle: 'smarterarticles' }, + ], + backend: [ + { name: 'DevOps Daily', handle: 'devopsdaily' }, + { name: 'Data Engineering', handle: 'sspdata' }, + { name: 'World of Technology', handle: 'thejvmbender' }, + ], + databases: [{ name: 'Data Engineering', handle: 'sspdata' }], + career: [ + { name: 'Devs Together Strong', handle: 'devstogetherstrong' }, + { name: 'Lonely Programmer', handle: 'lonely_programmer' }, + ], + golang: [{ name: 'DevOps Daily', handle: 'devopsdaily' }], + rust: [{ name: 'Daily Open Source Tools', handle: 'dailyopensourcetools' }], + opensource: [ + { name: 'Daily Open Source Tools', handle: 'dailyopensourcetools' }, + ], + testing: [{ name: 'Smarter Articles', handle: 'smarterarticles' }], + php: [{ name: 'PHP Dev', handle: 'phpdev' }], + java: [ + { name: 'Just Java', handle: 'justjava' }, + { name: 'World of Technology', handle: 'thejvmbender' }, + ], +}; +const TOP_SQUAD_SKELETON_KEYS = [ + 'top-squad-skeleton-1', + 'top-squad-skeleton-2', + 'top-squad-skeleton-3', + 'top-squad-skeleton-4', + 'top-squad-skeleton-5', + 'top-squad-skeleton-6', + 'top-squad-skeleton-7', + 'top-squad-skeleton-8', +]; +const QUEST_LOADING_KEYS = ['quest-loading-1', 'quest-loading-2']; + +const getProgressPercent = (value: number, target: number): number => + Math.min(Math.round((value / Math.max(target, 1)) * 100), 100); + +interface TopSquadStripItem { + id: string; + name: string; + permalink: string; + image: string; +} + +interface TopTagStripItem { + name: string; + slug: string; +} + +const TOP_ACTIVE_TAGS_30D: TopTagStripItem[] = [ + { name: 'AI', slug: 'ai' }, + { name: 'Webdev', slug: 'webdev' }, + { name: 'Backend', slug: 'backend' }, + { name: 'Databases', slug: 'databases' }, + { name: 'Career', slug: 'career' }, + { name: 'Golang', slug: 'golang' }, + { name: 'Rust', slug: 'rust' }, + { name: 'Opensource', slug: 'open-source' }, + { name: 'Testing', slug: 'testing' }, + { name: 'PHP', slug: 'php' }, + { name: 'Java', slug: 'java' }, + { name: 'Python', slug: 'python' }, + { name: 'JavaScript', slug: 'javascript' }, + { name: 'TypeScript', slug: 'typescript' }, + { name: 'DevOps', slug: 'devops' }, + { name: 'Security', slug: 'security' }, + { name: 'Cloud', slug: 'cloud' }, + { name: 'Kubernetes', slug: 'kubernetes' }, + { name: 'Next.js', slug: 'nextjs' }, + { name: 'React', slug: 'react' }, +]; +const SPONSORED_TAG_TOOLTIP_CONTENT = 'Sponsored by GitHub'; + +const TopSquadStories = ({ + squads, +}: { + squads: TopSquadStripItem[]; +}): ReactElement => { + const scrollRef = useRef(null); + const [showScrollLeft, setShowScrollLeft] = useState(false); + const [showScrollRight, setShowScrollRight] = useState(false); + + useEffect(() => { + const updateScrollControls = (): void => { + const element = scrollRef.current; + if (!element) { + return; + } + + const canScroll = + element.scrollLeft + element.clientWidth < element.scrollWidth - 1; + const canScrollLeft = element.scrollLeft > 1; + setShowScrollLeft(canScrollLeft); + setShowScrollRight(canScroll); + }; + + updateScrollControls(); + + const element = scrollRef.current; + element?.addEventListener('scroll', updateScrollControls); + window.addEventListener('resize', updateScrollControls); + + return () => { + element?.removeEventListener('scroll', updateScrollControls); + window.removeEventListener('resize', updateScrollControls); + }; + }, [squads.length]); + + const handleScrollRight = (): void => { + scrollRef.current?.scrollBy({ + left: 220, + behavior: 'smooth', + }); + }; + const handleScrollLeft = (): void => { + scrollRef.current?.scrollBy({ + left: -220, + behavior: 'smooth', + }); + }; + + return ( +
+
+ {squads.map((squad, index) => ( + +
+ {squad.name} + + #{index + 1} + +
+ + {squad.name} + +
+ ))} +
+ {showScrollLeft && ( + + )} + {showScrollRight && ( + + )} +
+ ); +}; + +const TopTagStories = ({ tags }: { tags: TopTagStripItem[] }): ReactElement => { + const scrollRef = useRef(null); + const [showScrollLeft, setShowScrollLeft] = useState(false); + const [showScrollRight, setShowScrollRight] = useState(false); + + useEffect(() => { + const updateScrollControls = (): void => { + const element = scrollRef.current; + if (!element) { + return; + } + + const canScroll = + element.scrollLeft + element.clientWidth < element.scrollWidth - 1; + const canScrollLeft = element.scrollLeft > 1; + setShowScrollLeft(canScrollLeft); + setShowScrollRight(canScroll); + }; + + updateScrollControls(); + + const element = scrollRef.current; + element?.addEventListener('scroll', updateScrollControls); + window.addEventListener('resize', updateScrollControls); + + return () => { + element?.removeEventListener('scroll', updateScrollControls); + window.removeEventListener('resize', updateScrollControls); + }; + }, [tags.length]); + + const handleScrollRight = (): void => { + scrollRef.current?.scrollBy({ + left: 220, + behavior: 'smooth', + }); + }; + const handleScrollLeft = (): void => { + scrollRef.current?.scrollBy({ + left: -220, + behavior: 'smooth', + }); + }; + + return ( +
+ + {showScrollLeft && ( + + )} + {showScrollRight && ( + + )} +
+ ); +}; + +const TopSquadStoriesSkeleton = (): ReactElement => ( +
+ {TOP_SQUAD_SKELETON_KEYS.map((key) => ( +
+
+
+
+ ))} +
+); + +type DailyQuestStripItem = { + quest: UserQuest; + isPlus: boolean; +}; + +const DailyQuestCard = ({ + item, +}: { + item: DailyQuestStripItem; +}): ReactElement => { + const { quest, isPlus } = item; + const progressPercent = getProgressPercent( + quest.progress, + quest.quest.targetCount, + ); + const visibleRewards = quest.rewards.filter( + (reward) => + reward.type === QuestRewardType.Xp || + reward.type === QuestRewardType.Cores, + ); + const tooltipContent = + quest.quest.description || `Complete: ${quest.quest.name}`; + + return ( + +
+
+

+ {quest.quest.name} +

+ {isPlus && ( + + )} +
+
+
+
+

+ {quest.progress}/{quest.quest.targetCount} +

+ {visibleRewards.length > 0 && ( +
+ {visibleRewards.map((reward, index) => ( + + {reward.type === QuestRewardType.Xp ? ( + + xp + + ) : ( + + )} + +{reward.amount}{' '} + {reward.type === QuestRewardType.Xp ? 'XP' : 'Cores'} + + ))} +
+ )} +
+ + ); +}; + +interface ExploreSocialStripsProps { + showTopSquads?: boolean; + showTopTags?: boolean; + showProgress?: boolean; + activeCategoryId?: ExploreCategoryId; +} + +export const ExploreSocialStrips = ({ + showTopSquads = true, + showTopTags = false, + showProgress = true, + activeCategoryId = 'explore', +}: ExploreSocialStripsProps): ReactElement | null => { + const { isLoggedIn, user } = useAuthContext(); + const queryClient = useQueryClient(); + const squadSeeds = + activeCategoryId === 'explore' + ? TOP_ACTIVE_SQUADS_30D + : CATEGORY_RELEVANT_SQUADS[activeCategoryId] ?? []; + const activeCategoryLabel = getExploreCategoryById(activeCategoryId)?.label; + + const topSquadQueries = useQueries({ + queries: squadSeeds.map(({ handle }) => ({ + queryKey: ['explore', 'top-active-squad', handle], + queryFn: () => getSquadStaticFields(handle), + enabled: showTopSquads, + })), + }); + + const isTopSquadsPending = topSquadQueries.some((query) => query.isPending); + + const topSquads = useMemo( + () => + squadSeeds.map(({ name, handle }, index) => { + const squadData = topSquadQueries[index]?.data as Squad | undefined; + + return { + id: squadData?.id ?? `top-active-squad-${index + 1}-${handle}`, + name: squadData?.name ?? name, + permalink: + squadData?.permalink ?? `https://app.daily.dev/squads/${handle}`, + image: squadData?.image ?? TOP_SQUAD_PLACEHOLDER_IMAGE, + }; + }), + [squadSeeds, topSquadQueries], + ); + const shouldRenderTopSquads = + showTopSquads && (isTopSquadsPending || topSquads.length > 0); + const shouldRenderTopTags = showTopTags; + const shouldRenderProgress = showProgress; + const questDashboardQueryKey = generateQueryKey(RequestKey.QuestDashboard, user); + const invalidateQuestDashboard = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: questDashboardQueryKey, + exact: true, + }); + }, [queryClient, questDashboardQueryKey]); + + const { data: questDashboard, isPending: isQuestsPending } = + useQuestDashboard(); + + useSubscription( + () => ({ + query: QUEST_UPDATE_SUBSCRIPTION, + }), + { + next: invalidateQuestDashboard, + }, + [user?.id], + ); + + useSubscription( + () => ({ + query: QUEST_ROTATION_UPDATE_SUBSCRIPTION, + }), + { + next: invalidateQuestDashboard, + }, + [user?.id], + ); + + const dailyQuests = useMemo(() => { + if (!questDashboard) { + return []; + } + + const regularQuests = questDashboard.daily.regular + .filter((quest) => !quest.locked) + .map((quest) => ({ quest, isPlus: false })); + const plusQuests = questDashboard.daily.plus.map((quest) => ({ + quest, + isPlus: true, + })); + + return [...regularQuests, ...plusQuests].slice(0, DAILY_QUESTS_LIMIT); + }, [questDashboard]); + + if (!shouldRenderTopSquads && !shouldRenderTopTags && !shouldRenderProgress) { + return null; + } + + return ( + <> + {shouldRenderTopSquads && ( +
+
+

+ {activeCategoryId === 'explore' + ? 'Top active squads' + : 'Relevant squads'} +

+

+ {activeCategoryId === 'explore' + ? '20 most active public squads over the last 30 day' + : `${squadSeeds.length} relevant public squads for ${ + activeCategoryLabel ?? 'this category' + }`} +

+
+ {isTopSquadsPending ? ( + + ) : ( + + )} +
+ )} + + {shouldRenderTopTags && ( +
+
+

+ Popular tags +

+
+ Popular tags across +
+
+ +
+ )} + + {shouldRenderProgress && ( +
+
+

+ Daily quests +

+
+ {!isLoggedIn ? ( +

+ Sign in to track daily quests. +

+ ) : ( +
+ {dailyQuests.map((item) => ( + + ))} + {isQuestsPending && + QUEST_LOADING_KEYS.map((key) => ( +
+ ))} +
+ )} +
+ )} + + ); +}; diff --git a/packages/webapp/components/explore/exploreCategories.ts b/packages/webapp/components/explore/exploreCategories.ts new file mode 100644 index 00000000000..0bdc3895e23 --- /dev/null +++ b/packages/webapp/components/explore/exploreCategories.ts @@ -0,0 +1,33 @@ +export const EXPLORE_CATEGORIES = [ + { id: 'explore', label: 'Explore', path: '/explore' }, + { id: 'videos', label: 'Videos', path: '/explore/videos', isVideos: true }, + { id: 'agentic', label: 'Agentic', path: '/explore/agentic', tag: 'agentic' }, + { id: 'webdev', label: 'Webdev', path: '/explore/webdev', tag: 'webdev' }, + { id: 'backend', label: 'Backend', path: '/explore/backend', tag: 'backend' }, + { + id: 'databases', + label: 'Databases', + path: '/explore/databases', + tag: 'databases', + }, + { id: 'career', label: 'Career', path: '/explore/career', tag: 'career' }, + { id: 'golang', label: 'Golang', path: '/explore/golang', tag: 'golang' }, + { id: 'rust', label: 'Rust', path: '/explore/rust', tag: 'rust' }, + { + id: 'opensource', + label: 'Opensource', + path: '/explore/opensource', + tag: 'open-source', + }, + { id: 'testing', label: 'Testing', path: '/explore/testing', tag: 'testing' }, + { id: 'php', label: 'PHP', path: '/explore/php', tag: 'php' }, + { id: 'java', label: 'Java', path: '/explore/java', tag: 'java' }, +] as const; + +export type ExploreCategory = (typeof EXPLORE_CATEGORIES)[number]; +export type ExploreCategoryId = ExploreCategory['id']; + +export const getExploreCategoryById = ( + id: string | undefined, +): ExploreCategory | undefined => + EXPLORE_CATEGORIES.find((category) => category.id === id); diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 2f617f379fa..a131b5ae312 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -43,6 +43,7 @@ "idb-keyval": "^5.1.5", "jotai": "^2.12.2", "lottie-react": "^2.4.1", + "lucide-react": "^1.7.0", "next": "15.5.14", "next-seo": "^5.4.0", "react": "18.3.1", diff --git a/packages/webapp/pages/explore.tsx b/packages/webapp/pages/explore.tsx new file mode 100644 index 00000000000..9ed630a05f3 --- /dev/null +++ b/packages/webapp/pages/explore.tsx @@ -0,0 +1,60 @@ +import type { GetStaticPropsResult } from 'next'; +import type { ReactElement } from 'react'; +import React from 'react'; +import type { DehydratedState } from '@tanstack/react-query'; +import { dehydrate, QueryClient } from '@tanstack/react-query'; +import type { NextSeoProps } from 'next-seo/lib/types'; +import { getLayout as getFooterNavBarLayout } from '../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../components/layouts/MainLayout'; +import { + ExplorePageContent, + prefetchExplorePageData, +} from '../components/explore/ExplorePageContent'; +import { defaultOpenGraph } from '../next-seo'; +import { getPageSeoTitles } from '../components/layouts/utils'; + +interface ExplorePageProps { + dehydratedState: DehydratedState; +} + +const seoTitles = getPageSeoTitles('Explore developer news'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { + ...defaultOpenGraph, + ...seoTitles.openGraph, + url: 'https://app.daily.dev/explore', + }, + description: + 'Scan happening now updates, latest stories, and top developer news in one place on daily.dev.', + canonical: 'https://app.daily.dev/explore', +}; + +const ExplorePage = (): ReactElement => ( + +); + +const getExploreLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +ExplorePage.getLayout = getExploreLayout; +ExplorePage.layoutProps = { + screenCentered: false, + seo, +}; + +export default ExplorePage; + +export async function getStaticProps(): Promise< + GetStaticPropsResult +> { + const queryClient = new QueryClient(); + await prefetchExplorePageData({ queryClient, categoryId: 'explore' }); + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + revalidate: 600, + }; +} diff --git a/packages/webapp/pages/explore/[category].tsx b/packages/webapp/pages/explore/[category].tsx new file mode 100644 index 00000000000..465c0d58257 --- /dev/null +++ b/packages/webapp/pages/explore/[category].tsx @@ -0,0 +1,96 @@ +import type { + GetStaticPathsResult, + GetStaticPropsContext, + GetStaticPropsResult, +} from 'next'; +import type { ReactElement } from 'react'; +import React from 'react'; +import type { DehydratedState } from '@tanstack/react-query'; +import { dehydrate, QueryClient } from '@tanstack/react-query'; +import type { NextSeoProps } from 'next-seo/lib/types'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; +import { + ExplorePageContent, + prefetchExplorePageData, +} from '../../components/explore/ExplorePageContent'; +import type { ExploreCategoryId } from '../../components/explore/exploreCategories'; +import { + EXPLORE_CATEGORIES, + getExploreCategoryById, +} from '../../components/explore/exploreCategories'; +import { defaultOpenGraph } from '../../next-seo'; +import { getPageSeoTitles } from '../../components/layouts/utils'; + +interface ExploreCategoryPageProps { + activeCategoryId: ExploreCategoryId; + dehydratedState: DehydratedState; +} + +const seoTitles = getPageSeoTitles('Explore developer news'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { + ...defaultOpenGraph, + ...seoTitles.openGraph, + }, + description: + 'Scan happening now updates, latest stories, and top developer news in one place on daily.dev.', +}; + +const ExploreCategoryPage = ({ + activeCategoryId, +}: ExploreCategoryPageProps): ReactElement => ( + +); + +const getExploreLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +ExploreCategoryPage.getLayout = getExploreLayout; +ExploreCategoryPage.layoutProps = { + screenCentered: false, + seo, +}; + +export default ExploreCategoryPage; + +export async function getStaticPaths(): Promise { + const paths = EXPLORE_CATEGORIES.filter( + (category) => category.id !== 'explore', + ).map((category) => ({ + params: { category: category.id }, + })); + + return { + paths, + fallback: false, + }; +} + +export async function getStaticProps( + context: GetStaticPropsContext<{ category: string }>, +): Promise> { + const categoryParam = context.params?.category; + const category = getExploreCategoryById(categoryParam); + + if (!category || category.id === 'explore') { + return { + notFound: true, + }; + } + + const queryClient = new QueryClient(); + await prefetchExplorePageData({ + queryClient, + categoryId: category.id, + }); + + return { + props: { + activeCategoryId: category.id, + dehydratedState: dehydrate(queryClient), + }, + revalidate: 600, + }; +} diff --git a/packages/webapp/public/assets/brief-card-magic.png b/packages/webapp/public/assets/brief-card-magic.png new file mode 100644 index 00000000000..e118ee2bbcb Binary files /dev/null and b/packages/webapp/public/assets/brief-card-magic.png differ diff --git a/packages/webapp/public/assets/explore-top-news-hero.png b/packages/webapp/public/assets/explore-top-news-hero.png new file mode 100644 index 00000000000..8b50af6189d Binary files /dev/null and b/packages/webapp/public/assets/explore-top-news-hero.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d8f2cb637e..52e92e409f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -932,6 +932,9 @@ importers: lottie-react: specifier: ^2.4.1 version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lucide-react: + specifier: ^1.7.0 + version: 1.7.0(react@18.3.1) next: specifier: 15.5.14 version: 15.5.14(@babel/core@7.26.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.82.0) @@ -7281,6 +7284,11 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lucide-react@1.7.0: + resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -8647,6 +8655,7 @@ packages: realistic-structured-clone@2.0.4: resolution: {integrity: sha512-lItAdBIFHUSe6fgztHPtmmWqKUgs+qhcYLi3wTRUl4OTB3Vb8aBVSjGfQZUvkmJCKoX3K9Wf7kyLp/F/208+7A==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. recast@0.23.9: resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==} @@ -17066,6 +17075,10 @@ snapshots: dependencies: yallist: 4.0.0 + lucide-react@1.7.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} magic-string@0.30.19: