From a268bfdd59f0b97498b351c8ad303c433b5fd6d2 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 11:59:02 +0000 Subject: [PATCH] onboarding UI ensures that we have a resume base id when we're in the app --- orchestrator/src/client/api/client.ts | 1 + .../src/client/components/OnboardingGate.tsx | 112 ++++++++++++++++-- .../components/BaseResumeSelection.tsx | 107 +++++++++++++++++ .../components/ReactiveResumeSection.tsx | 79 ++---------- 4 files changed, 218 insertions(+), 81 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index c8300b2..182e772 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -247,6 +247,7 @@ export async function updateSettings(update: { ukvisajobsEmail?: string | null ukvisajobsPassword?: string | null webhookSecret?: string | null + rxresumeBaseResumeId?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 46e0474..8d5e740 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -12,6 +12,7 @@ import * as api from "@client/api" import { useSettings } from "@client/hooks/useSettings" import { SettingsInput } from "@client/pages/settings/components/SettingsInput" import { formatSecretHint } from "@client/pages/settings/utils" +import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection" import type { ValidationResult } from "@shared/types" type ValidationState = ValidationResult & { checked: boolean } @@ -21,6 +22,7 @@ export const OnboardingGate: React.FC = () => { const [isSavingEnv, setIsSavingEnv] = useState(false) const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false) const [isValidatingRxresume, setIsValidatingRxresume] = useState(false) + const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false) const [openrouterValidation, setOpenrouterValidation] = useState({ valid: false, message: null, @@ -31,11 +33,17 @@ export const OnboardingGate: React.FC = () => { message: null, checked: false, }) + const [baseResumeValidation, setBaseResumeValidation] = useState({ + valid: false, + message: null, + checked: false, + }) const [currentStep, setCurrentStep] = useState(null) const [openrouterApiKey, setOpenrouterApiKey] = useState("") const [rxresumeEmail, setRxresumeEmail] = useState("") const [rxresumePassword, setRxresumePassword] = useState("") + const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState(null) const validateOpenrouter = useCallback(async (apiKey?: string) => { setIsValidatingOpenrouter(true) @@ -69,11 +77,27 @@ export const OnboardingGate: React.FC = () => { } }, []) + 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 hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint) const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()) const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint) const shouldOpen = Boolean(settings && !settingsLoading) - && !(openrouterValidation.valid && rxresumeValidation.valid) + && !(openrouterValidation.valid && rxresumeValidation.valid && baseResumeValidation.valid) const openrouterCurrent = settings?.openrouterApiKeyHint ? formatSecretHint(settings.openrouterApiKeyHint) @@ -85,6 +109,12 @@ export const OnboardingGate: React.FC = () => { ? formatSecretHint(settings.rxresumePasswordHint) : undefined + useEffect(() => { + if (settings) { + setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null) + } + }, [settings]) + const steps = useMemo( () => [ { @@ -92,15 +122,24 @@ export const OnboardingGate: React.FC = () => { label: "Connect AI", subtitle: "OpenRouter key", complete: openrouterValidation.valid, + disabled: false, }, { id: "rxresume", - label: "PDF Export", - subtitle: "RxResume login", + 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, }, ], - [openrouterValidation.valid, rxresumeValidation.valid] + [openrouterValidation.valid, rxresumeValidation.valid, baseResumeValidation.valid] ) const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id @@ -117,6 +156,7 @@ export const OnboardingGate: React.FC = () => { const results = await Promise.allSettled([ validateOpenrouter(), validateRxresume(), + validateBaseResume(), ]) const failed = results.find((result) => result.status === "rejected") @@ -219,17 +259,46 @@ export const OnboardingGate: React.FC = () => { } } + const handleSaveBaseResume = async (): Promise => { + if (!rxresumeBaseResumeId) { + toast.info("Select a base resume to continue") + return false + } + + try { + setIsSavingEnv(true) + await api.updateSettings({ rxresumeBaseResumeId: 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 || isValidatingOpenrouter || isValidatingRxresume + const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingBaseResume const canGoBack = stepIndex > 0 const primaryLabel = currentStep === "openrouter" ? (openrouterValidation.valid ? "Revalidate" : "Validate") : currentStep === "rxresume" ? (rxresumeValidation.valid ? "Revalidate" : "Validate") - : "Validate" + : currentStep === "baseresume" + ? (baseResumeValidation.valid ? "Revalidate" : "Validate") + : "Validate" const handlePrimaryAction = async () => { if (!currentStep) return @@ -241,6 +310,10 @@ export const OnboardingGate: React.FC = () => { await handleSaveRxresume() return } + if (currentStep === "baseresume") { + await handleSaveBaseResume() + return + } } const handleBack = () => { @@ -265,7 +338,7 @@ export const OnboardingGate: React.FC = () => { - + {steps.map((step, index) => { const isActive = step.id === currentStep const isComplete = step.complete @@ -273,13 +346,17 @@ export const OnboardingGate: React.FC = () => { return ( [data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none", + step.disabled && "opacity-50 cursor-not-allowed" + )} > @@ -356,6 +433,21 @@ export const OnboardingGate: React.FC = () => { + +
+

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. +

