diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx new file mode 100644 index 00000000000..cfe94823454 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -0,0 +1,137 @@ +import React from 'react'; + +export const AVATAR = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + +/* ------------------------------------------------------------------ prose */ + +export const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const P = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const Note = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +/* ---------------------------------------------------------- page furniture */ + +export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; + +/** + * Breakpoints matter more than usual here. A recommendation that only works + * on one of the three is not a recommendation. + */ +export const DEVICES: Record = + { + Desktop: { width: 680, viewport: '1020px and up' }, + Tablet: { width: 560, viewport: '768px' }, + Mobile: { width: 375, viewport: '375px' }, + }; + +/** A surface drawn at one real viewport width, so density is comparable. */ +export const Device = ({ + name, + children, + height, +}: { + name: DeviceName; + children: React.ReactNode; + /** Mobile surfaces pin a floating bar, so the frame needs a known height. */ + height?: number; +}) => ( +
+ + {name} · {DEVICES[name].viewport} + +
+ {children} +
+
+); + +/** Devices sit in a scroller rather than wrapping, so widths stay honest. */ +export const Rail = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Variant = ({ + step, + headline, + note, + children, +}: { + step: string; + headline: string; + note: string; + children: React.ReactNode; +}) => ( + // Full width so a device rail can scroll across the whole canvas. +
+
+ + {step} + + + {headline} + + {note} +
+ {children} +
+); + +export const Category = ({ + title, + covers, + verdict, + children, +}: { + title: string; + covers: string; + verdict: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {covers} +

+ {verdict} +

+
+
{children}
+
+); + +/** Every category page opens with the same header, so they read as a set. */ +export const SurfacePage = ({ + title, + intro, + map, + children, +}: { + title: string; + intro: string; + map: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+

{intro}

+ {map} +
+ {children} +
+); diff --git a/packages/storybook/stories/features/snapshot/surfaces/InviteOnboarding.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/InviteOnboarding.stories.tsx new file mode 100644 index 00000000000..50bd114fa69 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/InviteOnboarding.stories.tsx @@ -0,0 +1,279 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + MoveToIcon, + PlusIcon, + VIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type Spot = 'settings' | 'step' | 'progress'; + +const TARGETS = [ + ['X', 'bg-text-primary'], + ['WhatsApp', 'bg-accent-avocado-default'], + ['Facebook', 'bg-accent-bun-default'], + ['Reddit', 'bg-accent-ketchup-default'], + ['LinkedIn', 'bg-accent-blueCheese-default'], + ['Telegram', 'bg-accent-water-default'], + ['Email', 'bg-accent-burger-default'], +]; + +/* ---------------------------------------------------------- today: settings */ + +/** + * /settings/invite as it ships: three AccountContentSections, a TextField + * labelled "Your unique invite URL" with a Primary Copy link action button, + * then "or invite via" and the SocialShareList row. + */ +const SettingsScreen = ({ device }: { device: DeviceName }) => ( + +
+

+ Invite friends +

+ +
+

+ Grow the community +

+

+ Share daily.dev with developers you know. When they join through your + link, they'll show up in your referrals list below. +

+
+ +
+

+ Share your invite link +

+

+ Copy your personal link or share it directly on social platforms. +

+
+ + Your unique invite URL + +
+ + dly.to/tomer + + +
+
+ + or invite via + +
+ {TARGETS.slice(0, device === 'Mobile' ? 5 : 7).map( + ([label, tone]) => ( +
+ + + {label} + +
+ ), + )} +
+
+
+
+); + +/* ------------------------------------------------------- proposed: the step */ + +const Slot = ({ filled }: { filled?: boolean }) => + filled ? ( +
+ + + + +
+ ) : ( + + + + ); + +/** + * FunnelStepCtaWrapper's glass branch: a sticky top bar with the logo and + * Skip, the step content on the 32rem rail, then a scrim, the glass bar with + * a Medium Primary CTA, and the step dots. + */ +const StepScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: Spot; +}) => ( + +
+
+ + daily.dev + + +
+ +
+

+ Invite 3 friends, get a month of Plus +

+

+ They get daily.dev, you both get Plus. It counts as soon as they sign + up with your link. +

+ +
+ + + +
+ + {spot === 'progress' && ( + + 1 of 3 joined + + )} + +
+ + dly.to/tomer + + +
+ +
+ {TARGETS.slice(0, device === 'Mobile' ? 5 : 7).map( + ([label, tone]) => ( +
+ + + {label} + +
+ ), + )} +
+
+ +
+
+ +
+
+ {[0, 1, 2, 3, 4].map((dot) => ( + + ))} +
+
+
+
+); + +/* -------------------------------------------------------------------- page */ + +const InviteOnboarding = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Invite onboarding', + component: InviteOnboarding, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; diff --git a/packages/webapp/pages/onboarding/invite-preview.tsx b/packages/webapp/pages/onboarding/invite-preview.tsx new file mode 100644 index 00000000000..78e3c6e898c --- /dev/null +++ b/packages/webapp/pages/onboarding/invite-preview.tsx @@ -0,0 +1,191 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useState } from 'react'; +import { NextSeo } from 'next-seo'; +import { useRouter } from 'next/router'; +import { PlusIcon, VIcon } from '@dailydotdev/shared/src/components/icons'; +import { InviteLinkInput } from '@dailydotdev/shared/src/components/referral'; +import { SocialShareList } from '@dailydotdev/shared/src/components/widgets/SocialShareList'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { FunnelStepCtaWrapper } from '@dailydotdev/shared/src/features/onboarding/shared/FunnelStepCtaWrapper'; +import { FunnelProgressContext } from '@dailydotdev/shared/src/features/onboarding/shared/FunnelStepDots'; +import { StepHeadline } from '@dailydotdev/shared/src/features/onboarding/shared/StepHeadline'; +import { + ReferralCampaignKey, + useReferralCampaign, +} from '@dailydotdev/shared/src/hooks'; +import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { labels } from '@dailydotdev/shared/src/lib'; +import { link as links } from '@dailydotdev/shared/src/lib/links'; +import { + LogEvent, + TargetId, + TargetType, +} from '@dailydotdev/shared/src/lib/log'; +import type { ShareProvider } from '@dailydotdev/shared/src/lib/share'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; + +/** + * /onboarding/invite-preview — internal review surface for the invite step + * proposed in #6366: the same controls that ship on /settings/invite, moved + * to the moment in onboarding when the reward still means something. + * + * Drawn on the production funnel chrome and wired to the real referral link, + * so it renders as the step would in the funnel rather than as a mockup of + * it. `?state=progress` shows the one-of-three state. It sits outside + * `/dev/*` because that tree short-circuits the app shell, and these controls + * need auth and log context. Carries `noindex`/`nofollow`; reachable on + * preview + local but blocked on the canonical production hosts. + */ + +const NO_PROFILE = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + +type Spot = 'step' | 'progress'; + +const useReviewAllowed = (): boolean => { + const [allowed, setAllowed] = useState(true); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + const { hostname } = window.location; + setAllowed(hostname !== 'app.daily.dev' && hostname !== 'www.daily.dev'); + }, []); + return allowed; +}; + +const Slot = ({ filled, image }: { filled?: boolean; image?: string }) => { + if (!filled) { + return ( + + + + ); + } + + return ( +
+ + + + +
+ ); +}; + +const InviteStep = ({ spot }: { spot: Spot }): ReactElement => { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { url } = useReferralCampaign({ + campaignKey: ReferralCampaignKey.Generic, + }); + const inviteLink = url || links.referral.defaultUrl; + const [, onShareOrCopyLink] = useShareOrCopyLink({ + text: labels.referral.generic.inviteText, + link: inviteLink, + logObject: () => ({ + event_name: LogEvent.CopyReferralLink, + target_id: TargetId.InviteFriendsPage, + }), + }); + + const onLogShare = (provider: ShareProvider) => + logEvent({ + event_name: LogEvent.InviteReferral, + target_id: provider, + target_type: TargetType.InviteFriendsPage, + }); + + return ( + +
+ + +
+ + + +
+ + {spot === 'progress' && ( + + 1 of 3 joined + + )} + + + +
+ +
+
+
+ ); +}; + +// Five steps with the invite fourth, matching the position #6366 proposes. +const PROGRESS = { + chapters: [{ steps: 5 }], + position: { chapter: 0, step: 3 }, + isOnboarding: true, +}; + +const InviteOnboardingPreviewPage = (): ReactElement => { + const router = useRouter(); + const allowed = useReviewAllowed(); + + if (!allowed) { + return ; + } + + return ( + <> + +
+ + + +
+ + ); +}; + +InviteOnboardingPreviewPage.getLayout = (page: ReactNode): ReactNode => page; + +export default InviteOnboardingPreviewPage;