Skip to content
Open
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
18 changes: 17 additions & 1 deletion app/api/account/update/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@ import updateAccountInfo from "@/lib/supabase/account_info/updateAccountInfo";

export async function POST(req: NextRequest) {
const body = await req.json();
const { instruction, name, organization, accountId, image, jobTitle, roleType, companyName, knowledges } = body;
const {
instruction,
name,
organization,
accountId,
image,
jobTitle,
roleType,
companyName,
knowledges,
onboarding_data,
onboarding_status,
} = body;

try {
const found = await getAccountById(accountId);
Expand All @@ -26,6 +38,8 @@ export async function POST(req: NextRequest) {
role_type: roleType,
company_name: companyName,
knowledges,
onboarding_data,
onboarding_status,
});
} else {
await updateAccountInfo(accountId, {
Expand All @@ -36,6 +50,8 @@ export async function POST(req: NextRequest) {
role_type: roleType,
company_name: companyName,
knowledges,
onboarding_data,
onboarding_status,
});
}

Expand Down
24 changes: 24 additions & 0 deletions app/api/onboarding-templates/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { fetchOnboardingTemplates } from "@/lib/onboarding/fetchOnboardingTemplates";

export const runtime = "edge";

/**
* GET /api/onboarding-templates
* Fetches all onboarding templates (system templates with 'onboarding' tag)
*/
export async function GET() {
try {
const templates = await fetchOnboardingTemplates();
return NextResponse.json(templates);
} catch (error) {
console.error("Error fetching onboarding templates:", error);
return NextResponse.json(
{ error: "Failed to fetch onboarding templates" },
{ status: 500 }
);
}
}

export const dynamic = "force-dynamic";
export const revalidate = 0;
13 changes: 13 additions & 0 deletions app/onboarding/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default function OnboardingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="fixed inset-0 z-50 bg-background">
<div className="h-full w-full overflow-y-auto">
{children}
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions app/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import OnboardingFlow from "@/components/Onboarding/OnboardingFlow";

export default function OnboardingPage() {
return <OnboardingFlow />;
}
57 changes: 57 additions & 0 deletions components/Onboarding/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { useOnboarding, type OnboardingStep } from "@/hooks/useOnboarding";
import OnboardingProgress from "./OnboardingProgress";
import {
WelcomeStep,
RoleStep,
ArtistsStep,
TaskPickerStep,
RunningStep,
ResultStep,
RecurringStep,
CompleteStep,
} from "./steps";

interface StepComponentProps {
onNext: () => void;
onBack: () => void;
}

type StepComponent = React.ComponentType<StepComponentProps>;

const STEP_COMPONENTS: Record<OnboardingStep, StepComponent> = {
welcome: WelcomeStep,
role: RoleStep,
artists: ArtistsStep,
"task-picker": TaskPickerStep,
running: RunningStep,
result: ResultStep,
recurring: RecurringStep,
complete: CompleteStep,
};

export default function OnboardingFlow() {
const { step, nextStep, prevStep, currentStepIndex, totalSteps } =
useOnboarding();

const StepComponent = STEP_COMPONENTS[step];

return (
<div className="flex flex-col items-center min-h-screen py-8 px-4">
{/* Progress indicator - rendered above step content */}
<div className="w-full max-w-2xl mb-8">
<OnboardingProgress
currentStep={step}
currentStepIndex={currentStepIndex}
totalSteps={totalSteps}
/>
</div>

{/* Current step content */}
<div className="w-full max-w-2xl flex-1">
<StepComponent onNext={nextStep} onBack={prevStep} />
</div>
</div>
);
}
84 changes: 84 additions & 0 deletions components/Onboarding/OnboardingGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"use client";

import { useEffect, useRef } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useUserProvider } from "@/providers/UserProvder";
import { useArtistProvider } from "@/providers/ArtistProvider";
import useAccountOrganizations from "@/hooks/useAccountOrganizations";
import { needsOnboarding, OnboardingStatus } from "@/lib/onboarding";

