onboarding UI ensures that we have a resume base id when we're in the app
This commit is contained in:
parent
9dfb862649
commit
a268bfdd59
@ -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',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user