diff --git a/packages/shared/src/contexts/AuthContext.tsx b/packages/shared/src/contexts/AuthContext.tsx index 3cb45a31430..b7df5e02d5c 100644 --- a/packages/shared/src/contexts/AuthContext.tsx +++ b/packages/shared/src/contexts/AuthContext.tsx @@ -33,6 +33,7 @@ export interface LoginState { onLoginSuccess?: () => void; onRegistrationSuccess?: (user?: LoggedUser | AnonymousUser) => void; isLogin?: boolean; + afterAuth?: string; } type LoginOptions = Omit; @@ -186,6 +187,8 @@ export const AuthContextProvider = ({ setLoginState({ ...options, trigger }); if (isExtension) { params.delete(AFTER_AUTH_PARAM); + } else if (options.afterAuth) { + params.set(AFTER_AUTH_PARAM, options.afterAuth); } else if (!params.get(AFTER_AUTH_PARAM)) { params.set(AFTER_AUTH_PARAM, window.location.pathname); } diff --git a/packages/shared/src/features/hackathon/components/HackathonClosingCTA.tsx b/packages/shared/src/features/hackathon/components/HackathonClosingCTA.tsx new file mode 100644 index 00000000000..2d790e1413a --- /dev/null +++ b/packages/shared/src/features/hackathon/components/HackathonClosingCTA.tsx @@ -0,0 +1,31 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol } from '../../../components/utilities'; +import { HackathonSignupButton } from './HackathonSignupButton'; + +export const HackathonClosingCTA = (): ReactElement => { + return ( + + + Ready to build? + + + Sign up now and we'll send you the kickoff details and how to get + your API access before the hackathon opens. + +
+ +
+
+ ); +}; diff --git a/packages/shared/src/features/hackathon/components/HackathonFAQ.tsx b/packages/shared/src/features/hackathon/components/HackathonFAQ.tsx new file mode 100644 index 00000000000..0923ec2421f --- /dev/null +++ b/packages/shared/src/features/hackathon/components/HackathonFAQ.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol } from '../../../components/utilities'; +import { RadixAccordion } from '../../../components/accordion'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetId } from '../../../lib/log'; + +const faq = [ + { + title: 'When does the hackathon start?', + description: 'It will happen in May. Concrete dates are TBD.', + }, + { + title: 'Who can participate?', + description: + 'Any developer with a daily.dev account. New users and beginners welcome.', + }, + { + title: 'Do I need a Plus subscription?', + description: + 'No. The Public API is open to all hackathon participants for the duration of the event.', + }, + { + title: 'What stack should I use?', + description: + 'Whatever you want. Next.js, TanStack Start, Vite, Python. Pick whatever lets you ship fastest. The OpenAPI spec works with any language.', + }, + { + title: 'Can I use AI?', + description: + 'Yes. Claude Code, Cursor, Codex, vibe-code the whole thing if you want.', + }, + { + title: 'How do I submit my project?', + description: + 'Post about your project on social media tagging @dailydotdev with a link to your live URL and a short summary. That post is your submission.', + }, + { + title: "What happens if I don't finish?", + description: + 'Submit what you have. Half-finished but interesting beats polished but boring. We value creativity and usefulness as much as execution.', + }, +]; + +export const HackathonFAQ = (): ReactElement => { + const { logEvent } = useLogContext(); + + const handleFAQChange = (value: string) => { + if (!value) { + return; + } + logEvent({ + event_name: LogEvent.Click, + target_id: TargetId.HackathonPage, + extra: JSON.stringify({ faq: value }), + }); + }; + + return ( + + + Frequently asked questions (FAQ) + + + + ); +}; diff --git a/packages/shared/src/features/hackathon/components/HackathonHero.tsx b/packages/shared/src/features/hackathon/components/HackathonHero.tsx new file mode 100644 index 00000000000..bd039cbc636 --- /dev/null +++ b/packages/shared/src/features/hackathon/components/HackathonHero.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { DailyIcon } from '../../../components/icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { useIsLightTheme } from '../../../hooks/utils'; +import { briefButtonBg } from '../../../styles/custom'; +import { fromCDN } from '../../../lib/links'; +import { HackathonSignupButton } from './HackathonSignupButton'; + +export const HackathonHero = (): ReactElement => { + const isLightTheme = useIsLightTheme(); + + return ( + + + + + daily.dev Hackathon + + + daily.dev Hackathon + + Build something for developers, from developers. + + + 72 hours, three tracks, and full access to the daily.dev Public API.{' '} + Win 1 year of daily.dev Plus! + + + + + + ); +}; diff --git a/packages/shared/src/features/hackathon/components/HackathonHowItWorks.tsx b/packages/shared/src/features/hackathon/components/HackathonHowItWorks.tsx new file mode 100644 index 00000000000..2e1e7d6a3b1 --- /dev/null +++ b/packages/shared/src/features/hackathon/components/HackathonHowItWorks.tsx @@ -0,0 +1,127 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol } from '../../../components/utilities'; +import Link from '../../../components/utilities/Link'; +import { anchorDefaultRel } from '../../../lib/strings'; +import { webappUrl } from '../../../lib/constants'; + +export const HackathonHowItWorks = (): ReactElement => { + return ( + + + + Format + + + + 72 hours, async. Join from anywhere + + . To submit your submission, post about your project on social media + tagging @dailydotdev with your live URL and a short summary. + + + + + + Prizes + + + 1 year of{' '} + + + daily.dev Plus + + {' '} + with all the benefits for best submissions. Winning projects featured + on daily.dev and social media. + + + + + + Rules + + +

