onboarding UI ensures that we have a resume base id when we're in the app

This commit is contained in:
DaKheera47 2026-01-23 11:59:02 +00:00
parent 9dfb862649
commit a268bfdd59
4 changed files with 218 additions and 81 deletions

View File

@ -247,6 +247,7 @@ export async function updateSettings(update: {
ukvisajobsEmail?: string | null ukvisajobsEmail?: string | null
ukvisajobsPassword?: string | null ukvisajobsPassword?: string | null
webhookSecret?: string | null webhookSecret?: string | null
rxresumeBaseResumeId?: string | null
}): Promise<AppSettings> { }): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', { return fetchApi<AppSettings>('/settings', {
method: 'PATCH', method: 'PATCH',

View File

@ -12,6 +12,7 @@ import * as api from "@client/api"
import { useSettings } from "@client/hooks/useSettings" import { useSettings } from "@client/hooks/useSettings"
import { SettingsInput } from "@client/pages/settings/components/SettingsInput" import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
import { formatSecretHint } from "@client/pages/settings/utils" import { formatSecretHint } from "@client/pages/settings/utils"
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection"
import type { ValidationResult } from "@shared/types" import type { ValidationResult } from "@shared/types"
type ValidationState = ValidationResult & { checked: boolean } type ValidationState = ValidationResult & { checked: boolean }
@ -21,6 +22,7 @@ export const OnboardingGate: React.FC = () => {
const [isSavingEnv, setIsSavingEnv] = useState(false) const [isSavingEnv, setIsSavingEnv] = useState(false)
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false) const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false) const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false)
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({ const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
valid: false, valid: false,
message: null, message: null,
@ -31,11 +33,17 @@ export const OnboardingGate: React.FC = () => {
message: null, message: null,
checked: false, checked: false,
}) })
const [baseResumeValidation, setBaseResumeValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
})
const [currentStep, setCurrentStep] = useState<string | null>(null) const [currentStep, setCurrentStep] = useState<string | null>(null)
const [openrouterApiKey, setOpenrouterApiKey] = useState("") const [openrouterApiKey, setOpenrouterApiKey] = useState("")
const [rxresumeEmail, setRxresumeEmail] = useState("") const [rxresumeEmail, setRxresumeEmail] = useState("")
const [rxresumePassword, setRxresumePassword] = useState("") const [rxresumePassword, setRxresumePassword] = useState("")
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<string | null>(null)
const validateOpenrouter = useCallback(async (apiKey?: string) => { const validateOpenrouter = useCallback(async (apiKey?: string) => {
setIsValidatingOpenrouter(true) 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 hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()) const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint) const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
const shouldOpen = Boolean(settings && !settingsLoading) const shouldOpen = Boolean(settings && !settingsLoading)
&& !(openrouterValidation.valid && rxresumeValidation.valid) && !(openrouterValidation.valid && rxresumeValidation.valid && baseResumeValidation.valid)
const openrouterCurrent = settings?.openrouterApiKeyHint const openrouterCurrent = settings?.openrouterApiKeyHint
? formatSecretHint(settings.openrouterApiKeyHint) ? formatSecretHint(settings.openrouterApiKeyHint)
@ -85,6 +109,12 @@ export const OnboardingGate: React.FC = () => {
? formatSecretHint(settings.rxresumePasswordHint) ? formatSecretHint(settings.rxresumePasswordHint)
: undefined : undefined
useEffect(() => {
if (settings) {
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null)
}
}, [settings])
const steps = useMemo( const steps = useMemo(
() => [ () => [
{ {
@ -92,15 +122,24 @@ export const OnboardingGate: React.FC = () => {
label: "Connect AI", label: "Connect AI",
subtitle: "OpenRouter key", subtitle: "OpenRouter key",
complete: openrouterValidation.valid, complete: openrouterValidation.valid,
disabled: false,
}, },
{ {
id: "rxresume", id: "rxresume",
label: "PDF Export", label: "Connect Reactive Resume",
subtitle: "RxResume login", subtitle: "Reactive Resume login",
complete: rxresumeValidation.valid, 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 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([ const results = await Promise.allSettled([
validateOpenrouter(), validateOpenrouter(),
validateRxresume(), validateRxresume(),
validateBaseResume(),
]) ])
const failed = results.find((result) => result.status === "rejected") const failed = results.find((result) => result.status === "rejected")
@ -219,17 +259,46 @@ export const OnboardingGate: React.FC = () => {
} }
} }
const handleSaveBaseResume = async (): Promise<boolean> => {
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 resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0 const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
const completedSteps = steps.filter((step) => step.complete).length const completedSteps = steps.filter((step) => step.complete).length
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0 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 canGoBack = stepIndex > 0
const primaryLabel = currentStep === "openrouter" const primaryLabel = currentStep === "openrouter"
? (openrouterValidation.valid ? "Revalidate" : "Validate") ? (openrouterValidation.valid ? "Revalidate" : "Validate")
: currentStep === "rxresume" : currentStep === "rxresume"
? (rxresumeValidation.valid ? "Revalidate" : "Validate") ? (rxresumeValidation.valid ? "Revalidate" : "Validate")
: "Validate" : currentStep === "baseresume"
? (baseResumeValidation.valid ? "Revalidate" : "Validate")
: "Validate"
const handlePrimaryAction = async () => { const handlePrimaryAction = async () => {
if (!currentStep) return if (!currentStep) return
@ -241,6 +310,10 @@ export const OnboardingGate: React.FC = () => {
await handleSaveRxresume() await handleSaveRxresume()
return return
} }
if (currentStep === "baseresume") {
await handleSaveBaseResume()
return
}
} }
const handleBack = () => { const handleBack = () => {
@ -265,7 +338,7 @@ export const OnboardingGate: React.FC = () => {
</AlertDialogHeader> </AlertDialogHeader>
<Tabs value={currentStep} onValueChange={setCurrentStep}> <Tabs value={currentStep} onValueChange={setCurrentStep}>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-2"> <TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
{steps.map((step, index) => { {steps.map((step, index) => {
const isActive = step.id === currentStep const isActive = step.id === currentStep
const isComplete = step.complete const isComplete = step.complete
@ -273,13 +346,17 @@ export const OnboardingGate: React.FC = () => {
return ( return (
<FieldLabel <FieldLabel
key={step.id} key={step.id}
className="w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none" className={cn(
"w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none",
step.disabled && "opacity-50 cursor-not-allowed"
)}
> >
<TabsTrigger <TabsTrigger
value={step.id} value={step.id}
disabled={step.disabled}
className={cn( className={cn(
"w-full rounded-none border-b-2 border-transparent px-3 py-4 text-left shadow-none", "w-full rounded-md hover:bg-muted/60 border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive ? "border-primary bg-muted/60 text-foreground" : "text-muted-foreground" isActive ? "border-primary !bg-muted/60 text-foreground" : "text-muted-foreground"
)} )}
> >
<Field orientation="horizontal" className="items-start"> <Field orientation="horizontal" className="items-start">
@ -356,6 +433,21 @@ export const OnboardingGate: React.FC = () => {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="baseresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Select your template resume</p>
<p className="text-xs text-muted-foreground">Choose the resume you want to use as a template.
The selected resume will be used as a template for tailoring.
</p>
</div>
<BaseResumeSelection
value={rxresumeBaseResumeId}
onValueChange={setRxresumeBaseResumeId}
hasRxResumeAccess={rxresumeValidation.valid}
disabled={isSavingEnv}
/>
</TabsContent>
</Tabs> </Tabs>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -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<BaseResumeSelectionProps> = ({
value,
onValueChange,
hasRxResumeAccess,
disabled = false,
isLoading = false,
}) => {
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([])
const [isFetchingResumes, setIsFetchingResumes] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Template Resume</div>
<Button
variant="ghost"
size="sm"
onClick={fetchResumes}
disabled={isFetchingResumes || isLoading || disabled}
className="h-8 px-2"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isFetchingResumes ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<Select
value={value || ""}
onValueChange={(val: string) => onValueChange(val || null)}
disabled={disabled || isLoading || isFetchingResumes}
>
<SelectTrigger>
<SelectValue placeholder={resumes.length > 0 ? "Select a template resume..." : "No resumes found"} />
</SelectTrigger>
<SelectContent>
{resumes.map((resume) => (
<SelectItem key={resume.id} value={resume.id}>
{resume.name}
</SelectItem>
))}
</SelectContent>
</Select>
{resumes.length === 0 && !isFetchingResumes && !fetchError && (
<div className="text-xs text-amber-600 dark:text-amber-400 mt-2">
No resumes found in your account. Please create a resume on the{" "}
<a
href="https://rxresu.me"
target="_blank"
rel="noreferrer"
className="font-semibold underline underline-offset-2"
>
Reactive Resume website
</a>{" "}
first.
</div>
)}
{fetchError && (
<div className="text-xs text-destructive mt-1">
{fetchError}
</div>
)}
</div>
)
}

View File

@ -1,19 +1,17 @@
import React, { useEffect, useState } from "react" import React, { useEffect, useState } from "react"
import { Controller, useFormContext } from "react-hook-form" 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 { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { clampInt } from "@/lib/utils" import { clampInt } from "@/lib/utils"
import type { ResumeProjectCatalogItem } from "@shared/types" import type { ResumeProjectCatalogItem } from "@shared/types"
import { UpdateSettingsInput } from "@shared/settings-schema" import { UpdateSettingsInput } from "@shared/settings-schema"
import * as api from "../../../api" import { BaseResumeSelection } from "./BaseResumeSelection"
type ReactiveResumeSectionProps = { type ReactiveResumeSectionProps = {
rxResumeBaseResumeIdDraft: string | null rxResumeBaseResumeIdDraft: string | null
@ -40,30 +38,6 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
isSaving, isSaving,
}) => { }) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>() const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([])
const [isFetchingResumes, setIsFetchingResumes] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(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 ( return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4"> <AccordionItem value="reactive-resume" className="border rounded-lg px-4">
@ -90,49 +64,12 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="space-y-2"> <BaseResumeSelection
<div className="flex items-center justify-between"> value={rxResumeBaseResumeIdDraft}
<div className="text-sm font-medium">Base Resume</div> onValueChange={setRxResumeBaseResumeIdDraft}
<Button hasRxResumeAccess={hasRxResumeAccess}
variant="ghost" disabled={isLoading || isSaving}
size="sm" />
onClick={fetchResumes}
disabled={isFetchingResumes || isLoading || isSaving}
className="h-8 px-2"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isFetchingResumes ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<Select
value={rxResumeBaseResumeIdDraft || "none"}
onValueChange={(value: string) => setRxResumeBaseResumeIdDraft(value === "none" ? null : value)}
disabled={isLoading || isSaving || isFetchingResumes}
>
<SelectTrigger>
<SelectValue placeholder="Select a base resume..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (No profile data will be loaded)</SelectItem>
{resumes.map((resume) => (
<SelectItem key={resume.id} value={resume.id}>
{resume.name}
</SelectItem>
))}
</SelectContent>
</Select>
{fetchError && (
<div className="text-xs text-destructive mt-1">
{fetchError}
</div>
)}
<div className="text-xs text-muted-foreground mt-2">
The selected resume will be used as a template for tailoring.
</div>
</div>
<Separator /> <Separator />