-
Notifications
You must be signed in to change notification settings - Fork 1
feat(onboarding): onboarding modals + global auth-flow wiring #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
e2bb760
feat(onboarding): onboarding modals + global auth-flow wiring
0xdevcollins 8e17ef4
fix(onboarding): review fixes for textarea a11y, auth race, modal reset
0xdevcollins e30c589
refactor(onboarding): use Button primitive for avatar upload trigger
0xdevcollins File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| 'use client'; | ||
|
|
||
| import { useState } from 'react'; | ||
|
|
||
| import { Button } from '@/components/ui/button'; | ||
| import { ProfileSetupModal, RoleGateModal } from '@/features/onboarding'; | ||
|
|
||
| export default function OnboardingPreview() { | ||
| const [profileOpen, setProfileOpen] = useState(false); | ||
| const [roleOpen, setRoleOpen] = useState(false); | ||
|
|
||
| return ( | ||
| <main className='grid min-h-dvh place-items-center bg-ink p-10'> | ||
| <div className='flex flex-wrap items-center justify-center gap-4'> | ||
| <Button shape='pill' onClick={() => setProfileOpen(true)}> | ||
| Profile set up | ||
| </Button> | ||
| <Button shape='pill' onClick={() => setRoleOpen(true)}> | ||
| Complete Sign up | ||
| </Button> | ||
| </div> | ||
|
|
||
| <ProfileSetupModal | ||
| open={profileOpen} | ||
| onOpenChange={setProfileOpen} | ||
| onSubmit={() => setProfileOpen(false)} | ||
| /> | ||
| <RoleGateModal | ||
| open={roleOpen} | ||
| onOpenChange={setRoleOpen} | ||
| onContinue={() => setRoleOpen(false)} | ||
| onGoBack={() => setRoleOpen(false)} | ||
| /> | ||
| </main> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import * as React from 'react'; | ||
|
|
||
| import { cn } from '@/lib/utils'; | ||
|
|
||
| type TextareaProps = React.ComponentProps<'textarea'> & { | ||
| label?: string; | ||
| helperText?: string; | ||
| /** Class applied to the outer container (label + control + helper). */ | ||
| containerClassName?: string; | ||
| }; | ||
|
|
||
| /** | ||
| * Multi-line text field matching the Input design: neutral-600 border, eased | ||
| * focus glow, with an optional label and helper text. | ||
| */ | ||
| const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( | ||
| function Textarea( | ||
| { | ||
| className, | ||
| containerClassName, | ||
| label, | ||
| helperText, | ||
| id, | ||
| 'aria-describedby': ariaDescribedBy, | ||
| ...props | ||
| }, | ||
| ref | ||
| ) { | ||
| const generatedId = React.useId(); | ||
| const textareaId = id ?? generatedId; | ||
| const helperId = `${textareaId}-helper`; | ||
| const describedBy = helperText | ||
| ? [ariaDescribedBy, helperId].filter(Boolean).join(' ') | ||
| : ariaDescribedBy; | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn('flex w-full min-w-0 flex-col gap-1', containerClassName)} | ||
| > | ||
| {label ? ( | ||
| <label | ||
| htmlFor={textareaId} | ||
| className='text-sm font-medium text-[#929f9c]' | ||
| > | ||
| {label} | ||
| </label> | ||
| ) : null} | ||
| <textarea | ||
| ref={ref} | ||
| id={textareaId} | ||
| data-slot='textarea' | ||
| aria-describedby={describedBy || undefined} | ||
| className={cn( | ||
| 'min-h-[120px] w-full resize-none rounded-md border border-neutral-600 bg-transparent px-3 py-2 text-sm text-[#f1fff1] caret-primary-500 shadow-[0_0_0_0_transparent] transition-[color,background-color,border-color,box-shadow] duration-200 ease-out outline-none placeholder:text-[#7a8f8b]/70 hover:border-neutral-500 focus:border-primary-500 focus:bg-ink-soft focus:shadow-[0_0_0_4px_rgba(46,237,170,0.12)] disabled:cursor-not-allowed disabled:opacity-50', | ||
| className | ||
| )} | ||
| {...props} | ||
| /> | ||
| {helperText ? ( | ||
| <p id={helperId} className='text-sm text-[#929f9c]/80'> | ||
| {helperText} | ||
| </p> | ||
| ) : null} | ||
| </div> | ||
| ); | ||
| } | ||
| ); | ||
|
|
||
| export { Textarea }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| 'use client'; | ||
|
|
||
| import { useRouter } from 'next/navigation'; | ||
| import * as React from 'react'; | ||
|
|
||
| import { useAuth } from '../hooks/use-auth'; | ||
| import { GetStartedPopup } from './get-started-popup'; | ||
| import { OnboardingGate } from './onboarding-gate'; | ||
|
|
||
| interface RequireAuthOptions { | ||
| /** Send the user to the sign-up choose-path instead of sign-in. */ | ||
| mode?: 'sign-in' | 'sign-up'; | ||
| /** Where to return after auth. Defaults to the current URL. */ | ||
| redirectTo?: string; | ||
| /** Run when the user is already authenticated (the gated action). */ | ||
| onAuthed?: () => void; | ||
| } | ||
|
|
||
| interface AuthFlowContextValue { | ||
| isAuthenticated: boolean; | ||
| /** | ||
| * Gate an action behind auth. If signed in, runs `onAuthed` and returns true. | ||
| * Otherwise routes to sign-in / sign-up with a `redirect` back and returns | ||
| * false. | ||
| */ | ||
| requireAuth: (options?: RequireAuthOptions) => boolean; | ||
| } | ||
|
|
||
| const AuthFlowContext = React.createContext<AuthFlowContextValue | null>(null); | ||
|
|
||
| export function useAuthFlow() { | ||
| const ctx = React.useContext(AuthFlowContext); | ||
| if (!ctx) { | ||
| throw new Error('useAuthFlow must be used within an AuthFlowProvider'); | ||
| } | ||
| return ctx; | ||
| } | ||
|
|
||
| /** | ||
| * App-wide auth orchestration: a single entry point for triggering auth from | ||
| * anywhere (`requireAuth`), plus the post-auth onboarding gate and the | ||
| * get-started popup for guests. | ||
| */ | ||
| export function AuthFlowProvider({ children }: { children: React.ReactNode }) { | ||
| const router = useRouter(); | ||
| const { isAuthenticated, isPending } = useAuth(); | ||
|
|
||
| const requireAuth = React.useCallback( | ||
| (options: RequireAuthOptions = {}) => { | ||
| // Session still resolving: don't redirect an actually-authed user. | ||
| if (isPending) return false; | ||
| if (isAuthenticated) { | ||
| options.onAuthed?.(); | ||
| return true; | ||
| } | ||
| const current = | ||
| options.redirectTo ?? | ||
| (typeof window !== 'undefined' | ||
| ? `${window.location.pathname}${window.location.search}` | ||
| : '/'); | ||
| const base = options.mode === 'sign-up' ? '/sign-up' : '/sign-in'; | ||
| router.push(`${base}?redirect=${encodeURIComponent(current)}`); | ||
| return false; | ||
| }, | ||
| [isAuthenticated, isPending, router] | ||
| ); | ||
|
|
||
| const value = React.useMemo( | ||
| () => ({ isAuthenticated, requireAuth }), | ||
| [isAuthenticated, requireAuth] | ||
| ); | ||
|
|
||
| return ( | ||
| <AuthFlowContext.Provider value={value}> | ||
| {children} | ||
| <OnboardingGate /> | ||
| <GetStartedPopup /> | ||
| </AuthFlowContext.Provider> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| 'use client'; | ||
|
|
||
| import { usePathname, useRouter } from 'next/navigation'; | ||
| import * as React from 'react'; | ||
|
|
||
| import { GetStartedModal } from '@/features/marketing'; | ||
|
|
||
| import { useAuth } from '../hooks/use-auth'; | ||
|
|
||
| const DELAY_MS = 45_000; | ||
| const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; | ||
| const COOLDOWN_KEY = 'boundless:getStartedLastShown'; | ||
|
|
||
| /** Auth, app and utility surfaces where the popup must never appear. */ | ||
| const BLOCKED_PREFIXES = [ | ||
| '/sign-in', | ||
| '/sign-up', | ||
| '/reset-password', | ||
| '/dashboard', | ||
| '/organizations', | ||
| ]; | ||
|
|
||
| function isBlockedPath(pathname: string | null): boolean { | ||
| if (!pathname) return true; | ||
| if (pathname.includes('-preview')) return true; | ||
| return BLOCKED_PREFIXES.some( | ||
| prefix => pathname === prefix || pathname.startsWith(`${prefix}/`) | ||
| ); | ||
| } | ||
|
|
||
| function onCooldown(): boolean { | ||
| try { | ||
| const last = Number(localStorage.getItem(COOLDOWN_KEY) ?? 0); | ||
| return Date.now() - last < COOLDOWN_MS; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function stampCooldown() { | ||
| try { | ||
| localStorage.setItem(COOLDOWN_KEY, String(Date.now())); | ||
| } catch { | ||
| // ignore storage failures | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Nudges guests to register: after ~45s on an allowed page it opens the | ||
| * get-started modal, then stays quiet for 7 days. Never shown to authenticated | ||
| * users or on auth / app / preview routes. The timer resets on navigation | ||
| * between allowed pages. | ||
| */ | ||
| export function GetStartedPopup() { | ||
| const { isAuthenticated, isPending } = useAuth(); | ||
| const pathname = usePathname(); | ||
| const router = useRouter(); | ||
| const [open, setOpen] = React.useState(false); | ||
|
|
||
| const eligible = !isPending && !isAuthenticated && !isBlockedPath(pathname); | ||
|
|
||
| React.useEffect(() => { | ||
| if (!eligible || open || onCooldown()) return; | ||
| const timer = setTimeout(() => { | ||
| stampCooldown(); | ||
| setOpen(true); | ||
| }, DELAY_MS); | ||
| return () => clearTimeout(timer); | ||
| // Restart the timer when the route changes within allowed pages. | ||
| }, [eligible, pathname, open]); | ||
|
|
||
| return ( | ||
| <GetStartedModal | ||
| open={open && eligible} | ||
| onOpenChange={setOpen} | ||
| onGetStarted={() => { | ||
| setOpen(false); | ||
| router.push('/sign-up'); | ||
| }} | ||
| /> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| 'use client'; | ||
|
|
||
| import { useQueryClient } from '@tanstack/react-query'; | ||
| import * as React from 'react'; | ||
|
|
||
| import { ProfileSetupModal, RoleGateModal } from '@/features/onboarding'; | ||
| import { usersKeys } from '@/features/users'; | ||
|
|
||
| import { useAuth } from '../hooks/use-auth'; | ||
|
|
||
| const onboardedKey = (id: string) => `boundless:onboarded:${id}`; | ||
|
|
||
| function isOnboarded(id: string): boolean { | ||
| try { | ||
| return localStorage.getItem(onboardedKey(id)) === '1'; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function markOnboarded(id: string) { | ||
| try { | ||
| localStorage.setItem(onboardedKey(id), '1'); | ||
| } catch { | ||
| // ignore storage failures (private mode, etc.) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Shows the onboarding modals (role gate, then profile setup) to a signed-in | ||
| * user who hasn't completed it. "Needs onboarding" is inferred from a missing | ||
| * display name (fresh email-OTP signups), and the decision is remembered | ||
| * per-user in localStorage so it never nags. Completing or dismissing marks it | ||
| * done. | ||
| */ | ||
| export function OnboardingGate() { | ||
| const { isAuthenticated, isPending, user } = useAuth(); | ||
| const queryClient = useQueryClient(); | ||
| const [step, setStep] = React.useState<'role' | 'profile' | null>(null); | ||
| const [startedFor, setStartedFor] = React.useState<string | null>(null); | ||
|
|
||
| const userId = user?.id ?? null; | ||
| const needsOnboarding = | ||
| isAuthenticated && | ||
| !isPending && | ||
| !!userId && | ||
| !user?.name && | ||
| !isOnboarded(userId); | ||
|
|
||
| // Open the flow once per user (render-time state sync, no effect). | ||
| if (needsOnboarding && startedFor !== userId) { | ||
| setStartedFor(userId); | ||
| setStep('role'); | ||
| } | ||
| // Reset when the user signs out. | ||
| if (!isAuthenticated && startedFor !== null) { | ||
| setStartedFor(null); | ||
| setStep(null); | ||
| } | ||
|
|
||
| function complete() { | ||
| if (userId) markOnboarded(userId); | ||
| queryClient.invalidateQueries({ queryKey: usersKeys.all }); | ||
| setStep(null); | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| <RoleGateModal | ||
| open={step === 'role'} | ||
| onOpenChange={next => { | ||
| if (!next) complete(); | ||
| }} | ||
| onContinue={() => setStep('profile')} | ||
| onGoBack={complete} | ||
| /> | ||
| <ProfileSetupModal | ||
| open={step === 'profile'} | ||
| onOpenChange={next => { | ||
| if (!next) complete(); | ||
| }} | ||
| onSubmit={complete} | ||
| onChangeAccountType={() => setStep('role')} | ||
| /> | ||
| </> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.