Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/app/onboarding-preview/page.tsx
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>
);
}
69 changes: 69 additions & 0 deletions src/components/ui/textarea.tsx
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}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
);
}
);

export { Textarea };
80 changes: 80 additions & 0 deletions src/features/auth/components/auth-flow-provider.tsx
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>
);
}
82 changes: 82 additions & 0 deletions src/features/auth/components/get-started-popup.tsx
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');
}}
/>
);
}
87 changes: 87 additions & 0 deletions src/features/auth/components/onboarding-gate.tsx
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')}
/>
</>
);
}
1 change: 1 addition & 0 deletions src/features/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { AuthCard } from './components/auth-card';
export { AuthFlowProvider, useAuthFlow } from './components/auth-flow-provider';
export { AuthFooterPill } from './components/auth-footer-pill';
export { AuthTerms } from './components/auth-terms';
export { OrDivider } from './components/or-divider';
Expand Down
Loading
Loading