diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx index 1152cd8fec..7e0c0608c0 100644 --- a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx +++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx @@ -64,6 +64,9 @@ type HostedCommunityOnboardingProps = { * finished driving the flow. */ onReady?: () => void; + /** Use the production stage with local-only workshop behavior. */ + previewMode?: boolean; + onPreviewContinue?: (communityName: string) => void; /** * While true, render only the sign-in modal and keep the page scaffolding * hidden, so whatever screen launched the flow stays visible behind it. @@ -74,8 +77,136 @@ type HostedCommunityOnboardingProps = { export function HostedCommunityOnboarding({ onBack, onReady, + onPreviewContinue, + previewMode = false, stageHidden = false, }: HostedCommunityOnboardingProps) { + if (previewMode) { + return ( + undefined)} + /> + ); + } + + return ( + + ); +} + +function HostedCommunityOnboardingPreview({ + onBack, + onContinue, +}: { + onBack: () => void; + onContinue: (communityName: string) => void; +}) { + const [name, setName] = React.useState(""); + const displayName = name || "your-community"; + const composedAddressLength = + displayName.length + HOSTED_COMMUNITY_SUFFIX.length + 1; + const addressFontSize = `min(2.25rem, calc(90cqw / ${( + composedAddressLength * 0.62 + ).toFixed(2)}))`; + + const continueToCommunity = (event: React.FormEvent) => { + event.preventDefault(); + onContinue(name.trim() || "Your community"); + }; + + return ( +
+

+ Create a community +

+

+ Claim a Buzz address to get started. +

+ +
+ +
+
+ setName(event.target.value.toLowerCase())} + placeholder="your-community" + spellCheck={false} + style={{ + width: `${displayName.length}ch`, + fontSize: addressFontSize, + }} + value={name} + /> + + .{HOSTED_COMMUNITY_SUFFIX} + +
+
+
+

+ Community address status +

+
+ + + + + +
+ ); +} + +function HostedCommunityOnboardingLive({ + onBack, + onReady, + stageHidden = false, +}: Pick) { const onboarding = useCommunityOnboarding(); const shouldReduceMotion = useReducedMotion(); const localPubkey = useIdentityQuery().data?.pubkey ?? null; diff --git a/desktop/src/features/onboarding/onboardingPreview.test.mjs b/desktop/src/features/onboarding/onboardingPreview.test.mjs index c53ae307ff..7d1558d0d3 100644 --- a/desktop/src/features/onboarding/onboardingPreview.test.mjs +++ b/desktop/src/features/onboarding/onboardingPreview.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveOnboardingPreviewMode } from "./onboardingPreview.ts"; +import { + ONBOARDING_PREVIEW_JOURNEYS, + resolveOnboardingPreviewMode, +} from "./onboardingPreview.ts"; test("preview requires an exact development opt-in", () => { assert.equal( @@ -43,3 +46,31 @@ test("preview is available to the explicit E2E build", () => { true, ); }); + +test("today preserves the complete workshop journey", () => { + assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.today, { + afterAccount: "setup", + afterCommunityEntry: "community-connecting", + afterProfile: "starter-team", + communityChoiceBack: "config", + communityStep: 5, + finalStep: 7, + includeExistingCommunity: true, + profileStep: 6, + totalSteps: 7, + }); +}); + +test("V3 skips technical setup and the transitional community screens", () => { + assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.v3, { + afterAccount: "community-choice", + afterCommunityEntry: "community-profile", + afterProfile: "community-home", + communityChoiceBack: null, + communityStep: 3, + finalStep: 4, + includeExistingCommunity: false, + profileStep: 4, + totalSteps: 4, + }); +}); diff --git a/desktop/src/features/onboarding/onboardingPreview.ts b/desktop/src/features/onboarding/onboardingPreview.ts index a11a2edf55..a940dad29f 100644 --- a/desktop/src/features/onboarding/onboardingPreview.ts +++ b/desktop/src/features/onboarding/onboardingPreview.ts @@ -7,6 +7,68 @@ type OnboardingPreviewEnvironment = { search: string; }; +export type OnboardingPreviewPage = + | "landing" + | "email" + | "identity-key" + | "sign-in" + | "sign-in-key" + | "forgot-password" + | "backup-options" + | "backup-password" + | "setup" + | "config" + | "community-choice" + | "community-entry" + | "community-connecting" + | "community-profile" + | "starter-team" + | "welcome-channel" + | "community-home"; + +export type OnboardingPreviewVariant = "today" | "v3"; + +type OnboardingPreviewJourney = { + afterAccount: OnboardingPreviewPage; + afterCommunityEntry: OnboardingPreviewPage; + afterProfile: OnboardingPreviewPage; + communityChoiceBack: OnboardingPreviewPage | null; + communityStep: number; + finalStep: number; + includeExistingCommunity: boolean; + profileStep: number; + totalSteps: number; +}; + +/** Workshop-only route maps that preserve today's full flow beside V3. */ +export const ONBOARDING_PREVIEW_JOURNEYS: Record< + OnboardingPreviewVariant, + OnboardingPreviewJourney +> = { + today: { + afterAccount: "setup", + afterCommunityEntry: "community-connecting", + afterProfile: "starter-team", + communityChoiceBack: "config", + communityStep: 5, + finalStep: 7, + includeExistingCommunity: true, + profileStep: 6, + totalSteps: 7, + }, + v3: { + afterAccount: "community-choice", + afterCommunityEntry: "community-profile", + afterProfile: "community-home", + communityChoiceBack: null, + communityStep: 3, + finalStep: 4, + includeExistingCommunity: false, + profileStep: 4, + totalSteps: 4, + }, +}; + /** Resolve the explicit, non-production onboarding workshop route. */ export function resolveOnboardingPreviewMode({ dev, diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 7897e51ffd..12258352b7 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { Plus, Users } from "lucide-react"; +import { Users } from "lucide-react"; import { markCommunityOnboardingComplete, @@ -17,10 +17,6 @@ import { useAvatarPresentation } from "@/features/profile/avatarPresentationStor import { registerAvatarWhenReady } from "@/features/profile/avatarProfileSync"; import { profileQueryKey } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import { - parseEmojiAvatarDataUrl, - ProfileAvatarEditor, -} from "@/features/profile/ui/ProfileAvatarEditor"; import { getProfile, updateProfile } from "@/shared/api/tauriProfiles"; import { getIdentity, importIdentity } from "@/shared/api/tauriIdentity"; import { listPersonas } from "@/shared/api/tauriPersonas"; @@ -29,8 +25,6 @@ import type { AgentPersona } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; import { Button } from "@/shared/ui/button"; -import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; import { MembershipDenied } from "./MembershipDenied"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { @@ -42,6 +36,7 @@ import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; +import { CommunityProfileStage } from "./CommunityProfileStage"; function isRelayMembershipDeniedError(error: unknown): boolean { if (!(error instanceof Error)) return false; @@ -67,64 +62,6 @@ const ENTERING_CURTAIN_FADE_MS = 500; */ const ENTERING_CURTAIN_MAX_WAIT_MS = 8_000; -const NEUTRAL_EMOJI_PICKER_THEME_VARS = { - "--buzz-emoji-picker-rgb-background": - "var(--buzz-onboarding-emoji-picker-background)", - "--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)", - "--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)", -} as React.CSSProperties; - -function AvatarCircle({ - avatarUrl, - onClick, - previewName, - triggerRef, -}: { - avatarUrl: string; - onClick: () => void; - previewName: string; - triggerRef?: React.Ref; -}) { - const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl); - const presentation = useAvatarPresentation(avatarUrl); - const hasAvatar = - avatarUrl.trim().length > 0 && presentation?.state !== "failed"; - - return ( - - ); -} - function LoadingDots({ label }: { label: string }) { return ( (null); - const [avatarSquishKey, setAvatarSquishKey] = React.useState(0); const [transitionDirection, setTransitionDirection] = React.useState("forward"); const avatarPresentation = useAvatarPresentation(avatarUrl); const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); - const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false); - const [animatedPreviewEl, setAnimatedPreviewEl] = - React.useState(null); - const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] = - React.useState(false); - const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState< - string | null - >(null); const [starterPersonas, setStarterPersonas] = React.useState( [], ); @@ -185,14 +110,6 @@ export function CommunityOnboardingFlow({ const [isCommunityChangeOpen, setIsCommunityChangeOpen] = React.useState(false); const [isCurtainFading, setIsCurtainFading] = React.useState(false); - const nameInputRef = React.useRef(null); - const avatarTriggerRef = React.useRef(null); - const avatarEditorContentRef = React.useRef(null); - const [avatarEditorDialogHeight, setAvatarEditorDialogHeight] = - React.useState(null); - const animateEmojiAvatarChange = React.useCallback(() => { - setAvatarSquishKey((key) => key + 1); - }, []); // Also fetch on "entering": the curtain is a fresh mount of this component, // so the team-intro fetch from the pre-curtain instance isn't in this state. @@ -351,31 +268,6 @@ export function CommunityOnboardingFlow({ }); }, [isProfileStage]); - React.useLayoutEffect(() => { - if (isProfileStage && !isAvatarEditorOpen) { - nameInputRef.current?.focus(); - } - }, [isAvatarEditorOpen, isProfileStage]); - - React.useLayoutEffect(() => { - if (!isAvatarEditorOpen) { - setAvatarEditorDialogHeight(null); - return; - } - - const content = avatarEditorContentRef.current; - if (!content) return; - - const updateHeight = () => { - setAvatarEditorDialogHeight(content.getBoundingClientRect().height + 64); - }; - updateHeight(); - - const resizeObserver = new ResizeObserver(updateHeight); - resizeObserver.observe(content); - return () => resizeObserver.disconnect(); - }, [isAvatarEditorOpen]); - if (!transaction) return null; if (isMembershipDenied) { @@ -551,200 +443,17 @@ export function CommunityOnboardingFlow({ ) : isProfileStage ? ( - <> -
-
-

- Build your profile -

-

- Add a name and avatar. They’ll show up on your messages, - reactions, and agent handoffs. -

-
-
- setIsAvatarEditorOpen(true)} - previewName={displayName.trim() || "Your profile"} - triggerRef={avatarTriggerRef} - /> - -
- {transaction.error ? ( -

- {transaction.error} -

- ) : null} -
- - - - setIsAvatarEditorOpen(open)} - open={isAvatarEditorOpen} - > - { - event.preventDefault(); - avatarTriggerRef.current?.focus(); - }} - overlayVariant="transparent" - style={ - avatarEditorDialogHeight === null - ? undefined - : { height: avatarEditorDialogHeight } - } - > - - Edit your avatar - -
-
-
-
- {isAnimatedPreviewActive - ? null - : (() => { - if (localAvatarPreviewUrl) { - return ( - - ); - } - const emojiAvatar = - parseEmojiAvatarDataUrl(avatarUrl); - return emojiAvatar ? ( -
- 0 && - "buzz-avatar-squish", - )} - data-testid="community-avatar-live-preview-emoji" - key={avatarSquishKey} - > - {emojiAvatar.emoji} - -
- ) : ( - - ); - })()} -
- {animatedPreviewCaption ? ( -

- {animatedPreviewCaption} -

- ) : null} -
- setIsAvatarEditorOpen(false)} - onAnimatedPreviewActiveChange={ - setIsAnimatedPreviewActive - } - onAnimatedPreviewCaptionChange={ - setAnimatedPreviewCaption - } - onEmojiAvatarChange={animateEmojiAvatarChange} - onLocalPreviewChange={setLocalAvatarPreviewUrl} - onUploadingChange={setIsUploadingAvatar} - onUrlChange={setAvatarUrl} - presentation="onboarding-modal" - previewName={displayName.trim() || "Your profile"} - testIdPrefix="community-avatar" - /> -
- -
- + void saveProfile()} + onUploadingChange={setIsUploadingAvatar} + /> ) : ( <>

diff --git a/desktop/src/features/onboarding/ui/CommunityProfileStage.tsx b/desktop/src/features/onboarding/ui/CommunityProfileStage.tsx new file mode 100644 index 0000000000..e325180d1d --- /dev/null +++ b/desktop/src/features/onboarding/ui/CommunityProfileStage.tsx @@ -0,0 +1,352 @@ +import { Plus } from "lucide-react"; +import * as React from "react"; + +import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore"; +import type { AnimatedAvatarRecordingProcessor } from "@/features/profile/ui/AnimatedAvatarCapture.types"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + emojiAvatarDataUrl, + parseEmojiAvatarDataUrl, +} from "@/features/profile/ui/ProfileAvatarEditor.utils"; +import { ProfileAvatarEditor } from "@/features/profile/ui/ProfileAvatarEditor"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { OnboardingFooter } from "./OnboardingFooter"; + +const NEUTRAL_EMOJI_PICKER_THEME_VARS = { + "--buzz-emoji-picker-rgb-background": + "var(--buzz-onboarding-emoji-picker-background)", + "--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)", + "--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)", +} as React.CSSProperties; + +const PREVIEW_URL_AVATAR = emojiAvatarDataUrl("🐝", "#D7D72E"); + +function blobAsDataUrl(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error("Read failed.")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }); +} + +const processPreviewImage = (file: File) => blobAsDataUrl(file); + +const processPreviewAnimatedAvatar: AnimatedAvatarRecordingProcessor = ({ + posterBytes, +}) => + blobAsDataUrl( + new Blob([Uint8Array.from(posterBytes).buffer], { type: "image/png" }), + ); + +function AvatarCircle({ + avatarUrl, + onClick, + previewName, + triggerRef, +}: { + avatarUrl: string; + onClick: () => void; + previewName: string; + triggerRef?: React.Ref; +}) { + const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl); + const presentation = useAvatarPresentation(avatarUrl); + const hasAvatar = + avatarUrl.trim().length > 0 && presentation?.state !== "failed"; + + return ( + + ); +} + +export function CommunityProfileStage({ + avatarUrl, + displayName, + errorMessage, + isPending, + isUploadingAvatar, + onAvatarUrlChange, + onDisplayNameChange, + onNext, + onUploadingChange, + previewMode = false, +}: { + avatarUrl: string; + displayName: string; + errorMessage?: string; + isPending: boolean; + isUploadingAvatar: boolean; + onAvatarUrlChange: (value: string) => void; + onDisplayNameChange: (value: string) => void; + onNext: () => void; + onUploadingChange: (isUploading: boolean) => void; + previewMode?: boolean; +}) { + const [localAvatarPreviewUrl, setLocalAvatarPreviewUrl] = React.useState< + string | null + >(null); + const [avatarSquishKey, setAvatarSquishKey] = React.useState(0); + const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false); + const [animatedPreviewEl, setAnimatedPreviewEl] = + React.useState(null); + const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] = + React.useState(false); + const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState< + string | null + >(null); + const nameInputRef = React.useRef(null); + const avatarTriggerRef = React.useRef(null); + const avatarEditorContentRef = React.useRef(null); + const [avatarEditorDialogHeight, setAvatarEditorDialogHeight] = + React.useState(null); + const animateEmojiAvatarChange = React.useCallback(() => { + setAvatarSquishKey((key) => key + 1); + }, []); + const updateAvatarUrl = React.useCallback( + (nextAvatarUrl: string) => { + if ( + !previewMode || + nextAvatarUrl.length === 0 || + nextAvatarUrl.startsWith("blob:") || + nextAvatarUrl.startsWith("data:image/") + ) { + onAvatarUrlChange(nextAvatarUrl); + return; + } + onAvatarUrlChange(PREVIEW_URL_AVATAR); + }, + [onAvatarUrlChange, previewMode], + ); + + React.useLayoutEffect(() => { + if (!isAvatarEditorOpen) nameInputRef.current?.focus(); + }, [isAvatarEditorOpen]); + + React.useLayoutEffect(() => { + if (!isAvatarEditorOpen) { + setAvatarEditorDialogHeight(null); + return; + } + const content = avatarEditorContentRef.current; + if (!content) return; + + const updateHeight = () => { + setAvatarEditorDialogHeight(content.getBoundingClientRect().height + 64); + }; + updateHeight(); + const resizeObserver = new ResizeObserver(updateHeight); + resizeObserver.observe(content); + return () => resizeObserver.disconnect(); + }, [isAvatarEditorOpen]); + + return ( + <> +
+
+

