diff --git a/apps/web/src/features/applications/components/add-application-dialog.tsx b/apps/web/src/features/applications/components/add-application-dialog.tsx new file mode 100644 index 000000000..800162195 --- /dev/null +++ b/apps/web/src/features/applications/components/add-application-dialog.tsx @@ -0,0 +1,298 @@ +import type { RouterOutput } from "@/libs/orpc/client"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { useMutation } from "@tanstack/react-query"; +import { useEffect, useId, useState } from "react"; +import { Button } from "@reactive-resume/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@reactive-resume/ui/components/dialog"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { Input } from "@reactive-resume/ui/components/input"; +import { Label } from "@reactive-resume/ui/components/label"; +import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control"; +import { Textarea } from "@reactive-resume/ui/components/textarea"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { cn } from "@reactive-resume/utils/style"; +import { useDialogStore } from "@/dialogs/store"; +import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider"; +import { getOrpcErrorMessage } from "@/libs/error-message"; +import { orpc } from "@/libs/orpc/client"; +import { getStageLabel } from "../stages"; +import { useInvalidateApplications } from "../use-application-actions"; + +type Parsed = RouterOutput["applications"]["ai"]["parsePosting"]; +type Stage = "saved" | "applied" | "interview"; + +const STAGES: readonly Stage[] = ["saved", "applied", "interview"]; +const MAX_POSTING_CHARS = 20_000; +// Reading waits for a pause in typing, so it starts once the paste has landed. +const READ_DELAY_MS = 600; + +const isLink = (value: string) => /^https?:\/\/\S+$/i.test(value.trim()); + +type AddApplicationDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdded: (id: string) => void; +}; + +/** + * Add an application from a pasted link or posting. The posting is read (the page's own job data, or the AI + * provider when one is set up) into role and company, which stay editable; the posting itself is saved with the + * application for Check, the assistant and letters. Add, or Add and tailor a resume. + */ +export function AddApplicationDialog({ open, onOpenChange, onAdded }: AddApplicationDialogProps) { + // A fresh form for every open, kept in place while the dialog animates closed. + const [instance, setInstance] = useState(0); + const [wasOpen, setWasOpen] = useState(open); + if (open !== wasOpen) { + setWasOpen(open); + if (open) setInstance((count) => count + 1); + } + + return ( + + + onOpenChange(false)} onAdded={onAdded} /> + + + ); +} + +function AddApplicationForm({ onClose, onAdded }: { onClose: () => void; onAdded: (id: string) => void }) { + const id = useId(); + const [input, setInput] = useState(""); + const [role, setRole] = useState(""); + const [company, setCompany] = useState(""); + const [stage, setStage] = useState("applied"); + const [parsed, setParsed] = useState(null); + const { hasUsableProvider } = useHasUsableAiProvider(); + const invalidate = useInvalidateApplications(); + const openDialog = useDialogStore((state) => state.openDialog); + + const read = useMutation(orpc.applications.ai.parsePosting.mutationOptions()); + const { mutate: readPosting, reset: resetRead } = read; + const create = useMutation(orpc.applications.create.mutationOptions()); + + const text = input.trim(); + const link = isLink(text); + // A link can always be read (for the page's own job data); pasted text needs the AI provider. + const readable = text.length > 8 && (link || hasUsableProvider); + + useEffect(() => { + setParsed(null); + resetRead(); + if (!readable) return; + + const timeout = window.setTimeout(() => { + readPosting( + { input: text }, + { + onSuccess: (result) => { + setParsed(result); + setRole((current) => current || result.role); + setCompany((current) => current || result.company); + }, + }, + ); + }, READ_DELAY_MS); + return () => window.clearTimeout(timeout); + }, [text, readable, readPosting, resetRead]); + + const ready = role.trim().length > 0 && company.trim().length > 0 && !read.isPending && !create.isPending; + + const add = async (tailor: boolean) => { + if (!ready) return; + try { + const applicationId = await create.mutateAsync({ + company: company.trim(), + role: role.trim(), + status: stage, + ...(parsed?.location ? { location: parsed.location } : {}), + ...(parsed?.salary ? { salary: parsed.salary } : {}), + ...(parsed?.requirements.length ? { requirements: parsed.requirements } : {}), + ...(link ? { sourceUrl: text } : {}), + // The posting's text: read from the page for a link, or what was pasted. + ...(parsed?.jobDescription + ? { jobDescription: parsed.jobDescription } + : !link && text + ? { jobDescription: text.slice(0, MAX_POSTING_CHARS) } + : {}), + }); + invalidate(); + toast.add({ description: t`Added ${role.trim()} at ${company.trim()}` }); + onClose(); + onAdded(applicationId); + if (tailor) openDialog("document.new", { step: "copy", applicationId }); + } catch (error) { + toast.add({ + type: "error", + description: getOrpcErrorMessage(error, { fallback: t`Couldn't add the application.` }), + }); + } + }; + + return ( +
{ + event.preventDefault(); + void add(true); + }} + > + + + Add an application + + + Paste a job link or posting, then check the role and company. + + + +
+ +