/**
* OnboardingGuard component that redirects new org users to /onboarding
* if they haven't completed onboarding yet.
*
* Conditions for redirect:
* 1. User has at least one organization
* 2. onboarding_status.completed !== true
* 3. Organization has at least one artist
* 4. Not already on /onboarding route
*/
const OnboardingGuard = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const pathname = usePathname();
const { userData } = useUserProvider();
const { artists, isLoading: artistsLoading } = useArtistProvider();
const { data: organizations, isLoading: orgsLoading } =
useAccountOrganizations();
const hasChecked = useRef(false);

useEffect(() => {
// Skip if data is still loading
if (orgsLoading || artistsLoading) {
return;
}

// Skip if no userData yet
if (!userData) {
return;
}

// Skip if already on onboarding route
if (pathname?.startsWith("/onboarding")) {
return;
}

// Skip if already checked this session (to prevent redirect loops)
if (hasChecked.current) {
return;
}

// Parse onboarding_status from userData
const onboardingStatus = userData.onboarding_status as
| OnboardingStatus
| null
| undefined;
Comment on lines +50 to +54

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh onboarding status before redirect guard

The guard bases its redirect decision on userData.onboarding_status, but the completion flow only posts to /api/account/update and does not refresh userData in the same session. As a result, right after onboarding completion, the first navigation to / can still look incomplete to this guard and bounce the user back to /onboarding until a full reload. Consider updating userData with the completion response or checking a local completion flag so the guard reflects the just-finished state.

Useful? React with 👍 / 👎.


// Check if org has artists
const orgHasArtists = artists && artists.length > 0;

const shouldRedirect = needsOnboarding({
onboardingStatus,
organizations,
orgHasArtists,
});

// Mark as checked to prevent re-checking
hasChecked.current = true;

if (shouldRedirect) {
router.push("/onboarding");
}
}, [
userData,
organizations,
artists,
orgsLoading,
artistsLoading,
pathname,
router,
]);

return <>{children}</>;
};

export default OnboardingGuard;
86 changes: 86 additions & 0 deletions components/Onboarding/OnboardingProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { cn } from "@/lib/utils";
import { type OnboardingStep } from "@/hooks/useOnboarding";

interface OnboardingProgressProps {
currentStep: OnboardingStep;
currentStepIndex: number;
totalSteps: number;
className?: string;
}

// Brand primary color: #345A5D
const BRAND_PRIMARY = "#345A5D";

// Step order for dot display
const STEPS_ORDER: OnboardingStep[] = [
"welcome",
"role",
"artists",
"task-picker",
"running",
"result",
"recurring",
"complete",
];

export default function OnboardingProgress({
currentStepIndex,
totalSteps,
className,
}: OnboardingProgressProps) {
// Calculate progress percentage
const progressPercentage = ((currentStepIndex + 1) / totalSteps) * 100;

return (
<div className={cn("w-full max-w-md mx-auto", className)}>
{/* Step indicator text */}
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">
Step {currentStepIndex + 1} of {totalSteps}
</span>
<span className="text-sm text-muted-foreground">
{Math.round(progressPercentage)}%
</span>
</div>

{/* Progress bar */}
<div className="relative h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full transition-all duration-300 ease-out rounded-full"
style={{
width: `${progressPercentage}%`,
backgroundColor: BRAND_PRIMARY,
}}
/>
</div>

{/* Step dots */}
<div className="flex justify-between mt-3">
{STEPS_ORDER.map((step, index) => {
const isCompleted = index < currentStepIndex;
const isCurrent = index === currentStepIndex;

return (
<div
key={step}
className={cn(
"w-2.5 h-2.5 rounded-full transition-all duration-200",
isCompleted && "scale-100",
isCurrent && "scale-125 ring-2 ring-offset-2 ring-offset-background",
!isCompleted && !isCurrent && "bg-muted"
)}
style={{
backgroundColor: isCompleted || isCurrent ? BRAND_PRIMARY : undefined,
["--tw-ring-color" as string]: isCurrent ? BRAND_PRIMARY : undefined,
}}
aria-label={`Step ${index + 1}: ${step}`}
aria-current={isCurrent ? "step" : undefined}
/>
);
})}
</div>
</div>
);
}
Loading