Build your profile

+

+ Add a name and avatar. They’ll show up on your messages, reactions, + and agent handoffs. +

+
+
+ setIsAvatarEditorOpen(true)} + previewName={displayName.trim() || "Your profile"} + triggerRef={avatarTriggerRef} + /> + +
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+ + + + setIsAvatarEditorOpen(open)} + open={isAvatarEditorOpen} + > + { + event.preventDefault(); + avatarTriggerRef.current?.focus(); + }} + overlayVariant="transparent" + style={ + avatarEditorDialogHeight === null + ? undefined + : { height: avatarEditorDialogHeight } + } + > + Edit your avatar +
+
+
+
+ {isAnimatedPreviewActive ? null : localAvatarPreviewUrl ? ( + + ) : ( + (() => { + const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl); + return emojiAvatar ? ( +
+ 0 && "buzz-avatar-squish", + )} + data-testid="community-avatar-live-preview-emoji" + key={avatarSquishKey} + > + {emojiAvatar.emoji} + +
+ ) : ( + + ); + })() + )} +
+ {animatedPreviewCaption ? ( +

+ {animatedPreviewCaption} +

+ ) : null} +
+ setIsAvatarEditorOpen(false)} + onEmojiAvatarChange={animateEmojiAvatarChange} + onLocalPreviewChange={setLocalAvatarPreviewUrl} + onUploadingChange={onUploadingChange} + onUrlChange={updateAvatarUrl} + presentation="onboarding-modal" + previewName={displayName.trim() || "Your profile"} + processAnimatedAvatar={ + previewMode ? processPreviewAnimatedAvatar : undefined + } + processImage={previewMode ? processPreviewImage : undefined} + testIdPrefix="community-avatar" + /> +
+ +
+ + ); +} diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index 52b9ebbd07..699a9defe5 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -57,6 +57,8 @@ type InviteRedeemFormProps = { onConnect?: (relayWsUrl: string) => void; onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void; placeholder?: string; + /** Render the production form without discovery or relay requests. */ + previewMode?: boolean; variant?: "add-community" | "default" | "onboarding-spotlight"; }; @@ -69,6 +71,7 @@ export function InviteRedeemForm({ onConnect, onRedeem, placeholder, + previewMode = false, variant = "default", }: InviteRedeemFormProps) { const formId = React.useId(); @@ -104,6 +107,7 @@ export function InviteRedeemForm({ const needsRelayField = isBareCode && defaultRelayUrl !== undefined; React.useEffect(() => { + if (previewMode) return; const relayWsUrl = normalizedRelayUrl ? normalizedRelayUrl : parsedInvite && hasInviteRelay(parsedInvite) @@ -135,9 +139,10 @@ export function InviteRedeemForm({ cancelled = true; window.clearTimeout(timeoutId); }; - }, [bareCodeRelayUrl, normalizedRelayUrl, parsedInvite]); + }, [bareCodeRelayUrl, normalizedRelayUrl, parsedInvite, previewMode]); const canSubmit = + previewMode || (parsedInvite !== null && ("relayWsUrl" in parsedInvite || (isBareCode && bareCodeRelayUrl.trim().length > 0))) || @@ -150,6 +155,10 @@ export function InviteRedeemForm({ const handleSubmit = React.useCallback( async (event: React.FormEvent) => { event.preventDefault(); + if (previewMode) { + onConnect?.(normalizedRelayUrl ?? "wss://preview.invalid"); + return; + } if (normalizedRelayUrl) { setPolicyError(null); setIsLoadingPolicy(true); @@ -257,6 +266,7 @@ export function InviteRedeemForm({ onRedeem, parsedInvite, policyTarget, + previewMode, ], ); diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewApp.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewApp.tsx index d528149a2b..0bc21ae781 100644 --- a/desktop/src/features/onboarding/ui/OnboardingPreviewApp.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewApp.tsx @@ -1,12 +1,18 @@ -import { Check, RotateCcw, ShieldCheck } from "lucide-react"; +import { Check } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; import type { JoinPolicy } from "@/shared/api/invites"; +import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; +import { + ONBOARDING_PREVIEW_JOURNEYS, + type OnboardingPreviewPage, + type OnboardingPreviewVariant, +} from "../onboardingPreview"; import { BackupStep } from "./BackupStep"; import { JoinPolicyNotice } from "./JoinPolicyNotice"; import { LandingBees } from "./LandingBees"; @@ -21,6 +27,7 @@ import { BackupPasswordPreview, CommunityChoicePreview, CommunityConnectingPreview, + CommunityCreatePreview, CommunityEntryPreview, CommunityHomePreview, CommunityProfilePreview, @@ -30,28 +37,10 @@ import { WelcomeChannelPreview, } from "./OnboardingPreviewJourney"; import { OnboardingPreviewStep } from "./OnboardingPreviewShell"; +import { OnboardingPreviewControls } from "./OnboardingPreviewControls"; import { OnboardingSlideTransition } from "./OnboardingSlideTransition"; import { SetupStepPreview } from "./SetupStepPreview"; -type PreviewPage = - | "landing" - | "email" - | "identity-key" - | "sign-in" - | "sign-in-key" - | "forgot-password" - | "backup-options" - | "backup-password" - | "setup" - | "config" - | "community-choice" - | "community-entry" - | "community-connecting" - | "community-profile" - | "starter-team" - | "welcome-channel" - | "community-home"; - const EMAIL_SIGNUP_POLICY: JoinPolicy = { ageAttestationRequired: true, privacyMarkdown: "available", @@ -59,35 +48,11 @@ const EMAIL_SIGNUP_POLICY: JoinPolicy = { version: "email-signup-workshop", }; -function PreviewBanner({ onRestart }: { onRestart: () => void }) { - return ( - - ); -} - -function Landing({ onNavigate }: { onNavigate: (page: PreviewPage) => void }) { +function Landing({ + onNavigate, +}: { + onNavigate: (page: OnboardingPreviewPage) => void; +}) { return (
void; onContinue: (email: string) => void; onPasswordChange: (password: string) => void; + total: number; }) { const [email, setEmail] = React.useState(""); const [confirmPassword, setConfirmPassword] = React.useState(""); @@ -444,15 +411,13 @@ function EmailSignup({ const [openPolicyDocument, setOpenPolicyDocument] = React.useState< "terms" | "privacy" | null >(null); - const canContinue = - email.trim().includes("@") && - password.length > 0 && - confirmPassword === password && - ageConfirmed && - agreementConfirmed; return ( - + { event.preventDefault(); - if (canContinue) onContinue(email); + onContinue(email); }} > onContinue(email)} type="button" > @@ -562,6 +526,7 @@ function EmailSignIn({ onEmailChange, onForgotPassword, onSignInWithKey, + total, }: { email: string; onBack: () => void; @@ -569,12 +534,17 @@ function EmailSignIn({ onEmailChange: (email: string) => void; onForgotPassword: () => void; onSignInWithKey: () => void; + total: number; }) { const [password, setPassword] = React.useState(""); const canContinue = email.trim().length > 0 && password.length > 0; return ( - + void; + total: number; }) { const [email, setEmail] = React.useState(initialEmail); const [submitted, setSubmitted] = React.useState(false); @@ -658,6 +630,7 @@ function PasswordReset({ void; onContinue: () => void; + total: number; }) { return ( ("landing"); + const [variant, setVariant] = React.useState("v3"); + const [page, setPage] = React.useState("landing"); const [signInEmail, setSignInEmail] = React.useState(""); const [signupPassword, setSignupPassword] = React.useState(""); const [passwordResetEmail, setPasswordResetEmail] = React.useState(""); const [setupBackPage, setSetupBackPage] = - React.useState("landing"); + React.useState("landing"); const [communityRoute, setCommunityRoute] = React.useState("join"); const [communityName, setCommunityName] = React.useState("Block Community"); const [displayName, setDisplayName] = React.useState(""); - const [hasAvatar, setHasAvatar] = React.useState(false); + const [avatarUrl, setAvatarUrl] = React.useState(""); + const journey = ONBOARDING_PREVIEW_JOURNEYS[variant]; React.useEffect(() => { if (page === "landing") { @@ -788,6 +767,7 @@ export function OnboardingPreviewApp() { }, [page]); const restart = React.useCallback(() => { + resetAvatarPresentations(); setPage("landing"); setPasswordResetEmail(""); setRun((current) => current + 1); @@ -796,9 +776,25 @@ export function OnboardingPreviewApp() { setCommunityRoute("join"); setCommunityName("Block Community"); setDisplayName(""); - setHasAvatar(false); + setAvatarUrl(""); }, []); + const changeVariant = React.useCallback( + (nextVariant: OnboardingPreviewVariant) => { + setVariant(nextVariant); + restart(); + }, + [restart], + ); + + const continueFromAccount = React.useCallback( + (backPage: OnboardingPreviewPage) => { + setSetupBackPage(backPage); + setPage(journey.afterAccount); + }, + [journey.afterAccount], + ); + let content: React.ReactNode; if (page === "landing") { content = ; @@ -807,11 +803,9 @@ export function OnboardingPreviewApp() { setPage("landing")} - onContinue={() => { - setSetupBackPage("email"); - setPage("setup"); - }} + onContinue={() => continueFromAccount("email")} onPasswordChange={setSignupPassword} + total={journey.totalSteps} /> ); } else if (page === "identity-key") { @@ -819,15 +813,16 @@ export function OnboardingPreviewApp() { setPage("landing")} testId="onboarding-preview-identity-key" + total={journey.totalSteps} > { - setSetupBackPage("identity-key"); - setPage("setup"); - }} + onNext={() => continueFromAccount("identity-key")} onOpenPasswordBackup={() => setPage("backup-password")} - onShowOptions={() => setPage("backup-options")} + onShowOptions={() => { + setSetupBackPage("identity-key"); + setPage("backup-options"); + }} optionsExpanded={false} previewMode returningFromSecurity={false} @@ -839,10 +834,11 @@ export function OnboardingPreviewApp() { setPage("identity-key")} testId="onboarding-preview-backup-options" + total={journey.totalSteps} > setPage("setup")} + onNext={() => setPage(journey.afterAccount)} onOpenPasswordBackup={() => setPage("backup-password")} onShowOptions={() => setPage("backup-options")} optionsExpanded @@ -863,16 +859,14 @@ export function OnboardingPreviewApp() { setPage("landing")} - onContinue={() => { - setSetupBackPage("sign-in"); - setPage("setup"); - }} + onContinue={() => continueFromAccount("sign-in")} onEmailChange={setSignInEmail} onForgotPassword={() => { setPasswordResetEmail(signInEmail); setPage("forgot-password"); }} onSignInWithKey={() => setPage("sign-in-key")} + total={journey.totalSteps} /> ); } else if (page === "forgot-password") { @@ -880,16 +874,15 @@ export function OnboardingPreviewApp() { setPage("sign-in")} + total={journey.totalSteps} /> ); } else if (page === "sign-in-key") { content = ( setPage("sign-in")} - onContinue={() => { - setSetupBackPage("sign-in-key"); - setPage("setup"); - }} + onContinue={() => continueFromAccount("sign-in-key")} + total={journey.totalSteps} /> ); } else if (page === "setup") { @@ -909,24 +902,40 @@ export function OnboardingPreviewApp() { } else if (page === "community-choice") { content = ( setPage("config")} + current={journey.communityStep} + includeExistingCommunity={journey.includeExistingCommunity} + onBack={() => setPage(journey.communityChoiceBack ?? setupBackPage)} onChoose={(route) => { setCommunityRoute(route); setPage("community-entry"); }} + total={journey.totalSteps} /> ); } else if (page === "community-entry") { - content = ( - setPage("community-choice")} - onContinue={(name) => { - setCommunityName(name); - setPage("community-connecting"); - }} - route={communityRoute} - /> - ); + content = + communityRoute === "create" ? ( + setPage("community-choice")} + onContinue={(name) => { + setCommunityName(name); + setPage(journey.afterCommunityEntry); + }} + total={journey.totalSteps} + /> + ) : ( + setPage("community-choice")} + onContinue={(name) => { + setCommunityName(name); + setPage(journey.afterCommunityEntry); + }} + route={communityRoute} + total={journey.totalSteps} + /> + ); } else if (page === "community-connecting") { content = ( setHasAvatar((current) => !current)} + onAvatarUrlChange={setAvatarUrl} onBack={() => setPage("community-entry")} onDisplayNameChange={setDisplayName} - onNext={() => setPage("starter-team")} + onNext={() => setPage(journey.afterProfile)} + total={journey.totalSteps} /> ); } else if (page === "starter-team") { @@ -964,15 +975,20 @@ export function OnboardingPreviewApp() { } else { content = ( setPage("welcome-channel")} + displayName={displayName} /> ); } return ( <> - + {content} ); diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx new file mode 100644 index 0000000000..d7d3e7e43d --- /dev/null +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx @@ -0,0 +1,60 @@ +import { RotateCcw, ShieldCheck } from "lucide-react"; + +import type { OnboardingPreviewVariant } from "../onboardingPreview"; +import { Button } from "@/shared/ui/button"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; + +const PREVIEW_VARIANT_OPTIONS = [ + { label: "Onboarding today", value: "today" }, + { label: "V3", value: "v3" }, +] as const; + +export function OnboardingPreviewControls({ + onRestart, + onVariantChange, + variant, +}: { + onRestart: () => void; + onVariantChange: (variant: OnboardingPreviewVariant) => void; + variant: OnboardingPreviewVariant; +}) { + return ( + + ); +} diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx index 04ba4503a1..a75c6107d3 100644 --- a/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx @@ -1,9 +1,13 @@ -import { Check, Plus, Users } from "lucide-react"; +import { Check, Copy, Users } from "lucide-react"; import * as React from "react"; +import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding"; +import { pubkeyToNpub } from "@/shared/lib/nostrUtils"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { Input } from "@/shared/ui/input"; +import { InviteRedeemForm } from "./InviteRedeemForm"; +import { CommunityProfileStage } from "./CommunityProfileStage"; import { ONBOARDING_PRIMARY_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, @@ -11,11 +15,15 @@ import { import { OnboardingFooter } from "./OnboardingFooter"; import { OnboardingPreviewStep } from "./OnboardingPreviewShell"; import { OnboardingSlideTransition } from "./OnboardingSlideTransition"; +import { WelcomeChannelAppPreview } from "./WelcomeChannelAppPreview"; const FIELD_CLASS = "h-12 rounded-2xl border-foreground/15 bg-white px-4 text-sm shadow-none"; const COMMUNITY_OPTION_CLASS = "w-full max-w-[320px] items-center px-6 py-4 text-center text-sm font-normal leading-6 text-foreground [--buzz-card-textured-min-height:88px] transition-[filter] duration-150 ease-out hover:brightness-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35"; +const PREVIEW_PUBLIC_ID = pubkeyToNpub( + "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34", +); function LabeledField({ id, @@ -313,17 +321,32 @@ export function DefaultConfigPreview({ export type CommunityPreviewRoute = "join" | "create" | "existing"; export function CommunityChoicePreview({ + current = 5, + includeExistingCommunity = true, onBack, onChoose, + total, }: { + current?: number; + includeExistingCommunity?: boolean; onBack: () => void; onChoose: (route: CommunityPreviewRoute) => void; + total?: number; }) { + const routes: ReadonlyArray = [ + ["join", "Join a community"], + ["create", "Create a community"], + ...(includeExistingCommunity + ? ([["existing", "I already have a community"]] as const) + : []), + ]; + return (

Join or create a community

- Join with an invite, create your own community, or reconnect one you - already have. + {includeExistingCommunity + ? "Join with an invite, create your own community, or reconnect one you already have." + : "Join with an invite or create your own community."}

-
- {( - [ - ["join", "Join a community"], - ["create", "Create a community"], - ["existing", "I already have a community"], - ] as const - ).map(([route, label]) => ( +
+ {routes.map(([route, label]) => ( void; onContinue: (communityName: string) => void; - route: CommunityPreviewRoute; + route: Exclude; + total?: number; }) { - const [value, setValue] = React.useState(""); - const isCreate = route === "create"; - const heading = isCreate - ? "Create a community" - : route === "existing" - ? "Reconnect to your community" - : "Join a community"; + const [copiedPublicId, setCopiedPublicId] = React.useState(false); + const heading = + route === "existing" ? "Reconnect to your community" : "Join a community"; + const continueToCommunity = () => onContinue("Block Community"); return ( -

{heading}

-

- {isCreate - ? "Choose a name for the space where your people and agents will work together." - : route === "existing" +

+

{heading}

+

+ {route === "existing" ? "Enter the community URL or an invite link. Your role will be restored when you connect." : "Enter the invite link or community URL you received."} -

-
{ - event.preventDefault(); - onContinue( - isCreate ? value.trim() || "Your community" : "Block Community", - ); - }} - > - +
+
+ - - + {route === "join" ? ( +
+

+ Joining a private community? +

+

+ Some communities need the owner to add you before you can join. + Copy your public ID and send it to the community owner. +

+
+ + {PREVIEW_PUBLIC_ID} + + +
+
+ ) : null} +
+
+
+ ); +} + +export function CommunityCreatePreview({ + current = 5, + onBack, + onContinue, + total, +}: { + current?: number; + onBack: () => void; + onContinue: (communityName: string) => void; + total?: number; +}) { + return ( + + + ); @@ -471,80 +539,48 @@ export function CommunityConnectingPreview({ } export function CommunityProfilePreview({ + avatarUrl, + current = 6, displayName, - hasAvatar, - onAvatarChange, + onAvatarUrlChange, onBack, onDisplayNameChange, onNext, + total, }: { + avatarUrl: string; + current?: number; displayName: string; - hasAvatar: boolean; - onAvatarChange: () => void; + onAvatarUrlChange: (value: string) => void; onBack: () => void; onDisplayNameChange: (value: string) => void; onNext: () => void; + total?: number; }) { + const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); + return ( -
-

Build your profile

-

- Add a name and avatar. They’ll show up on your messages, reactions, - and agent handoffs. -

-
-
- - -
- - - +
); @@ -666,40 +702,19 @@ export function WelcomeChannelPreview({ } export function CommunityHomePreview({ + avatarUrl, communityName, - onBack, + displayName, }: { + avatarUrl: string; communityName: string; - onBack: () => void; + displayName: string; }) { return ( - - -

- {communityName} -

-

Welcome to Buzz

-

- You’re in the Welcome channel. Start with a message, meet the people - and agents here, or explore the rest of the community. -

- -

Fizz

-

- Welcome! I can show you around and help you get started. -

-
-
-
+ ); } diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewShell.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewShell.tsx index 384bf1e414..a1bfbfdabb 100644 --- a/desktop/src/features/onboarding/ui/OnboardingPreviewShell.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewShell.tsx @@ -10,12 +10,14 @@ export function OnboardingPreviewStep({ onBack, security = false, testId, + total, }: { children: React.ReactNode; current?: number; onBack?: () => void; security?: boolean; testId: string; + total?: number; }) { return (
- {!security ? : null} + {!security ? : null} diff --git a/desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx b/desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx new file mode 100644 index 0000000000..257749e2cf --- /dev/null +++ b/desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx @@ -0,0 +1,321 @@ +import { + AtSign, + Bot, + ChevronLeft, + ChevronRight, + FolderKanban, + Hash, + Home, + Mic, + Paperclip, + Plus, + Search, + Send, + Smile, + Sparkles, +} from "lucide-react"; +import * as React from "react"; + +import { + WelcomeComposerGuidanceLayer, + type WelcomeComposerBannerState, +} from "@/features/channels/ui/WelcomeComposerBanner"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { ChannelIntroBlock } from "@/features/messages/ui/ChannelIntroBlock"; +import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; +import { Button } from "@/shared/ui/button"; +import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; +import { cn } from "@/shared/lib/cn"; + +const CHANNEL_ROW_CLASS = + "flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition-colors"; + +export function WelcomeChannelAppPreview({ + avatarUrl, + communityName, + displayName, +}: { + avatarUrl: string; + communityName: string; + displayName: string; +}) { + const [bannerState, setBannerState] = + React.useState("prompt"); + const [draft, setDraft] = React.useState(""); + const [messages, setMessages] = React.useState([]); + const [activePreviewAction, setActivePreviewAction] = React.useState< + string | null + >(null); + const profileName = displayName.trim() || "Your profile"; + const submitDraft = (event: React.FormEvent) => { + event.preventDefault(); + const message = draft.trim(); + if (!message) return; + setMessages((current) => [...current, message]); + setDraft(""); + }; + + const intro = { + actions: [ + { + icon: , + label: "Browse channels", + onClick: () => setActivePreviewAction("Browse channels"), + testId: "welcome-intro-action-browse-channels", + }, + { + icon: , + label: "Create a channel", + onClick: () => setActivePreviewAction("Create a channel"), + testId: "welcome-intro-action-create-channel", + }, + { + icon: , + label: "Create an agent", + onClick: () => setActivePreviewAction("Create an agent"), + testId: "welcome-intro-action-create-agent", + }, + ], + channelKindLabel: "private welcome channel", + channelName: "Welcome", + description: null, + icon: , + }; + + return ( +
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+ +

+ Welcome +

+
+ +
+
+ + {messages.map((message, index) => ( +
+ +
+

{profileName}

+

{message}

+
+
+ ))} +
+
+ +
+
+ {bannerState !== "hidden" ? ( + setBannerState("hidden")} + state={bannerState} + > + {null} + + ) : null} + +
+