+
+ +
+
diff --git a/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx b/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx new file mode 100644 index 0000000..95c6b8b --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from "react" +import { RefreshCw } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import * as api from "@client/api" + +type BaseResumeSelectionProps = { + value: string | null + onValueChange: (value: string | null) => void + hasRxResumeAccess: boolean + disabled?: boolean + isLoading?: boolean +} + +export const BaseResumeSelection: React.FC = ({ + value, + onValueChange, + hasRxResumeAccess, + disabled = false, + isLoading = false, +}) => { + const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]) + const [isFetchingResumes, setIsFetchingResumes] = useState(false) + const [fetchError, setFetchError] = useState(null) + + const fetchResumes = async () => { + if (!hasRxResumeAccess) return + + setIsFetchingResumes(true) + setFetchError(null) + try { + const data = await api.getRxResumes() + setResumes(data) + + // Preselect if only one option is available and no value is currently set + if (data.length === 1 && !value) { + onValueChange(data[0].id) + } + } catch (error) { + setFetchError(error instanceof Error ? error.message : "Failed to fetch resumes") + } finally { + setIsFetchingResumes(false) + } + } + + useEffect(() => { + if (hasRxResumeAccess) { + fetchResumes() + } + }, [hasRxResumeAccess]) + + return ( +
+
+
Template Resume
+ +
+ + + + {resumes.length === 0 && !isFetchingResumes && !fetchError && ( +
+ No resumes found in your account. Please create a resume on the{" "} + + Reactive Resume website + {" "} + first. +
+ )} + + {fetchError && ( +
+ {fetchError} +
+ )} +
+ ) +} diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index 68ff0c8..eded825 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -1,19 +1,17 @@ import React, { useEffect, useState } from "react" import { Controller, useFormContext } from "react-hook-form" -import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react" +import { AlertCircle, CheckCircle2 } from "lucide-react" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" -import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Separator } from "@/components/ui/separator" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { clampInt } from "@/lib/utils" import type { ResumeProjectCatalogItem } from "@shared/types" import { UpdateSettingsInput } from "@shared/settings-schema" -import * as api from "../../../api" +import { BaseResumeSelection } from "./BaseResumeSelection" type ReactiveResumeSectionProps = { rxResumeBaseResumeIdDraft: string | null @@ -40,30 +38,6 @@ export const ReactiveResumeSection: React.FC = ({ isSaving, }) => { const { control, formState: { errors } } = useFormContext() - const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]) - const [isFetchingResumes, setIsFetchingResumes] = useState(false) - const [fetchError, setFetchError] = useState(null) - - const fetchResumes = async () => { - if (!hasRxResumeAccess) return - - setIsFetchingResumes(true) - setFetchError(null) - try { - const data = await api.getRxResumes() - setResumes(data) - } catch (error) { - setFetchError(error instanceof Error ? error.message : "Failed to fetch resumes") - } finally { - setIsFetchingResumes(false) - } - } - - useEffect(() => { - if (hasRxResumeAccess) { - fetchResumes() - } - }, [hasRxResumeAccess]) return ( @@ -90,49 +64,12 @@ export const ReactiveResumeSection: React.FC = ({ -
-
-
Base Resume
- -
- - - - {fetchError && ( -
- {fetchError} -
- )} - -
- The selected resume will be used as a template for tailoring. -
-
+