diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 795b3bd..1e8c846 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -1,11 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Check } from "lucide-react" import { toast } from "sonner" import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" -import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" +import { Progress } from "@/components/ui/progress" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { cn } from "@/lib/utils" import * as api from "@client/api" import { useSettings } from "@client/hooks/useSettings" @@ -13,33 +14,13 @@ import { SettingsInput } from "@client/pages/settings/components/SettingsInput" import { formatSecretHint } from "@client/pages/settings/utils" import type { ProfileStatusResponse, ResumeProfile } from "@shared/types" -type RequirementRowProps = { - label: string - helper?: string - complete: boolean -} - -const RequirementRow: React.FC = ({ label, helper, complete }) => ( -
-
-

{label}

- {helper &&

{helper}

} -
- - {complete ? "Ready" : "Next"} - -
-) - export const OnboardingGate: React.FC = () => { const { settings, isLoading: settingsLoading, refreshSettings } = useSettings() const [profileStatus, setProfileStatus] = useState(null) const [isCheckingProfile, setIsCheckingProfile] = useState(false) const [isSavingEnv, setIsSavingEnv] = useState(false) const [isUploadingResume, setIsUploadingResume] = useState(false) + const [currentStep, setCurrentStep] = useState(null) const [openrouterApiKey, setOpenrouterApiKey] = useState("") const [rxresumeEmail, setRxresumeEmail] = useState("") @@ -83,6 +64,39 @@ export const OnboardingGate: React.FC = () => { ? formatSecretHint(settings.rxresumePasswordHint) : undefined + const steps = useMemo( + () => [ + { + id: "openrouter", + label: "Connect AI", + subtitle: "OpenRouter key", + complete: hasOpenrouterKey, + }, + { + id: "rxresume", + label: "PDF Export", + subtitle: "RxResume login", + complete: hasRxresumeCredentials, + }, + { + id: "resume", + label: "Resume JSON", + subtitle: "Upload your file", + complete: hasBaseResume, + }, + ], + [hasBaseResume, hasOpenrouterKey, hasRxresumeCredentials] + ) + + const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id + + useEffect(() => { + if (!shouldOpen) return + if (!currentStep && defaultStep) { + setCurrentStep(defaultStep) + } + }, [currentStep, defaultStep, shouldOpen]) + const handleRefresh = async () => { const results = await Promise.allSettled([refreshSettings(), refreshProfileStatus()]) const failed = results.find((result) => result.status === "rejected") @@ -93,59 +107,73 @@ export const OnboardingGate: React.FC = () => { } } - const handleSaveCredentials = async () => { - if (!settings) return - const update: { openrouterApiKey?: string; rxresumeEmail?: string; rxresumePassword?: string } = {} + const handleSaveOpenrouter = async (): Promise => { const openrouterValue = openrouterApiKey.trim() + if (hasOpenrouterKey && !openrouterValue) return true + if (!openrouterValue) { + toast.info("Add your OpenRouter API key to continue") + return false + } + + try { + setIsSavingEnv(true) + await api.updateSettings({ openrouterApiKey: openrouterValue }) + await refreshSettings() + setOpenrouterApiKey("") + toast.success("OpenRouter connected") + return true + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save OpenRouter key" + toast.error(message) + return false + } finally { + setIsSavingEnv(false) + } + } + + const handleSaveRxresume = async (): Promise => { const emailValue = rxresumeEmail.trim() const passwordValue = rxresumePassword.trim() - const missing: string[] = [] - if (!hasOpenrouterKey && !openrouterValue) { - missing.push("OpenRouter API key") - } - - if (!hasRxresumeCredentials) { - if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email") - if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password") - } + if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email") + if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password") if (missing.length > 0) { toast.info("Almost there", { description: `Missing: ${missing.join(", ")}`, }) - return + return false } - if (openrouterValue) update.openrouterApiKey = openrouterValue + const update: { rxresumeEmail?: string; rxresumePassword?: string } = {} if (emailValue) update.rxresumeEmail = emailValue if (passwordValue) update.rxresumePassword = passwordValue if (Object.keys(update).length === 0) { - toast.info("Nothing new to save") - return + return true } try { setIsSavingEnv(true) await api.updateSettings(update) await refreshSettings() - setOpenrouterApiKey("") setRxresumePassword("") - toast.success("Credentials saved") + toast.success("RxResume connected") + return true } catch (error) { - const message = error instanceof Error ? error.message : "Failed to save credentials" + const message = error instanceof Error ? error.message : "Failed to save RxResume credentials" toast.error(message) + return false } finally { setIsSavingEnv(false) } } - const handleUploadResume = async () => { + const handleUploadResume = async (): Promise => { if (!resumeFile) { toast.info("Choose your base.json file") - return + return false } try { @@ -165,160 +193,205 @@ export const OnboardingGate: React.FC = () => { fileInputRef.current.value = "" } toast.success("Resume uploaded") + return true } catch (error) { const message = error instanceof Error ? error.message : "Failed to upload resume" toast.error(message) + return false } finally { setIsUploadingResume(false) } } const resumeFileName = resumeFile?.name || "" + const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0 + const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0 + const completedSteps = steps.filter((step) => step.complete).length + const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0 + const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isCheckingProfile + const canGoBack = stepIndex > 0 + const canGoForward = stepIndex < steps.length - 1 + const primaryLabel = currentStep === "resume" + ? (hasBaseResume ? "Finish" : "Upload and finish") + : (currentStep === "openrouter" && !hasOpenrouterKey) || (currentStep === "rxresume" && !hasRxresumeCredentials) + ? "Save" + : "Continue" - const checklist = useMemo( - () => [ - { - label: "OpenRouter API key", - helper: "Needed for scoring + tailoring", - complete: hasOpenrouterKey, - }, - { - label: "RxResume credentials", - helper: "Used to export PDFs", - complete: hasRxresumeCredentials, - }, - { - label: "Base resume JSON", - helper: "Upload resume-generator/base.json", - complete: hasBaseResume, - }, - ], - [hasBaseResume, hasOpenrouterKey, hasRxresumeCredentials] - ) + const handlePrimaryAction = async () => { + if (!currentStep) return + if (currentStep === "openrouter") { + await handleSaveOpenrouter() + return + } + if (currentStep === "rxresume") { + await handleSaveRxresume() + return + } + if (currentStep === "resume") { + if (hasBaseResume) { + await handleRefresh() + return + } + await handleUploadResume() + } + } - if (!shouldOpen) return null + const handleBack = () => { + if (!canGoBack) return + setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep) + } + + if (!shouldOpen || !currentStep) return null return ( event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} > - - Welcome to Job Ops - - Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end. - - +
+ + Welcome to Job Ops + + Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end. + + -
-
-
-

Quick setup checklist

- -
-
- {checklist.map((item) => ( - - ))} -
-
+ + + {steps.map((step, index) => { + const isActive = step.id === currentStep + const isComplete = step.complete - + return ( + +
+ + {isComplete ? : index + 1} + + + {step.label} + +
+ {step.subtitle} +
+ ) + })} +
-
-
-

OpenRouter

-

Used for job scoring, summaries, and tailoring.

-
- setOpenrouterApiKey(event.target.value), - }} - type="password" - placeholder="sk-or-v1..." - current={openrouterCurrent} - helper="Create a key at openrouter.ai" - disabled={isSavingEnv} - /> -
- - - -
-
-

RxResume account

-

Used to export tailored PDFs.

-
-
+ +
+

Connect OpenRouter

+

Used for job scoring, summaries, and tailoring.

+
setRxresumeEmail(event.target.value), - }} - placeholder="you@example.com" - current={rxresumeEmailCurrent} - disabled={isSavingEnv} - /> - setRxresumePassword(event.target.value), + name: "openrouterApiKey", + value: openrouterApiKey, + onChange: (event) => setOpenrouterApiKey(event.target.value), }} type="password" - placeholder="Enter password" - current={rxresumePasswordCurrent} + placeholder="sk-or-v1..." + current={openrouterCurrent} + helper="Create a key at openrouter.ai" disabled={isSavingEnv} /> -
-
- -
-
+ - - -
-
-

Base resume JSON

-

Upload your RxResume export named base.json.

-
-
-
- - setResumeFile(event.target.files?.[0] ?? null)} - disabled={isUploadingResume} - /> - {resumeFileName && ( -

Selected: {resumeFileName}

- )} + +
+

Link your RxResume account

+

Used to export tailored PDFs.

- +
+ +
+ +
Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and we will take a look.{" "}