+ Public URL required. Must be deployed and accessible on the + internet. +

+

+ Don't rebuild things we already have. Bookmarks, top reader, + reading streaks, briefings... +

+

+ Don't rebuild things we already have.{' '} + + Build on top, be creative, don't copy + + . +

+

+ Bonus points if your solution + is explorable and shareable by other users (image, card, page, + link). +

+
+
+ + + + What we provide + + +

+ + + API access + + {' '} + for all registered participants (no Plus requirement). +

+

+ + + OpenAPI spec + + {' '} + for client generation in any language. +

+

+ Use whatever stack you want. Next.js, TanStack Start, Vite, Go, even + PHP. +

+

+ Agent skills like{' '} + + + /daily-dev-ask + + {' '} + available to help you out. +

+
+
+
+ ); +}; diff --git a/packages/shared/src/features/hackathon/components/HackathonSignupButton.tsx b/packages/shared/src/features/hackathon/components/HackathonSignupButton.tsx new file mode 100644 index 00000000000..066669fedbe --- /dev/null +++ b/packages/shared/src/features/hackathon/components/HackathonSignupButton.tsx @@ -0,0 +1,151 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { AddUserIcon, VIcon } from '../../../components/icons'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { AuthTriggers } from '../../../lib/auth'; +import { LogEvent, TargetId } from '../../../lib/log'; +import { gqlClient } from '../../../graphql/common'; +import { JOIN_HACKATHON_MUTATION } from '../../../graphql/users'; +import { useUpdateQuery } from '../../../hooks/useUpdateQuery'; +import { getPathnameWithQuery } from '../../../lib/links'; +import { SimpleTooltip } from '../../../components/tooltips'; +import { hackathonParticipationQueryOptions } from '../queries'; +import { isTesting } from '../../../lib/constants'; + +type HackathonSignupButtonProps = { + size?: ButtonSize; + className?: string; +}; + +const AUTOJOIN_PARAM = 'autojoin'; + +export const HackathonSignupButton = ({ + size = ButtonSize.Large, + className, +}: HackathonSignupButtonProps): ReactElement => { + const router = useRouter(); + const { user, isLoggedIn, showLogin } = useAuthContext(); + const { logEvent } = useLogContext(); + const participationOptions = hackathonParticipationQueryOptions(user); + const { data, isPending } = useQuery(participationOptions); + const [getParticipation, setParticipation] = + useUpdateQuery(participationOptions); + const isParticipant = !!data?.whoami?.isHackathonParticipant; + const autojoinRequested = router.query[AUTOJOIN_PARAM] === '1'; + + const { mutateAsync: join, isPending: isJoining } = useMutation({ + mutationFn: () => gqlClient.request(JOIN_HACKATHON_MUTATION), + onMutate: () => { + const previous = getParticipation(); + if (previous) { + setParticipation({ + whoami: { ...previous.whoami, isHackathonParticipant: true }, + }); + } + return { previous }; + }, + onError: (_, __, context) => { + if (context?.previous) { + setParticipation(context.previous); + } + }, + }); + + useEffect(() => { + if ( + !autojoinRequested || + !isLoggedIn || + isPending || + isParticipant || + isJoining + ) { + return; + } + join(); + }, [ + autojoinRequested, + isLoggedIn, + isPending, + isParticipant, + isJoining, + join, + ]); + + useEffect(() => { + if (!autojoinRequested || !isParticipant) { + return; + } + const params = new URLSearchParams(window.location.search); + params.delete(AUTOJOIN_PARAM); + router.replace(getPathnameWithQuery(router.pathname, params), undefined, { + shallow: true, + }); + }, [autojoinRequested, isParticipant, router]); + + if (isLoggedIn && isParticipant) { + return ( + +
+ +
+
+ ); + } + + const handleClick = async () => { + logEvent({ + event_name: LogEvent.Click, + target_id: TargetId.HackathonPage, + extra: JSON.stringify({ action: isLoggedIn ? 'join' : 'login' }), + }); + + if (!isLoggedIn) { + const params = new URLSearchParams(window.location.search); + params.set(AUTOJOIN_PARAM, '1'); + showLogin({ + trigger: AuthTriggers.Hackathon, + options: { + afterAuth: getPathnameWithQuery(window.location.pathname, params), + }, + }); + return; + } + + await join(); + }; + + return ( + + ); +}; diff --git a/packages/shared/src/features/hackathon/components/HackathonTracks.tsx b/packages/shared/src/features/hackathon/components/HackathonTracks.tsx new file mode 100644 index 00000000000..45549f6f3cb --- /dev/null +++ b/packages/shared/src/features/hackathon/components/HackathonTracks.tsx @@ -0,0 +1,93 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { FlexCol } from '../../../components/utilities'; + +const tracks = [ + { + emoji: '🪞', + name: 'Developer Identity', + tagline: 'Tell developers something about themselves.', + description: + 'Your daily.dev profile has a lot in it. What you read, what you save, what you follow, your tech stack, your career. Turn that into something a developer would want to showcase or share.', + examples: [], + }, + { + emoji: '📊', + name: 'Content Intelligence', + tagline: 'Use daily.dev as a dataset.', + description: + 'daily.dev pulls from 1,300+ sources. Tag feeds, source feeds, search, comments, recommendations. Turn all that data into something useful for other developers.', + examples: [], + }, + { + emoji: '⚡', + name: 'Content → Action', + tagline: 'Turn reading into doing.', + description: + 'Developers bookmark things for later and never come back to them. Build the bridge between reading on daily.dev and actually doing something with what you read.', + examples: [], + }, +]; + +export const HackathonTracks = (): ReactElement => { + return ( + + + + Three tracks. Pick one. + + + Each track is a different angle on the daily.dev API. Pick the one + that fits what you want to build. + + +
+ {tracks.map(({ emoji, name, tagline, description, examples }) => ( + + {emoji} + + {name} + + + {tagline} + + + {description} + + {examples.length > 0 && ( + + {examples.map((example) => ( + + {example} + + ))} + + )} + + ))} +
+
+ ); +}; diff --git a/packages/shared/src/features/hackathon/queries.ts b/packages/shared/src/features/hackathon/queries.ts new file mode 100644 index 00000000000..5cee806f652 --- /dev/null +++ b/packages/shared/src/features/hackathon/queries.ts @@ -0,0 +1,21 @@ +import { queryOptions } from '@tanstack/react-query'; +import { gqlClient } from '../../graphql/common'; +import { + HACKATHON_PARTICIPATION_QUERY, + type HackathonParticipationData, +} from '../../graphql/users'; +import { generateQueryKey, RequestKey } from '../../lib/query'; +import type { LoggedUser } from '../../lib/user'; + +export const hackathonParticipationQueryOptions = ( + user?: Pick, +) => + queryOptions({ + queryKey: generateQueryKey(RequestKey.HackathonParticipation, user), + queryFn: () => + gqlClient.request( + HACKATHON_PARTICIPATION_QUERY, + ), + enabled: !!user?.id, + staleTime: Infinity, + }); diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index ecb44386323..41acf8be6c8 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -34,6 +34,30 @@ export const CHECK_LOCATION_QUERY = gql` } `; +export const JOIN_HACKATHON_MUTATION = gql` + mutation JoinHackathon { + joinHackathon { + _ + } + } +`; + +export const HACKATHON_PARTICIPATION_QUERY = gql` + query HackathonParticipation { + whoami { + id + isHackathonParticipant + } + } +`; + +export type HackathonParticipationData = { + whoami: { + id: string; + isHackathonParticipant: boolean; + }; +}; + export const USER_BY_ID_STATIC_FIELDS_QUERY = ` query User($id: ID!) { user(id: $id) { diff --git a/packages/shared/src/lib/auth.ts b/packages/shared/src/lib/auth.ts index 80f76643382..50c5c6d1d9a 100644 --- a/packages/shared/src/lib/auth.ts +++ b/packages/shared/src/lib/auth.ts @@ -77,6 +77,7 @@ export enum AuthTriggers { Gear = 'gear', AddToStack = 'add to stack', PostPage = 'post page', + Hackathon = 'hackathon', } export type AuthTriggersType = diff --git a/packages/shared/src/lib/links.spec.ts b/packages/shared/src/lib/links.spec.ts index c9e3c0805de..492882b291a 100644 --- a/packages/shared/src/lib/links.spec.ts +++ b/packages/shared/src/lib/links.spec.ts @@ -1,4 +1,4 @@ -import { withHttps } from './links'; +import { getPathnameWithQuery, withHttps } from './links'; describe('lib/links tests', () => { it('should return links as https links', () => { @@ -15,3 +15,39 @@ describe('lib/links tests', () => { }); }); }); + +describe('getPathnameWithQuery', () => { + it('returns pathname unchanged when params are empty', () => { + expect(getPathnameWithQuery('/foo', new URLSearchParams())).toBe('/foo'); + }); + + it('appends params to a clean pathname', () => { + const params = new URLSearchParams({ a: '1', b: '2' }); + expect(getPathnameWithQuery('/foo', params)).toBe('/foo?a=1&b=2'); + }); + + it('preserves existing query params on the pathname', () => { + expect(getPathnameWithQuery('/foo?a=1', new URLSearchParams())).toBe( + '/foo?a=1', + ); + }); + + it('merges existing pathname query with new params', () => { + const params = new URLSearchParams({ b: '2' }); + expect(getPathnameWithQuery('/foo?a=1', params)).toBe('/foo?a=1&b=2'); + }); + + it('lets new params override pathname query on key conflicts', () => { + const params = new URLSearchParams({ a: '2', b: '3' }); + expect(getPathnameWithQuery('/foo?a=1', params)).toBe('/foo?a=2&b=3'); + }); + + it('accepts a string params argument', () => { + expect(getPathnameWithQuery('/foo?a=1', 'b=2')).toBe('/foo?a=1&b=2'); + }); + + it('ignores whitespace-only existing query', () => { + expect(getPathnameWithQuery('/foo? ', new URLSearchParams())).toBe('/foo'); + expect(getPathnameWithQuery('/foo? ', 'a=1')).toBe('/foo?a=1'); + }); +}); diff --git a/packages/shared/src/lib/links.ts b/packages/shared/src/lib/links.ts index 10c4087abf1..16b206b1853 100644 --- a/packages/shared/src/lib/links.ts +++ b/packages/shared/src/lib/links.ts @@ -103,10 +103,15 @@ export const getPathnameWithQuery = ( pathname: string, params: URLSearchParams | string, ): string => { - const searchParams = new URLSearchParams(params); - const queryString = searchParams.toString(); + const [basePath, existingQuery] = pathname.split('?'); + const merged = new URLSearchParams(existingQuery?.trim()); + const overrides = new URLSearchParams(params); + overrides.forEach((value, key) => { + merged.set(key, value); + }); + const queryString = merged.toString(); - return `${pathname}${queryString ? `?${queryString}` : ''}`; + return `${basePath}${queryString ? `?${queryString}` : ''}`; }; export const agentsHighlightsPath = '/highlights/vibes'; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 6a4b7a3a4a5..62c5b33bcfe 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -595,6 +595,7 @@ export enum TargetId { HighlightsCard = 'highlights card', AskPage = 'ask page', AskUpsellSearch = 'ask upsell search', + HackathonPage = 'hackathon page', // Onboarding v2 GitHub = 'github', AI = 'ai', diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index f8a40f003ca..48ed8e45569 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -270,6 +270,7 @@ export enum RequestKey { ShowcaseAchievements = 'showcase_achievements', PostHighlights = 'post_highlights', MarketingCtas = 'marketing_ctas', + HackathonParticipation = 'hackathon_participation', BrowserExtensionInstalled = 'browser_extension_installed', LiveRooms = 'live_rooms', } diff --git a/packages/webapp/pages/hackathon/index.tsx b/packages/webapp/pages/hackathon/index.tsx new file mode 100644 index 00000000000..c552bdaba0f --- /dev/null +++ b/packages/webapp/pages/hackathon/index.tsx @@ -0,0 +1,94 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import Head from 'next/head'; +import type { NextSeoProps } from 'next-seo'; +import { FlexCol } from '@dailydotdev/shared/src/components/utilities'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { fromCDN } from '@dailydotdev/shared/src/lib/links'; +import { HackathonHero } from '@dailydotdev/shared/src/features/hackathon/components/HackathonHero'; +import { HackathonTracks } from '@dailydotdev/shared/src/features/hackathon/components/HackathonTracks'; +import { HackathonHowItWorks } from '@dailydotdev/shared/src/features/hackathon/components/HackathonHowItWorks'; +import { HackathonFAQ } from '@dailydotdev/shared/src/features/hackathon/components/HackathonFAQ'; +import { HackathonClosingCTA } from '@dailydotdev/shared/src/features/hackathon/components/HackathonClosingCTA'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; +import { defaultOpenGraph, defaultSeo } from '../../next-seo'; +import { getPageSeoTitles } from '../../components/layouts/utils'; + +const HACKATHON_URL = 'https://app.daily.dev/hackathon'; +const HACKATHON_TITLE = 'Hackathon'; +const HACKATHON_DESCRIPTION = + '72 hours, the daily.dev Public API, and three open tracks. Build something for developers, from developers.'; + +// TODO move to cloudinary +const HACKATHON_OG_IMAGE = fromCDN('/assets/hackathon-og.png'); + +const seoTitles = getPageSeoTitles(HACKATHON_TITLE); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { + ...defaultOpenGraph, + ...seoTitles.openGraph, + images: [{ url: HACKATHON_OG_IMAGE }], + }, + ...defaultSeo, + description: HACKATHON_DESCRIPTION, +}; + +const getHackathonJsonLd = (): string => + JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Event', + name: HACKATHON_TITLE, + description: HACKATHON_DESCRIPTION, + url: HACKATHON_URL, + eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode', + organizer: { + '@type': 'Organization', + name: 'daily.dev', + url: 'https://app.daily.dev', + }, + }); + +const HackathonPage = (): ReactElement => { + return ( +
+ +