import * as api from "@client/api"; import { useDemoInfo } from "@client/hooks/useDemoInfo"; import { useSettings } from "@client/hooks/useSettings"; import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection"; import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; import { formatSecretHint, getLlmProviderConfig, LLM_PROVIDER_LABELS, LLM_PROVIDERS, normalizeLlmProvider, } from "@client/pages/settings/utils"; import type { UpdateSettingsInput } from "@shared/settings-schema.js"; import type { ValidationResult } from "@shared/types.js"; import { Check } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { toast } from "sonner"; import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle, } from "@/components/ui/field"; import { Progress } from "@/components/ui/progress"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; type ValidationState = ValidationResult & { checked: boolean }; type OnboardingFormData = { llmProvider: string; llmBaseUrl: string; llmApiKey: string; rxresumeEmail: string; rxresumePassword: string; rxresumeBaseResumeId: string | null; }; function getStepPrimaryLabel(input: { currentStep: string | null; llmValidated: boolean; rxresumeValidated: boolean; baseResumeValidated: boolean; }): string { const toLabel = (isValidated: boolean): string => isValidated ? "Revalidate" : "Validate"; if (input.currentStep === "llm") return toLabel(input.llmValidated); if (input.currentStep === "rxresume") return toLabel(input.rxresumeValidated); if (input.currentStep === "baseresume") return toLabel(input.baseResumeValidated); return "Validate"; } export const OnboardingGate: React.FC = () => { const { settings, isLoading: settingsLoading, refreshSettings, } = useSettings(); const [isSavingEnv, setIsSavingEnv] = useState(false); const [isValidatingLlm, setIsValidatingLlm] = useState(false); const [isValidatingRxresume, setIsValidatingRxresume] = useState(false); const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false); const [llmValidation, setLlmValidation] = useState({ valid: false, message: null, checked: false, }); const [rxresumeValidation, setRxresumeValidation] = useState( { valid: false, message: null, checked: false, }, ); const [baseResumeValidation, setBaseResumeValidation] = useState({ valid: false, message: null, checked: false, }); const [currentStep, setCurrentStep] = useState(null); const demoInfo = useDemoInfo(); const demoMode = demoInfo?.demoMode ?? false; const { control, watch, getValues, reset, setValue } = useForm({ defaultValues: { llmProvider: "", llmBaseUrl: "", llmApiKey: "", rxresumeEmail: "", rxresumePassword: "", rxresumeBaseResumeId: null, }, }); const llmProvider = watch("llmProvider"); const validateLlm = useCallback(async () => { const values = getValues(); const selectedProvider = normalizeLlmProvider( values.llmProvider || settings?.llmProvider?.value || "openrouter", ); const providerConfig = getLlmProviderConfig(selectedProvider); const { requiresApiKey, showBaseUrl } = providerConfig; setIsValidatingLlm(true); try { const result = await api.validateLlm({ provider: selectedProvider, baseUrl: showBaseUrl ? values.llmBaseUrl.trim() || undefined : undefined, apiKey: requiresApiKey ? values.llmApiKey.trim() || undefined : undefined, }); setLlmValidation({ ...result, checked: true }); return result; } catch (error) { const message = error instanceof Error ? error.message : "LLM validation failed"; const result = { valid: false, message }; setLlmValidation({ ...result, checked: true }); return result; } finally { setIsValidatingLlm(false); } }, [getValues, settings?.llmProvider]); const validateRxresume = useCallback(async () => { const values = getValues(); setIsValidatingRxresume(true); try { const result = await api.validateRxresume( values.rxresumeEmail.trim() || undefined, values.rxresumePassword.trim() || undefined, ); setRxresumeValidation({ ...result, checked: true }); return result; } catch (error) { const message = error instanceof Error ? error.message : "RxResume validation failed"; const result = { valid: false, message }; setRxresumeValidation({ ...result, checked: true }); return result; } finally { setIsValidatingRxresume(false); } }, [getValues]); const validateBaseResume = useCallback(async () => { setIsValidatingBaseResume(true); try { const result = await api.validateResumeConfig(); setBaseResumeValidation({ ...result, checked: true }); return result; } catch (error) { const message = error instanceof Error ? error.message : "Base resume validation failed"; const result = { valid: false, message }; setBaseResumeValidation({ ...result, checked: true }); return result; } finally { setIsValidatingBaseResume(false); } }, []); const selectedProvider = normalizeLlmProvider( llmProvider || settings?.llmProvider?.value || "openrouter", ); const providerConfig = getLlmProviderConfig(selectedProvider); const { normalizedProvider, showApiKey, showBaseUrl, requiresApiKey: requiresLlmKey, } = providerConfig; const llmKeyHint = settings?.llmApiKeyHint ?? null; const hasLlmKey = Boolean(llmKeyHint); const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()); const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint); const hasCheckedValidations = (requiresLlmKey ? llmValidation.checked : true) && rxresumeValidation.checked && baseResumeValidation.checked; const llmValidated = requiresLlmKey ? llmValidation.valid : true; const shouldOpen = !demoMode && Boolean(settings && !settingsLoading) && hasCheckedValidations && !(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid); const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim() ? settings.rxresumeEmail : undefined; const rxresumePasswordCurrent = settings?.rxresumePasswordHint ? formatSecretHint(settings?.rxresumePasswordHint) : undefined; // Initialize form values from settings useEffect(() => { if (settings) { reset({ llmProvider: settings.llmProvider?.value || "", llmBaseUrl: settings.llmBaseUrl?.value || "", llmApiKey: "", rxresumeEmail: "", rxresumePassword: "", rxresumeBaseResumeId: settings.rxresumeBaseResumeId || null, }); } }, [settings, reset]); // Clear base URL when provider doesn't require it useEffect(() => { if (!showBaseUrl) { setValue("llmBaseUrl", ""); } }, [showBaseUrl, setValue]); // Reset LLM validation when provider changes useEffect(() => { if (!selectedProvider) return; setLlmValidation({ valid: false, message: null, checked: false }); }, [selectedProvider]); const steps = useMemo( () => [ { id: "llm", label: "LLM Provider", subtitle: "Provider + credentials", complete: llmValidated, disabled: false, }, { id: "rxresume", label: "Connect Reactive Resume", subtitle: "Reactive Resume login", complete: rxresumeValidation.valid, disabled: false, }, { id: "baseresume", label: "Select Template Resume", subtitle: "Template selection", complete: baseResumeValidation.valid, disabled: !rxresumeValidation.valid, }, ], [llmValidated, rxresumeValidation.valid, baseResumeValidation.valid], ); const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id; useEffect(() => { if (!shouldOpen) return; if (!currentStep && defaultStep) { setCurrentStep(defaultStep); } }, [currentStep, defaultStep, shouldOpen]); const runAllValidations = useCallback(async () => { if (!settings) return; const validations: Promise[] = []; if (requiresLlmKey) { validations.push(validateLlm()); } else { setLlmValidation({ valid: true, message: null, checked: true }); } validations.push(validateRxresume(), validateBaseResume()); const results = await Promise.allSettled(validations); const failed = results.find((result) => result.status === "rejected"); if (failed) { const reason = failed.status === "rejected" ? failed.reason : null; const message = reason instanceof Error ? reason.message : "Validation checks failed"; toast.error(message); } }, [ settings, requiresLlmKey, validateLlm, validateRxresume, validateBaseResume, ]); // Run validations on mount when needed useEffect(() => { if (demoMode) return; if (!settings || settingsLoading) return; const needsValidation = (requiresLlmKey ? !llmValidation.checked : false) || !rxresumeValidation.checked || !baseResumeValidation.checked; if (!needsValidation) return; void runAllValidations(); }, [ settings, settingsLoading, requiresLlmKey, llmValidation.checked, rxresumeValidation.checked, baseResumeValidation.checked, runAllValidations, demoMode, ]); const handleRefresh = async () => { const results = await Promise.allSettled([ refreshSettings(), runAllValidations(), ]); const failed = results.find((result) => result.status === "rejected"); if (failed) { const reason = failed.status === "rejected" ? failed.reason : null; const message = reason instanceof Error ? reason.message : "Failed to refresh setup"; toast.error(message); } }; const handleSaveLlm = async (): Promise => { const values = getValues(); const apiKeyValue = values.llmApiKey.trim(); const baseUrlValue = values.llmBaseUrl.trim(); if (requiresLlmKey && !apiKeyValue && !hasLlmKey) { toast.info("Add your LLM API key to continue"); return false; } try { const validation = requiresLlmKey ? await validateLlm() : { valid: true, message: null }; if (!validation.valid) { toast.error(validation.message || "LLM validation failed"); return false; } const update: Partial = { llmProvider: normalizedProvider, llmBaseUrl: showBaseUrl ? baseUrlValue || null : null, }; if (showApiKey && apiKeyValue) { update.llmApiKey = apiKeyValue; } setIsSavingEnv(true); await api.updateSettings(update); await refreshSettings(); setValue("llmApiKey", ""); toast.success("LLM provider connected"); return true; } catch (error) { const message = error instanceof Error ? error.message : "Failed to save LLM settings"; toast.error(message); return false; } finally { setIsSavingEnv(false); } }; const handleSaveRxresume = async (): Promise => { const values = getValues(); const emailValue = values.rxresumeEmail.trim(); const passwordValue = values.rxresumePassword.trim(); const missing: string[] = []; 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 false; } try { const validation = await validateRxresume(); if (!validation.valid) { toast.error(validation.message || "RxResume validation failed"); return false; } const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}; if (emailValue) update.rxresumeEmail = emailValue; if (passwordValue) update.rxresumePassword = passwordValue; if (Object.keys(update).length > 0) { setIsSavingEnv(true); await api.updateSettings(update); await refreshSettings(); setValue("rxresumePassword", ""); } toast.success("RxResume connected"); return true; } catch (error) { const message = error instanceof Error ? error.message : "Failed to save RxResume credentials"; toast.error(message); return false; } finally { setIsSavingEnv(false); } }; const handleSaveBaseResume = async (): Promise => { const values = getValues(); if (!values.rxresumeBaseResumeId) { toast.info("Select a base resume to continue"); return false; } try { setIsSavingEnv(true); await api.updateSettings({ rxresumeBaseResumeId: values.rxresumeBaseResumeId, }); const validation = await validateBaseResume(); if (!validation.valid) { toast.error(validation.message || "Base resume validation failed"); return false; } await refreshSettings(); toast.success("Base resume set"); return true; } catch (error) { const message = error instanceof Error ? error.message : "Failed to save base resume"; toast.error(message); return false; } finally { setIsSavingEnv(false); } }; 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 || settingsLoading || isValidatingLlm || isValidatingRxresume || isValidatingBaseResume; const canGoBack = stepIndex > 0; const primaryLabel = getStepPrimaryLabel({ currentStep, llmValidated, rxresumeValidated: rxresumeValidation.valid, baseResumeValidated: baseResumeValidation.valid, }); const handlePrimaryAction = async () => { if (!currentStep) return; if (currentStep === "llm") { await handleSaveLlm(); return; } if (currentStep === "rxresume") { await handleSaveRxresume(); return; } if (currentStep === "baseresume") { await handleSaveBaseResume(); return; } }; const handleBack = () => { if (!canGoBack) return; setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep); }; if (!shouldOpen || !currentStep) return null; return ( 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. {steps.map((step, index) => { const isActive = step.id === currentStep; const isComplete = step.complete; return ( [data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none", step.disabled && "opacity-50 cursor-not-allowed", )} > {step.label} {step.subtitle} {isComplete ? ( ) : ( index + 1 )} ); })}

Connect LLM provider

Used for job scoring, summaries, and tailoring.

( )} />

{providerConfig.providerHint}

{showBaseUrl && ( ( )} /> )} {showApiKey && ( ( )} /> )}

Link your RxResume account

Used to export tailored PDFs. Create an account{" "} here {" "} on RxResume v4 using email/password.

( )} /> ( )} />

Select your template resume

Choose the resume you want to use as a template. The selected resume will be used as a template for tailoring.

( )} />
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.{" "} Open an issue .
); };