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
ukvisajobsPassword?: string | null
webhookSecret?: string | null
rxresumeBaseResumeId?: string | null
}): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',

View File

@ -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<ValidationState>({
valid: false,
message: null,
@ -31,11 +33,17 @@ export const OnboardingGate: React.FC = () => {
message: null,
checked: false,
})
const [baseResumeValidation, setBaseResumeValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
})
const [currentStep, setCurrentStep] = useState<string | null>(null)
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
const [rxresumeEmail, setRxresumeEmail] = useState("")
const [rxresumePassword, setRxresumePassword] = useState("")
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<string | null>(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<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 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 = () => {
</AlertDialogHeader>
<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) => {
const isActive = step.id === currentStep
const isComplete = step.complete
@ -273,13 +346,17 @@ export const OnboardingGate: React.FC = () => {
return (
<FieldLabel
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
value={step.id}
disabled={step.disabled}
className={cn(
"w-full rounded-none border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive ? "border-primary bg-muted/60 text-foreground" : "text-muted-foreground"
"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"
)}
>
<Field orientation="horizontal" className="items-start">
@ -356,6 +433,21 @@ export const OnboardingGate: React.FC = () => {
</div>
</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>
<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 { 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<ReactiveResumeSectionProps> = ({
isSaving,
}) => {
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 (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
@ -90,49 +64,12 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
</AlertDescription>
</Alert>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Base Resume</div>
<Button
variant="ghost"
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>
<BaseResumeSelection
value={rxResumeBaseResumeIdDraft}
onValueChange={setRxResumeBaseResumeIdDraft}
hasRxResumeAccess={hasRxResumeAccess}
disabled={isLoading || isSaving}
/>
<Separator />