diff --git a/packages/shared/src/components/streak/StreakShareCard.tsx b/packages/shared/src/components/streak/StreakShareCard.tsx new file mode 100644 index 00000000000..45b951218c4 --- /dev/null +++ b/packages/shared/src/components/streak/StreakShareCard.tsx @@ -0,0 +1,73 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import LogoIcon from '../../svg/LogoIcon'; +import LogoText from '../../svg/LogoText'; +import { ReadingStreakIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import type { PublicUserStreak } from '../../graphql/users'; + +const Stat = ({ + value, + label, +}: { + value: number; + label: string; +}): ReactElement => ( + + + {value} + + + {label} + + +); + +// Rendered headless by the screenshot service (see +// `webapp/pages/image-generator/streak/[userId]`), so it must stay +// self-contained: no context, no data fetching, fixed size. +export function StreakShareCard({ + streak, +}: { + streak: PublicUserStreak; +}): ReactElement { + return ( +
+
+ + +
+ +
+ + + {streak.current} + + + day reading streak + +
+ +
+ + +
+
+ ); +} diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.spec.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.spec.tsx new file mode 100644 index 00000000000..3d6bdf6d90c --- /dev/null +++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.spec.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { ReadingStreakPopup } from './ReadingStreakPopup'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { UserStreak } from '../../../graphql/users'; +import { DayOfWeek } from '../../../lib/date'; +import type { LoggedUser } from '../../../lib/user'; + +jest.mock('next/router', () => ({ + useRouter: () => ({ push: jest.fn(), isReady: true, query: {} }), +})); + +jest.mock('../../../graphql/users', () => ({ + ...jest.requireActual('../../../graphql/users'), + getReadingStreak30Days: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../../../hooks/useActions', () => ({ + useActions: () => ({ + completeAction: jest.fn(), + checkHasCompleted: () => false, + isActionsFetched: true, + }), +})); + +const user = { + id: '1', + name: 'Ido', + username: 'idoshamun', + permalink: 'https://app.daily.dev/idoshamun', + timezone: 'Etc/UTC', +} as unknown as LoggedUser; + +const streak: UserStreak = { + current: 12, + max: 40, + total: 120, + weekStart: DayOfWeek.Monday, + lastViewAt: new Date(), +}; + +const renderPopup = ({ + enabled = false, + overrides = {}, +}: { + enabled?: boolean; + overrides?: Partial; +} = {}): RenderResult => { + const gb = new GrowthBook(); + gb.setFeatures({ + sharing_visibility: { defaultValue: enabled }, + share_streak: { defaultValue: enabled }, + }); + + return render( + + + , + ); +}; + +const getSettingsLink = (): HTMLElement => + document.querySelector( + 'a[href*="account/customization/streaks"]', + )!; + +describe('ReadingStreakPopup share action', () => { + it('keeps the current UI untouched when the flag is off', async () => { + renderPopup(); + + expect(await screen.findByText('Current streak')).toBeInTheDocument(); + expect(screen.queryByLabelText('Share streak')).not.toBeInTheDocument(); + // No wrapper is injected: settings stays a direct child of the action row. + expect(getSettingsLink().parentElement?.className).toContain('mt-4'); + }); + + it('renders the share action in the existing row when enabled', async () => { + renderPopup({ enabled: true }); + + await waitFor(() => + expect(screen.getByLabelText('Share streak')).toBeInTheDocument(), + ); + // Share and settings share one row wrapper, so the popup height is stable. + const shareButton = screen.getByLabelText('Share streak'); + expect(shareButton.parentElement).toContainElement(getSettingsLink()); + }); + + it('hides the share action with nothing to share yet', () => { + renderPopup({ enabled: true, overrides: { current: 0 } }); + + expect(screen.queryByLabelText('Share streak')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx index dc1fe8f4006..a008bab29c0 100644 --- a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx +++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx @@ -47,6 +47,11 @@ import { NotificationSvg } from '../../notifications/NotificationSvg'; import { usePushNotificationContext } from '../../../contexts/PushNotificationContext'; import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; +import ConditionalWrapper from '../../ConditionalWrapper'; +import { ShareStreakButton } from './ShareStreakButton'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { useSharingVisibility } from '../../../hooks/useSharingVisibility'; +import { featureShareStreak } from '../../../lib/featureManagement'; const getStreak = ({ value, @@ -136,6 +141,19 @@ export function ReadingStreakPopup({ ); const { onTogglePermission, acceptedJustNow } = usePushNotificationMutation(); + // Nothing to be proud of at zero, and a share needs a profile to point at. + const hasShareableStreak = streak?.current > 0 && !!user?.permalink; + const { isEnabled: isSharingVisible } = + useSharingVisibility(hasShareableStreak); + const { value: isShareStreakEnabled } = useConditionalFeature({ + feature: featureShareStreak, + // Only evaluated once the master gate passes, so control users are never + // bucketed into the share_streak experiment. + shouldEvaluate: hasShareableStreak && isSharingVisible, + }); + const canShareStreak = + hasShareableStreak && isSharingVisible && isShareStreakEnabled; + const showAlert = isPushSupported && isAlertShown && @@ -303,16 +321,36 @@ export function ReadingStreakPopup({ - - - + ( + // Share sits inside the existing action row so the popup keeps + // its height; on mobile both buttons split the row evenly. +
+ + {children} +
+ )} + > + + + +
{showAlert && ( diff --git a/packages/shared/src/components/streak/popup/ShareStreakButton.spec.tsx b/packages/shared/src/components/streak/popup/ShareStreakButton.spec.tsx new file mode 100644 index 00000000000..aa38ae4d637 --- /dev/null +++ b/packages/shared/src/components/streak/popup/ShareStreakButton.spec.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { ShareStreakButton, getStreakShareText } from './ShareStreakButton'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { ToastNotification } from '../../../hooks/useToastNotification'; +import { TOAST_NOTIF_KEY } from '../../../hooks/useToastNotification'; + +const link = 'https://app.daily.dev/idoshamun'; +const writeText = jest.fn().mockResolvedValue(undefined); +const share = jest.fn().mockResolvedValue(undefined); +const canShare = jest.fn(); +// `shouldUseNativeShare` gates on `isMobile()`, which reads this flag. +const setMobile = () => { + globalThis.localStorage.mobile = 'true'; +}; + +beforeEach(() => { + jest.clearAllMocks(); + delete globalThis.localStorage.mobile; + Object.assign(globalThis.navigator, { clipboard: { writeText } }); + delete (globalThis.navigator as { share?: unknown }).share; + delete (globalThis.navigator as { canShare?: unknown }).canShare; +}); + +let client: QueryClient; + +const renderComponent = ( + props: Partial[0]> = {}, +): RenderResult => { + client = new QueryClient(); + + return render( + + + , + ); +}; + +const clickShare = async () => { + await act(async () => { + fireEvent.click(screen.getByLabelText('Share streak')); + }); +}; + +describe('getStreakShareText', () => { + it('sounds like a developer and carries the streak length', () => { + expect(getStreakShareText(3)).toBe( + '3-day reading streak on daily.dev and counting.', + ); + expect(getStreakShareText(45)).toContain('45 days straight'); + expect(getStreakShareText(120)).toContain('120 days without breaking'); + }); +}); + +describe('ShareStreakButton on desktop', () => { + it('copies the link to the clipboard and shows a toast', async () => { + renderComponent(); + + await clickShare(); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + await waitFor(() => + expect( + client.getQueryData(TOAST_NOTIF_KEY)?.message, + ).toEqual('✅ Copied link to clipboard'), + ); + }); +}); + +describe('ShareStreakButton on mobile', () => { + beforeEach(setMobile); + + it('opens the native share sheet with the streak text and link', async () => { + Object.assign(globalThis.navigator, { share }); + + renderComponent(); + await clickShare(); + + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + expect(share).toHaveBeenCalledWith({ + text: `${getStreakShareText(12)}\n${link}`, + }); + expect(writeText).not.toHaveBeenCalled(); + }); + + it('attaches the streak image when the platform can share files', async () => { + Object.assign(globalThis.navigator, { share, canShare }); + canShare.mockReturnValue(true); + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob(['x'], { type: 'image/png' }), + }); + + renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' }); + await clickShare(); + + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + const [payload] = share.mock.calls[0]; + expect(payload.files).toHaveLength(1); + expect(payload.files[0].type).toBe('image/png'); + }); + + it('falls back to link + text when file sharing is unsupported', async () => { + Object.assign(globalThis.navigator, { share, canShare }); + canShare.mockReturnValue(false); + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob(['x'], { type: 'image/png' }), + }); + + renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' }); + await clickShare(); + + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + expect(share).toHaveBeenCalledWith({ + text: `${getStreakShareText(12)}\n${link}`, + }); + }); + + it('falls back to link + text when navigator.canShare is missing', async () => { + Object.assign(globalThis.navigator, { share }); + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob(['x'], { type: 'image/png' }), + }); + + renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' }); + await clickShare(); + + await waitFor(() => expect(share).toHaveBeenCalledTimes(1)); + expect(share).toHaveBeenCalledWith({ + text: `${getStreakShareText(12)}\n${link}`, + }); + }); +}); diff --git a/packages/shared/src/components/streak/popup/ShareStreakButton.tsx b/packages/shared/src/components/streak/popup/ShareStreakButton.tsx new file mode 100644 index 00000000000..1b7736250df --- /dev/null +++ b/packages/shared/src/components/streak/popup/ShareStreakButton.tsx @@ -0,0 +1,92 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import { Button, ButtonVariant } from '../../buttons/Button'; +import { ShareIcon } from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { useShareStreak } from '../../../hooks/streaks/useShareStreak'; +import type { ShareStreakResult } from '../../../hooks/streaks/useShareStreak'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, Origin, TargetType } from '../../../lib/log'; +import { ReferralCampaignKey } from '../../../lib/referral'; + +// Kept short on purpose: on mobile this label sits next to "Settings" in the +// same row, and anything longer overflows a 375px drawer. +const LABEL = 'Share streak'; + +// A streak is a status moment, so the copy carries the number and sounds like +// the developer who earned it — the link is the footnote, not the headline. +export const getStreakShareText = (current: number): string => { + if (current >= 100) { + return `${current} days without breaking my reading streak on daily.dev. Not stopping now.`; + } + + if (current >= 30) { + return `${current} days straight of reading dev news on daily.dev. The habit stuck.`; + } + + return `${current}-day reading streak on daily.dev and counting.`; +}; + +interface ShareStreakButtonProps { + currentStreak: number; + link: string; + /** + * Generated streak image. Left undefined until the backend screenshot + * service renders `/image-generator/streak/[userId]` and exposes the URL — + * the share degrades to link + text without it. + */ + imageUrl?: string; + showLabel?: boolean; + className?: string; +} + +export function ShareStreakButton({ + currentStreak, + link, + imageUrl, + showLabel, + className, +}: ShareStreakButtonProps): ReactElement { + const { logEvent } = useLogContext(); + + const onShare = useCallback( + ({ provider, withImage }: ShareStreakResult) => { + // Same target shape as the milestone share events so analytics can + // union streak shares regardless of the surface they came from. + logEvent({ + event_name: LogEvent.ShareLog, + target_type: TargetType.StreaksMilestone, + target_id: String(currentStreak), + extra: JSON.stringify({ + origin: Origin.ReadingStreak, + provider, + with_image: withImage, + }), + }); + }, + [currentStreak, logEvent], + ); + + const { isSharing, onShareStreak } = useShareStreak({ + link, + text: getStreakShareText(currentStreak), + cid: ReferralCampaignKey.ShareProfile, + imageUrl, + onShare, + }); + + return ( + + + + ); +} diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index 34d47893974..0dad7c978ec 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -657,6 +657,30 @@ export const getReadingStreak = async (): Promise => { return res.userStreak; }; +export type PublicUserStreak = Pick; + +// Public, by-id counterpart of `USER_STREAK_QUERY`, used to render the +// shareable streak image outside of an authenticated session. +export const USER_STREAK_PROFILE_QUERY = gql` + query UserStreakProfile($id: ID!) { + userStreakProfile(id: $id) { + current + max + total + } + } +`; + +export const getUserStreakProfile = async ( + id: string, +): Promise => { + const res = await gqlClient.request<{ + userStreakProfile: PublicUserStreak | null; + }>(USER_STREAK_PROFILE_QUERY, { id }); + + return res.userStreakProfile; +}; + export const USER_PROFILE_ANALYTICS_QUERY = gql` query UserProfileAnalytics($userId: ID!) { userProfileAnalytics(userId: $userId) { diff --git a/packages/shared/src/hooks/streaks/useShareStreak.ts b/packages/shared/src/hooks/streaks/useShareStreak.ts new file mode 100644 index 00000000000..813256b00a5 --- /dev/null +++ b/packages/shared/src/hooks/streaks/useShareStreak.ts @@ -0,0 +1,128 @@ +import { useCallback, useState } from 'react'; +import { useCopyLink } from '../useCopy'; +import { useGetShortUrl } from '../utils/useGetShortUrl'; +import { ShareProvider } from '../../lib/share'; +import { shouldUseNativeShare } from '../../lib/func'; +import type { ReferralCampaignKey } from '../../lib/referral'; + +export interface ShareStreakResult { + provider: ShareProvider; + /** Whether the streak image rode along in the native share sheet. */ + withImage: boolean; +} + +export interface UseShareStreakProps { + link: string; + text: string; + cid?: ReferralCampaignKey; + /** + * Rendered streak image (produced by the backend screenshot service from + * `/image-generator/streak/[userId]`). Optional on purpose: the image is a + * separable upgrade, and the link+text share must keep working whenever the + * image is missing, still generating, or the platform can't share files. + */ + imageUrl?: string; + imageFileName?: string; + onShare?: (result: ShareStreakResult) => void; +} + +export interface UseShareStreak { + isSharing: boolean; + onShareStreak: () => Promise; +} + +const STREAK_IMAGE_TYPE = 'image/png'; + +const fetchStreakImage = async ( + url: string, + fileName: string, +): Promise => { + try { + const response = await fetch(url); + + if (!response.ok) { + return null; + } + + const blob = await response.blob(); + + return new File([blob], fileName, { type: blob.type || STREAK_IMAGE_TYPE }); + } catch (err) { + return null; + } +}; + +// `canShare` is not implemented everywhere `share` is (notably older iOS and +// several in-app browsers), so treat a missing implementation as "no files". +const canShareFiles = (files: File[]): boolean => { + const nav = globalThis?.navigator; + + return typeof nav?.canShare === 'function' && nav.canShare({ files }); +}; + +export const useShareStreak = ({ + link, + text, + cid, + imageUrl, + imageFileName = 'daily-dev-streak.png', + onShare, +}: UseShareStreakProps): UseShareStreak => { + const [isSharing, setIsSharing] = useState(false); + const [, copyLink] = useCopyLink(); + const { getShortUrl } = useGetShortUrl(); + + const onShareStreak = useCallback(async () => { + setIsSharing(true); + + try { + const shortLink = cid ? await getShortUrl(link, cid) : link; + + if (imageUrl && shouldUseNativeShare()) { + const file = await fetchStreakImage(imageUrl, imageFileName); + + if (file && canShareFiles([file])) { + try { + await navigator.share({ + text: `${text}\n${shortLink}`, + files: [file], + }); + onShare?.({ provider: ShareProvider.Native, withImage: true }); + + return; + } catch (err) { + // User dismissed the sheet or the platform refused the payload — + // fall through to the link share rather than dead-ending. + } + } + } + + if (shouldUseNativeShare()) { + try { + await navigator.share({ text: `${text}\n${shortLink}` }); + onShare?.({ provider: ShareProvider.Native, withImage: false }); + } catch (err) { + // Sharing was dismissed; nothing to report. + } + + return; + } + + await copyLink({ link: shortLink }); + onShare?.({ provider: ShareProvider.CopyLink, withImage: false }); + } finally { + setIsSharing(false); + } + }, [ + cid, + copyLink, + getShortUrl, + imageFileName, + imageUrl, + link, + onShare, + text, + ]); + + return { isSharing, onShareStreak }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..27a78088cfc 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,9 @@ export const featureSharingVisibility = new Feature( // high-traffic icon, so it ramps on its own flag to watch share/copy metrics. // Keep the default `false` (control = existing `LinkIcon`). export const featureShareCopyIcon = new Feature('share_copy_icon', false); + +// Adds a "Share my streak" action to the reading-streak popup action row. A +// streak is a status moment, so this ramps separately from the generic +// copy-link surfaces to watch whether identity shares convert differently. +// Keep the default `false` (control = the popup without a share action). +export const featureShareStreak = new Feature('share_streak', false); diff --git a/packages/storybook/stories/components/ShareStreakButton.stories.tsx b/packages/storybook/stories/components/ShareStreakButton.stories.tsx new file mode 100644 index 00000000000..f4207846ee0 --- /dev/null +++ b/packages/storybook/stories/components/ShareStreakButton.stories.tsx @@ -0,0 +1,130 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + ShareStreakButton, + getStreakShareText, +} from '@dailydotdev/shared/src/components/streak/popup/ShareStreakButton'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import { fn } from 'storybook/test'; + +const permalink = 'https://app.daily.dev/idoshamun'; + +const meta: Meta = { + title: 'Components/Share/ShareStreakButton', + component: ShareStreakButton, + args: { + currentStreak: 12, + link: permalink, + }, + argTypes: { + currentStreak: { control: 'number' }, + showLabel: { + control: 'boolean', + description: 'Mobile: the action row shows the label next to the icon', + }, + imageUrl: { + control: 'text', + description: + 'Generated streak image. When set and the platform supports `navigator.canShare({ files })`, the native sheet carries the image instead of just the link.', + }, + }, + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + const LogContext = getLogContextStatic(); + + const mockUser = { + id: '1', + name: 'Ido Shamun', + username: 'idoshamun', + email: 'ido@daily.dev', + image: + 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + providers: ['google'], + createdAt: '2024-01-01T00:00:00.000Z', + permalink, + } as unknown as LoggedUser; + + return ( + + + false, + }} + > +
+ +
+
+
+
+ ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +// Early streak — the copy stays matter-of-fact. +export const ShortStreak: Story = { + args: { currentStreak: 5 }, + parameters: { docs: { description: { story: getStreakShareText(5) } } }, +}; + +// The habit stuck: copy switches at 30 days. +export const LongStreak: Story = { + args: { currentStreak: 45 }, + parameters: { docs: { description: { story: getStreakShareText(45) } } }, +}; + +// Milestone territory: copy switches again at 100 days. +export const MilestoneStreak: Story = { + args: { currentStreak: 180 }, + parameters: { docs: { description: { story: getStreakShareText(180) } } }, +}; + +// Mobile action row: icon + label, full width next to Settings. +export const WithLabel: Story = { + args: { currentStreak: 45, showLabel: true, className: 'w-full' }, +}; + +// With a generated image available, the native sheet carries the picture +// (mobile + `navigator.canShare({ files })` only) and otherwise degrades to +// the link + text share. +export const WithStreakImage: Story = { + args: { + currentStreak: 45, + imageUrl: 'https://media.daily.dev/image/upload/streak-placeholder.png', + }, +}; diff --git a/packages/storybook/stories/components/StreakShareCard.stories.tsx b/packages/storybook/stories/components/StreakShareCard.stories.tsx new file mode 100644 index 00000000000..a9a0cd088aa --- /dev/null +++ b/packages/storybook/stories/components/StreakShareCard.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { StreakShareCard } from '@dailydotdev/shared/src/components/streak/StreakShareCard'; + +// What the screenshot service renders at +// `/image-generator/streak/[userId]` once the route is wired on the backend. +const meta: Meta = { + title: 'Components/Share/StreakShareCard', + component: StreakShareCard, +}; + +export default meta; + +type Story = StoryObj; + +export const ShortStreak: Story = { + args: { streak: { current: 5, max: 9, total: 24 } }, +}; + +export const LongStreak: Story = { + args: { streak: { current: 45, max: 45, total: 210 } }, +}; + +export const MilestoneStreak: Story = { + args: { streak: { current: 180, max: 180, total: 640 } }, +}; diff --git a/packages/webapp/pages/image-generator/streak/[userId].tsx b/packages/webapp/pages/image-generator/streak/[userId].tsx new file mode 100644 index 00000000000..e844859cfd6 --- /dev/null +++ b/packages/webapp/pages/image-generator/streak/[userId].tsx @@ -0,0 +1,44 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { + GetStaticPathsResult, + GetStaticPropsContext, + GetStaticPropsResult, +} from 'next'; +import { StreakShareCard } from '@dailydotdev/shared/src/components/streak/StreakShareCard'; +import type { PublicUserStreak } from '@dailydotdev/shared/src/graphql/users'; +import { getUserStreakProfile } from '@dailydotdev/shared/src/graphql/users'; + +export async function getStaticPaths(): Promise { + return { paths: [], fallback: 'blocking' }; +} + +interface PageProps { + streak: PublicUserStreak; +} + +export async function getStaticProps({ + params, +}: GetStaticPropsContext): Promise> { + const userId = params?.userId as string; + + if (!userId) { + return { notFound: true, revalidate: false }; + } + + const streak = await getUserStreakProfile(userId); + + if (!streak) { + return { notFound: true, revalidate: false }; + } + + return { props: { streak }, revalidate: 60 }; +} + +const StreakImagePage = ({ streak }: PageProps): ReactElement => ( +
+ +
+); + +export default StreakImagePage;