mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 14:57:00 +10:00
feat(jobs): add job listings with AI-powered resume tailoring (#2788)
* feat: add job listings feature with JSearch API integration, resume tailoring, and per-user rate limiting * feat(jobs): add search filters UI, filter helper functions with tests, and job_search_quota DB migration * feat(jobs): add pagination with 30 results per page and prev/next navigation * refactor(job-detail): Adjust sheet width and scroll area height * feat(ai): Add resume tailoring feature and prompt * refactor(ai): Revise tailoring prompts and schema for full skill rewrite * feat(ai): Add reference tailoring and output sanitization * feat(testing): Add Vitest testing framework * fix: address PR review - atomic rate limiting, calendar-month quota, skill sync warning, gitignore routeTree.gen.ts * feat(jobs): Add location filter to job listings * feat(job-listings): Add DOCX document generation * feat(job-listings): Enable search by location and on Enter key * feat(job-listings): Split location filter into city, state, and country * feat(jobs): Implement job search adapter and JSearch * Update 'locale/' directory * feat(resume): Simplify filename generation and add tests * fix(JSearch): reduce JSearch API usage to 1 request per search to prevent quota exhaustion * fix(JSearch): Displayed quota amounts on Job Search functionality and settings fixed to pull from RapidAPI/JSearch response * fix(internal rate limit): Removed internal rate limit and .env.example addition, cloud based implementation handles. * style(job-filters): Adjust layout of switch filters * fix(typecheck): Fixed typecheck issues introduced to sync with origin * feat(jobs): Enhance tailor dialog with apply link and tags * feat(locale files): updated locale files with the latest build * feat(jobs): Add job search provider and integrate testing functionality - Introduced `createJobSearchProvider` function to instantiate a JSearchProvider. - Enhanced job search provider with methods for searching jobs, retrieving job details, and testing connection. - Updated `vite.config.ts` to include new testing configurations and plugins. - Added new dependencies in `package.json` for testing and document generation. - Removed obsolete `vitest.config.ts` file. - Improved job search provider tests for better coverage and reliability. * refactor: Update job search routes and remove obsolete test configurations - Removed the test configuration from `vite.config.ts`. - Updated localization files to reflect changes in job search routes, renaming references from `jobs` to `job-search` across multiple languages. - Adjusted autofix workflow to run formatting without the `--fix` flag for better control over code style adjustments. * chore: Update dependencies and improve animation performance - Added `jsdom` as a new dependency in `package.json`. - Updated `vite-plus` and `vitest` to the latest versions for better compatibility. - Enhanced animation components with `willChange` styles to optimize rendering performance. - Adjusted various UI components to improve responsiveness and visual effects. - Removed obsolete job details functionality from the job search provider and related tests. * chore(locales): Update localization files for job search improvements - Modified job search related strings to remove references to "this month" for a more concise format. - Updated file references in localization entries to reflect changes in the job search component structure. - Added new strings for API usage, quota remaining, and job fetching error messages across multiple languages. - Removed obsolete "Monthly Usage" string from localization files. * chore(dependencies): Update @typescript/native-preview to version 7.0.0-dev.20260319.1 --------- Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
+34
-22
@@ -13,6 +13,7 @@ import {
|
||||
StopIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -343,32 +344,43 @@ export function AIChat() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
data-role={message.role}
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-xl px-3.5 py-2.5",
|
||||
"data-[role=user]:rounded-br-sm data-[role=user]:bg-primary data-[role=user]:text-primary-foreground",
|
||||
"data-[role=assistant]:rounded-bl-sm data-[role=assistant]:bg-muted data-[role=assistant]:text-foreground",
|
||||
)}
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
layout
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
{message.parts.map((part, i) =>
|
||||
part.type === "text" ? <span key={i}>{part.text}</span> : null,
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<MessageParts message={message} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
data-role={message.role}
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-md px-3.5 py-2.5",
|
||||
"data-[role=user]:rounded-br-sm data-[role=user]:bg-primary data-[role=user]:text-primary-foreground",
|
||||
"data-[role=assistant]:rounded-bl-sm data-[role=assistant]:bg-muted data-[role=assistant]:text-foreground",
|
||||
)}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
{message.parts.map((part, i) =>
|
||||
part.type === "text" ? <span key={i}>{part.text}</span> : null,
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<MessageParts message={message} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{status === "submitted" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl rounded-bl-sm bg-muted px-3.5 py-2.5">
|
||||
<div className="rounded-md rounded-bl-sm bg-muted px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<CircleNotchIcon className="size-3 animate-spin" />
|
||||
<span>
|
||||
|
||||
@@ -70,7 +70,13 @@ export const CometCard = ({
|
||||
ref={ref}
|
||||
initial={{ scale: 1, z: 0 }}
|
||||
className="relative rounded-md"
|
||||
style={{ rotateX, rotateY, translateX, translateY }}
|
||||
style={{
|
||||
rotateX: rotateX,
|
||||
rotateY: rotateY,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
willChange: "transform",
|
||||
}}
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
@@ -79,7 +85,11 @@ export const CometCard = ({
|
||||
|
||||
<motion.div
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
style={{
|
||||
background: glareBackground,
|
||||
opacity: glareOpacity,
|
||||
willChange: "opacity",
|
||||
}}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -86,7 +86,6 @@ export function CountUp({
|
||||
if (typeof onStart === "function") {
|
||||
onStart();
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? from : to);
|
||||
}, delay * 1000);
|
||||
@@ -105,7 +104,7 @@ export function CountUp({
|
||||
clearTimeout(durationTimeoutId);
|
||||
};
|
||||
}
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration, formatValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on("change", (latest: number) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export const Spotlight = ({
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw"
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0"
|
||||
@@ -75,6 +76,7 @@ export const Spotlight = ({
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0"
|
||||
|
||||
@@ -39,7 +39,9 @@ export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": a
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={(e) => setCursor({ x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={(e) => {
|
||||
setCursor({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="textGradient" gradientUnits="userSpaceOnUse" cx="50%" cy="50%" r="25%">
|
||||
|
||||
@@ -71,9 +71,10 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
<span className="max-w-32 truncate">{chip}</span>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: isHovered ? 40 : 0, marginInlineStart: isHovered ? 8 : 0, opacity: isHovered ? 1 : 0 }}
|
||||
transition={{ duration: 0.15, ease: "linear" }}
|
||||
className="flex shrink-0 items-center gap-x-1 overflow-hidden"
|
||||
animate={isHovered ? { opacity: 1, scaleX: 1, x: 0 } : { opacity: 0, scaleX: 0.95, x: -3 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
className="ms-2 flex w-10 shrink-0 origin-left items-center gap-x-1 overflow-hidden"
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -83,7 +84,7 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
e.stopPropagation();
|
||||
onEdit(index);
|
||||
}}
|
||||
className="rounded-sm p-0.5 hover:bg-secondary hover:text-foreground focus:outline-none"
|
||||
className="rounded-md p-0.5 hover:bg-secondary hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<PencilSimpleIcon className="size-3.5" />
|
||||
</button>
|
||||
@@ -95,7 +96,7 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
e.stopPropagation();
|
||||
onRemove(index);
|
||||
}}
|
||||
className="rounded-sm p-0.5 hover:bg-destructive/10 hover:text-destructive focus:outline-none"
|
||||
className="rounded-md p-0.5 hover:bg-destructive/10 hover:text-destructive focus:outline-none"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
@@ -109,9 +110,10 @@ type Props = Omit<React.ComponentProps<"div">, "value" | "onChange"> & {
|
||||
value?: string[];
|
||||
defaultValue?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
hideDescription?: boolean;
|
||||
};
|
||||
|
||||
export function ChipInput({ value, defaultValue = [], onChange, className, ...props }: Props) {
|
||||
export function ChipInput({ value, defaultValue = [], onChange, className, hideDescription = false, ...props }: Props) {
|
||||
const [chips, setChips] = useControlledState<string[]>({
|
||||
value,
|
||||
defaultValue,
|
||||
@@ -293,11 +295,14 @@ export function ChipInput({ value, defaultValue = [], onChange, className, ...pr
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Press <Kbd>{RETURN_KEY}</Kbd> or <Kbd>{COMMA_KEY}</Kbd> to add or save the current keyword.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{!hideDescription && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Press <Kbd>{RETURN_KEY}</Kbd> or <Kbd>{COMMA_KEY}</Kbd> to add or save the current keyword.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps)
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="max-w-fit rounded-xl p-2">
|
||||
<PopoverContent className="max-w-fit rounded-md p-2">
|
||||
<ReactColorColorful color={color} onChange={onColorChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -8,9 +8,7 @@ function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item data-slot="accordion-item" className={cn("not-last:border-b", className)} {...props} />
|
||||
);
|
||||
return <AccordionPrimitive.Item data-slot="accordion-item" className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
|
||||
|
||||
@@ -22,7 +22,7 @@ function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdr
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:fade-out-0 motion-reduce:data-closed:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -44,7 +44,7 @@ function AlertDialogContent({
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-2xl data-[size=sm]:max-w-sm data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-md bg-background p-6 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-2xl data-[size=sm]:max-w-sm motion-reduce:duration-0 data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-1 rounded-lg border px-4 py-3 text-start text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pe-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-1 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
"group/alert relative grid w-full gap-1 rounded-md border px-4 py-3 text-start text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pe-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-1 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -12,7 +12,7 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
"flex size-full flex-col overflow-hidden rounded-md! bg-popover p-1 text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -41,7 +41,7 @@ function CommandDialog({
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
|
||||
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-md! p-0", className)}
|
||||
showClose={showClose}
|
||||
>
|
||||
{children}
|
||||
@@ -53,7 +53,7 @@ function CommandDialog({
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<InputGroup className="h-8! rounded-md! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
@@ -115,7 +115,7 @@ function CommandItem({ className, children, ...props }: React.ComponentProps<typ
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:**:[svg]:text-foreground",
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-md! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:**:[svg]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -90,7 +90,7 @@ function ContextMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -115,7 +115,7 @@ function ContextMenuSubTrigger({
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex cursor-default items-center rounded-md px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -144,7 +144,7 @@ function ContextMenuCheckboxItem({
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -177,7 +177,7 @@ function ContextMenuRadioItem({
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -28,7 +28,7 @@ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props)
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:fade-out-0 motion-reduce:data-closed:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -50,7 +50,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-2xl data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-md bg-background p-6 text-sm ring-1 ring-foreground/10 duration-100 outline-none motion-reduce:duration-0 sm:max-w-2xl data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -37,7 +37,7 @@ function DropdownMenuContent({
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -83,7 +83,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden transition-colors duration-100 select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive motion-reduce:duration-0 dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -108,7 +108,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -131,7 +131,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"w-auto min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"w-auto min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
|
||||
className,
|
||||
)}
|
||||
align={align}
|
||||
@@ -157,7 +157,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden transition-colors duration-100 select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 motion-reduce:duration-0 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -193,7 +193,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden transition-colors duration-100 select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 motion-reduce:duration-0 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -31,7 +31,7 @@ function HoverCardContent({
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"z-50 w-64 origin-(--transform-origin) rounded-md bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-md bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -33,7 +33,7 @@ function PopoverContent({
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
|
||||
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Progress({ className, children, value, ...props }: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn("relative flex h-1.5 w-full items-center overflow-x-hidden rounded-full bg-muted", className)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label className={cn("text-sm font-medium", className)} data-slot="progress-label" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn("ml-auto text-sm text-muted-foreground tabular-nums", className)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress, ProgressTrack, ProgressIndicator, ProgressLabel, ProgressValue };
|
||||
@@ -17,7 +17,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_WIDTH_ICON = "4rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
@@ -222,7 +222,7 @@ function Sidebar({
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-md group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -283,7 +283,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-md md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -329,7 +329,7 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
className={cn("w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-md p-[3px] text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -36,7 +36,7 @@ function TooltipContent({
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-md data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 motion-reduce:duration-0 motion-reduce:data-[state=delayed-open]:animate-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
type ISOCountry = {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const ISO_COUNTRIES: readonly ISOCountry[] = [
|
||||
{ code: "AF", name: "Afghanistan" },
|
||||
{ code: "AX", name: "Aland Islands" },
|
||||
{ code: "AL", name: "Albania" },
|
||||
{ code: "DZ", name: "Algeria" },
|
||||
{ code: "AS", name: "American Samoa" },
|
||||
{ code: "AD", name: "Andorra" },
|
||||
{ code: "AO", name: "Angola" },
|
||||
{ code: "AI", name: "Anguilla" },
|
||||
{ code: "AQ", name: "Antarctica" },
|
||||
{ code: "AG", name: "Antigua and Barbuda" },
|
||||
{ code: "AR", name: "Argentina" },
|
||||
{ code: "AM", name: "Armenia" },
|
||||
{ code: "AW", name: "Aruba" },
|
||||
{ code: "AU", name: "Australia" },
|
||||
{ code: "AT", name: "Austria" },
|
||||
{ code: "AZ", name: "Azerbaijan" },
|
||||
{ code: "BS", name: "Bahamas" },
|
||||
{ code: "BH", name: "Bahrain" },
|
||||
{ code: "BD", name: "Bangladesh" },
|
||||
{ code: "BB", name: "Barbados" },
|
||||
{ code: "BY", name: "Belarus" },
|
||||
{ code: "BE", name: "Belgium" },
|
||||
{ code: "BZ", name: "Belize" },
|
||||
{ code: "BJ", name: "Benin" },
|
||||
{ code: "BM", name: "Bermuda" },
|
||||
{ code: "BT", name: "Bhutan" },
|
||||
{ code: "BO", name: "Bolivia" },
|
||||
{ code: "BQ", name: "Bonaire, Sint Eustatius and Saba" },
|
||||
{ code: "BA", name: "Bosnia and Herzegovina" },
|
||||
{ code: "BW", name: "Botswana" },
|
||||
{ code: "BV", name: "Bouvet Island" },
|
||||
{ code: "BR", name: "Brazil" },
|
||||
{ code: "IO", name: "British Indian Ocean Territory" },
|
||||
{ code: "BN", name: "Brunei Darussalam" },
|
||||
{ code: "BG", name: "Bulgaria" },
|
||||
{ code: "BF", name: "Burkina Faso" },
|
||||
{ code: "BI", name: "Burundi" },
|
||||
{ code: "CV", name: "Cabo Verde" },
|
||||
{ code: "KH", name: "Cambodia" },
|
||||
{ code: "CM", name: "Cameroon" },
|
||||
{ code: "CA", name: "Canada" },
|
||||
{ code: "KY", name: "Cayman Islands" },
|
||||
{ code: "CF", name: "Central African Republic" },
|
||||
{ code: "TD", name: "Chad" },
|
||||
{ code: "CL", name: "Chile" },
|
||||
{ code: "CN", name: "China" },
|
||||
{ code: "CX", name: "Christmas Island" },
|
||||
{ code: "CC", name: "Cocos (Keeling) Islands" },
|
||||
{ code: "CO", name: "Colombia" },
|
||||
{ code: "KM", name: "Comoros" },
|
||||
{ code: "CD", name: "Democratic Republic of the Congo" },
|
||||
{ code: "CG", name: "Congo" },
|
||||
{ code: "CK", name: "Cook Islands" },
|
||||
{ code: "CR", name: "Costa Rica" },
|
||||
{ code: "CI", name: "Cote d'Ivoire" },
|
||||
{ code: "HR", name: "Croatia" },
|
||||
{ code: "CU", name: "Cuba" },
|
||||
{ code: "CW", name: "Curacao" },
|
||||
{ code: "CY", name: "Cyprus" },
|
||||
{ code: "CZ", name: "Czechia" },
|
||||
{ code: "DK", name: "Denmark" },
|
||||
{ code: "DJ", name: "Djibouti" },
|
||||
{ code: "DM", name: "Dominica" },
|
||||
{ code: "DO", name: "Dominican Republic" },
|
||||
{ code: "EC", name: "Ecuador" },
|
||||
{ code: "EG", name: "Egypt" },
|
||||
{ code: "SV", name: "El Salvador" },
|
||||
{ code: "GQ", name: "Equatorial Guinea" },
|
||||
{ code: "ER", name: "Eritrea" },
|
||||
{ code: "EE", name: "Estonia" },
|
||||
{ code: "SZ", name: "Eswatini" },
|
||||
{ code: "ET", name: "Ethiopia" },
|
||||
{ code: "FK", name: "Falkland Islands (Malvinas)" },
|
||||
{ code: "FO", name: "Faroe Islands" },
|
||||
{ code: "FJ", name: "Fiji" },
|
||||
{ code: "FI", name: "Finland" },
|
||||
{ code: "FR", name: "France" },
|
||||
{ code: "GF", name: "French Guiana" },
|
||||
{ code: "PF", name: "French Polynesia" },
|
||||
{ code: "TF", name: "French Southern Territories" },
|
||||
{ code: "GA", name: "Gabon" },
|
||||
{ code: "GM", name: "Gambia" },
|
||||
{ code: "GE", name: "Georgia" },
|
||||
{ code: "DE", name: "Germany" },
|
||||
{ code: "GH", name: "Ghana" },
|
||||
{ code: "GI", name: "Gibraltar" },
|
||||
{ code: "GR", name: "Greece" },
|
||||
{ code: "GL", name: "Greenland" },
|
||||
{ code: "GD", name: "Grenada" },
|
||||
{ code: "GP", name: "Guadeloupe" },
|
||||
{ code: "GU", name: "Guam" },
|
||||
{ code: "GT", name: "Guatemala" },
|
||||
{ code: "GG", name: "Guernsey" },
|
||||
{ code: "GN", name: "Guinea" },
|
||||
{ code: "GW", name: "Guinea-Bissau" },
|
||||
{ code: "GY", name: "Guyana" },
|
||||
{ code: "HT", name: "Haiti" },
|
||||
{ code: "HM", name: "Heard Island and McDonald Islands" },
|
||||
{ code: "VA", name: "Holy See" },
|
||||
{ code: "HN", name: "Honduras" },
|
||||
{ code: "HK", name: "Hong Kong" },
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: "IS", name: "Iceland" },
|
||||
{ code: "IN", name: "India" },
|
||||
{ code: "ID", name: "Indonesia" },
|
||||
{ code: "IR", name: "Iran" },
|
||||
{ code: "IQ", name: "Iraq" },
|
||||
{ code: "IE", name: "Ireland" },
|
||||
{ code: "IM", name: "Isle of Man" },
|
||||
{ code: "IL", name: "Israel" },
|
||||
{ code: "IT", name: "Italy" },
|
||||
{ code: "JM", name: "Jamaica" },
|
||||
{ code: "JP", name: "Japan" },
|
||||
{ code: "JE", name: "Jersey" },
|
||||
{ code: "JO", name: "Jordan" },
|
||||
{ code: "KZ", name: "Kazakhstan" },
|
||||
{ code: "KE", name: "Kenya" },
|
||||
{ code: "KI", name: "Kiribati" },
|
||||
{ code: "KP", name: "North Korea" },
|
||||
{ code: "KR", name: "South Korea" },
|
||||
{ code: "KW", name: "Kuwait" },
|
||||
{ code: "KG", name: "Kyrgyzstan" },
|
||||
{ code: "LA", name: "Laos" },
|
||||
{ code: "LV", name: "Latvia" },
|
||||
{ code: "LB", name: "Lebanon" },
|
||||
{ code: "LS", name: "Lesotho" },
|
||||
{ code: "LR", name: "Liberia" },
|
||||
{ code: "LY", name: "Libya" },
|
||||
{ code: "LI", name: "Liechtenstein" },
|
||||
{ code: "LT", name: "Lithuania" },
|
||||
{ code: "LU", name: "Luxembourg" },
|
||||
{ code: "MO", name: "Macao" },
|
||||
{ code: "MK", name: "North Macedonia" },
|
||||
{ code: "MG", name: "Madagascar" },
|
||||
{ code: "MW", name: "Malawi" },
|
||||
{ code: "MY", name: "Malaysia" },
|
||||
{ code: "MV", name: "Maldives" },
|
||||
{ code: "ML", name: "Mali" },
|
||||
{ code: "MT", name: "Malta" },
|
||||
{ code: "MH", name: "Marshall Islands" },
|
||||
{ code: "MQ", name: "Martinique" },
|
||||
{ code: "MR", name: "Mauritania" },
|
||||
{ code: "MU", name: "Mauritius" },
|
||||
{ code: "YT", name: "Mayotte" },
|
||||
{ code: "MX", name: "Mexico" },
|
||||
{ code: "FM", name: "Micronesia" },
|
||||
{ code: "MD", name: "Moldova" },
|
||||
{ code: "MC", name: "Monaco" },
|
||||
{ code: "MN", name: "Mongolia" },
|
||||
{ code: "ME", name: "Montenegro" },
|
||||
{ code: "MS", name: "Montserrat" },
|
||||
{ code: "MA", name: "Morocco" },
|
||||
{ code: "MZ", name: "Mozambique" },
|
||||
{ code: "MM", name: "Myanmar" },
|
||||
{ code: "NA", name: "Namibia" },
|
||||
{ code: "NR", name: "Nauru" },
|
||||
{ code: "NP", name: "Nepal" },
|
||||
{ code: "NL", name: "Netherlands" },
|
||||
{ code: "NC", name: "New Caledonia" },
|
||||
{ code: "NZ", name: "New Zealand" },
|
||||
{ code: "NI", name: "Nicaragua" },
|
||||
{ code: "NE", name: "Niger" },
|
||||
{ code: "NG", name: "Nigeria" },
|
||||
{ code: "NU", name: "Niue" },
|
||||
{ code: "NF", name: "Norfolk Island" },
|
||||
{ code: "MP", name: "Northern Mariana Islands" },
|
||||
{ code: "NO", name: "Norway" },
|
||||
{ code: "OM", name: "Oman" },
|
||||
{ code: "PK", name: "Pakistan" },
|
||||
{ code: "PW", name: "Palau" },
|
||||
{ code: "PS", name: "Palestine" },
|
||||
{ code: "PA", name: "Panama" },
|
||||
{ code: "PG", name: "Papua New Guinea" },
|
||||
{ code: "PY", name: "Paraguay" },
|
||||
{ code: "PE", name: "Peru" },
|
||||
{ code: "PH", name: "Philippines" },
|
||||
{ code: "PN", name: "Pitcairn" },
|
||||
{ code: "PL", name: "Poland" },
|
||||
{ code: "PT", name: "Portugal" },
|
||||
{ code: "PR", name: "Puerto Rico" },
|
||||
{ code: "QA", name: "Qatar" },
|
||||
{ code: "RE", name: "Reunion" },
|
||||
{ code: "RO", name: "Romania" },
|
||||
{ code: "RU", name: "Russian Federation" },
|
||||
{ code: "RW", name: "Rwanda" },
|
||||
{ code: "BL", name: "Saint Barthelemy" },
|
||||
{ code: "SH", name: "Saint Helena, Ascension and Tristan da Cunha" },
|
||||
{ code: "KN", name: "Saint Kitts and Nevis" },
|
||||
{ code: "LC", name: "Saint Lucia" },
|
||||
{ code: "MF", name: "Saint Martin (French)" },
|
||||
{ code: "PM", name: "Saint Pierre and Miquelon" },
|
||||
{ code: "VC", name: "Saint Vincent and the Grenadines" },
|
||||
{ code: "WS", name: "Samoa" },
|
||||
{ code: "SM", name: "San Marino" },
|
||||
{ code: "ST", name: "Sao Tome and Principe" },
|
||||
{ code: "SA", name: "Saudi Arabia" },
|
||||
{ code: "SN", name: "Senegal" },
|
||||
{ code: "RS", name: "Serbia" },
|
||||
{ code: "SC", name: "Seychelles" },
|
||||
{ code: "SL", name: "Sierra Leone" },
|
||||
{ code: "SG", name: "Singapore" },
|
||||
{ code: "SX", name: "Sint Maarten (Dutch)" },
|
||||
{ code: "SK", name: "Slovakia" },
|
||||
{ code: "SI", name: "Slovenia" },
|
||||
{ code: "SB", name: "Solomon Islands" },
|
||||
{ code: "SO", name: "Somalia" },
|
||||
{ code: "ZA", name: "South Africa" },
|
||||
{ code: "GS", name: "South Georgia and South Sandwich Islands" },
|
||||
{ code: "SS", name: "South Sudan" },
|
||||
{ code: "ES", name: "Spain" },
|
||||
{ code: "LK", name: "Sri Lanka" },
|
||||
{ code: "SD", name: "Sudan" },
|
||||
{ code: "SR", name: "Suriname" },
|
||||
{ code: "SJ", name: "Svalbard and Jan Mayen" },
|
||||
{ code: "SE", name: "Sweden" },
|
||||
{ code: "CH", name: "Switzerland" },
|
||||
{ code: "SY", name: "Syrian Arab Republic" },
|
||||
{ code: "TW", name: "Taiwan" },
|
||||
{ code: "TJ", name: "Tajikistan" },
|
||||
{ code: "TZ", name: "Tanzania" },
|
||||
{ code: "TH", name: "Thailand" },
|
||||
{ code: "TL", name: "Timor-Leste" },
|
||||
{ code: "TG", name: "Togo" },
|
||||
{ code: "TK", name: "Tokelau" },
|
||||
{ code: "TO", name: "Tonga" },
|
||||
{ code: "TT", name: "Trinidad and Tobago" },
|
||||
{ code: "TN", name: "Tunisia" },
|
||||
{ code: "TR", name: "Turkey" },
|
||||
{ code: "TM", name: "Turkmenistan" },
|
||||
{ code: "TC", name: "Turks and Caicos Islands" },
|
||||
{ code: "TV", name: "Tuvalu" },
|
||||
{ code: "UG", name: "Uganda" },
|
||||
{ code: "UA", name: "Ukraine" },
|
||||
{ code: "AE", name: "United Arab Emirates" },
|
||||
{ code: "GB", name: "United Kingdom" },
|
||||
{ code: "US", name: "United States of America" },
|
||||
{ code: "UY", name: "Uruguay" },
|
||||
{ code: "UZ", name: "Uzbekistan" },
|
||||
{ code: "VU", name: "Vanuatu" },
|
||||
{ code: "VE", name: "Venezuela" },
|
||||
{ code: "VN", name: "Viet Nam" },
|
||||
{ code: "VG", name: "Virgin Islands (British)" },
|
||||
{ code: "VI", name: "Virgin Islands (U.S.)" },
|
||||
{ code: "WF", name: "Wallis and Futuna" },
|
||||
{ code: "EH", name: "Western Sahara" },
|
||||
{ code: "YE", name: "Yemen" },
|
||||
{ code: "ZM", name: "Zambia" },
|
||||
{ code: "ZW", name: "Zimbabwe" },
|
||||
] as const;
|
||||
@@ -176,7 +176,7 @@ function RoleFields({ role, index, onRemove }: RoleFieldsProps) {
|
||||
initial={{ opacity: 1, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="relative grid rounded border sm:col-span-full sm:grid-cols-2"
|
||||
className="relative grid rounded-md border sm:col-span-full sm:grid-cols-2"
|
||||
>
|
||||
<div className="col-span-full flex items-center justify-between rounded-t bg-border/30 px-2 py-1.5">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
You are an expert ATS (Applicant Tracking System) optimization specialist and resume tailoring assistant. Your task is to tailor a resume so it maximizes relevance for a specific job posting while maintaining truthfulness and professional integrity.
|
||||
|
||||
## Strict Formatting and Character Rules
|
||||
|
||||
1. **No emdashes or endashes.** NEVER use the characters — (emdash) or – (endash) in any output. Use commas, periods, semicolons, colons, or regular hyphens (-) instead.
|
||||
2. **No curly/smart quotes.** NEVER use curly single quotes (' '), curly double quotes (" "), or backtick quotes. Use only straight single quotes (') and straight double quotes (").
|
||||
3. **No special whitespace.** Use only standard spaces (U+0020). Do not use non-breaking spaces, thin spaces, em spaces, or any other Unicode whitespace characters.
|
||||
4. **No ellipsis character.** Use three periods (...) instead of the ellipsis character (…).
|
||||
5. **ASCII punctuation only.** Use only standard ASCII punctuation throughout all output. No Unicode bullet characters - use HTML `<li>` tags for lists instead.
|
||||
6. **HTML content fields** must use valid HTML: `<p>` for paragraphs, `<ul>`/`<li>` for bullet lists, `<strong>` for bold, `<em>` for italic.
|
||||
7. **Do not use markdown.** All text output is HTML.
|
||||
|
||||
## ATS Optimization Strategy
|
||||
|
||||
- Incorporate relevant keywords from the job description naturally into the summary and experience descriptions.
|
||||
- Use action verbs that mirror the language in the job posting (e.g., if the job says "manage", use "managed" rather than "oversaw").
|
||||
- Quantify achievements where numbers already exist in the resume. Do not fabricate statistics.
|
||||
- Front-load the most relevant qualifications in the summary.
|
||||
- Ensure skill names and keywords align with terminology used in the job posting.
|
||||
|
||||
## Summary Tailoring
|
||||
|
||||
Rewrite the summary to:
|
||||
|
||||
- Lead with the candidate's most relevant qualifications for this specific role.
|
||||
- Incorporate 2-3 key terms directly from the job description.
|
||||
- Keep it concise: 2-3 sentences, approximately 50-75 words.
|
||||
- Focus on value the candidate brings to this specific position.
|
||||
|
||||
## Experience Tailoring — CRITICAL, MANDATORY
|
||||
|
||||
**You MUST rewrite the description for EVERY experience item in the resume.** This is the most important part of tailoring. Do NOT skip any experience.
|
||||
|
||||
For each experience item:
|
||||
|
||||
- **Read the user's original description** to understand what they actually did in that role.
|
||||
- **Read the target job description** to understand what the employer is looking for.
|
||||
- **Rewrite the description** so it explains what the candidate did in that position AS IT RELATES to the target job. Emphasize transferable skills, relevant achievements, and applicable responsibilities.
|
||||
- Use action verbs matching the job posting's language.
|
||||
- Preserve all factual content but adjust emphasis and wording for maximum relevance.
|
||||
- If the experience has role progression (multiple roles at one company): Tailor EACH role's description individually.
|
||||
|
||||
**Even for seemingly unrelated positions** (e.g., restaurant manager applying for a tech role), rewrite the description to highlight transferable skills like leadership, team management, customer relations, process optimization, problem-solving, etc. Every work experience has transferable value.
|
||||
|
||||
**NEVER return an empty experiences array.** Every resume has experiences worth tailoring. Include ALL of them with rewritten descriptions.
|
||||
|
||||
## References Tailoring — MANDATORY
|
||||
|
||||
**You MUST rewrite the description for EVERY reference in the resume.** Reference descriptions should be professional, concise, and relevant to the target job.
|
||||
|
||||
**CRITICAL: Write references from the resume owner's first-person perspective.** The resume owner is describing their own references. Use "my", "me", and "I" — NOT the candidate's name or third-person pronouns like "his", "her", "their", or "[candidate's]".
|
||||
|
||||
For each reference:
|
||||
|
||||
- Rewrite the description from the resume owner's point of view, explaining how this reference knows them and what they can speak to.
|
||||
- Examples of correct tone: "Christian served as my Co-Manager at The Landing Restaurant and can speak to my leadership skills and work ethic." or "Alex was my direct manager at Revelation Machinery and can speak to my performance in account management and business development."
|
||||
- Highlight the professional relationship and relevant qualifications for the target role.
|
||||
- Keep descriptions professional and concise (1-2 sentences).
|
||||
- Do NOT change the reference's name, position, phone, or website. Only rewrite the description field.
|
||||
|
||||
## Skills Strategy — FULL REWRITE
|
||||
|
||||
Instead of toggling existing skills, you will produce the **complete curated skills list** for the tailored resume. This ensures consistent formatting, icons, labels, and appropriate quantity.
|
||||
|
||||
### Guidelines
|
||||
|
||||
1. **Curate, don't dump.** Aim for **6-10 skill items total**. A focused skills section is more impactful than an exhaustive one. Too many skills will overflow the resume page.
|
||||
2. **Rewrite existing skills** that are relevant: standardize their name, proficiency label, keywords, and icon to be consistent with each other and with the job posting terminology.
|
||||
3. **Add new skills** only if BOTH conditions are met:
|
||||
- The skill appears in the job requirements, description, or qualifications.
|
||||
- Evidence for that skill exists in the candidate's experience descriptions.
|
||||
4. **Omit irrelevant skills** entirely. They will remain hidden on the tailored copy.
|
||||
5. **Mark new skills** with `isNew: true`. These are skills NOT present in the original resume. The user will be asked if they want to save new skills back to their original resume for future use.
|
||||
6. **Use consistent formatting** across all skills:
|
||||
- `name`: A category label matching job posting terminology (e.g., "Frontend Development", "Data Analysis", "Project Management").
|
||||
- `keywords`: 2-5 specific technologies or competencies as tags (e.g., ["React", "TypeScript", "Next.js"]).
|
||||
- `proficiency`: A consistent label style across all skills (e.g., all use "Developer" or all use "Advanced" - pick one style and use it for every skill).
|
||||
- `icon`: A Phosphor icon name that visually represents the category. Use these: "code" for programming, "database" for data, "cloud" for cloud/infra, "wrench" for tools, "paint-brush" for design, "globe" for web, "users" for leadership/team, "chart-bar" for analytics, "shield-check" for security, "terminal" for DevOps. Use empty string "" if unsure.
|
||||
|
||||
## Truthfulness Rules
|
||||
|
||||
1. Only emphasize existing experience; never fabricate qualifications or achievements.
|
||||
2. Do not add experience items, education, or certifications that do not exist in the resume.
|
||||
3. Preserve the candidate's voice and tone where possible.
|
||||
4. When adjusting wording, ensure the meaning remains accurate.
|
||||
|
||||
## Current Resume Data
|
||||
|
||||
```json
|
||||
{{RESUME_DATA}}
|
||||
```
|
||||
|
||||
## Target Job Posting
|
||||
|
||||
**Title**: {{JOB_TITLE}}
|
||||
**Company**: {{COMPANY}}
|
||||
|
||||
### Job Description
|
||||
|
||||
{{JOB_DESCRIPTION}}
|
||||
|
||||
### Key Qualifications and Highlights
|
||||
|
||||
{{JOB_HIGHLIGHTS}}
|
||||
|
||||
### Required Skills
|
||||
|
||||
{{JOB_SKILLS}}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createJobSearchProvider } from "./factory";
|
||||
import { JSearchProvider } from "./providers/jsearch";
|
||||
|
||||
describe("createJobSearchProvider", () => {
|
||||
it("should return a JSearchProvider instance", () => {
|
||||
const provider = createJobSearchProvider("test-api-key");
|
||||
|
||||
expect(provider).toBeInstanceOf(JSearchProvider);
|
||||
});
|
||||
|
||||
it("should create provider with correct API key", async () => {
|
||||
const provider = createJobSearchProvider("my-secret-key");
|
||||
|
||||
// Provider should have the API key (indirectly verified by checking it's a JSearchProvider)
|
||||
expect(provider).toBeInstanceOf(JSearchProvider);
|
||||
});
|
||||
|
||||
it("should have all required JobSearchProvider methods", () => {
|
||||
const provider = createJobSearchProvider("test-api-key");
|
||||
|
||||
expect(provider).toHaveProperty("search");
|
||||
expect(provider).toHaveProperty("testConnection");
|
||||
|
||||
expect(typeof provider.search).toBe("function");
|
||||
expect(typeof provider.testConnection).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { JobSearchProvider } from "./provider";
|
||||
|
||||
import { JSearchProvider } from "./providers/jsearch";
|
||||
|
||||
/**
|
||||
* Creates a job search provider instance
|
||||
*
|
||||
* Currently supports only JSearch. This factory provides an extension point
|
||||
* for adding additional providers in the future (LinkedIn, Indeed, etc.).
|
||||
*
|
||||
* **Future Extension Example:**
|
||||
* ```typescript
|
||||
* type ProviderType = "JSEARCH" | "LINKEDIN" | "INDEED";
|
||||
*
|
||||
* export function createJobSearchProvider(
|
||||
* type: ProviderType,
|
||||
* apiKey: string
|
||||
* ): JobSearchProvider {
|
||||
* switch (type) {
|
||||
* case "JSEARCH":
|
||||
* return new JSearchProvider(apiKey);
|
||||
* case "LINKEDIN":
|
||||
* return new LinkedInProvider(apiKey);
|
||||
* case "INDEED":
|
||||
* return new IndeedProvider(apiKey);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param apiKey - API key for the job search provider
|
||||
* @returns Job search provider instance (currently JSearch)
|
||||
*/
|
||||
export function createJobSearchProvider(apiKey: string): JobSearchProvider {
|
||||
// Currently only JSearch is supported
|
||||
// Future: Add provider type parameter to support multiple providers
|
||||
return new JSearchProvider(apiKey);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RapidApiQuota, SearchParams, SearchResponse } from "@/schema/jobs";
|
||||
|
||||
/**
|
||||
* Abstract interface for job search providers
|
||||
*
|
||||
* This interface enables multiple job search provider implementations (JSearch, LinkedIn, Indeed, etc.)
|
||||
* while maintaining a consistent API surface. All providers must implement these three core methods.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class LinkedInProvider implements JobSearchProvider {
|
||||
* async search(params: SearchParams): Promise<SearchResponse> {
|
||||
* // LinkedIn-specific implementation
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface JobSearchProvider {
|
||||
/**
|
||||
* Search for job listings matching the given parameters
|
||||
*
|
||||
* @param params - Search parameters (query, location, filters, etc.)
|
||||
* @returns Search response with job listings, metadata, and optional API quota info
|
||||
* @throws Error if API request fails or rate limit is exceeded
|
||||
*/
|
||||
search(params: SearchParams): Promise<SearchResponse & { rapidApiQuota?: RapidApiQuota }>;
|
||||
|
||||
/**
|
||||
* Test the provider connection with a minimal API request
|
||||
*
|
||||
* Used to validate API credentials without consuming significant quota.
|
||||
*
|
||||
* @returns Connection result with success status and optional API quota info
|
||||
*/
|
||||
testConnection(): Promise<{ success: boolean; rapidApiQuota?: RapidApiQuota }>;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { JSearchProvider } from "./jsearch";
|
||||
|
||||
// --- Mock fetch globally ---
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as never;
|
||||
|
||||
// --- Mock helpers ---
|
||||
|
||||
function mockHeaders(limit?: number, remaining?: number) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (limit !== undefined) headers["x-ratelimit-requests-limit"] = String(limit);
|
||||
if (remaining !== undefined) headers["x-ratelimit-requests-remaining"] = String(remaining);
|
||||
return { get: (key: string) => headers[key.toLowerCase()] ?? null };
|
||||
}
|
||||
|
||||
function mockOkResponse(data: unknown, limit?: number, remaining?: number) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => data,
|
||||
headers: mockHeaders(limit, remaining),
|
||||
};
|
||||
}
|
||||
|
||||
function mockErrorResponse(status: number, statusText: string) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
statusText,
|
||||
text: async () => "",
|
||||
headers: mockHeaders(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Mock response data ---
|
||||
|
||||
const mockSearchResponse = {
|
||||
status: "OK",
|
||||
request_id: "test-request-id",
|
||||
parameters: {},
|
||||
data: [
|
||||
{
|
||||
job_id: "job-1",
|
||||
job_title: "Software Engineer",
|
||||
employer_name: "Acme Corp",
|
||||
employer_logo: null,
|
||||
employer_website: null,
|
||||
employer_company_type: null,
|
||||
employer_linkedin: null,
|
||||
job_publisher: "LinkedIn",
|
||||
job_employment_type: "FULLTIME",
|
||||
job_apply_link: "https://example.com/apply",
|
||||
job_apply_is_direct: false,
|
||||
job_apply_quality_score: null,
|
||||
job_description: "Build software",
|
||||
job_is_remote: false,
|
||||
job_city: "San Francisco",
|
||||
job_state: "CA",
|
||||
job_country: "US",
|
||||
job_latitude: null,
|
||||
job_longitude: null,
|
||||
job_posted_at_timestamp: null,
|
||||
job_posted_at_datetime_utc: "",
|
||||
job_offer_expiration_datetime_utc: null,
|
||||
job_offer_expiration_timestamp: null,
|
||||
job_min_salary: null,
|
||||
job_max_salary: null,
|
||||
job_salary_currency: null,
|
||||
job_salary_period: null,
|
||||
job_benefits: null,
|
||||
job_google_link: null,
|
||||
job_required_experience: {
|
||||
no_experience_required: false,
|
||||
required_experience_in_months: null,
|
||||
experience_mentioned: false,
|
||||
experience_preferred: false,
|
||||
},
|
||||
job_required_skills: null,
|
||||
job_required_education: {
|
||||
postgraduate_degree: false,
|
||||
professional_certification: false,
|
||||
high_school: false,
|
||||
associates_degree: false,
|
||||
bachelors_degree: false,
|
||||
degree_mentioned: false,
|
||||
degree_preferred: false,
|
||||
professional_certification_mentioned: false,
|
||||
},
|
||||
job_experience_in_place_of_education: null,
|
||||
job_highlights: null,
|
||||
job_posting_language: null,
|
||||
job_onet_soc: null,
|
||||
job_onet_job_zone: null,
|
||||
job_occupational_categories: null,
|
||||
job_naics_code: null,
|
||||
job_naics_name: null,
|
||||
apply_options: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe("JSearchProvider", () => {
|
||||
let provider: JSearchProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new JSearchProvider("test-api-key");
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- search() ---
|
||||
|
||||
describe("search", () => {
|
||||
it("should construct correct query parameters", async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockOkResponse(mockSearchResponse));
|
||||
|
||||
await provider.search({
|
||||
query: "engineer",
|
||||
num_pages: 2,
|
||||
date_posted: "week",
|
||||
country: "DE",
|
||||
remote_jobs_only: true,
|
||||
employment_types: "FULLTIME",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const callUrl = mockFetch.mock.calls[0][0];
|
||||
expect(callUrl).toContain("query=engineer");
|
||||
expect(callUrl).toContain("num_pages=2");
|
||||
expect(callUrl).toContain("date_posted=week");
|
||||
expect(callUrl).toContain("country=DE");
|
||||
expect(callUrl).toContain("remote_jobs_only=true");
|
||||
expect(callUrl).toContain("employment_types=FULLTIME");
|
||||
});
|
||||
|
||||
it("should include correct headers", async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockOkResponse(mockSearchResponse));
|
||||
|
||||
await provider.search({ query: "test", num_pages: 1 });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), {
|
||||
headers: {
|
||||
"X-RapidAPI-Key": "test-api-key",
|
||||
"X-RapidAPI-Host": "jsearch.p.rapidapi.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return parsed search response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockOkResponse(mockSearchResponse));
|
||||
|
||||
const result = await provider.search({ query: "engineer", num_pages: 1 });
|
||||
|
||||
expect(result.status).toBe("OK");
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].job_title).toBe("Software Engineer");
|
||||
});
|
||||
|
||||
it("should extract RapidAPI quota from response headers", async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockOkResponse(mockSearchResponse, 200, 195));
|
||||
|
||||
const result = await provider.search({ query: "test", num_pages: 1 });
|
||||
|
||||
expect(result.rapidApiQuota).toEqual({ limit: 200, remaining: 195, used: 5 });
|
||||
});
|
||||
|
||||
it("should return undefined quota when headers are missing", async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockOkResponse(mockSearchResponse));
|
||||
|
||||
const result = await provider.search({ query: "test", num_pages: 1 });
|
||||
|
||||
expect(result.rapidApiQuota).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should retry on 429 status with exponential backoff", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(mockErrorResponse(429, "Too Many Requests"))
|
||||
.mockResolvedValueOnce(mockErrorResponse(429, "Too Many Requests"))
|
||||
.mockResolvedValueOnce(mockOkResponse(mockSearchResponse));
|
||||
|
||||
const promise = provider.search({ query: "test", num_pages: 1 });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await promise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error on non-200 response without retrying", async () => {
|
||||
mockFetch.mockResolvedValue(mockErrorResponse(500, "Internal Server Error"));
|
||||
|
||||
await expect(provider.search({ query: "test", num_pages: 1 })).rejects.toThrow("JSearch API error: 500");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries network failures and succeeds", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new TypeError("Network down"))
|
||||
.mockRejectedValueOnce(new TypeError("Network still down"))
|
||||
.mockResolvedValueOnce(mockOkResponse(mockSearchResponse));
|
||||
|
||||
const pending = provider.search({ query: "test", num_pages: 1 });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await pending;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- testConnection() ---
|
||||
|
||||
describe("testConnection", () => {
|
||||
it("should return success true on successful connection", async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockOkResponse(mockSearchResponse, 200, 198));
|
||||
|
||||
const result = await provider.testConnection();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.rapidApiQuota).toEqual({ limit: 200, remaining: 198, used: 2 });
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const callUrl = mockFetch.mock.calls[0][0];
|
||||
expect(callUrl).toContain("query=test");
|
||||
expect(callUrl).toContain("num_pages=1");
|
||||
});
|
||||
|
||||
it("should return success false on connection failure", async () => {
|
||||
mockFetch.mockResolvedValue(mockErrorResponse(401, "Unauthorized"));
|
||||
|
||||
const result = await provider.testConnection();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.rapidApiQuota).toBeUndefined();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return success false on network error", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockFetch.mockRejectedValue(new TypeError("Network error"));
|
||||
const promise = provider.testConnection();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.rapidApiQuota).toBeUndefined();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type z from "zod";
|
||||
|
||||
import { type RapidApiQuota, type SearchParams, type SearchResponse, searchResponseSchema } from "@/schema/jobs";
|
||||
|
||||
import type { JobSearchProvider } from "../provider";
|
||||
|
||||
const JSEARCH_BASE_URL = "https://jsearch.p.rapidapi.com";
|
||||
const JSEARCH_HOST = "jsearch.p.rapidapi.com";
|
||||
const MAX_RETRIES = 3;
|
||||
const INITIAL_BACKOFF_MS = 1000;
|
||||
|
||||
/**
|
||||
* JSearch API Provider Implementation
|
||||
*
|
||||
* JSearch is a Google for Jobs aggregator that provides access to job listings from multiple sources.
|
||||
*
|
||||
* **Important Implementation Details:**
|
||||
* - **Country Filtering**: Country can be passed explicitly as ISO 3166-1 alpha-2
|
||||
* - Use the `country` search parameter (e.g., "US", "DE")
|
||||
* - Keep `query` focused on role/keywords and optional free-form location text
|
||||
* - **Data Source**: Aggregates jobs from multiple platforms via Google for Jobs
|
||||
* - **Retry Logic**: Implements exponential backoff for 429 (rate limit) responses
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new JSearchProvider("your-api-key");
|
||||
* const results = await provider.search({
|
||||
* query: "software engineer in San Francisco, CA, United States",
|
||||
* num_pages: 1
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class JSearchProvider implements JobSearchProvider {
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
private extractRapidApiQuota(headers: Headers): RapidApiQuota | undefined {
|
||||
const limitStr = headers.get("x-ratelimit-requests-limit");
|
||||
const remainingStr = headers.get("x-ratelimit-requests-remaining");
|
||||
|
||||
if (!limitStr || !remainingStr) return undefined;
|
||||
|
||||
const limit = Number.parseInt(limitStr, 10);
|
||||
const remaining = Number.parseInt(remainingStr, 10);
|
||||
|
||||
if (Number.isNaN(limit) || Number.isNaN(remaining)) return undefined;
|
||||
|
||||
return { limit, remaining, used: limit - remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the JSearch API with retry logic
|
||||
*
|
||||
* Implements exponential backoff for transient failures and rate limiting.
|
||||
*
|
||||
* @param path - API endpoint path (e.g., "/search?query=...")
|
||||
* @param schema - Zod schema for response validation
|
||||
* @returns Parsed data and optional RapidAPI quota from response headers
|
||||
* @throws Error if all retries are exhausted or non-retryable error occurs
|
||||
*/
|
||||
private async jsearchRequest<T>(
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
): Promise<{ data: T; rapidApiQuota?: RapidApiQuota }> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
let response: Response | null = null;
|
||||
|
||||
try {
|
||||
response = await fetch(`${JSEARCH_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
"X-RapidAPI-Key": this.apiKey,
|
||||
"X-RapidAPI-Host": JSEARCH_HOST,
|
||||
},
|
||||
});
|
||||
|
||||
// Retry on rate limit with exponential backoff
|
||||
if (response.status === 429) {
|
||||
await response.text();
|
||||
const backoff = INITIAL_BACKOFF_MS * 2 ** attempt;
|
||||
await this.sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`JSearch API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const data = schema.parse(json);
|
||||
const rapidApiQuota = this.extractRapidApiQuota(response.headers);
|
||||
|
||||
return { data, rapidApiQuota };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
const isRetryableNetworkError = error instanceof TypeError;
|
||||
const shouldRetry = response === null && isRetryableNetworkError && attempt < MAX_RETRIES - 1;
|
||||
if (!shouldRetry) throw lastError;
|
||||
|
||||
const backoff = INITIAL_BACKOFF_MS * 2 ** attempt;
|
||||
await this.sleep(backoff);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Request failed after retries");
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async search(params: SearchParams): Promise<SearchResponse & { rapidApiQuota?: RapidApiQuota }> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("query", params.query);
|
||||
if (params.page) query.set("page", String(params.page));
|
||||
if (params.num_pages) query.set("num_pages", String(params.num_pages));
|
||||
if (params.date_posted) query.set("date_posted", params.date_posted);
|
||||
if (params.country) query.set("country", params.country);
|
||||
if (params.remote_jobs_only) query.set("remote_jobs_only", String(params.remote_jobs_only));
|
||||
if (params.employment_types) query.set("employment_types", params.employment_types);
|
||||
if (params.job_requirements) query.set("job_requirements", params.job_requirements);
|
||||
if (params.radius) query.set("radius", String(params.radius));
|
||||
if (params.exclude_job_publishers) query.set("exclude_job_publishers", params.exclude_job_publishers);
|
||||
if (params.categories) query.set("categories", params.categories);
|
||||
|
||||
const result = await this.jsearchRequest(`/search?${query.toString()}`, searchResponseSchema);
|
||||
return { ...result.data, rapidApiQuota: result.rapidApiQuota };
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ success: boolean; rapidApiQuota?: RapidApiQuota }> {
|
||||
try {
|
||||
const query = new URLSearchParams({ query: "test", num_pages: "1" });
|
||||
const result = await this.jsearchRequest(`/search?${query.toString()}`, searchResponseSchema);
|
||||
return { success: true, rapidApiQuota: result.rapidApiQuota };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { WritableDraft } from "immer";
|
||||
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
|
||||
import type { RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
type TestStatus = "unverified" | "success" | "failure";
|
||||
|
||||
type JobsStoreState = {
|
||||
rapidApiKey: string;
|
||||
testStatus: TestStatus;
|
||||
rapidApiQuota: RapidApiQuota | null;
|
||||
};
|
||||
|
||||
type JobsStoreActions = {
|
||||
set: (fn: (draft: WritableDraft<JobsStoreState>) => void) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
type JobsStore = JobsStoreState & JobsStoreActions;
|
||||
|
||||
const initialState: JobsStoreState = {
|
||||
rapidApiKey: "",
|
||||
testStatus: "unverified",
|
||||
rapidApiQuota: null,
|
||||
};
|
||||
|
||||
export const useJobsStore = create<JobsStore>()(
|
||||
persist(
|
||||
immer((set) => ({
|
||||
...initialState,
|
||||
set: (fn) => {
|
||||
set((draft) => {
|
||||
const prev = { rapidApiKey: draft.rapidApiKey };
|
||||
|
||||
fn(draft);
|
||||
|
||||
if (draft.rapidApiKey !== prev.rapidApiKey) {
|
||||
draft.testStatus = "unverified";
|
||||
}
|
||||
});
|
||||
},
|
||||
reset: () => set(() => initialState),
|
||||
})),
|
||||
{
|
||||
name: "jobs-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
testStatus: state.testStatus,
|
||||
rapidApiKey: state.rapidApiKey,
|
||||
rapidApiQuota: state.rapidApiQuota,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -4,7 +4,9 @@ import { AISDKError, type UIMessage } from "ai";
|
||||
import { OllamaError } from "ai-sdk-ollama";
|
||||
import z, { flattenError, ZodError } from "zod";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
import { jobResultSchema } from "@/schema/jobs";
|
||||
import { type ResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
import { tailorOutputSchema } from "@/schema/tailor";
|
||||
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema } from "../services/ai";
|
||||
@@ -172,6 +174,54 @@ export const aiRouter = {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
tailorResume: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/tailor-resume",
|
||||
tags: ["AI"],
|
||||
operationId: "tailorResume",
|
||||
summary: "Auto-tailor resume for a job posting",
|
||||
description:
|
||||
"Uses AI to automatically tailor a resume for a specific job posting. Rewrites the summary, adjusts experience descriptions, and curates skills for ATS optimization. Returns structured modifications as a simplified output object. Requires authentication and AI credentials.",
|
||||
successDescription: "Structured tailoring output returned successfully.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
...aiCredentialsSchema.shape,
|
||||
resumeData: resumeDataSchema,
|
||||
job: jobResultSchema,
|
||||
}),
|
||||
)
|
||||
.output(tailorOutputSchema)
|
||||
.errors({
|
||||
BAD_GATEWAY: {
|
||||
message: "The AI provider returned an error or is unreachable.",
|
||||
status: 502,
|
||||
},
|
||||
BAD_REQUEST: {
|
||||
message: "The AI returned an improperly formatted structure.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await aiService.tailorResume(input);
|
||||
} catch (error) {
|
||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { aiRouter } from "./ai";
|
||||
import { authRouter } from "./auth";
|
||||
import { flagsRouter } from "./flags";
|
||||
import { jobsRouter } from "./jobs";
|
||||
import { printerRouter } from "./printer";
|
||||
import { resumeRouter } from "./resume";
|
||||
import { statisticsRouter } from "./statistics";
|
||||
@@ -10,8 +11,9 @@ export default {
|
||||
ai: aiRouter,
|
||||
auth: authRouter,
|
||||
flags: flagsRouter,
|
||||
resume: resumeRouter,
|
||||
storage: storageRouter,
|
||||
jobs: jobsRouter,
|
||||
printer: printerRouter,
|
||||
resume: resumeRouter,
|
||||
statistics: statisticsRouter,
|
||||
storage: storageRouter,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import z from "zod";
|
||||
|
||||
import { postFilterOptionsSchema, searchParamsSchema } from "@/schema/jobs";
|
||||
|
||||
import { protectedProcedure } from "../context";
|
||||
import { jobsService } from "../services/jobs";
|
||||
|
||||
export const jobsRouter = {
|
||||
testConnection: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/jobs/test-connection",
|
||||
tags: ["Jobs"],
|
||||
operationId: "testJobsConnection",
|
||||
summary: "Test RapidAPI JSearch connection",
|
||||
description:
|
||||
"Validates the RapidAPI key by performing a minimal test search against the JSearch API. Requires authentication.",
|
||||
successDescription: "The RapidAPI key is valid and JSearch is reachable.",
|
||||
})
|
||||
.input(z.object({ apiKey: z.string().min(1) }))
|
||||
.errors({
|
||||
BAD_GATEWAY: {
|
||||
message: "The JSearch API returned an error or is unreachable.",
|
||||
status: 502,
|
||||
},
|
||||
})
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await jobsService.testConnection(input.apiKey);
|
||||
} catch (error) {
|
||||
throw new ORPCError("BAD_GATEWAY", {
|
||||
message: error instanceof Error ? error.message : "Connection test failed",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
search: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/jobs/search",
|
||||
tags: ["Jobs"],
|
||||
operationId: "searchJobs",
|
||||
summary: "Search for job listings",
|
||||
description:
|
||||
"Searches the JSearch API for job listings matching the given parameters. Results are deduplicated and optionally filtered. Requires authentication.",
|
||||
successDescription: "Job search results returned successfully.",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
apiKey: z.string().min(1),
|
||||
params: searchParamsSchema,
|
||||
filters: postFilterOptionsSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.errors({
|
||||
BAD_GATEWAY: {
|
||||
message: "The JSearch API returned an error or is unreachable.",
|
||||
status: 502,
|
||||
},
|
||||
})
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
const response = await jobsService.search(input.apiKey, input.params);
|
||||
|
||||
let jobs = jobsService.deduplicateJobs(response.data);
|
||||
|
||||
if (input.filters) {
|
||||
jobs = jobsService.applyPostFilters(jobs, input.filters);
|
||||
}
|
||||
|
||||
return { data: jobs, rapidApiQuota: response.rapidApiQuota };
|
||||
} catch (error) {
|
||||
throw new ORPCError("BAD_GATEWAY", {
|
||||
message: error instanceof Error ? error.message : "Search failed",
|
||||
});
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { jsonrepair } from "jsonrepair";
|
||||
import { match } from "ts-pattern";
|
||||
import z, { flattenError, ZodError } from "zod";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
import chatSystemPromptTemplate from "@/integrations/ai/prompts/chat-system.md?raw";
|
||||
@@ -24,12 +25,14 @@ import docxParserSystemPrompt from "@/integrations/ai/prompts/docx-parser-system
|
||||
import docxParserUserPrompt from "@/integrations/ai/prompts/docx-parser-user.md?raw";
|
||||
import pdfParserSystemPrompt from "@/integrations/ai/prompts/pdf-parser-system.md?raw";
|
||||
import pdfParserUserPrompt from "@/integrations/ai/prompts/pdf-parser-user.md?raw";
|
||||
import tailorSystemPromptTemplate from "@/integrations/ai/prompts/tailor-system.md?raw";
|
||||
import {
|
||||
executePatchResume,
|
||||
patchResumeDescription,
|
||||
patchResumeInputSchema,
|
||||
} from "@/integrations/ai/tools/patch-resume";
|
||||
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
import { type TailorOutput, tailorOutputSchema } from "@/schema/tailor";
|
||||
import { isObject } from "@/utils/sanitize";
|
||||
|
||||
const aiExtractionTemplate = {
|
||||
@@ -438,9 +441,55 @@ async function chat(input: ChatInput) {
|
||||
return streamToEventIterator(result.toUIMessageStream());
|
||||
}
|
||||
|
||||
export const aiService = {
|
||||
testConnection,
|
||||
parsePdf,
|
||||
parseDocx,
|
||||
chat,
|
||||
function formatJobHighlights(highlights: Record<string, string[]> | null): string {
|
||||
if (!highlights) return "None provided.";
|
||||
return Object.entries(highlights)
|
||||
.map(([key, values]) => `${key}:\n${values.map((v) => `- ${v}`).join("\n")}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildTailorSystemPrompt(resumeData: ResumeData, job: JobResult): string {
|
||||
return tailorSystemPromptTemplate
|
||||
.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2))
|
||||
.replace("{{JOB_TITLE}}", job.job_title)
|
||||
.replace("{{COMPANY}}", job.employer_name)
|
||||
.replace("{{JOB_DESCRIPTION}}", job.job_description || "No description provided.")
|
||||
.replace("{{JOB_HIGHLIGHTS}}", formatJobHighlights(job.job_highlights))
|
||||
.replace("{{JOB_SKILLS}}", (job.job_required_skills || []).join(", ") || "None specified.");
|
||||
}
|
||||
|
||||
type TailorResumeInput = z.infer<typeof aiCredentialsSchema> & {
|
||||
resumeData: ResumeData;
|
||||
job: JobResult;
|
||||
};
|
||||
|
||||
async function tailorResume(input: TailorResumeInput): Promise<TailorOutput> {
|
||||
const model = getModel(input);
|
||||
const systemPrompt = buildTailorSystemPrompt(input.resumeData, input.job);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
output: Output.object({ schema: tailorOutputSchema }),
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
content: `Please tailor this resume for the ${input.job.job_title} position at ${input.job.employer_name}. Optimize for ATS compatibility and relevance.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (result.output == null) {
|
||||
throw new Error("AI returned no structured tailoring output.");
|
||||
}
|
||||
|
||||
return tailorOutputSchema.parse(result.output);
|
||||
}
|
||||
|
||||
export const aiService = {
|
||||
chat,
|
||||
parseDocx,
|
||||
parsePdf,
|
||||
tailorResume,
|
||||
testConnection,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { JobResult, PostFilterOptions, RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
// --- Mock factory ---
|
||||
|
||||
const mockProvider = {
|
||||
search: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/integrations/jobs/factory", () => ({
|
||||
createJobSearchProvider: () => mockProvider,
|
||||
}));
|
||||
|
||||
const { jobsService } = await import("./jobs");
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeJob(overrides?: Partial<JobResult>): JobResult {
|
||||
return {
|
||||
job_id: "job-1",
|
||||
job_title: "Software Engineer",
|
||||
employer_name: "Acme Corp",
|
||||
employer_logo: null,
|
||||
employer_website: null,
|
||||
employer_company_type: null,
|
||||
employer_linkedin: null,
|
||||
job_publisher: "LinkedIn",
|
||||
job_employment_type: "FULLTIME",
|
||||
job_apply_link: "https://example.com/apply",
|
||||
job_apply_is_direct: false,
|
||||
job_apply_quality_score: null,
|
||||
job_description: "Build amazing software with React and TypeScript.",
|
||||
job_is_remote: false,
|
||||
job_city: "New York",
|
||||
job_state: "NY",
|
||||
job_country: "US",
|
||||
job_latitude: null,
|
||||
job_longitude: null,
|
||||
job_posted_at_timestamp: null,
|
||||
job_posted_at_datetime_utc: "",
|
||||
job_offer_expiration_datetime_utc: null,
|
||||
job_offer_expiration_timestamp: null,
|
||||
job_min_salary: null,
|
||||
job_max_salary: null,
|
||||
job_salary_currency: null,
|
||||
job_salary_period: null,
|
||||
job_benefits: null,
|
||||
job_google_link: null,
|
||||
job_required_experience: {
|
||||
no_experience_required: false,
|
||||
required_experience_in_months: null,
|
||||
experience_mentioned: false,
|
||||
experience_preferred: false,
|
||||
},
|
||||
job_required_skills: null,
|
||||
job_required_education: {
|
||||
postgraduate_degree: false,
|
||||
professional_certification: false,
|
||||
high_school: false,
|
||||
associates_degree: false,
|
||||
bachelors_degree: false,
|
||||
degree_mentioned: false,
|
||||
degree_preferred: false,
|
||||
professional_certification_mentioned: false,
|
||||
},
|
||||
job_experience_in_place_of_education: null,
|
||||
job_highlights: null,
|
||||
job_posting_language: null,
|
||||
job_onet_soc: null,
|
||||
job_onet_job_zone: null,
|
||||
job_occupational_categories: null,
|
||||
job_naics_code: null,
|
||||
job_naics_name: null,
|
||||
apply_options: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mockQuota: RapidApiQuota = { limit: 200, remaining: 195, used: 5 };
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- testConnection ---
|
||||
|
||||
describe("testConnection", () => {
|
||||
it("delegates to provider and returns success with quota", async () => {
|
||||
mockProvider.testConnection.mockResolvedValue({ success: true, rapidApiQuota: mockQuota });
|
||||
|
||||
const result = await jobsService.testConnection("test-key");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.rapidApiQuota).toEqual(mockQuota);
|
||||
expect(mockProvider.testConnection).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns success false when provider fails", async () => {
|
||||
mockProvider.testConnection.mockResolvedValue({ success: false });
|
||||
|
||||
const result = await jobsService.testConnection("bad-key");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.rapidApiQuota).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- search ---
|
||||
|
||||
describe("search", () => {
|
||||
it("delegates to provider and returns results with quota", async () => {
|
||||
const searchResponse = {
|
||||
status: "OK",
|
||||
request_id: "req-1",
|
||||
parameters: {},
|
||||
data: [makeJob()],
|
||||
rapidApiQuota: mockQuota,
|
||||
};
|
||||
mockProvider.search.mockResolvedValue(searchResponse);
|
||||
|
||||
const result = await jobsService.search("test-key", { query: "react", num_pages: 1 });
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].job_title).toBe("Software Engineer");
|
||||
expect(result.rapidApiQuota).toEqual(mockQuota);
|
||||
expect(mockProvider.search).toHaveBeenCalledWith({ query: "react", num_pages: 1 });
|
||||
});
|
||||
|
||||
it("returns search results without rapidApiQuota when headers missing", async () => {
|
||||
const searchResponse = {
|
||||
status: "OK",
|
||||
request_id: "req-1",
|
||||
parameters: {},
|
||||
data: [makeJob()],
|
||||
};
|
||||
mockProvider.search.mockResolvedValue(searchResponse);
|
||||
|
||||
const result = await jobsService.search("test-key", { query: "react", num_pages: 1 });
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.rapidApiQuota).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- deduplicateJobs ---
|
||||
|
||||
describe("deduplicateJobs", () => {
|
||||
it("returns all jobs when there are no duplicates", () => {
|
||||
const jobs = [
|
||||
makeJob({ job_id: "1", job_title: "Engineer", employer_name: "A", job_city: "NYC" }),
|
||||
makeJob({ job_id: "2", job_title: "Designer", employer_name: "B", job_city: "LA" }),
|
||||
];
|
||||
const result = jobsService.deduplicateJobs(jobs);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("removes duplicates with same title, company, and city", () => {
|
||||
const jobs = [
|
||||
makeJob({ job_id: "1", job_title: "Engineer", employer_name: "Acme", job_city: "NYC" }),
|
||||
makeJob({ job_id: "2", job_title: "Engineer", employer_name: "Acme", job_city: "NYC" }),
|
||||
makeJob({ job_id: "3", job_title: "Engineer", employer_name: "Acme", job_city: "LA" }),
|
||||
];
|
||||
const result = jobsService.deduplicateJobs(jobs);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].job_id).toBe("1");
|
||||
expect(result[1].job_id).toBe("3");
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const jobs = [
|
||||
makeJob({ job_id: "1", job_title: "Software Engineer", employer_name: "Acme Corp", job_city: "NYC" }),
|
||||
makeJob({ job_id: "2", job_title: "software engineer", employer_name: "acme corp", job_city: "nyc" }),
|
||||
];
|
||||
const result = jobsService.deduplicateJobs(jobs);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].job_id).toBe("1");
|
||||
});
|
||||
|
||||
it("keeps the first occurrence", () => {
|
||||
const jobs = [
|
||||
makeJob({ job_id: "first", job_title: "Dev", employer_name: "Co", job_city: "X" }),
|
||||
makeJob({ job_id: "second", job_title: "Dev", employer_name: "Co", job_city: "X" }),
|
||||
];
|
||||
const result = jobsService.deduplicateJobs(jobs);
|
||||
expect(result[0].job_id).toBe("first");
|
||||
});
|
||||
|
||||
it("handles empty array", () => {
|
||||
expect(jobsService.deduplicateJobs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles empty city (matches other empty cities)", () => {
|
||||
const jobs = [
|
||||
makeJob({ job_id: "1", job_title: "Dev", employer_name: "Co", job_city: "" }),
|
||||
makeJob({ job_id: "2", job_title: "Dev", employer_name: "Co", job_city: "" }),
|
||||
];
|
||||
const result = jobsService.deduplicateJobs(jobs);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- applyPostFilters ---
|
||||
|
||||
describe("applyPostFilters", () => {
|
||||
const baseJobs = [
|
||||
makeJob({
|
||||
job_id: "1",
|
||||
job_title: "React Developer",
|
||||
employer_name: "TechCo",
|
||||
job_description: "Build React apps with TypeScript.",
|
||||
job_min_salary: 80000,
|
||||
job_max_salary: 120000,
|
||||
job_apply_is_direct: true,
|
||||
}),
|
||||
makeJob({
|
||||
job_id: "2",
|
||||
job_title: "Senior Java Engineer",
|
||||
employer_name: "BigCorp",
|
||||
job_description: "Enterprise Java development.",
|
||||
job_min_salary: 100000,
|
||||
job_max_salary: 160000,
|
||||
job_apply_is_direct: false,
|
||||
}),
|
||||
makeJob({
|
||||
job_id: "3",
|
||||
job_title: "Junior Frontend Dev",
|
||||
employer_name: "StartupXYZ",
|
||||
job_description: "Build web interfaces using React and CSS.",
|
||||
job_min_salary: null,
|
||||
job_max_salary: null,
|
||||
job_apply_is_direct: true,
|
||||
}),
|
||||
];
|
||||
|
||||
it("returns all jobs with no filters", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, {});
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
// -- Salary filters --
|
||||
|
||||
it("filters by minSalary", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { minSalary: 90000 });
|
||||
// Job 1 (max 120k >= 90k) ✓, Job 2 (max 160k >= 90k) ✓, Job 3 (no salary → included) ✓
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("filters by minSalary excluding low-salary jobs", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { minSalary: 130000 });
|
||||
// Job 1 (max 120k < 130k) ✗, Job 2 (max 160k >= 130k) ✓, Job 3 (no salary) ✓
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((j) => j.job_id)).toEqual(["2", "3"]);
|
||||
});
|
||||
|
||||
it("filters by maxSalary", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { maxSalary: 90000 });
|
||||
// Job 1 (min 80k <= 90k) ✓, Job 2 (min 100k > 90k) ✗, Job 3 (no salary) ✓
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((j) => j.job_id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("filters by salary range", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { minSalary: 90000, maxSalary: 130000 });
|
||||
// Job 1 (80k-120k, overlaps 90k-130k) ✓, Job 2 (100k-160k, min 100k <= 130k) ✓, Job 3 (no salary) ✓
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("includes jobs with no salary data when salary filters are set", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { minSalary: 200000 });
|
||||
// Job 3 has no salary → always included
|
||||
expect(result.map((j) => j.job_id)).toContain("3");
|
||||
});
|
||||
|
||||
// -- Keyword filters --
|
||||
|
||||
it("filters by includeKeywords", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { includeKeywords: ["React"] });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((j) => j.job_id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("includeKeywords is case-insensitive", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { includeKeywords: ["react"] });
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores empty includeKeywords values", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { includeKeywords: ["", "react", " "] });
|
||||
expect(result.map((job) => job.job_id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("filters by excludeKeywords", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { excludeKeywords: ["java"] });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((j) => j.job_id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("excludeKeywords is case-insensitive", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { excludeKeywords: ["JAVA"] });
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores empty excludeKeywords values", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { excludeKeywords: ["", "java", " "] });
|
||||
expect(result.map((job) => job.job_id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("combines include and exclude keywords", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, {
|
||||
includeKeywords: ["React"],
|
||||
excludeKeywords: ["Junior"],
|
||||
});
|
||||
|
||||
// Include: jobs 1, 3. Exclude "Junior": removes job 3
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].job_id).toBe("1");
|
||||
});
|
||||
|
||||
// -- Company filters --
|
||||
|
||||
it("filters by excludeCompanies", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { excludeCompanies: ["BigCorp"] });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((j) => j.job_id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("excludeCompanies is case-insensitive", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { excludeCompanies: ["bigcorp"] });
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores empty excludeCompanies values", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { excludeCompanies: ["", "BigCorp", " "] });
|
||||
expect(result.map((job) => job.job_id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
// -- Direct apply filter --
|
||||
|
||||
it("filters for directApplyOnly", () => {
|
||||
const result = jobsService.applyPostFilters(baseJobs, { directApplyOnly: true });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((j) => j.job_id)).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
// -- Combined filters --
|
||||
|
||||
it("applies all filters together", () => {
|
||||
const filters: PostFilterOptions = {
|
||||
includeKeywords: ["React"],
|
||||
excludeCompanies: ["StartupXYZ"],
|
||||
directApplyOnly: true,
|
||||
};
|
||||
const result = jobsService.applyPostFilters(baseJobs, filters);
|
||||
// Include "React": jobs 1, 3. Exclude "StartupXYZ": removes 3. DirectOnly: job 1 is direct ✓
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].job_id).toBe("1");
|
||||
});
|
||||
|
||||
it("handles empty jobs array", () => {
|
||||
const result = jobsService.applyPostFilters([], { includeKeywords: ["react"] });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { JobResult, PostFilterOptions, RapidApiQuota, SearchParams, SearchResponse } from "@/schema/jobs";
|
||||
|
||||
import { createJobSearchProvider } from "@/integrations/jobs/factory";
|
||||
|
||||
// --- Provider-Delegated Operations ---
|
||||
|
||||
async function search(
|
||||
apiKey: string,
|
||||
params: SearchParams,
|
||||
): Promise<SearchResponse & { rapidApiQuota?: RapidApiQuota }> {
|
||||
const provider = createJobSearchProvider(apiKey);
|
||||
return provider.search(params);
|
||||
}
|
||||
|
||||
async function testConnection(apiKey: string): Promise<{ success: boolean; rapidApiQuota?: RapidApiQuota }> {
|
||||
const provider = createJobSearchProvider(apiKey);
|
||||
return provider.testConnection();
|
||||
}
|
||||
|
||||
// --- Post-Search Filtering ---
|
||||
|
||||
function applyPostFilters(jobs: JobResult[], options: PostFilterOptions): JobResult[] {
|
||||
let filtered = jobs;
|
||||
|
||||
const normalizeKeywords = (values: string[]) => values.map((value) => value.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
if (options.minSalary != null || options.maxSalary != null) {
|
||||
filtered = filtered.filter((job) => {
|
||||
if (job.job_min_salary == null && job.job_max_salary == null) return true;
|
||||
const jobMin = job.job_min_salary ?? 0;
|
||||
const jobMax = job.job_max_salary ?? Number.POSITIVE_INFINITY;
|
||||
if (options.minSalary != null && jobMax < options.minSalary) return false;
|
||||
if (options.maxSalary != null && jobMin > options.maxSalary) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeKeywords?.length) {
|
||||
const keywords = normalizeKeywords(options.includeKeywords);
|
||||
|
||||
if (keywords.length > 0) {
|
||||
filtered = filtered.filter((job) => {
|
||||
const text = `${job.job_title} ${job.job_description}`.toLowerCase();
|
||||
return keywords.some((keyword) => text.includes(keyword));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.excludeKeywords?.length) {
|
||||
const keywords = normalizeKeywords(options.excludeKeywords);
|
||||
|
||||
if (keywords.length > 0) {
|
||||
filtered = filtered.filter((job) => {
|
||||
const text = `${job.job_title} ${job.job_description}`.toLowerCase();
|
||||
return !keywords.some((keyword) => text.includes(keyword));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.excludeCompanies?.length) {
|
||||
const companies = normalizeKeywords(options.excludeCompanies);
|
||||
|
||||
if (companies.length > 0) {
|
||||
filtered = filtered.filter((job) => !companies.includes(job.employer_name.toLowerCase()));
|
||||
}
|
||||
}
|
||||
|
||||
if (options.directApplyOnly) {
|
||||
filtered = filtered.filter((job) => job.job_apply_is_direct);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function deduplicateJobs(jobs: JobResult[]): JobResult[] {
|
||||
const seen = new Set<string>();
|
||||
return jobs.filter((job) => {
|
||||
const key = `${job.job_title.toLowerCase()}|${job.employer_name.toLowerCase()}|${job.job_city?.toLowerCase() ?? ""}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const jobsService = {
|
||||
search,
|
||||
testConnection,
|
||||
applyPostFilters,
|
||||
deduplicateJobs,
|
||||
};
|
||||
@@ -29,10 +29,12 @@ import { Route as ApiHealthRouteImport } from "./routes/api/health";
|
||||
import { Route as UsernameSlugRouteImport } from "./routes/$username/$slug";
|
||||
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
|
||||
import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index";
|
||||
import { Route as DashboardJobSearchIndexRouteImport } from "./routes/dashboard/job-search/index";
|
||||
import { Route as BuilderResumeIdIndexRouteImport } from "./routes/builder/$resumeId/index";
|
||||
import { Route as UploadsUserIdSplatRouteImport } from "./routes/uploads/$userId.$";
|
||||
import { Route as DashboardSettingsProfileRouteImport } from "./routes/dashboard/settings/profile";
|
||||
import { Route as DashboardSettingsPreferencesRouteImport } from "./routes/dashboard/settings/preferences";
|
||||
import { Route as DashboardSettingsJobSearchRouteImport } from "./routes/dashboard/settings/job-search";
|
||||
import { Route as DashboardSettingsDangerZoneRouteImport } from "./routes/dashboard/settings/danger-zone";
|
||||
import { Route as DashboardSettingsApiKeysRouteImport } from "./routes/dashboard/settings/api-keys";
|
||||
import { Route as DashboardSettingsAiRouteImport } from "./routes/dashboard/settings/ai";
|
||||
@@ -140,6 +142,11 @@ const DashboardResumesIndexRoute = DashboardResumesIndexRouteImport.update({
|
||||
path: "/resumes/",
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any);
|
||||
const DashboardJobSearchIndexRoute = DashboardJobSearchIndexRouteImport.update({
|
||||
id: "/job-search/",
|
||||
path: "/job-search/",
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any);
|
||||
const BuilderResumeIdIndexRoute = BuilderResumeIdIndexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
@@ -162,6 +169,12 @@ const DashboardSettingsPreferencesRoute =
|
||||
path: "/settings/preferences",
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any);
|
||||
const DashboardSettingsJobSearchRoute =
|
||||
DashboardSettingsJobSearchRouteImport.update({
|
||||
id: "/settings/job-search",
|
||||
path: "/settings/job-search",
|
||||
getParentRoute: () => DashboardRouteRoute,
|
||||
} as any);
|
||||
const DashboardSettingsDangerZoneRoute =
|
||||
DashboardSettingsDangerZoneRouteImport.update({
|
||||
id: "/settings/danger-zone",
|
||||
@@ -226,10 +239,12 @@ export interface FileRoutesByFullPath {
|
||||
"/dashboard/settings/ai": typeof DashboardSettingsAiRoute;
|
||||
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
|
||||
"/dashboard/settings/danger-zone": typeof DashboardSettingsDangerZoneRoute;
|
||||
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
|
||||
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
|
||||
"/dashboard/settings/profile": typeof DashboardSettingsProfileRoute;
|
||||
"/uploads/$userId/$": typeof UploadsUserIdSplatRoute;
|
||||
"/builder/$resumeId/": typeof BuilderResumeIdIndexRoute;
|
||||
"/dashboard/job-search/": typeof DashboardJobSearchIndexRoute;
|
||||
"/dashboard/resumes/": typeof DashboardResumesIndexRoute;
|
||||
"/dashboard/settings/authentication/": typeof DashboardSettingsAuthenticationIndexRoute;
|
||||
}
|
||||
@@ -255,10 +270,12 @@ export interface FileRoutesByTo {
|
||||
"/dashboard/settings/ai": typeof DashboardSettingsAiRoute;
|
||||
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
|
||||
"/dashboard/settings/danger-zone": typeof DashboardSettingsDangerZoneRoute;
|
||||
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
|
||||
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
|
||||
"/dashboard/settings/profile": typeof DashboardSettingsProfileRoute;
|
||||
"/uploads/$userId/$": typeof UploadsUserIdSplatRoute;
|
||||
"/builder/$resumeId": typeof BuilderResumeIdIndexRoute;
|
||||
"/dashboard/job-search": typeof DashboardJobSearchIndexRoute;
|
||||
"/dashboard/resumes": typeof DashboardResumesIndexRoute;
|
||||
"/dashboard/settings/authentication": typeof DashboardSettingsAuthenticationIndexRoute;
|
||||
}
|
||||
@@ -289,10 +306,12 @@ export interface FileRoutesById {
|
||||
"/dashboard/settings/ai": typeof DashboardSettingsAiRoute;
|
||||
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
|
||||
"/dashboard/settings/danger-zone": typeof DashboardSettingsDangerZoneRoute;
|
||||
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
|
||||
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
|
||||
"/dashboard/settings/profile": typeof DashboardSettingsProfileRoute;
|
||||
"/uploads/$userId/$": typeof UploadsUserIdSplatRoute;
|
||||
"/builder/$resumeId/": typeof BuilderResumeIdIndexRoute;
|
||||
"/dashboard/job-search/": typeof DashboardJobSearchIndexRoute;
|
||||
"/dashboard/resumes/": typeof DashboardResumesIndexRoute;
|
||||
"/dashboard/settings/authentication/": typeof DashboardSettingsAuthenticationIndexRoute;
|
||||
}
|
||||
@@ -323,10 +342,12 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/settings/ai"
|
||||
| "/dashboard/settings/api-keys"
|
||||
| "/dashboard/settings/danger-zone"
|
||||
| "/dashboard/settings/job-search"
|
||||
| "/dashboard/settings/preferences"
|
||||
| "/dashboard/settings/profile"
|
||||
| "/uploads/$userId/$"
|
||||
| "/builder/$resumeId/"
|
||||
| "/dashboard/job-search/"
|
||||
| "/dashboard/resumes/"
|
||||
| "/dashboard/settings/authentication/";
|
||||
fileRoutesByTo: FileRoutesByTo;
|
||||
@@ -352,10 +373,12 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/settings/ai"
|
||||
| "/dashboard/settings/api-keys"
|
||||
| "/dashboard/settings/danger-zone"
|
||||
| "/dashboard/settings/job-search"
|
||||
| "/dashboard/settings/preferences"
|
||||
| "/dashboard/settings/profile"
|
||||
| "/uploads/$userId/$"
|
||||
| "/builder/$resumeId"
|
||||
| "/dashboard/job-search"
|
||||
| "/dashboard/resumes"
|
||||
| "/dashboard/settings/authentication";
|
||||
id:
|
||||
@@ -385,10 +408,12 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/settings/ai"
|
||||
| "/dashboard/settings/api-keys"
|
||||
| "/dashboard/settings/danger-zone"
|
||||
| "/dashboard/settings/job-search"
|
||||
| "/dashboard/settings/preferences"
|
||||
| "/dashboard/settings/profile"
|
||||
| "/uploads/$userId/$"
|
||||
| "/builder/$resumeId/"
|
||||
| "/dashboard/job-search/"
|
||||
| "/dashboard/resumes/"
|
||||
| "/dashboard/settings/authentication/";
|
||||
fileRoutesById: FileRoutesById;
|
||||
@@ -551,6 +576,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DashboardResumesIndexRouteImport;
|
||||
parentRoute: typeof DashboardRouteRoute;
|
||||
};
|
||||
"/dashboard/job-search/": {
|
||||
id: "/dashboard/job-search/";
|
||||
path: "/job-search";
|
||||
fullPath: "/dashboard/job-search/";
|
||||
preLoaderRoute: typeof DashboardJobSearchIndexRouteImport;
|
||||
parentRoute: typeof DashboardRouteRoute;
|
||||
};
|
||||
"/builder/$resumeId/": {
|
||||
id: "/builder/$resumeId/";
|
||||
path: "/";
|
||||
@@ -579,6 +611,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DashboardSettingsPreferencesRouteImport;
|
||||
parentRoute: typeof DashboardRouteRoute;
|
||||
};
|
||||
"/dashboard/settings/job-search": {
|
||||
id: "/dashboard/settings/job-search";
|
||||
path: "/settings/job-search";
|
||||
fullPath: "/dashboard/settings/job-search";
|
||||
preLoaderRoute: typeof DashboardSettingsJobSearchRouteImport;
|
||||
parentRoute: typeof DashboardRouteRoute;
|
||||
};
|
||||
"/dashboard/settings/danger-zone": {
|
||||
id: "/dashboard/settings/danger-zone";
|
||||
path: "/settings/danger-zone";
|
||||
@@ -674,8 +713,10 @@ interface DashboardRouteRouteChildren {
|
||||
DashboardSettingsAiRoute: typeof DashboardSettingsAiRoute;
|
||||
DashboardSettingsApiKeysRoute: typeof DashboardSettingsApiKeysRoute;
|
||||
DashboardSettingsDangerZoneRoute: typeof DashboardSettingsDangerZoneRoute;
|
||||
DashboardSettingsJobSearchRoute: typeof DashboardSettingsJobSearchRoute;
|
||||
DashboardSettingsPreferencesRoute: typeof DashboardSettingsPreferencesRoute;
|
||||
DashboardSettingsProfileRoute: typeof DashboardSettingsProfileRoute;
|
||||
DashboardJobSearchIndexRoute: typeof DashboardJobSearchIndexRoute;
|
||||
DashboardResumesIndexRoute: typeof DashboardResumesIndexRoute;
|
||||
DashboardSettingsAuthenticationIndexRoute: typeof DashboardSettingsAuthenticationIndexRoute;
|
||||
}
|
||||
@@ -685,8 +726,10 @@ const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
|
||||
DashboardSettingsAiRoute: DashboardSettingsAiRoute,
|
||||
DashboardSettingsApiKeysRoute: DashboardSettingsApiKeysRoute,
|
||||
DashboardSettingsDangerZoneRoute: DashboardSettingsDangerZoneRoute,
|
||||
DashboardSettingsJobSearchRoute: DashboardSettingsJobSearchRoute,
|
||||
DashboardSettingsPreferencesRoute: DashboardSettingsPreferencesRoute,
|
||||
DashboardSettingsProfileRoute: DashboardSettingsProfileRoute,
|
||||
DashboardJobSearchIndexRoute: DashboardJobSearchIndexRoute,
|
||||
DashboardResumesIndexRoute: DashboardResumesIndexRoute,
|
||||
DashboardSettingsAuthenticationIndexRoute:
|
||||
DashboardSettingsAuthenticationIndexRoute,
|
||||
|
||||
@@ -109,7 +109,7 @@ const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"
|
||||
className="flex size-12 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"
|
||||
whileHover={{ rotate: [0, -10, 10, 0] }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ const getFaqItems = (): FAQItemData[] => [
|
||||
<a
|
||||
href={crowdinUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonVariants({ variant: "link", className: "h-auto px-0!" })}
|
||||
>
|
||||
contribute to the translations on Crowdin
|
||||
@@ -76,7 +76,8 @@ export function FAQ() {
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans context="Every word needs to be wrapped in a tag">
|
||||
<span>Frequently</span>
|
||||
@@ -90,7 +91,8 @@ export function FAQ() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
transition={{ duration: 0.45, delay: 0.08 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Accordion multiple>
|
||||
{faqItems.map((item, index) => (
|
||||
@@ -114,7 +116,8 @@ function FAQItemComponent({ item, index }: FAQItemComponentProps) {
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||
transition={{ duration: 0.24, delay: Math.min(0.16, index * 0.03) }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<AccordionItem value={item.question} className="group border-t">
|
||||
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TranslateIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
@@ -30,9 +31,7 @@ type Feature = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
type FeatureCardProps = Feature & {
|
||||
index: number;
|
||||
};
|
||||
type FeatureCardProps = Feature;
|
||||
|
||||
const getFeatures = (): Feature[] => [
|
||||
{
|
||||
@@ -133,7 +132,7 @@ const getFeatures = (): Feature[] => [
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps) {
|
||||
function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
@@ -141,10 +140,13 @@ function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps
|
||||
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
|
||||
"hover:bg-secondary/30",
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.03, ease: "easeOut" }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
whileHover={{ y: -3, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
{/* Hover gradient overlay */}
|
||||
<div
|
||||
@@ -154,7 +156,7 @@ function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps
|
||||
|
||||
{/* Icon */}
|
||||
<div aria-hidden="true" className="relative">
|
||||
<div className="inline-flex rounded-lg bg-primary/5 p-2.5 text-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
|
||||
<div className="inline-flex rounded-md bg-primary/5 p-2.5 text-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
|
||||
<Icon size={24} weight="thin" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +171,8 @@ function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps
|
||||
}
|
||||
|
||||
export function Features() {
|
||||
const features = useMemo(() => getFeatures(), []);
|
||||
|
||||
return (
|
||||
<section id="features">
|
||||
{/* Header */}
|
||||
@@ -177,7 +181,8 @@ export function Features() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Features</Trans>
|
||||
@@ -193,8 +198,8 @@ export function Features() {
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="xs:grid-cols-2 grid grid-cols-1 border-t xl:grid-cols-4">
|
||||
{getFeatures().map((feature, index) => (
|
||||
<FeatureCard key={feature.id} {...feature} index={index} />
|
||||
{features.map((feature) => (
|
||||
<FeatureCard key={feature.id} {...feature} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -54,7 +54,8 @@ export function Footer() {
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "opacity" }}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Brand Column */}
|
||||
@@ -83,7 +84,7 @@ export function Footer() {
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${social.label} (${t`opens in new tab`})`}
|
||||
>
|
||||
<social.icon aria-hidden="true" size={18} />
|
||||
@@ -141,8 +142,9 @@ function FooterLink({ url, label }: FooterLinkItem) {
|
||||
aria-hidden="true"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded bg-primary"
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary"
|
||||
style={{ willChange: "width, opacity" }}
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Header() {
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
}, [y]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
@@ -49,7 +49,7 @@ export function Header() {
|
||||
className="fixed inset-x-0 top-0 z-50 border-b border-transparent bg-background/80 backdrop-blur-lg transition-colors"
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
>
|
||||
<ProductHuntBanner />
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ export function Hero() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||
transition={{ duration: 1.1, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<CometCard glareOpacity={0} className="3xl:max-w-7xl relative -mb-12 max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
|
||||
<video
|
||||
@@ -32,7 +33,7 @@ export function Hero() {
|
||||
fetchPriority="high"
|
||||
src="/videos/timelapse.mp4"
|
||||
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
|
||||
className="pointer-events-none size-full rounded-lg border object-cover"
|
||||
className="pointer-events-none size-full rounded-md border object-cover"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -47,9 +48,12 @@ export function Hero() {
|
||||
<motion.a
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
transition={{ duration: 0.45, delay: 0.55 }}
|
||||
whileHover={{ y: -2, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.985 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.rxresu.me/getting-started"
|
||||
>
|
||||
<Badge variant="secondary" className="h-auto gap-1.5 px-3 py-0.5">
|
||||
@@ -62,7 +66,8 @@ export function Hero() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1 }}
|
||||
transition={{ duration: 0.45, delay: 0.7 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans>
|
||||
<p className="font-medium tracking-tight text-muted-foreground md:text-lg">Finally,</p>
|
||||
@@ -77,7 +82,8 @@ export function Hero() {
|
||||
className="max-w-xl text-base leading-relaxed text-muted-foreground md:text-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1.2 }}
|
||||
transition={{ duration: 0.45, delay: 0.82 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans>
|
||||
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
|
||||
@@ -90,7 +96,8 @@ export function Hero() {
|
||||
className="flex flex-col items-center gap-3 sm:flex-row sm:gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1.4 }}
|
||||
transition={{ duration: 0.45, delay: 0.95 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -115,7 +122,7 @@ export function Hero() {
|
||||
className="gap-2 px-4"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer">
|
||||
<BookIcon aria-hidden="true" className="size-4" />
|
||||
<Trans>Learn More</Trans>
|
||||
<span className="sr-only">
|
||||
@@ -134,12 +141,13 @@ export function Hero() {
|
||||
className="absolute inset-s-1/2 bottom-8 -translate-x-1/2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 2, duration: 1 }}
|
||||
transition={{ delay: 1.25, duration: 0.7 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5"
|
||||
animate={{ y: [0, 5, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
|
||||
</motion.div>
|
||||
|
||||
@@ -20,7 +20,8 @@ export function Prefooter() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold tracking-tight md:text-4xl">
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
|
||||
@@ -15,10 +15,12 @@ function TemplateItem({ metadata }: TemplateItemProps) {
|
||||
<motion.div
|
||||
className="group relative shrink-0"
|
||||
initial={{ scale: 1, zIndex: 10 }}
|
||||
whileHover={{ scale: 1.08, zIndex: 20 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
whileHover={{ scale: 1.06, zIndex: 20 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 26 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<div className="relative aspect-page w-48 overflow-hidden rounded-lg border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
|
||||
<div className="relative aspect-page w-48 overflow-hidden rounded-md border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
|
||||
{/* Subtle overlay on hover */}
|
||||
@@ -88,7 +90,8 @@ export function Templates() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Templates</Trans>
|
||||
|
||||
@@ -37,10 +37,12 @@ function TestimonialCard({ testimonial }: TestimonialCardProps) {
|
||||
<motion.div
|
||||
className="group relative w-[320px] shrink-0 sm:w-[360px] md:w-[400px]"
|
||||
initial={{ scale: 1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
whileHover={{ y: -3, scale: 1.02 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 24 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<div className="relative flex h-full flex-col rounded-xl border bg-card p-5 shadow-sm transition-shadow duration-300 group-hover:shadow-xl">
|
||||
<div className="relative flex h-full flex-col rounded-md border bg-card p-5 shadow-sm transition-shadow duration-300 group-hover:shadow-xl">
|
||||
<p className="flex-1 leading-relaxed text-muted-foreground">"{testimonial}"</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -103,7 +105,7 @@ export function Testimonials() {
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-foreground underline underline-offset-2 transition-colors hover:text-primary"
|
||||
>
|
||||
{email}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrowUUpRightIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
FilePdfIcon,
|
||||
type Icon,
|
||||
@@ -27,6 +28,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/file";
|
||||
import { buildDocx } from "@/utils/resume/docx";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
export function BuilderDock() {
|
||||
@@ -66,17 +68,29 @@ export function BuilderDock() {
|
||||
|
||||
const onDownloadJSON = useCallback(async () => {
|
||||
if (!resume?.data) return;
|
||||
const filename = generateFilename(resume.data.basics.name, "json");
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume?.data]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume?.data) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume?.data]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume?.id) return;
|
||||
|
||||
const filename = generateFilename(resume.data.basics.name, "pdf");
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`, {
|
||||
description: t`This may take a while depending on the server capacity. Please do not close the window or refresh the page.`,
|
||||
});
|
||||
@@ -89,15 +103,16 @@ export function BuilderDock() {
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume?.id, resume?.data.basics.name, printResumeAsPDF]);
|
||||
}, [resume?.id, resume?.name, printResumeAsPDF]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-4 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 0.5, y: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
initial={{ opacity: 0, y: -18 }}
|
||||
animate={{ opacity: 0.6, y: 0 }}
|
||||
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center rounded-l-full rounded-r-full bg-popover px-2 shadow-xl"
|
||||
>
|
||||
<DockIcon
|
||||
@@ -126,6 +141,7 @@ export function BuilderDock() {
|
||||
<AIChat />
|
||||
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
|
||||
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
|
||||
<DockIcon icon={FileDocIcon} title={t`Download DOCX`} onClick={() => onDownloadDOCX()} />
|
||||
<DockIcon
|
||||
title={t`Download PDF`}
|
||||
disabled={isPrinting}
|
||||
@@ -151,9 +167,16 @@ function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockI
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
|
||||
whileTap={disabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ function CustomSectionContainer({ section }: { section: CustomSection }) {
|
||||
section.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Badge variant="secondary" className="mb-1.5 rounded-sm">
|
||||
<Badge variant="secondary" className="mb-1.5 rounded-md">
|
||||
{getSectionTitle(section.type)}
|
||||
</Badge>
|
||||
<span className="line-clamp-1 text-base font-medium text-wrap">{section.title}</span>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ExperienceSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="experience" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{section.items.map((item) => {
|
||||
return (
|
||||
<SectionItem
|
||||
|
||||
@@ -24,7 +24,7 @@ export function SkillsSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="skills" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="skills" item={item} title={item.name} subtitle={item.proficiency} />
|
||||
))}
|
||||
|
||||
@@ -241,9 +241,11 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
value={item}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 1, y: -10 }}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="group relative flex h-18 border-b select-none"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -191,6 +191,8 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center"
|
||||
>
|
||||
<div className="size-4 rounded-md bg-foreground" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -9,6 +9,7 @@ import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/file";
|
||||
import { buildDocx } from "@/utils/resume/docx";
|
||||
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -20,15 +21,26 @@ export function ExportSectionBuilder() {
|
||||
);
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
const filename = generateFilename(resume.data.basics.name, "json");
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
const filename = generateFilename(resume.data.basics.name, "pdf");
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`, {
|
||||
description: t`This may take a while depending on the server capacity. Please do not close the window or refresh the page.`,
|
||||
});
|
||||
@@ -62,6 +74,23 @@ export function ExportSectionBuilder() {
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadDOCX}
|
||||
className="h-auto gap-x-4 p-4! text-start font-normal whitespace-normal active:scale-98"
|
||||
>
|
||||
<FileDocIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">DOCX</h6>
|
||||
<p className="text-xs leading-normal text-muted-foreground">
|
||||
<Trans>
|
||||
Download a copy of your resume as a Word document. Use this file to further customize your resume in
|
||||
Microsoft Word or Google Docs.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
|
||||
@@ -112,9 +112,9 @@ export function SharingSectionBuilder() {
|
||||
/>
|
||||
|
||||
<Label htmlFor="sharing-switch" className="my-2 flex flex-col items-start gap-y-1 font-normal">
|
||||
<p className="font-medium">
|
||||
<span className="font-medium">
|
||||
<Trans>Allow Public Access</Trans>
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans>Anyone with the link can view and download the resume.</Trans>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
GearSixIcon,
|
||||
KeyIcon,
|
||||
ReadCvLogoIcon,
|
||||
@@ -48,6 +49,11 @@ const appSidebarItems = [
|
||||
label: msg`Resumes`,
|
||||
href: "/dashboard/resumes",
|
||||
},
|
||||
{
|
||||
icon: <BriefcaseIcon />,
|
||||
label: msg`Job Search`,
|
||||
href: "/dashboard/job-search",
|
||||
},
|
||||
] as const satisfies SidebarItem[];
|
||||
|
||||
const settingsSidebarItems = [
|
||||
@@ -76,6 +82,11 @@ const settingsSidebarItems = [
|
||||
label: msg`Artificial Intelligence`,
|
||||
href: "/dashboard/settings/ai",
|
||||
},
|
||||
{
|
||||
icon: <BriefcaseIcon />,
|
||||
label: msg`Job Search API`,
|
||||
href: "/dashboard/settings/job-search",
|
||||
},
|
||||
{
|
||||
icon: <WarningIcon />,
|
||||
label: msg`Danger Zone`,
|
||||
@@ -183,9 +194,11 @@ export function DashboardSidebar() {
|
||||
{state === "expanded" && (
|
||||
<motion.div
|
||||
key="copyright"
|
||||
initial={{ y: 50, height: 0, opacity: 0 }}
|
||||
animate={{ y: 0, height: "auto", opacity: 1 }}
|
||||
exit={{ y: 50, height: 0, opacity: 0 }}
|
||||
initial={{ y: 12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 12, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Copyright className="shrink-0 p-2 wrap-break-word whitespace-normal" />
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PostFilterOptions, SearchParams } from "@/schema/jobs";
|
||||
|
||||
// --- Pagination ---
|
||||
|
||||
export const RESULTS_PER_PAGE = 10;
|
||||
export const FETCH_NUM_PAGES = 1;
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type FilterState = {
|
||||
// API-level (SearchParams)
|
||||
datePosted: string | null;
|
||||
remoteOnly: boolean;
|
||||
employmentType: string | null;
|
||||
jobRequirements: string | null;
|
||||
countryCode: string;
|
||||
// Post-filters (PostFilterOptions)
|
||||
minSalary: string;
|
||||
maxSalary: string;
|
||||
includeKeywords: string[];
|
||||
excludeKeywords: string[];
|
||||
excludeCompanies: string[];
|
||||
directApplyOnly: boolean;
|
||||
};
|
||||
|
||||
export const initialFilterState: FilterState = {
|
||||
datePosted: null,
|
||||
remoteOnly: false,
|
||||
employmentType: null,
|
||||
jobRequirements: null,
|
||||
countryCode: "US",
|
||||
minSalary: "",
|
||||
maxSalary: "",
|
||||
includeKeywords: [],
|
||||
excludeKeywords: [],
|
||||
excludeCompanies: [],
|
||||
directApplyOnly: false,
|
||||
};
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
export function buildSearchParams(query: string, filters: FilterState, page?: number): SearchParams {
|
||||
const effectiveQuery = query.trim();
|
||||
const countryCode = filters.countryCode.trim().toUpperCase() || initialFilterState.countryCode;
|
||||
|
||||
const params: SearchParams = { query: effectiveQuery, num_pages: FETCH_NUM_PAGES };
|
||||
if (page && page > 1) params.page = page;
|
||||
if (filters.datePosted && filters.datePosted !== "all") {
|
||||
params.date_posted = filters.datePosted as SearchParams["date_posted"];
|
||||
}
|
||||
params.country = countryCode;
|
||||
if (filters.remoteOnly) params.remote_jobs_only = true;
|
||||
if (filters.employmentType) params.employment_types = filters.employmentType;
|
||||
if (filters.jobRequirements) params.job_requirements = filters.jobRequirements;
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildPostFilters(filters: FilterState): PostFilterOptions {
|
||||
const result: PostFilterOptions = {};
|
||||
const minSalaryInput = filters.minSalary.trim();
|
||||
const maxSalaryInput = filters.maxSalary.trim();
|
||||
const minSal = Number(minSalaryInput);
|
||||
const maxSal = Number(maxSalaryInput);
|
||||
|
||||
if (minSalaryInput !== "" && Number.isFinite(minSal) && minSal >= 0) result.minSalary = minSal;
|
||||
if (maxSalaryInput !== "" && Number.isFinite(maxSal) && maxSal >= 0) result.maxSalary = maxSal;
|
||||
if (result.minSalary != null && result.maxSalary != null && result.minSalary > result.maxSalary) {
|
||||
[result.minSalary, result.maxSalary] = [result.maxSalary, result.minSalary];
|
||||
}
|
||||
|
||||
if (filters.includeKeywords.length > 0) result.includeKeywords = filters.includeKeywords;
|
||||
if (filters.excludeKeywords.length > 0) result.excludeKeywords = filters.excludeKeywords;
|
||||
if (filters.excludeCompanies.length > 0) result.excludeCompanies = filters.excludeCompanies;
|
||||
if (filters.directApplyOnly) result.directApplyOnly = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hasActiveFilters(filters: FilterState): boolean {
|
||||
return (
|
||||
(filters.datePosted !== null && filters.datePosted !== "all") ||
|
||||
filters.remoteOnly ||
|
||||
filters.employmentType !== null ||
|
||||
filters.jobRequirements !== null ||
|
||||
filters.countryCode !== initialFilterState.countryCode ||
|
||||
filters.minSalary !== "" ||
|
||||
filters.maxSalary !== "" ||
|
||||
filters.includeKeywords.length > 0 ||
|
||||
filters.excludeKeywords.length > 0 ||
|
||||
filters.excludeCompanies.length > 0 ||
|
||||
filters.directApplyOnly
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
BriefcaseIcon,
|
||||
BuildingsIcon,
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
MapPinIcon,
|
||||
MoneyIcon,
|
||||
StarIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
|
||||
import { formatSalary, isValidExternalUrl } from "./job-utils";
|
||||
import { TailorDialog } from "./tailor-dialog";
|
||||
|
||||
type Props = {
|
||||
job: JobResult | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
const isAIEnabled = useAIStore((s) => s.enabled);
|
||||
const [tailorOpen, setTailorOpen] = useState(false);
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
|
||||
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
|
||||
const hasApplyLink = isValidExternalUrl(job.job_apply_link);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="data-[side=right]:sm:w-[30vw] data-[side=right]:sm:max-w-none data-[side=right]:sm:min-w-[400px]"
|
||||
>
|
||||
<SheetHeader>
|
||||
<div className="flex items-start gap-x-3">
|
||||
{job.employer_logo ? (
|
||||
<img
|
||||
src={job.employer_logo}
|
||||
alt={job.employer_name}
|
||||
className="size-12 shrink-0 rounded-md object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<BuildingsIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="text-lg">{job.job_title}</SheetTitle>
|
||||
<SheetDescription>{job.employer_name}</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 px-4">
|
||||
<div className="flex flex-col gap-y-4 pb-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{location && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MapPinIcon className="size-3" />
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_is_remote && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<GlobeIcon className="size-3" />
|
||||
<Trans>Remote</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_employment_type && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<BriefcaseIcon className="size-3" />
|
||||
{job.job_employment_type.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{salary && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MoneyIcon className="size-3" />
|
||||
{salary}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_posted_at_datetime_utc && (
|
||||
<Badge variant="outline" className="gap-x-1">
|
||||
<ClockIcon className="size-3" />
|
||||
{new Date(job.job_posted_at_datetime_utc).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
disabled={!hasApplyLink}
|
||||
render={
|
||||
<a href={hasApplyLink ? job.job_apply_link : "#"} target="_blank" rel="noopener noreferrer" />
|
||||
}
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!isAIEnabled}
|
||||
onClick={() => setTailorOpen(true)}
|
||||
>
|
||||
<StarIcon />
|
||||
<Trans>Tailor Resume</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{job.job_description && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium">
|
||||
<Trans>Description</Trans>
|
||||
</h4>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{job.job_description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.job_highlights && Object.keys(job.job_highlights).length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
{Object.entries(job.job_highlights).map(([category, items]) => (
|
||||
<div key={category} className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium capitalize">{category.replaceAll("_", " ")}</h4>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{(items as string[]).map((item: string, i: number) => (
|
||||
<li key={i} className="text-sm text-muted-foreground">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.job_required_skills && job.job_required_skills.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium">
|
||||
<Trans>Required Skills</Trans>
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{job.job_required_skills.map((skill) => (
|
||||
<Badge key={skill} variant="outline">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.job_benefits && job.job_benefits.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium">
|
||||
<Trans>Benefits</Trans>
|
||||
</h4>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{job.job_benefits.map((benefit) => (
|
||||
<li key={benefit} className="text-sm text-muted-foreground">
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.apply_options.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium">
|
||||
<Trans>Apply Via</Trans>
|
||||
</h4>
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
{job.apply_options.map((option, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={option.apply_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
|
||||
{option.publisher || t`Apply Link`}
|
||||
{option.is_direct && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Trans>Direct</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<TailorDialog job={job} open={tailorOpen} onOpenChange={setTailorOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import type { RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
export function formatSalary(
|
||||
min: number | null,
|
||||
max: number | null,
|
||||
currency: string | null,
|
||||
period: string | null,
|
||||
): string {
|
||||
if (!min && !max) return "";
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
const resolvedCurrency = currency ?? "USD";
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: resolvedCurrency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
return `${resolvedCurrency} ${amount.toLocaleString()}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (min && max) return `${formatCurrency(min)} - ${formatCurrency(max)}${period ? ` / ${period}` : ""}`;
|
||||
if (min) return `${formatCurrency(min)}+${period ? ` / ${period}` : ""}`;
|
||||
if (!max) return "";
|
||||
return `Up to ${formatCurrency(max)}${period ? ` / ${period}` : ""}`;
|
||||
}
|
||||
|
||||
export function formatPostedDate(timestamp: number | null): string {
|
||||
if (!timestamp) return "";
|
||||
|
||||
const postedDate = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - postedDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 0) return t`Today`;
|
||||
if (diffDays === 1) return t`Yesterday`;
|
||||
if (diffDays < 7) return t`${diffDays} days ago`;
|
||||
if (diffDays < 30) return t`${Math.floor(diffDays / 7)} weeks ago`;
|
||||
return t`${Math.floor(diffDays / 30)} months ago`;
|
||||
}
|
||||
|
||||
export function getQuotaStatus(quota: RapidApiQuota): "healthy" | "warning" | "critical" {
|
||||
if (quota.limit <= 0) return "healthy";
|
||||
const usage = quota.used / quota.limit;
|
||||
if (usage >= 0.9) return "critical";
|
||||
if (usage >= 0.75) return "warning";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
export function isValidExternalUrl(url: string | null | undefined): boolean {
|
||||
if (!url?.trim()) return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildPostFilters,
|
||||
buildSearchParams,
|
||||
FETCH_NUM_PAGES,
|
||||
type FilterState,
|
||||
hasActiveFilters,
|
||||
initialFilterState,
|
||||
RESULTS_PER_PAGE,
|
||||
} from "./filter-helpers";
|
||||
|
||||
// --- buildSearchParams ---
|
||||
|
||||
describe("buildSearchParams", () => {
|
||||
it("returns query and num_pages when all filters are default", () => {
|
||||
const result = buildSearchParams("react developer", initialFilterState);
|
||||
expect(result).toEqual({ query: "react developer", num_pages: FETCH_NUM_PAGES, country: "US" });
|
||||
});
|
||||
|
||||
it("trims the query string", () => {
|
||||
const result = buildSearchParams(" react developer ", initialFilterState);
|
||||
expect(result.query).toBe("react developer");
|
||||
});
|
||||
|
||||
it("includes date_posted when datePosted is set", () => {
|
||||
const filters: FilterState = { ...initialFilterState, datePosted: "week" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.date_posted).toBe("week");
|
||||
});
|
||||
|
||||
it("omits date_posted when datePosted is all", () => {
|
||||
const filters: FilterState = { ...initialFilterState, datePosted: "all" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.date_posted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes remote_jobs_only when remoteOnly is true", () => {
|
||||
const filters: FilterState = { ...initialFilterState, remoteOnly: true };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.remote_jobs_only).toBe(true);
|
||||
});
|
||||
|
||||
it("omits remote_jobs_only when remoteOnly is false", () => {
|
||||
const result = buildSearchParams("engineer", initialFilterState);
|
||||
expect(result.remote_jobs_only).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes employment_types when employmentType is set", () => {
|
||||
const filters: FilterState = { ...initialFilterState, employmentType: "FULLTIME" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.employment_types).toBe("FULLTIME");
|
||||
});
|
||||
|
||||
it("includes job_requirements when jobRequirements is set", () => {
|
||||
const filters: FilterState = { ...initialFilterState, jobRequirements: "under_3_years_experience" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.job_requirements).toBe("under_3_years_experience");
|
||||
});
|
||||
|
||||
it("includes country using the default countryCode", () => {
|
||||
const result = buildSearchParams("engineer", initialFilterState);
|
||||
expect(result.country).toBe("US");
|
||||
});
|
||||
|
||||
it("includes country using a custom countryCode", () => {
|
||||
const filters: FilterState = { ...initialFilterState, countryCode: "DE" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.country).toBe("DE");
|
||||
});
|
||||
|
||||
it("normalizes countryCode to uppercase", () => {
|
||||
const filters: FilterState = { ...initialFilterState, countryCode: "de" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.country).toBe("DE");
|
||||
});
|
||||
|
||||
it("falls back to US when countryCode is empty", () => {
|
||||
const filters: FilterState = { ...initialFilterState, countryCode: "" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.country).toBe("US");
|
||||
});
|
||||
|
||||
it("keeps query focused on user text", () => {
|
||||
const filters: FilterState = { ...initialFilterState, countryCode: "DE" };
|
||||
const result = buildSearchParams("engineer in berlin", filters);
|
||||
expect(result.query).toBe("engineer in berlin");
|
||||
});
|
||||
|
||||
it("includes all filter params when all are set", () => {
|
||||
const filters: FilterState = {
|
||||
...initialFilterState,
|
||||
datePosted: "today",
|
||||
remoteOnly: true,
|
||||
employmentType: "CONTRACTOR",
|
||||
jobRequirements: "no_experience",
|
||||
countryCode: "CA",
|
||||
};
|
||||
const result = buildSearchParams("designer", filters);
|
||||
expect(result).toEqual({
|
||||
query: "designer",
|
||||
num_pages: FETCH_NUM_PAGES,
|
||||
date_posted: "today",
|
||||
country: "CA",
|
||||
remote_jobs_only: true,
|
||||
employment_types: "CONTRACTOR",
|
||||
job_requirements: "no_experience",
|
||||
});
|
||||
});
|
||||
it("always includes num_pages", () => {
|
||||
const result = buildSearchParams("test", initialFilterState);
|
||||
expect(result.num_pages).toBe(FETCH_NUM_PAGES);
|
||||
});
|
||||
|
||||
it("omits page param when page is undefined", () => {
|
||||
const result = buildSearchParams("test", initialFilterState);
|
||||
expect(result.page).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits page param when page is 1", () => {
|
||||
const result = buildSearchParams("test", initialFilterState, 1);
|
||||
expect(result.page).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes page param when page > 1", () => {
|
||||
const result = buildSearchParams("test", initialFilterState, 2);
|
||||
expect(result.page).toBe(2);
|
||||
});
|
||||
|
||||
it("includes page param for higher page numbers", () => {
|
||||
const result = buildSearchParams("test", initialFilterState, 5);
|
||||
expect(result.page).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Pagination constants ---
|
||||
|
||||
describe("pagination constants", () => {
|
||||
it("RESULTS_PER_PAGE is 10", () => {
|
||||
expect(RESULTS_PER_PAGE).toBe(10);
|
||||
});
|
||||
|
||||
it("FETCH_NUM_PAGES is 1", () => {
|
||||
expect(FETCH_NUM_PAGES).toBe(1);
|
||||
});
|
||||
|
||||
it("FETCH_NUM_PAGES matches RESULTS_PER_PAGE worth of results", () => {
|
||||
// Each API page returns ~10 results, so FETCH_NUM_PAGES * 10 should equal RESULTS_PER_PAGE
|
||||
expect(FETCH_NUM_PAGES * 10).toBe(RESULTS_PER_PAGE);
|
||||
});
|
||||
});
|
||||
|
||||
// --- buildPostFilters ---
|
||||
|
||||
describe("buildPostFilters", () => {
|
||||
it("returns empty object when all filters are default", () => {
|
||||
const result = buildPostFilters(initialFilterState);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("includes minSalary when valid positive number", () => {
|
||||
const filters: FilterState = { ...initialFilterState, minSalary: "50000" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.minSalary).toBe(50000);
|
||||
});
|
||||
|
||||
it("includes minSalary when value is zero", () => {
|
||||
const filters: FilterState = { ...initialFilterState, minSalary: "0" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.minSalary).toBe(0);
|
||||
});
|
||||
|
||||
it("includes maxSalary when valid positive number", () => {
|
||||
const filters: FilterState = { ...initialFilterState, maxSalary: "150000" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.maxSalary).toBe(150000);
|
||||
});
|
||||
|
||||
it("omits minSalary for empty string", () => {
|
||||
const result = buildPostFilters(initialFilterState);
|
||||
expect(result.minSalary).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits minSalary for non-numeric string", () => {
|
||||
const filters: FilterState = { ...initialFilterState, minSalary: "abc" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.minSalary).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes includeKeywords when non-empty", () => {
|
||||
const filters: FilterState = { ...initialFilterState, includeKeywords: ["react", "typescript"] };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.includeKeywords).toEqual(["react", "typescript"]);
|
||||
});
|
||||
|
||||
it("omits includeKeywords when empty array", () => {
|
||||
const result = buildPostFilters(initialFilterState);
|
||||
expect(result.includeKeywords).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes excludeKeywords when non-empty", () => {
|
||||
const filters: FilterState = { ...initialFilterState, excludeKeywords: ["senior"] };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.excludeKeywords).toEqual(["senior"]);
|
||||
});
|
||||
|
||||
it("includes excludeCompanies when non-empty", () => {
|
||||
const filters: FilterState = { ...initialFilterState, excludeCompanies: ["Spam Inc"] };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.excludeCompanies).toEqual(["Spam Inc"]);
|
||||
});
|
||||
|
||||
it("includes directApplyOnly when true", () => {
|
||||
const filters: FilterState = { ...initialFilterState, directApplyOnly: true };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.directApplyOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("omits directApplyOnly when false", () => {
|
||||
const result = buildPostFilters(initialFilterState);
|
||||
expect(result.directApplyOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes all post-filters when all are set", () => {
|
||||
const filters: FilterState = {
|
||||
...initialFilterState,
|
||||
minSalary: "80000",
|
||||
maxSalary: "200000",
|
||||
includeKeywords: ["react"],
|
||||
excludeKeywords: ["java"],
|
||||
excludeCompanies: ["Acme"],
|
||||
directApplyOnly: true,
|
||||
};
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result).toEqual({
|
||||
minSalary: 80000,
|
||||
maxSalary: 200000,
|
||||
includeKeywords: ["react"],
|
||||
excludeKeywords: ["java"],
|
||||
excludeCompanies: ["Acme"],
|
||||
directApplyOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes salary range when min is greater than max", () => {
|
||||
const filters: FilterState = { ...initialFilterState, minSalary: "150000", maxSalary: "80000" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.minSalary).toBe(80000);
|
||||
expect(result.maxSalary).toBe(150000);
|
||||
});
|
||||
});
|
||||
|
||||
// --- hasActiveFilters ---
|
||||
|
||||
describe("hasActiveFilters", () => {
|
||||
it("returns false for initial/default filter state", () => {
|
||||
expect(hasActiveFilters(initialFilterState)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when datePosted is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, datePosted: "week" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when datePosted is all", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, datePosted: "all" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when remoteOnly is true", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, remoteOnly: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when employmentType is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, employmentType: "FULLTIME" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when jobRequirements is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, jobRequirements: "no_experience" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when countryCode differs from default", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, countryCode: "DE" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when minSalary is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, minSalary: "50000" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when maxSalary is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, maxSalary: "150000" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when includeKeywords has items", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, includeKeywords: ["react"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when excludeKeywords has items", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, excludeKeywords: ["java"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when excludeCompanies has items", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, excludeCompanies: ["Acme"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when directApplyOnly is true", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, directApplyOnly: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when multiple filters are active", () => {
|
||||
expect(
|
||||
hasActiveFilters({
|
||||
...initialFilterState,
|
||||
remoteOnly: true,
|
||||
employmentType: "FULLTIME",
|
||||
minSalary: "60000",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { msg, t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CaretRightIcon, FunnelIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ISO_COUNTRIES } from "@/constants/iso-countries";
|
||||
|
||||
import { type FilterState, hasActiveFilters, initialFilterState } from "./filter-helpers";
|
||||
|
||||
export {
|
||||
buildPostFilters,
|
||||
buildSearchParams,
|
||||
type FilterState,
|
||||
hasActiveFilters,
|
||||
initialFilterState,
|
||||
RESULTS_PER_PAGE,
|
||||
} from "./filter-helpers";
|
||||
|
||||
// --- Combobox option constants ---
|
||||
|
||||
const datePostedOptions = [
|
||||
{ value: "all", label: msg`Any time` },
|
||||
{ value: "today", label: msg`Today` },
|
||||
{ value: "3days", label: msg`Last 3 days` },
|
||||
{ value: "week", label: msg`This week` },
|
||||
{ value: "month", label: msg`This month` },
|
||||
] as const;
|
||||
|
||||
const employmentTypeOptions = [
|
||||
{ value: "FULLTIME", label: msg`Full-time` },
|
||||
{ value: "PARTTIME", label: msg`Part-time` },
|
||||
{ value: "CONTRACTOR", label: msg`Contractor` },
|
||||
{ value: "INTERN", label: msg`Intern` },
|
||||
] as const;
|
||||
|
||||
const experienceOptions = [
|
||||
{ value: "no_experience", label: msg`No experience` },
|
||||
{ value: "under_3_years_experience", label: msg`Under 3 years` },
|
||||
{ value: "more_than_3_years_experience", label: msg`More than 3 years` },
|
||||
{ value: "no_degree", label: msg`No degree required` },
|
||||
] as const;
|
||||
|
||||
// --- Component ---
|
||||
|
||||
type SearchFiltersProps = {
|
||||
filters: FilterState;
|
||||
onFiltersChange: (filters: FilterState) => void;
|
||||
};
|
||||
|
||||
export function SearchFilters({ filters, onFiltersChange }: SearchFiltersProps) {
|
||||
const { i18n } = useLingui();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const _employmentTypeOptions = useMemo(() => {
|
||||
return employmentTypeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: i18n.t(option.label),
|
||||
}));
|
||||
}, [i18n.locale]);
|
||||
|
||||
const _datePostedOptions = useMemo(() => {
|
||||
return datePostedOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: i18n.t(option.label),
|
||||
}));
|
||||
}, [i18n.locale]);
|
||||
|
||||
const _experienceOptions = useMemo(() => {
|
||||
return experienceOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: i18n.t(option.label),
|
||||
}));
|
||||
}, [i18n.locale]);
|
||||
|
||||
const _countryOptions = useMemo(() => {
|
||||
return ISO_COUNTRIES.map((country) => ({
|
||||
value: country.code,
|
||||
label: country.name,
|
||||
keywords: [country.code],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
onFiltersChange({ ...filters, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Quick Filters */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="filter-remote" className="text-xs text-muted-foreground">
|
||||
<Trans>Remote</Trans>
|
||||
</Label>
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
id="filter-remote"
|
||||
checked={filters.remoteOnly}
|
||||
onCheckedChange={(v) => updateFilter("remoteOnly", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="filter-direct-apply" className="text-xs text-muted-foreground">
|
||||
<Trans>Direct Apply</Trans>
|
||||
</Label>
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
id="filter-direct-apply"
|
||||
checked={filters.directApplyOnly}
|
||||
onCheckedChange={(v) => updateFilter("directApplyOnly", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
<Trans>Type</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
options={_employmentTypeOptions}
|
||||
value={filters.employmentType}
|
||||
onValueChange={(v) => updateFilter("employmentType", v)}
|
||||
placeholder={t`Any type`}
|
||||
className="h-9 w-[140px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
<Trans>Date Posted</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
options={_datePostedOptions}
|
||||
value={filters.datePosted}
|
||||
onValueChange={(v) => updateFilter("datePosted", v)}
|
||||
placeholder={t`Any time`}
|
||||
className="h-9 w-[140px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
<Trans>Experience</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
options={_experienceOptions}
|
||||
value={filters.jobRequirements}
|
||||
onValueChange={(v) => updateFilter("jobRequirements", v)}
|
||||
placeholder={t`Any level`}
|
||||
className="h-9 w-[160px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
<Trans>Country</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
options={_countryOptions}
|
||||
value={filters.countryCode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
updateFilter("countryCode", value);
|
||||
}}
|
||||
placeholder={t`Select country`}
|
||||
searchPlaceholder={t`Search countries`}
|
||||
className="h-9 w-[260px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters(filters) && (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-x-1" onClick={() => onFiltersChange(initialFilterState)}>
|
||||
<XIcon className="size-3.5" />
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters Toggle */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-x-1.5 text-muted-foreground"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
>
|
||||
<FunnelIcon className="size-3.5" />
|
||||
<Trans>Advanced Filters</Trans>
|
||||
<CaretRightIcon
|
||||
className="size-3 transition-transform"
|
||||
style={{ transform: showAdvanced ? "rotate(90deg)" : "rotate(0deg)" }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Advanced Filters Panel */}
|
||||
<AnimatePresence initial={false}>
|
||||
{showAdvanced && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Minimum Salary</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={filters.minSalary}
|
||||
onChange={(e) => updateFilter("minSalary", e.target.value)}
|
||||
placeholder={t`e.g. 50000`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Maximum Salary</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={filters.maxSalary}
|
||||
onChange={(e) => updateFilter("maxSalary", e.target.value)}
|
||||
placeholder={t`e.g. 150000`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Include Keywords</Trans>
|
||||
</Label>
|
||||
<ChipInput
|
||||
hideDescription
|
||||
value={filters.includeKeywords}
|
||||
onChange={(v) => updateFilter("includeKeywords", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Exclude Keywords</Trans>
|
||||
</Label>
|
||||
<ChipInput
|
||||
hideDescription
|
||||
value={filters.excludeKeywords}
|
||||
onChange={(v) => updateFilter("excludeKeywords", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Exclude Companies</Trans>
|
||||
</Label>
|
||||
<ChipInput
|
||||
hideDescription
|
||||
value={filters.excludeCompanies}
|
||||
onChange={(v) => updateFilter("excludeCompanies", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
import type { NewSkillInfo } from "@/schema/tailor";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { client, orpc } from "@/integrations/orpc/client";
|
||||
import { buildSkillSyncOperations, tailorOutputToPatches, validateTailorOutput } from "@/utils/resume/tailor";
|
||||
import { slugify } from "@/utils/string";
|
||||
|
||||
type Props = {
|
||||
job: JobResult;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type DialogPhase =
|
||||
| { step: "select" }
|
||||
| { step: "tailoring" }
|
||||
| { step: "skill-sync"; newResumeId: string; newSkills: NewSkillInfo[]; sourceResumeId: string };
|
||||
|
||||
export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { data: resumes, isLoading } = useQuery(orpc.resume.list.queryOptions());
|
||||
|
||||
const [phase, setPhase] = useState<DialogPhase>({ step: "select" });
|
||||
const [selectedSkills, setSelectedSkills] = useState<Set<number>>(new Set());
|
||||
|
||||
const aiEnabled = useAIStore((s) => s.enabled);
|
||||
const aiProvider = useAIStore((s) => s.provider);
|
||||
const aiModel = useAIStore((s) => s.model);
|
||||
const aiApiKey = useAIStore((s) => s.apiKey);
|
||||
const aiBaseURL = useAIStore((s) => s.baseURL);
|
||||
|
||||
const { mutate: duplicateResume, isPending: isDuplicating } = useMutation(orpc.resume.duplicate.mutationOptions());
|
||||
|
||||
const resetDialog = () => {
|
||||
setPhase({ step: "select" });
|
||||
setSelectedSkills(new Set());
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) resetDialog();
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const navigateToBuilder = (resumeId: string) => {
|
||||
if (job.job_apply_link) {
|
||||
window.open(job.job_apply_link, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
handleOpenChange(false);
|
||||
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
|
||||
};
|
||||
|
||||
const handleSelectResume = async (resumeId: string, resumeName: string) => {
|
||||
const tailorName = `${resumeName} - ${job.job_title}`;
|
||||
const tailorSlug = slugify(`${tailorName}-${Date.now()}`);
|
||||
|
||||
// If AI is not enabled, fall back to duplicating and navigating to builder
|
||||
if (!aiEnabled) {
|
||||
duplicateResume(
|
||||
{ id: resumeId, name: tailorName, slug: tailorSlug, tags: ["tailored"] },
|
||||
{
|
||||
onSuccess: (newResumeId) => navigateToBuilder(newResumeId),
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to duplicate resume`, { description: error.message });
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// AI-powered tailoring pipeline
|
||||
setPhase({ step: "tailoring" });
|
||||
|
||||
try {
|
||||
// Step 1: Duplicate the resume
|
||||
const newResumeId = await client.resume.duplicate({
|
||||
id: resumeId,
|
||||
name: tailorName,
|
||||
slug: tailorSlug,
|
||||
tags: ["tailored"],
|
||||
});
|
||||
|
||||
// Step 2: Fetch the full resume data
|
||||
const resume = await client.resume.getById({ id: newResumeId });
|
||||
|
||||
// Step 3: Call AI tailor endpoint
|
||||
const tailorOutput = await client.ai.tailorResume({
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
apiKey: aiApiKey,
|
||||
baseURL: aiBaseURL,
|
||||
resumeData: resume.data,
|
||||
job,
|
||||
});
|
||||
|
||||
// Step 4: Validate AI output
|
||||
const errors = validateTailorOutput(tailorOutput, resume.data);
|
||||
if (errors.length > 0) {
|
||||
toast.error(t`AI returned some invalid references`, {
|
||||
description: errors.join("; "),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 5: Convert to patches and apply
|
||||
const { operations, newSkills } = tailorOutputToPatches(tailorOutput, resume.data);
|
||||
|
||||
if (operations.length > 0) {
|
||||
await client.resume.patch({ id: newResumeId, operations });
|
||||
}
|
||||
|
||||
// Step 6: If new skills were found, show sync dialog
|
||||
if (newSkills.length > 0) {
|
||||
setSelectedSkills(new Set(newSkills.map((_, i) => i)));
|
||||
setPhase({
|
||||
step: "skill-sync",
|
||||
newResumeId,
|
||||
newSkills,
|
||||
sourceResumeId: resumeId,
|
||||
});
|
||||
} else {
|
||||
toast.success(t`Resume tailored successfully`);
|
||||
navigateToBuilder(newResumeId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(t`Tailoring failed`, { description: message });
|
||||
setPhase({ step: "select" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkillSync = async () => {
|
||||
if (phase.step !== "skill-sync") return;
|
||||
|
||||
const { newResumeId, newSkills, sourceResumeId } = phase;
|
||||
const skillsToSync = Array.from(selectedSkills).map((i) => newSkills[i]);
|
||||
|
||||
if (skillsToSync.length > 0) {
|
||||
try {
|
||||
const operations = buildSkillSyncOperations(skillsToSync);
|
||||
await client.resume.patch({ id: sourceResumeId, operations });
|
||||
toast.success(t`Added ${skillsToSync.length} new skills to your original resume`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(t`Failed to sync skills`, { description: message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t`Resume tailored successfully`);
|
||||
navigateToBuilder(newResumeId);
|
||||
};
|
||||
|
||||
const handleSkipSync = () => {
|
||||
if (phase.step !== "skill-sync") return;
|
||||
toast.success(t`Resume tailored successfully`);
|
||||
navigateToBuilder(phase.newResumeId);
|
||||
};
|
||||
|
||||
const toggleSkill = (index: number) => {
|
||||
setSelectedSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
{phase.step === "select" && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Tailor Resume</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Select a resume to tailor for "{job.job_title}" at {job.employer_name}. A copy will be created
|
||||
{aiEnabled ? " and the AI will optimize it for this position." : "."}
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-80">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : resumes && resumes.length > 0 ? (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{resumes.map((resume) => (
|
||||
<Button
|
||||
key={resume.id}
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-x-3 py-3"
|
||||
disabled={isDuplicating}
|
||||
onClick={() => handleSelectResume(resume.id, resume.name)}
|
||||
>
|
||||
<ReadCvLogoIcon className="size-5 shrink-0" />
|
||||
<div className="min-w-0 text-start">
|
||||
<p className="truncate font-medium">{resume.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{new Date(resume.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
{resume.tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{resume.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDuplicating && <Spinner className="ms-auto" />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>No resumes found. Create a resume first.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>
|
||||
<Trans>Cancel</Trans>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase.step === "tailoring" && (
|
||||
<div className="flex flex-col items-center gap-y-4 py-12">
|
||||
<Spinner className="size-10" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">
|
||||
<Trans>Tailoring your resume...</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>Optimizing summary, experience, and skills for {job.job_title}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.step === "skill-sync" && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>New Skills Detected</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
The AI identified new skills from your experience that match this job. Select which ones to save back
|
||||
to your original resume for future applications. This will permanently modify your original resume and
|
||||
cannot be undone.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-80">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{phase.newSkills.map((skill, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${skill.name}-${index}`}
|
||||
className="flex items-center gap-x-3 rounded-md border p-3 text-start hover:bg-muted/50"
|
||||
onClick={() => toggleSkill(index)}
|
||||
>
|
||||
<Switch checked={selectedSkills.has(index)} onCheckedChange={() => toggleSkill(index)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{skill.name}</p>
|
||||
{skill.proficiency && <p className="text-xs text-muted-foreground">{skill.proficiency}</p>}
|
||||
{skill.keywords.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{skill.keywords.map((kw) => (
|
||||
<Badge key={kw} variant="secondary" className="text-[10px]">
|
||||
{kw}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleSkipSync}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button onClick={handleSkillSync}>
|
||||
<Trans>Save {selectedSkills.size} Skills</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { JobResult, RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
import {
|
||||
buildPostFilters,
|
||||
buildSearchParams,
|
||||
type FilterState,
|
||||
initialFilterState,
|
||||
RESULTS_PER_PAGE,
|
||||
} from "./search-filters";
|
||||
|
||||
type ActiveFilterChip = { key: keyof FilterState; label: string; value?: string };
|
||||
|
||||
export function useJobSearch() {
|
||||
const rapidApiKey = useJobsStore((state) => state.rapidApiKey);
|
||||
const testStatus = useJobsStore((state) => state.testStatus);
|
||||
const setJobsStore = useJobsStore((state) => state.set);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [filters, setFilters] = useState<FilterState>(initialFilterState);
|
||||
const [jobs, setJobs] = useState<JobResult[]>([]);
|
||||
const [quota, setQuota] = useState<RapidApiQuota | null>(null);
|
||||
const [selectedJob, setSelectedJob] = useState<JobResult | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const { mutate: searchJobs, isPending } = useMutation(orpc.jobs.search.mutationOptions());
|
||||
|
||||
const isConfigured = Boolean(rapidApiKey && testStatus === "success");
|
||||
|
||||
const executeSearch = useCallback(
|
||||
(page: number) => {
|
||||
if (!rapidApiKey) return;
|
||||
|
||||
const requestId = ++requestIdRef.current;
|
||||
const effectiveQuery = query.trim() || "jobs";
|
||||
const params = buildSearchParams(effectiveQuery, filters, page);
|
||||
const postFilters = buildPostFilters(filters);
|
||||
setError(null);
|
||||
|
||||
searchJobs(
|
||||
{ apiKey: rapidApiKey, params, filters: postFilters },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
setHasMore(data.data.length >= RESULTS_PER_PAGE);
|
||||
setJobs(data.data.slice(0, RESULTS_PER_PAGE));
|
||||
setQuota(data.rapidApiQuota ?? null);
|
||||
|
||||
const rapidApiQuota = data.rapidApiQuota;
|
||||
if (rapidApiQuota) {
|
||||
setJobsStore((draft) => {
|
||||
draft.rapidApiQuota = rapidApiQuota;
|
||||
});
|
||||
}
|
||||
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setError(error.message);
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[filters, query, rapidApiKey, searchJobs, setJobsStore],
|
||||
);
|
||||
|
||||
const handleSearch = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setHasSearched(true);
|
||||
setCurrentPage(1);
|
||||
executeSearch(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
executeSearch(page);
|
||||
};
|
||||
|
||||
const handleJobClick = (job: JobResult) => {
|
||||
setSelectedJob(job);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const removeFilter = (key: keyof FilterState, value?: string) => {
|
||||
if (key === "includeKeywords" || key === "excludeKeywords" || key === "excludeCompanies") {
|
||||
const currentValues = filters[key];
|
||||
if (!Array.isArray(currentValues)) return;
|
||||
const nextValues = value ? currentValues.filter((item) => item !== value) : [];
|
||||
setFilters({ ...filters, [key]: nextValues });
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "remoteOnly" || key === "directApplyOnly") {
|
||||
setFilters({ ...filters, [key]: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setFilters({ ...filters, [key]: initialFilterState[key] as never });
|
||||
};
|
||||
|
||||
const activeFilterChips: ActiveFilterChip[] = [
|
||||
...(filters.remoteOnly ? [{ key: "remoteOnly" as const, label: t`Remote only` }] : []),
|
||||
...(filters.directApplyOnly ? [{ key: "directApplyOnly" as const, label: t`Direct apply only` }] : []),
|
||||
...(filters.employmentType ? [{ key: "employmentType" as const, label: filters.employmentType }] : []),
|
||||
...(filters.jobRequirements ? [{ key: "jobRequirements" as const, label: filters.jobRequirements }] : []),
|
||||
...(filters.datePosted && filters.datePosted !== "all"
|
||||
? [{ key: "datePosted" as const, label: filters.datePosted }]
|
||||
: []),
|
||||
...filters.includeKeywords.map((value) => ({ key: "includeKeywords" as const, label: `+${value}`, value })),
|
||||
...filters.excludeKeywords.map((value) => ({ key: "excludeKeywords" as const, label: `-${value}`, value })),
|
||||
...filters.excludeCompanies.map((value) => ({
|
||||
key: "excludeCompanies" as const,
|
||||
label: t`Exclude ${value}`,
|
||||
value,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
activeFilterChips,
|
||||
currentPage,
|
||||
error,
|
||||
executeSearch,
|
||||
filters,
|
||||
handleJobClick,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
hasMore,
|
||||
hasSearched,
|
||||
isConfigured,
|
||||
isPending,
|
||||
jobs,
|
||||
query,
|
||||
quota,
|
||||
removeFilter,
|
||||
scrollRef,
|
||||
selectedJob,
|
||||
setFilters,
|
||||
setQuery,
|
||||
setSheetOpen,
|
||||
sheetOpen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BriefcaseIcon,
|
||||
BuildingsIcon,
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MapPinIcon,
|
||||
MoneyIcon,
|
||||
WarningCircleIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
import { JobDetailSheet } from "./-components/job-detail";
|
||||
import { formatPostedDate, formatSalary, getQuotaStatus } from "./-components/job-utils";
|
||||
import { hasActiveFilters, initialFilterState, SearchFilters } from "./-components/search-filters";
|
||||
import { useJobSearch } from "./-components/use-job-search";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/job-search/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function JobCard({ job, onClick }: { job: JobResult; onClick: () => void }) {
|
||||
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
|
||||
const posted = formatPostedDate(job.job_posted_at_timestamp);
|
||||
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col gap-y-3 rounded-md border bg-card p-4 text-start transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<div className="flex items-start gap-x-3">
|
||||
{job.employer_logo ? (
|
||||
<img src={job.employer_logo} alt={job.employer_name} className="size-10 shrink-0 rounded-md object-contain" />
|
||||
) : (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<BuildingsIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-medium">{job.job_title}</h3>
|
||||
<p className="truncate text-sm text-muted-foreground">{job.employer_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{location && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MapPinIcon className="size-3" />
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_is_remote && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<GlobeIcon className="size-3" />
|
||||
<Trans>Remote</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_employment_type && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<BriefcaseIcon className="size-3" />
|
||||
{job.job_employment_type.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{salary && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MoneyIcon className="size-3" />
|
||||
{salary}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{posted && (
|
||||
<Badge variant="outline" className="gap-x-1">
|
||||
<ClockIcon className="size-3" />
|
||||
{posted}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const {
|
||||
activeFilterChips,
|
||||
currentPage,
|
||||
error,
|
||||
executeSearch,
|
||||
filters,
|
||||
handleJobClick,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
hasMore,
|
||||
hasSearched,
|
||||
isConfigured,
|
||||
isPending,
|
||||
jobs,
|
||||
query,
|
||||
quota,
|
||||
removeFilter,
|
||||
scrollRef,
|
||||
selectedJob,
|
||||
setFilters,
|
||||
setQuery,
|
||||
setSheetOpen,
|
||||
sheetOpen,
|
||||
} = useJobSearch();
|
||||
|
||||
const showFilterChips = useMemo(() => hasActiveFilters(filters), [filters]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={BriefcaseIcon} title={t`Job Search`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
{!isConfigured ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex max-w-xl flex-col items-center gap-y-4 py-12 text-center"
|
||||
>
|
||||
<MagnifyingGlassIcon className="size-12 text-muted-foreground" weight="light" />
|
||||
<h2 className="text-lg font-medium">
|
||||
<Trans>Configure Job Search</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>To search for job listings, you need to configure your RapidAPI key in settings.</Trans>
|
||||
</p>
|
||||
<Button nativeButton={false} variant="outline" render={<Link to="/dashboard/settings/job-search" />}>
|
||||
<Trans>Go to Settings</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSearch} className="flex items-end gap-x-3">
|
||||
<div className="flex flex-1 flex-col gap-y-2">
|
||||
<Label htmlFor="job-query">
|
||||
<Trans>Search</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="job-query"
|
||||
name="job-query"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t`e.g. frontend developer jobs in Berlin`}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? <Spinner /> : <MagnifyingGlassIcon />}
|
||||
<Trans>Search</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div ref={scrollRef} />
|
||||
|
||||
<SearchFilters filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
{showFilterChips && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{activeFilterChips.map((chip) => (
|
||||
<button
|
||||
key={`${chip.key}-${chip.value ?? chip.label}`}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border bg-background px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => removeFilter(chip.key, chip.value)}
|
||||
>
|
||||
{chip.label}
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
))}
|
||||
<Button size="sm" variant="ghost" onClick={() => setFilters(initialFilterState)}>
|
||||
<Trans>Clear all</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quota && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
getQuotaStatus(quota) === "healthy" && "text-emerald-600",
|
||||
getQuotaStatus(quota) === "warning" && "text-amber-600",
|
||||
getQuotaStatus(quota) === "critical" && "text-red-600",
|
||||
)}
|
||||
>
|
||||
<Trans>Quota: {quota.remaining} remaining</Trans>
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
{quota.used} / {quota.limit} requests used
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && jobs.length === 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="flex animate-pulse flex-col gap-y-3 rounded-md border bg-card p-4">
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-5/6 rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isPending && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningCircleIcon className="mt-0.5 size-4 text-destructive" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
<Trans>Could not fetch jobs</Trans>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => executeSearch(currentPage)}>
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{jobs.map((job) => (
|
||||
<JobCard key={job.job_id} job={job} onClick={() => handleJobClick(job)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1 || isPending}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<CaretLeftIcon className="size-4" />
|
||||
<Trans>Previous</Trans>
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<Trans>Page {currentPage}</Trans>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || isPending}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<Trans>Next</Trans>
|
||||
<CaretRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasSearched && !isPending && jobs.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>No jobs found. Try a different search query.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JobDetailSheet job={selectedJob} open={sheetOpen} onOpenChange={setSheetOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,24 +31,31 @@ export function ResumeCard({ resume }: ResumeCardProps) {
|
||||
return (
|
||||
<ResumeContextMenu resume={resume}>
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
|
||||
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
|
||||
{match({ isLoading, imageSrc: screenshotData?.url })
|
||||
.with({ isLoading: true }, () => (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<CircleNotchIcon weight="thin" className="size-12 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
.with({ imageSrc: P.string }, ({ imageSrc }) => (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={resume.name}
|
||||
className={cn("size-full object-cover transition-all", resume.isLocked && "blur-xs")}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
<motion.div
|
||||
whileHover={{ y: -2, scale: 1.005 }}
|
||||
whileTap={{ scale: 0.998 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
|
||||
{match({ isLoading, imageSrc: screenshotData?.url })
|
||||
.with({ isLoading: true }, () => (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<CircleNotchIcon weight="thin" className="size-12 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
.with({ imageSrc: P.string }, ({ imageSrc }) => (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={resume.name}
|
||||
className={cn("size-full object-cover transition-all", resume.isLocked && "blur-xs")}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
<ResumeLockOverlay isLocked={resume.isLocked} />
|
||||
</BaseCard>
|
||||
<ResumeLockOverlay isLocked={resume.isLocked} />
|
||||
</BaseCard>
|
||||
</motion.div>
|
||||
</Link>
|
||||
</ResumeContextMenu>
|
||||
);
|
||||
@@ -63,6 +70,8 @@ function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ willChange: "opacity" }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-full bg-popover p-6">
|
||||
|
||||
@@ -15,28 +15,40 @@ type Props = {
|
||||
export function GridView({ resumes }: Props) {
|
||||
return (
|
||||
<div className="3xl:grid-cols-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<motion.div initial={{ opacity: 0, x: -50 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -50 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<CreateResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{resumes?.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, y: -50, filter: "blur(12px)" }}
|
||||
transition={{ delay: (index + 2) * 0.05 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
filter: "blur(8px)",
|
||||
}}
|
||||
transition={{ duration: 0.2, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<ResumeCard resume={resume} />
|
||||
</motion.div>
|
||||
|
||||
@@ -31,7 +31,13 @@ export function ListView({ resumes }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<motion.div initial={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -50 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
@@ -50,10 +56,11 @@ export function ListView({ resumes }: Props) {
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -73,15 +80,16 @@ export function ListView({ resumes }: Props) {
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{resumes?.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -50, filter: "blur(12px)" }}
|
||||
transition={{ delay: (index + 2) * 0.05 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<ResumeListItem resume={resume} />
|
||||
</motion.div>
|
||||
|
||||
@@ -221,11 +221,12 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-sm border bg-popover p-6">
|
||||
<div className="rounded-sm bg-primary/10 p-2.5">
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -66,11 +66,12 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-sm border bg-popover p-6">
|
||||
<div className="rounded-sm bg-primary/10 p-2.5">
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<BookOpenIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
@@ -90,7 +91,7 @@ function RouteComponent() {
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me/api-reference" target="_blank" rel="noopener">
|
||||
<a href="https://docs.rxresu.me/api-reference" target="_blank" rel="noopener noreferrer">
|
||||
<LinkSimpleIcon />
|
||||
<Trans>API Reference</Trans>
|
||||
</a>
|
||||
@@ -111,7 +112,7 @@ function RouteComponent() {
|
||||
<Trans>Create a new API key</Trans>
|
||||
</Button>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{apiKeys.map((key, index) => (
|
||||
<motion.div
|
||||
key={key.id}
|
||||
@@ -119,7 +120,8 @@ function RouteComponent() {
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ delay: index * 0.08 }}
|
||||
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<KeyIcon />
|
||||
|
||||
@@ -130,9 +132,16 @@ function RouteComponent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.03 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -29,7 +29,8 @@ export function PasswordSection() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center justify-between gap-x-4"
|
||||
>
|
||||
<h2 className="flex items-center gap-x-3 text-base font-medium">
|
||||
@@ -39,21 +40,35 @@ export function PasswordSection() {
|
||||
|
||||
{match(hasPassword)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleUpdatePassword}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleUpdatePassword}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Set Password</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Set Password</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</motion.div>
|
||||
|
||||
@@ -40,7 +40,8 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: animationDelay }}
|
||||
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
@@ -52,16 +53,30 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
|
||||
{match(isConnected)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleUnlink}>
|
||||
<LinkBreakIcon />
|
||||
<Trans>Disconnect</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleUnlink}>
|
||||
<LinkBreakIcon />
|
||||
<Trans>Disconnect</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button variant="outline" onClick={handleLink}>
|
||||
<LinkIcon />
|
||||
<Trans>Connect</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleLink}>
|
||||
<LinkIcon />
|
||||
<Trans>Connect</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,8 @@ export function TwoFactorSection() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
@@ -45,16 +46,30 @@ export function TwoFactorSection() {
|
||||
|
||||
{match(hasTwoFactor)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleLeftIcon />
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleLeftIcon />
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleRightIcon />
|
||||
<Trans>Enable 2FA</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleRightIcon />
|
||||
<Trans>Enable 2FA</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,8 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-4"
|
||||
>
|
||||
<PasswordSection />
|
||||
|
||||
@@ -62,7 +62,8 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<p className="leading-relaxed">
|
||||
@@ -76,15 +77,18 @@ function RouteComponent() {
|
||||
placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`}
|
||||
/>
|
||||
|
||||
<Button
|
||||
<motion.div
|
||||
className="justify-self-end"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={!isConfirmationValid}
|
||||
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
|
||||
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BriefcaseIcon, CheckCircleIcon, InfoIcon, LinkSimpleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/job-search")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RapidAPIKeyForm() {
|
||||
const { set, rapidApiKey, testStatus } = useJobsStore();
|
||||
|
||||
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.jobs.testConnection.mutationOptions());
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.rapidApiKey = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
testConnection(
|
||||
{ apiKey: rapidApiKey },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = data.success ? "success" : "failure";
|
||||
draft.rapidApiQuota = data.rapidApiQuota ?? null;
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
draft.rapidApiQuota = null;
|
||||
});
|
||||
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="rapidapi-key">
|
||||
<Trans>RapidAPI Key</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="rapidapi-key"
|
||||
name="rapidapi-key"
|
||||
type="password"
|
||||
value={rapidApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder={t`Enter your RapidAPI key`}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Get your API key from RapidAPI by subscribing to the JSearch API.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" disabled={isTesting || !rapidApiKey} onClick={handleTestConnection}>
|
||||
{match({ isTesting, testStatus })
|
||||
.with({ isTesting: true }, () => <Spinner />)
|
||||
.with({ isTesting: false, testStatus: "success" }, () => <CheckCircleIcon className="text-success" />)
|
||||
.with({ isTesting: false, testStatus: "failure" }, () => <XCircleIcon className="text-destructive" />)
|
||||
.otherwise(() => null)}
|
||||
<Trans>Test Connection</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RapidAPIQuotaDisplay() {
|
||||
const { testStatus, rapidApiQuota } = useJobsStore();
|
||||
|
||||
if (!rapidApiQuota || testStatus !== "success") return null;
|
||||
|
||||
const { used, limit, remaining } = rapidApiQuota;
|
||||
const percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Progress value={percent} id="jobs-quota-progress" className="w-full max-w-md">
|
||||
<ProgressLabel>
|
||||
<Trans>API Usage</Trans>
|
||||
</ProgressLabel>
|
||||
<ProgressValue />
|
||||
</Progress>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
{used} of {limit} requests used ({remaining} remaining)
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const isClient = useIsClient();
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={BriefcaseIcon} title={t`Job Search API`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>What is JSearch API?</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
JSearch aggregates job listings from multiple job boards using Google for Jobs. You can filter by
|
||||
country (ISO alpha-2 code), date posted, job type, remote options, and experience level.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
href="https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkSimpleIcon />
|
||||
<Trans>JSearch API Documentation</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
Your RapidAPI key is stored locally on your browser. It is only sent to the server when making a request
|
||||
to search for jobs, and is never stored or logged on our servers.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<RapidAPIKeyForm />
|
||||
<RapidAPIQuotaDisplay />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,8 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
@@ -47,7 +48,7 @@ function RouteComponent() {
|
||||
nativeButton={false}
|
||||
className="h-5 justify-start text-xs text-muted-foreground active:scale-100"
|
||||
render={
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener">
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Help translate the app to your language</Trans>
|
||||
<ArrowRightIcon className="size-3" />
|
||||
</a>
|
||||
|
||||
@@ -123,7 +123,8 @@ function RouteComponent() {
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
@@ -214,12 +215,14 @@ function RouteComponent() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center gap-x-4 justify-self-end"
|
||||
>
|
||||
<Button type="reset" variant="ghost" onClick={onCancel}>
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyOptionSchema,
|
||||
jobDetailsResponseSchema,
|
||||
jobRequiredEducationSchema,
|
||||
jobRequiredExperienceSchema,
|
||||
jobResultSchema,
|
||||
postFilterOptionsSchema,
|
||||
rapidApiQuotaSchema,
|
||||
searchParamsSchema,
|
||||
searchResponseSchema,
|
||||
} from "./jobs";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeMinimalJob(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
job_id: "abc123",
|
||||
job_title: "Software Engineer",
|
||||
employer_name: "Acme Corp",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- searchParamsSchema ---
|
||||
|
||||
describe("searchParamsSchema", () => {
|
||||
it("accepts a valid query-only search", () => {
|
||||
const result = searchParamsSchema.parse({ query: "react developer" });
|
||||
expect(result.query).toBe("react developer");
|
||||
});
|
||||
|
||||
it("accepts all optional fields", () => {
|
||||
const result = searchParamsSchema.parse({
|
||||
query: "engineer",
|
||||
page: 2,
|
||||
num_pages: 3,
|
||||
date_posted: "week",
|
||||
country: "DE",
|
||||
remote_jobs_only: true,
|
||||
employment_types: "FULLTIME",
|
||||
job_requirements: "under_3_years_experience",
|
||||
radius: 50,
|
||||
exclude_job_publishers: "Indeed",
|
||||
categories: "it-jobs",
|
||||
});
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.country).toBe("DE");
|
||||
expect(result.remote_jobs_only).toBe(true);
|
||||
expect(result.date_posted).toBe("week");
|
||||
});
|
||||
|
||||
it("rejects empty query", () => {
|
||||
expect(() => searchParamsSchema.parse({ query: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing query", () => {
|
||||
expect(() => searchParamsSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid date_posted value", () => {
|
||||
expect(() => searchParamsSchema.parse({ query: "test", date_posted: "yesterday" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid country values", () => {
|
||||
expect(() => searchParamsSchema.parse({ query: "test", country: "usa" })).toThrow();
|
||||
expect(() => searchParamsSchema.parse({ query: "test", country: "U" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-positive page numbers", () => {
|
||||
expect(() => searchParamsSchema.parse({ query: "test", page: 0 })).toThrow();
|
||||
expect(() => searchParamsSchema.parse({ query: "test", page: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects num_pages greater than allowed cap", () => {
|
||||
expect(() => searchParamsSchema.parse({ query: "test", num_pages: 11 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --- jobRequiredExperienceSchema ---
|
||||
|
||||
describe("jobRequiredExperienceSchema", () => {
|
||||
it("parses valid experience data", () => {
|
||||
const result = jobRequiredExperienceSchema.parse({
|
||||
no_experience_required: true,
|
||||
required_experience_in_months: 24,
|
||||
experience_mentioned: true,
|
||||
experience_preferred: false,
|
||||
});
|
||||
expect(result.no_experience_required).toBe(true);
|
||||
expect(result.required_experience_in_months).toBe(24);
|
||||
});
|
||||
|
||||
it("falls back to defaults for missing fields", () => {
|
||||
const result = jobRequiredExperienceSchema.parse({});
|
||||
expect(result.no_experience_required).toBe(false);
|
||||
expect(result.required_experience_in_months).toBeNull();
|
||||
expect(result.experience_mentioned).toBe(false);
|
||||
expect(result.experience_preferred).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- jobRequiredEducationSchema ---
|
||||
|
||||
describe("jobRequiredEducationSchema", () => {
|
||||
it("parses valid education data", () => {
|
||||
const result = jobRequiredEducationSchema.parse({
|
||||
bachelors_degree: true,
|
||||
degree_mentioned: true,
|
||||
});
|
||||
expect(result.bachelors_degree).toBe(true);
|
||||
expect(result.degree_mentioned).toBe(true);
|
||||
expect(result.postgraduate_degree).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to defaults for missing fields", () => {
|
||||
const result = jobRequiredEducationSchema.parse({});
|
||||
expect(result.bachelors_degree).toBe(false);
|
||||
expect(result.high_school).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- applyOptionSchema ---
|
||||
|
||||
describe("applyOptionSchema", () => {
|
||||
it("parses valid apply option", () => {
|
||||
const result = applyOptionSchema.parse({
|
||||
publisher: "LinkedIn",
|
||||
apply_link: "https://example.com/apply",
|
||||
is_direct: true,
|
||||
});
|
||||
expect(result.publisher).toBe("LinkedIn");
|
||||
expect(result.is_direct).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to defaults for missing fields", () => {
|
||||
const result = applyOptionSchema.parse({});
|
||||
expect(result.publisher).toBe("");
|
||||
expect(result.apply_link).toBe("");
|
||||
expect(result.is_direct).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- jobResultSchema ---
|
||||
|
||||
describe("jobResultSchema", () => {
|
||||
it("parses a minimal job result (required + catch defaults)", () => {
|
||||
const result = jobResultSchema.parse(makeMinimalJob());
|
||||
expect(result.job_id).toBe("abc123");
|
||||
expect(result.job_title).toBe("Software Engineer");
|
||||
expect(result.employer_name).toBe("Acme Corp");
|
||||
expect(result.employer_logo).toBeNull();
|
||||
expect(result.job_is_remote).toBe(false);
|
||||
expect(result.apply_options).toEqual([]);
|
||||
expect(result.job_required_experience.no_experience_required).toBe(false);
|
||||
});
|
||||
|
||||
it("parses a full job result", () => {
|
||||
const result = jobResultSchema.parse(
|
||||
makeMinimalJob({
|
||||
employer_logo: "https://logo.com/img.png",
|
||||
job_is_remote: true,
|
||||
job_city: "New York",
|
||||
job_state: "NY",
|
||||
job_country: "US",
|
||||
job_min_salary: 80000,
|
||||
job_max_salary: 120000,
|
||||
job_salary_currency: "USD",
|
||||
job_salary_period: "YEAR",
|
||||
job_benefits: ["Health Insurance", "401k"],
|
||||
job_required_skills: ["React", "TypeScript"],
|
||||
job_highlights: {
|
||||
Qualifications: ["3+ years experience", "BS in CS"],
|
||||
Responsibilities: ["Build UI", "Review code"],
|
||||
},
|
||||
apply_options: [{ publisher: "LinkedIn", apply_link: "https://linkedin.com/apply", is_direct: true }],
|
||||
}),
|
||||
);
|
||||
expect(result.employer_logo).toBe("https://logo.com/img.png");
|
||||
expect(result.job_is_remote).toBe(true);
|
||||
expect(result.job_min_salary).toBe(80000);
|
||||
expect(result.job_benefits).toEqual(["Health Insurance", "401k"]);
|
||||
expect(result.job_highlights?.Qualifications).toHaveLength(2);
|
||||
expect(result.apply_options).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects missing required fields", () => {
|
||||
expect(() => jobResultSchema.parse({})).toThrow();
|
||||
expect(() => jobResultSchema.parse({ job_id: "abc" })).toThrow();
|
||||
});
|
||||
|
||||
it("catches invalid optional fields gracefully", () => {
|
||||
const result = jobResultSchema.parse(
|
||||
makeMinimalJob({
|
||||
employer_logo: 12345, // wrong type → catch(null)
|
||||
job_is_remote: "yes", // wrong type → catch(false)
|
||||
job_benefits: "not-an-array", // wrong type → catch(null)
|
||||
}),
|
||||
);
|
||||
expect(result.employer_logo).toBeNull();
|
||||
expect(result.job_is_remote).toBe(false);
|
||||
expect(result.job_benefits).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- searchResponseSchema ---
|
||||
|
||||
describe("searchResponseSchema", () => {
|
||||
it("parses a valid search response", () => {
|
||||
const result = searchResponseSchema.parse({
|
||||
status: "OK",
|
||||
request_id: "req-123",
|
||||
parameters: { query: "react" },
|
||||
data: [makeMinimalJob()],
|
||||
});
|
||||
expect(result.status).toBe("OK");
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].job_id).toBe("abc123");
|
||||
});
|
||||
|
||||
it("falls back to empty array for invalid data", () => {
|
||||
const result = searchResponseSchema.parse({
|
||||
status: "OK",
|
||||
request_id: "req-123",
|
||||
data: "invalid",
|
||||
});
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to empty object for invalid parameters", () => {
|
||||
const result = searchResponseSchema.parse({
|
||||
status: "OK",
|
||||
request_id: "req-123",
|
||||
parameters: 42,
|
||||
});
|
||||
expect(result.parameters).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// --- jobDetailsResponseSchema ---
|
||||
|
||||
describe("jobDetailsResponseSchema", () => {
|
||||
it("parses a valid job details response", () => {
|
||||
const result = jobDetailsResponseSchema.parse({
|
||||
status: "OK",
|
||||
request_id: "req-456",
|
||||
data: [makeMinimalJob()],
|
||||
});
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles empty data array", () => {
|
||||
const result = jobDetailsResponseSchema.parse({
|
||||
status: "OK",
|
||||
request_id: "req-456",
|
||||
data: [],
|
||||
});
|
||||
expect(result.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- postFilterOptionsSchema ---
|
||||
|
||||
describe("postFilterOptionsSchema", () => {
|
||||
it("parses all filter options", () => {
|
||||
const result = postFilterOptionsSchema.parse({
|
||||
minSalary: 50000,
|
||||
maxSalary: 150000,
|
||||
includeKeywords: ["react", "typescript"],
|
||||
excludeKeywords: ["senior"],
|
||||
excludeCompanies: ["Spam Inc"],
|
||||
directApplyOnly: true,
|
||||
});
|
||||
expect(result.minSalary).toBe(50000);
|
||||
expect(result.includeKeywords).toHaveLength(2);
|
||||
expect(result.directApplyOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts empty object (all optional)", () => {
|
||||
const result = postFilterOptionsSchema.parse({});
|
||||
expect(result.minSalary).toBeUndefined();
|
||||
expect(result.directApplyOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects negative salary filters", () => {
|
||||
expect(() => postFilterOptionsSchema.parse({ minSalary: -1 })).toThrow();
|
||||
expect(() => postFilterOptionsSchema.parse({ maxSalary: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects minSalary greater than maxSalary", () => {
|
||||
expect(() => postFilterOptionsSchema.parse({ minSalary: 200000, maxSalary: 100000 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --- rapidApiQuotaSchema ---
|
||||
|
||||
describe("rapidApiQuotaSchema", () => {
|
||||
it("parses valid RapidAPI quota", () => {
|
||||
const result = rapidApiQuotaSchema.parse({ limit: 200, remaining: 195, used: 5 });
|
||||
expect(result.limit).toBe(200);
|
||||
expect(result.remaining).toBe(195);
|
||||
expect(result.used).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects missing fields", () => {
|
||||
expect(() => rapidApiQuotaSchema.parse({ limit: 200 })).toThrow();
|
||||
expect(() => rapidApiQuotaSchema.parse({})).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import z from "zod";
|
||||
|
||||
// --- JSearch API Request Types ---
|
||||
|
||||
export const searchParamsSchema = z.object({
|
||||
query: z.string().min(1),
|
||||
page: z.number().int().positive().optional(),
|
||||
num_pages: z.number().int().positive().max(10).optional(),
|
||||
date_posted: z.enum(["all", "today", "3days", "week", "month"]).optional(),
|
||||
country: z
|
||||
.string()
|
||||
.length(2)
|
||||
.regex(/^[A-Z]{2}$/)
|
||||
.optional(),
|
||||
remote_jobs_only: z.boolean().optional(),
|
||||
employment_types: z.string().optional(),
|
||||
job_requirements: z.string().optional(),
|
||||
radius: z.number().positive().optional(),
|
||||
exclude_job_publishers: z.string().optional(),
|
||||
categories: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SearchParams = z.infer<typeof searchParamsSchema>;
|
||||
|
||||
// --- JSearch API Response Types ---
|
||||
|
||||
export const jobRequiredExperienceSchema = z.object({
|
||||
no_experience_required: z.boolean().catch(false),
|
||||
required_experience_in_months: z.number().nullable().catch(null),
|
||||
experience_mentioned: z.boolean().catch(false),
|
||||
experience_preferred: z.boolean().catch(false),
|
||||
});
|
||||
|
||||
export const jobRequiredEducationSchema = z.object({
|
||||
postgraduate_degree: z.boolean().catch(false),
|
||||
professional_certification: z.boolean().catch(false),
|
||||
high_school: z.boolean().catch(false),
|
||||
associates_degree: z.boolean().catch(false),
|
||||
bachelors_degree: z.boolean().catch(false),
|
||||
degree_mentioned: z.boolean().catch(false),
|
||||
degree_preferred: z.boolean().catch(false),
|
||||
professional_certification_mentioned: z.boolean().catch(false),
|
||||
});
|
||||
|
||||
export const applyOptionSchema = z.object({
|
||||
publisher: z.string().catch(""),
|
||||
apply_link: z.string().catch(""),
|
||||
is_direct: z.boolean().catch(false),
|
||||
});
|
||||
|
||||
export const jobResultSchema = z.object({
|
||||
job_id: z.string(),
|
||||
job_title: z.string(),
|
||||
employer_name: z.string(),
|
||||
employer_logo: z.string().nullable().catch(null),
|
||||
employer_website: z.string().nullable().catch(null),
|
||||
employer_company_type: z.string().nullable().catch(null),
|
||||
employer_linkedin: z.string().nullable().catch(null),
|
||||
job_publisher: z.string().catch(""),
|
||||
job_employment_type: z.string().catch(""),
|
||||
job_apply_link: z.string().catch(""),
|
||||
job_apply_is_direct: z.boolean().catch(false),
|
||||
job_apply_quality_score: z.number().nullable().catch(null),
|
||||
job_description: z.string().catch(""),
|
||||
job_is_remote: z.boolean().catch(false),
|
||||
job_city: z.string().catch(""),
|
||||
job_state: z.string().catch(""),
|
||||
job_country: z.string().catch(""),
|
||||
job_latitude: z.number().nullable().catch(null),
|
||||
job_longitude: z.number().nullable().catch(null),
|
||||
job_posted_at_timestamp: z.number().nullable().catch(null),
|
||||
job_posted_at_datetime_utc: z.string().catch(""),
|
||||
job_offer_expiration_datetime_utc: z.string().nullable().catch(null),
|
||||
job_offer_expiration_timestamp: z.number().nullable().catch(null),
|
||||
job_min_salary: z.number().nullable().catch(null),
|
||||
job_max_salary: z.number().nullable().catch(null),
|
||||
job_salary_currency: z.string().nullable().catch(null),
|
||||
job_salary_period: z.string().nullable().catch(null),
|
||||
job_benefits: z.array(z.string()).nullable().catch(null),
|
||||
job_google_link: z.string().nullable().catch(null),
|
||||
job_required_experience: jobRequiredExperienceSchema.catch({
|
||||
no_experience_required: false,
|
||||
required_experience_in_months: null,
|
||||
experience_mentioned: false,
|
||||
experience_preferred: false,
|
||||
}),
|
||||
job_required_skills: z.array(z.string()).nullable().catch(null),
|
||||
job_required_education: jobRequiredEducationSchema.catch({
|
||||
postgraduate_degree: false,
|
||||
professional_certification: false,
|
||||
high_school: false,
|
||||
associates_degree: false,
|
||||
bachelors_degree: false,
|
||||
degree_mentioned: false,
|
||||
degree_preferred: false,
|
||||
professional_certification_mentioned: false,
|
||||
}),
|
||||
job_experience_in_place_of_education: z.boolean().nullable().catch(null),
|
||||
job_highlights: z.record(z.string(), z.array(z.string())).nullable().catch(null),
|
||||
job_posting_language: z.string().nullable().catch(null),
|
||||
job_onet_soc: z.string().nullable().catch(null),
|
||||
job_onet_job_zone: z.string().nullable().catch(null),
|
||||
job_occupational_categories: z.array(z.string()).nullable().catch(null),
|
||||
job_naics_code: z.string().nullable().catch(null),
|
||||
job_naics_name: z.string().nullable().catch(null),
|
||||
apply_options: z.array(applyOptionSchema).catch([]),
|
||||
});
|
||||
|
||||
export type JobResult = z.infer<typeof jobResultSchema>;
|
||||
|
||||
export const searchResponseSchema = z.object({
|
||||
status: z.string(),
|
||||
request_id: z.string(),
|
||||
parameters: z.record(z.string(), z.string()).catch({}),
|
||||
data: z.array(jobResultSchema).catch([]),
|
||||
});
|
||||
|
||||
export type SearchResponse = z.infer<typeof searchResponseSchema>;
|
||||
|
||||
export const jobDetailsResponseSchema = z.object({
|
||||
status: z.string(),
|
||||
request_id: z.string(),
|
||||
data: z.array(jobResultSchema).catch([]),
|
||||
});
|
||||
|
||||
export type JobDetailsResponse = z.infer<typeof jobDetailsResponseSchema>;
|
||||
|
||||
// --- Client-side Filter Types ---
|
||||
|
||||
export const postFilterOptionsSchema = z
|
||||
.object({
|
||||
minSalary: z.number().nonnegative().optional(),
|
||||
maxSalary: z.number().nonnegative().optional(),
|
||||
includeKeywords: z.array(z.string()).optional(),
|
||||
excludeKeywords: z.array(z.string()).optional(),
|
||||
excludeCompanies: z.array(z.string()).optional(),
|
||||
directApplyOnly: z.boolean().optional(),
|
||||
})
|
||||
.refine((value) => value.minSalary == null || value.maxSalary == null || value.minSalary <= value.maxSalary, {
|
||||
message: "minSalary must be less than or equal to maxSalary",
|
||||
path: ["minSalary"],
|
||||
});
|
||||
|
||||
export type PostFilterOptions = z.infer<typeof postFilterOptionsSchema>;
|
||||
|
||||
// --- Rate Limiting Types ---
|
||||
|
||||
export const rapidApiQuotaSchema = z.object({
|
||||
limit: z.number(),
|
||||
remaining: z.number(),
|
||||
used: z.number(),
|
||||
});
|
||||
|
||||
export type RapidApiQuota = z.infer<typeof rapidApiQuotaSchema>;
|
||||
@@ -0,0 +1,95 @@
|
||||
import z from "zod";
|
||||
|
||||
const tailoredSkillSchema = z.object({
|
||||
name: z.string().min(1).describe("Skill category name, e.g. 'Frontend Development', 'Cloud Infrastructure'."),
|
||||
keywords: z
|
||||
.array(z.string())
|
||||
.describe("Specific technologies or competencies displayed as tags, e.g. ['React', 'TypeScript', 'Next.js']."),
|
||||
proficiency: z
|
||||
.string()
|
||||
.describe("Proficiency label, e.g. 'Advanced', 'Intermediate', 'Developer', 'Languages'. Use consistent style."),
|
||||
icon: z
|
||||
.string()
|
||||
.describe(
|
||||
"A Phosphor icon name from @phosphor-icons/web matching the skill category, or empty string if unsure. Examples: 'code', 'database', 'cloud', 'wrench', 'paint-brush', 'globe'.",
|
||||
),
|
||||
isNew: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"true if this skill was NOT in the original resume and was inferred from experience + job requirements. false if it existed in the original resume.",
|
||||
),
|
||||
});
|
||||
|
||||
export const tailorOutputSchema = z.object({
|
||||
summary: z.object({
|
||||
content: z
|
||||
.string()
|
||||
.describe(
|
||||
"Tailored HTML summary content highlighting the candidate's most relevant experience for the target job. Use <p> tags for paragraphs. 2-3 sentences, 50-75 words. No emdashes or endashes.",
|
||||
),
|
||||
}),
|
||||
|
||||
experiences: z
|
||||
.array(
|
||||
z.object({
|
||||
index: z
|
||||
.number()
|
||||
.describe("Zero-based index of the experience item in the resume's sections.experience.items array."),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
"Tailored HTML description emphasizing achievements and responsibilities relevant to the target job. Use <p>, <ul>, <li> tags. No emdashes or endashes.",
|
||||
),
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
index: z.number().describe("Zero-based index of the role within this experience's roles array."),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
"Tailored HTML description for this specific role. Use <p>, <ul>, <li> tags. No emdashes or endashes.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Tailored role-level updates. Use an empty array when the experience does not have role progression.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"You MUST include ALL experience items that have any relevance to the target job. Rewrite their descriptions to emphasize relevant achievements. Only omit experiences completely unrelated to the job.",
|
||||
),
|
||||
|
||||
references: z
|
||||
.array(
|
||||
z.object({
|
||||
index: z
|
||||
.number()
|
||||
.describe("Zero-based index of the reference item in the resume's sections.references.items array."),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
"Rewritten professional reference description tailored to the target job. Should highlight how this reference can speak to relevant skills and experience. Use <p> tags. No emdashes or endashes.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Rewrite ALL reference descriptions to be professional and relevant to the target job. Each description should explain how this reference relates to the candidate's qualifications for the position.",
|
||||
),
|
||||
|
||||
skills: z
|
||||
.array(tailoredSkillSchema)
|
||||
.describe(
|
||||
"The complete curated skills list for the tailored resume. Include relevant existing skills (rewritten for consistency) and new inferred skills. Aim for 6-10 skills total to avoid overflowing the page. Each skill must have consistent icon, proficiency label, and keywords style.",
|
||||
),
|
||||
});
|
||||
|
||||
export type TailorOutput = z.infer<typeof tailorOutputSchema>;
|
||||
|
||||
export type TailoredSkill = z.infer<typeof tailoredSkillSchema>;
|
||||
|
||||
export type NewSkillInfo = {
|
||||
name: string;
|
||||
keywords: string[];
|
||||
proficiency: string;
|
||||
};
|
||||
@@ -154,7 +154,7 @@
|
||||
|
||||
/* Accessibility: Focus visible styles for links */
|
||||
a:focus-visible {
|
||||
@apply rounded-sm outline-2 outline-offset-2 outline-ring;
|
||||
@apply rounded-md outline-2 outline-offset-2 outline-ring;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { generateFilename } from "./file";
|
||||
|
||||
describe("generateFilename", () => {
|
||||
it("should return name with extension", () => {
|
||||
expect(generateFilename("My Resume", "docx")).toBe("my-resume.docx");
|
||||
});
|
||||
|
||||
it("should return name without extension when none provided", () => {
|
||||
expect(generateFilename("My Resume")).toBe("my-resume");
|
||||
});
|
||||
|
||||
it("should preserve the exact resume name with special characters", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "docx")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.docx",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with pdf extension", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "pdf")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with json extension", () => {
|
||||
expect(generateFilename("John Doe - CS Base - Program Coordinator", "json")).toBe(
|
||||
"john-doe-cs-base-program-coordinator.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
-14
@@ -1,21 +1,8 @@
|
||||
import { slugify } from "./string";
|
||||
|
||||
function getReadableTimestamp(now: Date) {
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
const h = String(now.getHours()).padStart(2, "0");
|
||||
const min = String(now.getMinutes()).padStart(2, "0");
|
||||
|
||||
return `${y}${m}${d}_${h}${min}`;
|
||||
}
|
||||
|
||||
export function generateFilename(prefix: string, extension?: string) {
|
||||
const now = new Date();
|
||||
const name = slugify(prefix);
|
||||
const timestamp = getReadableTimestamp(now);
|
||||
|
||||
return `${name}_${timestamp}${extension ? `.${extension}` : ""}`;
|
||||
return `${name}${extension ? `.${extension}` : ""}`;
|
||||
}
|
||||
|
||||
export function downloadWithAnchor(blob: Blob, filename: string) {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { Document } from "docx";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { buildDocument } from "./builder";
|
||||
|
||||
function makeResumeData(overrides?: Partial<ResumeData>): ResumeData {
|
||||
return { ...defaultResumeData, ...overrides };
|
||||
}
|
||||
|
||||
describe("buildDocument", () => {
|
||||
it("produces a valid Document from default resume data", () => {
|
||||
const doc = buildDocument(defaultResumeData);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("includes name in header when present", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "John Doe" },
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("includes contact info in header", () => {
|
||||
const data = makeResumeData({
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
name: "Jane Smith",
|
||||
email: "jane@example.com",
|
||||
phone: "+1-555-1234",
|
||||
location: "San Francisco, CA",
|
||||
website: { url: "https://jane.dev", label: "jane.dev" },
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders summary section when visible", () => {
|
||||
const data = makeResumeData({
|
||||
summary: {
|
||||
title: "About Me",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
content: "<p>I am a software engineer.</p>",
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("omits summary when hidden", () => {
|
||||
const data = makeResumeData({
|
||||
summary: {
|
||||
title: "About Me",
|
||||
columns: 1,
|
||||
hidden: true,
|
||||
content: "<p>Should not appear.</p>",
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders experience items", () => {
|
||||
const data = makeResumeData({
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
experience: {
|
||||
title: "Experience",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "Acme Corp",
|
||||
position: "Developer",
|
||||
location: "Remote",
|
||||
period: "2020-2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Built features</p>",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("filters hidden items from experience", () => {
|
||||
const data = makeResumeData({
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
experience: {
|
||||
title: "Experience",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "exp-1",
|
||||
hidden: true,
|
||||
company: "Hidden Corp",
|
||||
position: "Ghost",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// Should produce a document but the experience section should be omitted
|
||||
// (all items hidden → section heading omitted)
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders skills with keywords", () => {
|
||||
const data = makeResumeData({
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
skills: {
|
||||
title: "Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["React", "Node.js", "Vite"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("follows section order from layout metadata", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [
|
||||
{
|
||||
fullWidth: false,
|
||||
main: ["experience", "education"],
|
||||
sidebar: ["skills"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders custom sections by type", () => {
|
||||
const data = makeResumeData({
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-1",
|
||||
title: "Side Projects",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
type: "projects",
|
||||
items: [
|
||||
{
|
||||
id: "proj-1",
|
||||
hidden: false,
|
||||
name: "My App",
|
||||
period: "2023",
|
||||
website: { url: "https://myapp.dev", label: "myapp.dev" },
|
||||
description: "<p>A cool app.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [
|
||||
{
|
||||
fullWidth: false,
|
||||
main: ["custom-1"],
|
||||
sidebar: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("extracts primary color from metadata", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
design: {
|
||||
...defaultResumeData.metadata.design,
|
||||
colors: {
|
||||
primary: "rgba(59, 130, 246, 1)",
|
||||
text: "rgba(0, 0, 0, 1)",
|
||||
background: "rgba(255, 255, 255, 1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// Should not throw — the color is used for section headings
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders full-width layout when sidebar is empty", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [
|
||||
{
|
||||
fullWidth: true,
|
||||
main: ["experience"],
|
||||
sidebar: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("applies page format and margins from metadata", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
page: {
|
||||
...defaultResumeData.metadata.page,
|
||||
format: "letter",
|
||||
marginX: 20,
|
||||
marginY: 15,
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("applies typography settings from metadata", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
typography: {
|
||||
body: {
|
||||
fontFamily: "Arial",
|
||||
fontWeights: ["400"],
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
heading: {
|
||||
fontFamily: "Georgia",
|
||||
fontWeights: ["700"],
|
||||
fontSize: 16,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("produces a valid DOCX blob via Packer", async () => {
|
||||
const { Packer } = await import("docx");
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Test User" },
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
const blob = await Packer.toBlob(doc);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
|
||||
// DOCX files are ZIP archives — first 2 bytes are "PK" (0x50, 0x4B)
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const view = new Uint8Array(buffer);
|
||||
expect(view[0]).toBe(0x50);
|
||||
expect(view[1]).toBe(0x4b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import {
|
||||
BorderStyle,
|
||||
convertMillimetersToTwip,
|
||||
Document,
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
|
||||
import type { ResumeData, SectionType } from "@/schema/resume/data";
|
||||
|
||||
import { parseColorString } from "@/utils/color";
|
||||
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
|
||||
|
||||
// --- Color helpers ---
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function getColorHex(rgba: string, fallback: string): string {
|
||||
const parsed = parseColorString(rgba);
|
||||
if (!parsed) return fallback;
|
||||
return rgbToHex(parsed.r, parsed.g, parsed.b);
|
||||
}
|
||||
|
||||
// --- Unit conversion helpers ---
|
||||
|
||||
/** Convert pt font size to docx half-points (docx uses half-pt units). */
|
||||
function ptToHalfPt(pt: number): number {
|
||||
return Math.round(pt * 2);
|
||||
}
|
||||
|
||||
/** Convert pt spacing to twips (1pt = 20 twips). */
|
||||
function ptToTwips(pt: number): number {
|
||||
return Math.round(pt * 20);
|
||||
}
|
||||
|
||||
// --- Page size constants (in mm) ---
|
||||
|
||||
const PAGE_SIZES: Record<string, { width: number; height: number }> = {
|
||||
a4: { width: 210, height: 297 },
|
||||
letter: { width: 215.9, height: 279.4 },
|
||||
};
|
||||
|
||||
// --- Invisible border preset for table cells ---
|
||||
|
||||
const NO_BORDERS = {
|
||||
top: { style: BorderStyle.NONE, size: 0 },
|
||||
bottom: { style: BorderStyle.NONE, size: 0 },
|
||||
left: { style: BorderStyle.NONE, size: 0 },
|
||||
right: { style: BorderStyle.NONE, size: 0 },
|
||||
} as const;
|
||||
|
||||
// --- Template layout config ---
|
||||
|
||||
interface TemplateConfig {
|
||||
/** Which side the sidebar appears on */
|
||||
sidebarSide: "left" | "right";
|
||||
/** Sidebar background: "solid" = full primary color, "tint" = 20% opacity, "none" = no background */
|
||||
sidebarBackground: "solid" | "tint" | "none";
|
||||
/** Where the header is rendered */
|
||||
headerPosition: "full-width" | "main-only" | "sidebar-only";
|
||||
}
|
||||
|
||||
const TEMPLATE_CONFIGS: Record<string, TemplateConfig> = {
|
||||
azurill: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
bronzor: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
chikorita: { sidebarSide: "right", sidebarBackground: "solid", headerPosition: "main-only" },
|
||||
ditgar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
ditto: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
gengar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
glalie: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
kakuna: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
lapras: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
leafish: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
onyx: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
pikachu: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "main-only" },
|
||||
rhyhorn: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
};
|
||||
|
||||
const DEFAULT_TEMPLATE_CONFIG: TemplateConfig = {
|
||||
sidebarSide: "left",
|
||||
sidebarBackground: "none",
|
||||
headerPosition: "full-width",
|
||||
};
|
||||
|
||||
/**
|
||||
* Blends a hex color toward white at the given opacity (0-1).
|
||||
* Used to approximate CSS `background-color: rgba(r,g,b, 0.2)` on a white background.
|
||||
*/
|
||||
function blendWithWhite(hex: string, opacity: number): string {
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16);
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16);
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16);
|
||||
const blend = (c: number) => Math.round(c * opacity + 255 * (1 - opacity));
|
||||
return [blend(r), blend(g), blend(b)].map((c) => c.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// --- Section rendering dispatch ---
|
||||
|
||||
const BUILT_IN_SECTIONS = new Set<string>([
|
||||
"profiles",
|
||||
"experience",
|
||||
"education",
|
||||
"projects",
|
||||
"skills",
|
||||
"languages",
|
||||
"interests",
|
||||
"awards",
|
||||
"certifications",
|
||||
"publications",
|
||||
"volunteer",
|
||||
"references",
|
||||
]);
|
||||
|
||||
function renderSection(sectionId: string, data: ResumeData, colorHex: string): Paragraph[] {
|
||||
if (sectionId === "summary") {
|
||||
return renderSummary(data.summary, colorHex);
|
||||
}
|
||||
|
||||
if (BUILT_IN_SECTIONS.has(sectionId)) {
|
||||
const section = data.sections[sectionId as SectionType];
|
||||
if (section) {
|
||||
return renderBuiltInSection(sectionId as SectionType, section, colorHex);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const customSection = data.customSections.find((cs) => cs.id === sectionId);
|
||||
if (customSection) {
|
||||
return renderCustomSection(customSection, colorHex);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- Header ---
|
||||
|
||||
function buildHeader(data: ResumeData, colorHex: string, textColorHex: string): Paragraph[] {
|
||||
const paragraphs: Paragraph[] = [];
|
||||
const { basics } = data;
|
||||
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
|
||||
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
|
||||
// h2 = calc(var(--page-heading-font-size) * 1.25pt)
|
||||
const nameSize = ptToHalfPt(data.metadata.typography.heading.fontSize * 1.25);
|
||||
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
|
||||
|
||||
if (basics.name) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
spacing: { after: 60 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.name,
|
||||
bold: true,
|
||||
size: nameSize,
|
||||
font: headingFont,
|
||||
color: colorHex,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (basics.headline) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 60 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.headline,
|
||||
italics: true,
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
color: textColorHex,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const contactParts: (TextRun | ExternalHyperlink)[] = [];
|
||||
const addSeparator = () => {
|
||||
if (contactParts.length > 0) {
|
||||
contactParts.push(new TextRun({ text: " | ", color: "999999", size: bodySize, font: bodyFont }));
|
||||
}
|
||||
};
|
||||
|
||||
if (basics.email) {
|
||||
const mailtoLink = toSafeDocxLink(`mailto:${basics.email}`);
|
||||
if (mailtoLink) {
|
||||
addSeparator();
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: mailtoLink,
|
||||
children: [
|
||||
new TextRun({ text: basics.email, color: colorHex, underline: {}, size: bodySize, font: bodyFont }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (basics.phone) {
|
||||
addSeparator();
|
||||
contactParts.push(new TextRun({ text: basics.phone, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
|
||||
if (basics.location) {
|
||||
addSeparator();
|
||||
contactParts.push(new TextRun({ text: basics.location, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
|
||||
if (basics.website.url) {
|
||||
const websiteLink = toSafeDocxLink(basics.website.url);
|
||||
if (websiteLink) {
|
||||
addSeparator();
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: websiteLink,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.website.label || websiteLink,
|
||||
color: colorHex,
|
||||
underline: {},
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of basics.customFields) {
|
||||
if (!field.text) continue;
|
||||
addSeparator();
|
||||
if (field.link) {
|
||||
const customLink = toSafeDocxLink(field.link);
|
||||
if (customLink) {
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: customLink,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: field.text,
|
||||
color: colorHex,
|
||||
underline: {},
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
} else {
|
||||
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
}
|
||||
|
||||
if (contactParts.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: contactParts,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// --- Two-column table layout ---
|
||||
|
||||
function buildTwoColumnTable(
|
||||
mainParagraphs: Paragraph[],
|
||||
sidebarParagraphs: Paragraph[],
|
||||
sidebarWidthPct: number,
|
||||
gapXTwips: number,
|
||||
sidebarSide: "left" | "right",
|
||||
sidebarShadingHex?: string,
|
||||
): Table {
|
||||
const mainWidthPct = 100 - sidebarWidthPct;
|
||||
|
||||
// DOCX table cells require at least one child
|
||||
const mainChildren = mainParagraphs.length > 0 ? mainParagraphs : [new Paragraph({})];
|
||||
const sidebarChildren = sidebarParagraphs.length > 0 ? sidebarParagraphs : [new Paragraph({})];
|
||||
|
||||
const sidebarShading = sidebarShadingHex
|
||||
? { fill: sidebarShadingHex, type: ShadingType.CLEAR, color: "auto" }
|
||||
: undefined;
|
||||
|
||||
const sidebarCell = new TableCell({
|
||||
width: { size: sidebarWidthPct, type: WidthType.PERCENTAGE },
|
||||
borders: NO_BORDERS,
|
||||
margins: sidebarSide === "left" ? { right: gapXTwips } : { left: gapXTwips },
|
||||
children: sidebarChildren,
|
||||
...(sidebarShading ? { shading: sidebarShading } : {}),
|
||||
});
|
||||
|
||||
const mainCell = new TableCell({
|
||||
width: { size: mainWidthPct, type: WidthType.PERCENTAGE },
|
||||
borders: NO_BORDERS,
|
||||
margins: sidebarSide === "left" ? { left: gapXTwips } : { right: gapXTwips },
|
||||
children: mainChildren,
|
||||
});
|
||||
|
||||
const cells = sidebarSide === "left" ? [sidebarCell, mainCell] : [mainCell, sidebarCell];
|
||||
|
||||
return new Table({
|
||||
rows: [new TableRow({ children: cells })],
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0 },
|
||||
bottom: { style: BorderStyle.NONE, size: 0 },
|
||||
left: { style: BorderStyle.NONE, size: 0 },
|
||||
right: { style: BorderStyle.NONE, size: 0 },
|
||||
insideHorizontal: { style: BorderStyle.NONE, size: 0 },
|
||||
insideVertical: { style: BorderStyle.NONE, size: 0 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete docx Document from resume data.
|
||||
*
|
||||
* Mirrors the resume builder's visual layout:
|
||||
* - Header with name, headline, and contact info (centered, full-width)
|
||||
* - Two-column table layout (main + sidebar) matching `metadata.layout`
|
||||
* - Typography (font family, size, line height) from `metadata.typography`
|
||||
* - Page margins and format (A4/Letter) from `metadata.page`
|
||||
* - Primary, text, and background colors from `metadata.design.colors`
|
||||
*/
|
||||
export function buildDocument(data: ResumeData): Document {
|
||||
const colorHex = getColorHex(data.metadata.design.colors.primary, "DC2626");
|
||||
const textColorHex = getColorHex(data.metadata.design.colors.text, "000000");
|
||||
const bgColorHex = getColorHex(data.metadata.design.colors.background, "FFFFFF");
|
||||
|
||||
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
|
||||
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
|
||||
const lineSpacing = Math.round(data.metadata.typography.body.lineHeight * 240);
|
||||
|
||||
const { page } = data.metadata;
|
||||
const pageSize = PAGE_SIZES[page.format] ?? PAGE_SIZES.a4;
|
||||
// Margins and gaps are defined in points (pt), not mm
|
||||
const marginXTwips = ptToTwips(page.marginX);
|
||||
const marginYTwips = ptToTwips(page.marginY);
|
||||
const gapXTwips = ptToTwips(page.gapX);
|
||||
|
||||
const sidebarWidth = data.metadata.layout.sidebarWidth;
|
||||
|
||||
// Template-aware layout config
|
||||
const templateConfig = TEMPLATE_CONFIGS[data.metadata.template] ?? DEFAULT_TEMPLATE_CONFIG;
|
||||
|
||||
// Compute sidebar background shading hex
|
||||
let sidebarShadingHex: string | undefined;
|
||||
if (templateConfig.sidebarBackground === "solid") {
|
||||
sidebarShadingHex = colorHex;
|
||||
} else if (templateConfig.sidebarBackground === "tint") {
|
||||
sidebarShadingHex = blendWithWhite(colorHex, 0.2);
|
||||
}
|
||||
|
||||
// Determine sidebar text colors — inverted when sidebar has a solid background
|
||||
const sidebarTextColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : textColorHex;
|
||||
const sidebarHeadingColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : colorHex;
|
||||
|
||||
// Configure heading typography for section renderers
|
||||
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
|
||||
const headingSize = ptToHalfPt(data.metadata.typography.heading.fontSize);
|
||||
|
||||
const documentChildren: (Paragraph | Table)[] = [];
|
||||
|
||||
// Header placement depends on template
|
||||
if (templateConfig.headerPosition === "full-width") {
|
||||
// Configure colors for main content
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
documentChildren.push(...buildHeader(data, colorHex, textColorHex));
|
||||
}
|
||||
|
||||
// Process each page in the layout
|
||||
for (const layoutPage of data.metadata.layout.pages) {
|
||||
const isFullWidth = layoutPage.fullWidth || layoutPage.sidebar.length === 0;
|
||||
|
||||
if (isFullWidth) {
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
for (const sectionId of [...layoutPage.main, ...layoutPage.sidebar]) {
|
||||
documentChildren.push(...renderSection(sectionId, data, colorHex));
|
||||
}
|
||||
} else {
|
||||
// Render main sections with normal colors
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
|
||||
const mainParagraphs: Paragraph[] = [];
|
||||
if (templateConfig.headerPosition === "main-only") {
|
||||
mainParagraphs.push(...buildHeader(data, colorHex, textColorHex));
|
||||
}
|
||||
for (const sectionId of layoutPage.main) {
|
||||
mainParagraphs.push(...renderSection(sectionId, data, colorHex));
|
||||
}
|
||||
|
||||
// Render sidebar sections with potentially inverted colors
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex: sidebarTextColorHex,
|
||||
primaryColorHex: sidebarHeadingColorHex,
|
||||
});
|
||||
|
||||
const sidebarParagraphs: Paragraph[] = [];
|
||||
if (templateConfig.headerPosition === "sidebar-only") {
|
||||
sidebarParagraphs.push(...buildHeader(data, sidebarHeadingColorHex, sidebarTextColorHex));
|
||||
}
|
||||
for (const sectionId of layoutPage.sidebar) {
|
||||
sidebarParagraphs.push(...renderSection(sectionId, data, sidebarHeadingColorHex));
|
||||
}
|
||||
|
||||
if (mainParagraphs.length > 0 || sidebarParagraphs.length > 0) {
|
||||
documentChildren.push(
|
||||
buildTwoColumnTable(
|
||||
mainParagraphs,
|
||||
sidebarParagraphs,
|
||||
sidebarWidth,
|
||||
gapXTwips,
|
||||
templateConfig.sidebarSide,
|
||||
sidebarShadingHex,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: {
|
||||
font: bodyFont,
|
||||
size: bodySize,
|
||||
color: textColorHex,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: { line: lineSpacing },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
background: bgColorHex !== "FFFFFF" ? { color: bgColorHex } : undefined,
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
size: {
|
||||
width: convertMillimetersToTwip(pageSize.width),
|
||||
height: convertMillimetersToTwip(pageSize.height),
|
||||
},
|
||||
margin: {
|
||||
top: marginYTwips,
|
||||
bottom: marginYTwips,
|
||||
left: marginXTwips,
|
||||
right: marginXTwips,
|
||||
},
|
||||
},
|
||||
},
|
||||
children: documentChildren,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { ExternalHyperlink, Paragraph, TextRun } from "docx";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { htmlToParagraphs } from "./html-to-docx";
|
||||
|
||||
// Helper to extract the internal children array from a Paragraph.
|
||||
// docx stores children in the `root` property's internal array.
|
||||
function getChildren(paragraph: Paragraph): unknown[] {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accessing internal docx structure for testing
|
||||
const root = (paragraph as any).root;
|
||||
if (Array.isArray(root)) return root;
|
||||
return [];
|
||||
}
|
||||
|
||||
describe("htmlToParagraphs", () => {
|
||||
it("returns empty array for empty string", () => {
|
||||
expect(htmlToParagraphs("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for whitespace-only string", () => {
|
||||
expect(htmlToParagraphs(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a plain paragraph", () => {
|
||||
const result = htmlToParagraphs("<p>Hello</p>");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("parses bold text", () => {
|
||||
const result = htmlToParagraphs("<p><strong>Bold</strong></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const textRuns = children.filter((c) => c instanceof TextRun);
|
||||
expect(textRuns.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("parses italic text", () => {
|
||||
const result = htmlToParagraphs("<p><em>Italic</em></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const textRuns = children.filter((c) => c instanceof TextRun);
|
||||
expect(textRuns.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("parses nested bold and italic", () => {
|
||||
const result = htmlToParagraphs("<p><strong><em>Both</em></strong></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses unordered list", () => {
|
||||
const result = htmlToParagraphs("<ul><li>A</li><li>B</li></ul>");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses ordered list", () => {
|
||||
const result = htmlToParagraphs("<ol><li>A</li><li>B</li></ol>");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses hyperlink", () => {
|
||||
const result = htmlToParagraphs('<p><a href="https://example.com">Link</a></p>');
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("does not create hyperlink for unsafe javascript links", () => {
|
||||
const result = htmlToParagraphs('<p><a href="javascript:alert(1)">Click me</a></p>');
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not create hyperlink for unsafe data links", () => {
|
||||
const result = htmlToParagraphs('<p><a href="data:text/html;base64,PHNjcmlwdD4=">Click me</a></p>');
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses mixed inline formatting", () => {
|
||||
const result = htmlToParagraphs("<p>Normal <strong>bold</strong> end</p>");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
// Should have multiple TextRuns: "Normal ", bold "bold", " end"
|
||||
const textRuns = children.filter((c) => c instanceof TextRun);
|
||||
expect(textRuns.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("parses multiple paragraphs", () => {
|
||||
const result = htmlToParagraphs("<p>A</p><p>B</p>");
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses line break within paragraph", () => {
|
||||
const result = htmlToParagraphs("<p>Line1<br>Line2</p>");
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const paragraph = result[0];
|
||||
expect(paragraph).toBeDefined();
|
||||
// Should contain TextRuns with a break between
|
||||
const children = getChildren(paragraph as Paragraph);
|
||||
expect(children.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("handles empty paragraph gracefully", () => {
|
||||
const result = htmlToParagraphs("<p></p>");
|
||||
// Empty paragraphs may be skipped
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses strikethrough text", () => {
|
||||
const result = htmlToParagraphs("<p><s>struck</s></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses underline text", () => {
|
||||
const result = htmlToParagraphs("<p><u>under</u></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
type IShadingAttributesProperties,
|
||||
type ISpacingProperties,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
} from "docx";
|
||||
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
export interface HtmlStyleConfig {
|
||||
font?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
linkColor?: string;
|
||||
}
|
||||
|
||||
interface InlineStyle {
|
||||
bold?: boolean;
|
||||
italics?: boolean;
|
||||
underline?: Record<string, never>;
|
||||
strike?: boolean;
|
||||
font?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
shading?: IShadingAttributesProperties;
|
||||
}
|
||||
|
||||
type InlineChild = TextRun | ExternalHyperlink;
|
||||
|
||||
/** Module-level link color, set per htmlToParagraphs invocation. */
|
||||
let currentLinkColor = "0563C1";
|
||||
|
||||
const HEADING_MAP: Record<string, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||
H1: HeadingLevel.HEADING_1,
|
||||
H2: HeadingLevel.HEADING_2,
|
||||
H3: HeadingLevel.HEADING_3,
|
||||
H4: HeadingLevel.HEADING_4,
|
||||
H5: HeadingLevel.HEADING_5,
|
||||
H6: HeadingLevel.HEADING_6,
|
||||
};
|
||||
|
||||
function mergeStyle(parent: InlineStyle, tag: string): InlineStyle {
|
||||
const next = { ...parent };
|
||||
|
||||
switch (tag) {
|
||||
case "STRONG":
|
||||
case "B":
|
||||
next.bold = true;
|
||||
break;
|
||||
case "EM":
|
||||
case "I":
|
||||
next.italics = true;
|
||||
break;
|
||||
case "U":
|
||||
next.underline = {};
|
||||
break;
|
||||
case "S":
|
||||
case "STRIKE":
|
||||
next.strike = true;
|
||||
break;
|
||||
case "CODE":
|
||||
next.font = "Courier New";
|
||||
break;
|
||||
case "MARK":
|
||||
next.shading = { fill: "FFFF00" };
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
|
||||
const children: InlineChild[] = [];
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.textContent ?? "";
|
||||
if (text) {
|
||||
children.push(new TextRun({ text, ...style }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
const el = child as Element;
|
||||
const tag = el.tagName;
|
||||
|
||||
if (tag === "BR") {
|
||||
children.push(new TextRun({ break: 1 }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag === "A") {
|
||||
const href = toSafeDocxLink(el.getAttribute("href") ?? "");
|
||||
const linkChildren = collectInlineChildren(el, { ...style, color: currentLinkColor, underline: {} });
|
||||
if (linkChildren.length > 0 && href) {
|
||||
children.push(new ExternalHyperlink({ link: href, children: linkChildren as TextRun[] }));
|
||||
} else {
|
||||
children.push(...linkChildren);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const merged = mergeStyle(style, tag);
|
||||
children.push(...collectInlineChildren(el, merged));
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function processBlockElement(
|
||||
el: Element,
|
||||
style: InlineStyle,
|
||||
paragraphs: Paragraph[],
|
||||
listLevel?: number,
|
||||
numberingRef?: string,
|
||||
listIndex?: number,
|
||||
): void {
|
||||
const tag = el.tagName;
|
||||
|
||||
if (HEADING_MAP[tag]) {
|
||||
const inlineChildren = collectInlineChildren(el, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(new Paragraph({ heading: HEADING_MAP[tag], children: inlineChildren }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "P" || tag === "DIV") {
|
||||
const inlineChildren = collectInlineChildren(el, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
...(listLevel != null && numberingRef
|
||||
? { numbering: { reference: numberingRef, level: listLevel, instance: listIndex } }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "UL" || tag === "OL") {
|
||||
const isOrdered = tag === "OL";
|
||||
const level = listLevel != null ? listLevel + 1 : 0;
|
||||
|
||||
for (const li of el.children) {
|
||||
if (li.tagName !== "LI") continue;
|
||||
|
||||
const hasNestedBlocks = Array.from(li.children).some(
|
||||
(c) => c.tagName === "UL" || c.tagName === "OL" || c.tagName === "P",
|
||||
);
|
||||
|
||||
if (hasNestedBlocks) {
|
||||
for (const liChild of li.childNodes) {
|
||||
if (liChild.nodeType === Node.TEXT_NODE) {
|
||||
const text = (liChild.textContent ?? "").trim();
|
||||
if (text) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text, ...style })],
|
||||
...(isOrdered && numberingRef
|
||||
? { numbering: { reference: numberingRef, level, instance: listIndex } }
|
||||
: { bullet: { level } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (liChild.nodeType === Node.ELEMENT_NODE) {
|
||||
processBlockElement(liChild as Element, style, paragraphs, level, numberingRef, listIndex);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const inlineChildren = collectInlineChildren(li, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
...(isOrdered && numberingRef
|
||||
? { numbering: { reference: numberingRef, level, instance: listIndex } }
|
||||
: { bullet: { level } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "BLOCKQUOTE") {
|
||||
const indent: ISpacingProperties = {};
|
||||
const inlineChildren = collectInlineChildren(el, { ...style, italics: true });
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
indent: { left: 720 },
|
||||
spacing: indent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "PRE") {
|
||||
const text = el.textContent ?? "";
|
||||
if (text) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text, font: "Courier New", ...style })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "HR") {
|
||||
paragraphs.push(new Paragraph({ children: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "LI") {
|
||||
const inlineChildren = collectInlineChildren(el, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
bullet: { level: listLevel ?? 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: treat as inline container
|
||||
const inlineChildren = collectInlineChildren(el, style);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(new Paragraph({ children: inlineChildren }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HTML string (from TipTap rich text editor) into an array of docx Paragraphs.
|
||||
* Uses the browser's DOMParser to parse HTML, then walks the DOM tree to produce
|
||||
* structured docx content with proper formatting (bold, italic, lists, links, etc.).
|
||||
*
|
||||
* @param html - The HTML string to convert
|
||||
* @param styleConfig - Optional base typography (font, size, color) to apply to all text runs
|
||||
*/
|
||||
export function htmlToParagraphs(html: string, styleConfig?: HtmlStyleConfig): Paragraph[] {
|
||||
if (!html || !html.trim()) return [];
|
||||
|
||||
currentLinkColor = styleConfig?.linkColor ?? "0563C1";
|
||||
|
||||
const baseStyle: InlineStyle = {};
|
||||
if (styleConfig?.font) baseStyle.font = styleConfig.font;
|
||||
if (styleConfig?.size) baseStyle.size = styleConfig.size;
|
||||
if (styleConfig?.color) baseStyle.color = styleConfig.color;
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const paragraphs: Paragraph[] = [];
|
||||
|
||||
for (const child of doc.body.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = (child.textContent ?? "").trim();
|
||||
if (text) {
|
||||
paragraphs.push(new Paragraph({ children: [new TextRun({ text, ...baseStyle })] }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
processBlockElement(child as Element, baseStyle, paragraphs);
|
||||
}
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
/**
|
||||
* Builds a DOCX file from resume data and returns it as a Blob.
|
||||
*
|
||||
* Uses dynamic imports to lazy-load the `docx` package (~200KB gzipped)
|
||||
* so it's only downloaded when the user actually clicks the DOCX export button.
|
||||
*/
|
||||
export async function buildDocx(data: ResumeData): Promise<Blob> {
|
||||
const { buildDocument } = await import("./builder");
|
||||
const { Packer } = await import("docx");
|
||||
const doc = buildDocument(data);
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const SAFE_DOCX_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
|
||||
|
||||
export function toSafeDocxLink(value: string): string | null {
|
||||
const input = value.trim();
|
||||
if (!input) return null;
|
||||
|
||||
if (input.startsWith("mailto:")) {
|
||||
const email = input.slice("mailto:".length).trim();
|
||||
if (!email) return null;
|
||||
return `mailto:${email}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
if (!SAFE_DOCX_LINK_SCHEMES.has(url.protocol)) return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import { BorderStyle, ExternalHyperlink, HeadingLevel, Paragraph, TabStopPosition, TabStopType, TextRun } from "docx";
|
||||
|
||||
import type { CustomSection, CustomSectionType, ResumeData, SectionType } from "@/schema/resume/data";
|
||||
|
||||
import { type HtmlStyleConfig, htmlToParagraphs } from "./html-to-docx";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
type Sections = ResumeData["sections"];
|
||||
|
||||
/** Module-level typography/color config, set by the builder before rendering. */
|
||||
let headingFont: string | undefined;
|
||||
let headingSize: number | undefined;
|
||||
let bodyFont: string | undefined;
|
||||
let bodySize: number | undefined;
|
||||
let textColor: string | undefined;
|
||||
let primaryColor: string | undefined;
|
||||
|
||||
/**
|
||||
* Configures the typography and colors used by all section renderers.
|
||||
* Must be called before any render functions.
|
||||
*/
|
||||
export function setRenderConfig(config: {
|
||||
headingFont: string;
|
||||
headingSizeHalfPt: number;
|
||||
bodyFont: string;
|
||||
bodySizeHalfPt: number;
|
||||
textColorHex: string;
|
||||
primaryColorHex: string;
|
||||
}): void {
|
||||
headingFont = config.headingFont;
|
||||
headingSize = config.headingSizeHalfPt;
|
||||
bodyFont = config.bodyFont;
|
||||
bodySize = config.bodySizeHalfPt;
|
||||
textColor = config.textColorHex;
|
||||
primaryColor = config.primaryColorHex;
|
||||
}
|
||||
|
||||
function getHtmlStyle(): HtmlStyleConfig {
|
||||
return {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
...(primaryColor ? { linkColor: primaryColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a section heading paragraph with primary color and bottom border.
|
||||
* Uses the heading typography set via `setRenderConfig`.
|
||||
*/
|
||||
function sectionHeading(title: string, colorHex: string): Paragraph {
|
||||
// Section headings use <h6> in templates: calc(var(--page-heading-font-size) * 0.75pt)
|
||||
const h6Size = headingSize ? Math.round(headingSize * 0.75) : 16;
|
||||
|
||||
return new Paragraph({
|
||||
heading: HeadingLevel.HEADING_6,
|
||||
spacing: { before: 240, after: 120 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: colorHex.replace("#", "") },
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: title,
|
||||
bold: true,
|
||||
color: colorHex.replace("#", ""),
|
||||
size: h6Size,
|
||||
...(headingFont ? { font: headingFont } : {}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function titleAndSubtitle(primary: string, secondary: string, rightText?: string): Paragraph {
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
const children: (TextRun | ExternalHyperlink)[] = [new TextRun({ text: primary, bold: true, ...baseRun })];
|
||||
|
||||
if (secondary) {
|
||||
children.push(new TextRun({ text: ` — ${secondary}`, ...baseRun }));
|
||||
}
|
||||
|
||||
if (rightText) {
|
||||
children.push(new TextRun({ text: `\t${rightText}`, italics: true, ...baseRun }));
|
||||
}
|
||||
|
||||
return new Paragraph({
|
||||
tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
|
||||
spacing: { before: 120 },
|
||||
children,
|
||||
});
|
||||
}
|
||||
|
||||
function locationAndPeriod(location: string, period: string): Paragraph | null {
|
||||
const parts = [location, period].filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: parts.join(" | "),
|
||||
italics: true,
|
||||
color: textColor ?? "666666",
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
}),
|
||||
],
|
||||
spacing: { after: 60 },
|
||||
});
|
||||
}
|
||||
|
||||
function websiteParagraph(url: string, label: string): Paragraph | null {
|
||||
const safeUrl = toSafeDocxLink(url);
|
||||
if (!safeUrl) return null;
|
||||
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new ExternalHyperlink({
|
||||
link: safeUrl,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: label || safeUrl,
|
||||
color: primaryColor ?? "0563C1",
|
||||
underline: {},
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
spacing: { after: 60 },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Section-specific renderers ---
|
||||
|
||||
export function renderSummary(summary: ResumeData["summary"], colorHex: string): Paragraph[] {
|
||||
if (summary.hidden || !summary.content) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [];
|
||||
if (summary.title) {
|
||||
paragraphs.push(sectionHeading(summary.title, colorHex));
|
||||
}
|
||||
paragraphs.push(...htmlToParagraphs(summary.content, getHtmlStyle()));
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderExperience(section: Sections["experience"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.roles && item.roles.length > 0) {
|
||||
paragraphs.push(titleAndSubtitle(item.company, "", item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
for (const role of item.roles) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 80 },
|
||||
children: [
|
||||
new TextRun({ text: role.position, bold: true, italics: true, ...baseRun }),
|
||||
...(role.period ? [new TextRun({ text: ` — ${role.period}`, italics: true, ...baseRun })] : []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (role.description) {
|
||||
paragraphs.push(...htmlToParagraphs(role.description, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
paragraphs.push(titleAndSubtitle(item.company, item.position, item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderEducation(section: Sections["education"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const degreeArea = [item.degree, item.area].filter(Boolean).join(", ");
|
||||
paragraphs.push(titleAndSubtitle(item.school, degreeArea, item.period));
|
||||
|
||||
if (item.grade) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `Grade: ${item.grade}`, italics: true, ...baseRun })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderProjects(section: Sections["projects"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.name, "", item.period));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderSkills(section: Sections["skills"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.proficiency) {
|
||||
children.push(new TextRun({ text: ` — ${item.proficiency}`, ...baseRun }));
|
||||
}
|
||||
|
||||
if (item.keywords.length > 0) {
|
||||
children.push(new TextRun({ text: `: ${item.keywords.join(", ")}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderLanguages(section: Sections["languages"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.language, bold: true, ...baseRun })];
|
||||
|
||||
if (item.fluency) {
|
||||
children.push(new TextRun({ text: ` — ${item.fluency}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderInterests(section: Sections["interests"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.keywords.length > 0) {
|
||||
children.push(new TextRun({ text: `: ${item.keywords.join(", ")}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderAwards(section: Sections["awards"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.awarder, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderCertifications(section: Sections["certifications"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.issuer, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderPublications(section: Sections["publications"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.publisher, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderVolunteer(section: Sections["volunteer"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.organization, "", item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderReferences(section: Sections["references"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.position) {
|
||||
children.push(new TextRun({ text: ` — ${item.position}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 120 }, children }));
|
||||
|
||||
if (item.phone) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: item.phone, ...baseRun })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderProfiles(section: Sections["profiles"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: (TextRun | ExternalHyperlink)[] = [new TextRun({ text: item.network, bold: true, ...baseRun })];
|
||||
|
||||
if (item.username) {
|
||||
children.push(new TextRun({ text: ` — ${item.username}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from built-in section type to its renderer.
|
||||
*/
|
||||
const sectionRenderers: Record<SectionType, (section: Sections[SectionType], colorHex: string) => Paragraph[]> = {
|
||||
experience: renderExperience as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
education: renderEducation as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
projects: renderProjects as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
skills: renderSkills as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
languages: renderLanguages as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
interests: renderInterests as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
awards: renderAwards as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
certifications: renderCertifications as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
publications: renderPublications as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
volunteer: renderVolunteer as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
references: renderReferences as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
profiles: renderProfiles as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a built-in section by type.
|
||||
*/
|
||||
export function renderBuiltInSection(type: SectionType, section: Sections[SectionType], colorHex: string): Paragraph[] {
|
||||
const renderer = sectionRenderers[type];
|
||||
if (!renderer) return [];
|
||||
return renderer(section, colorHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a custom section by dispatching to the matching built-in renderer based on its type.
|
||||
*/
|
||||
export function renderCustomSection(section: CustomSection, colorHex: string): Paragraph[] {
|
||||
if (section.hidden) return [];
|
||||
|
||||
const visibleItems = section.items.filter((item) => !item.hidden);
|
||||
if (visibleItems.length === 0) return [];
|
||||
|
||||
const sectionType = section.type as CustomSectionType;
|
||||
|
||||
// Summary-type custom sections render HTML content
|
||||
if (sectionType === "summary") {
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
for (const item of visibleItems) {
|
||||
if ("content" in item && item.content) {
|
||||
paragraphs.push(...htmlToParagraphs(item.content, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// Cover letter type — render recipient + content
|
||||
if (sectionType === "cover-letter") {
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
for (const item of visibleItems) {
|
||||
if ("recipient" in item && item.recipient) {
|
||||
paragraphs.push(...htmlToParagraphs(item.recipient, getHtmlStyle()));
|
||||
}
|
||||
if ("content" in item && item.content) {
|
||||
paragraphs.push(...htmlToParagraphs(item.content, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// Build a synthetic section matching the built-in type for the renderer
|
||||
const sectionKey = sectionType as SectionType;
|
||||
if (sectionKey in sectionRenderers) {
|
||||
const syntheticSection = {
|
||||
title: section.title,
|
||||
columns: section.columns,
|
||||
hidden: false,
|
||||
items: visibleItems,
|
||||
} as Sections[SectionType];
|
||||
|
||||
return renderBuiltInSection(sectionKey, syntheticSection, colorHex);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
import type { TailorOutput } from "@/schema/tailor";
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import {
|
||||
buildSkillSyncOperations,
|
||||
type JsonPatchOperation,
|
||||
sanitizeText,
|
||||
tailorOutputToPatches,
|
||||
validateTailorOutput,
|
||||
} from "./tailor";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test helper needs dynamic access to operation values
|
||||
function opValue(op: JsonPatchOperation | undefined): any {
|
||||
if (op && "value" in op) return op.value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper to create resume data with experience, skills, and references
|
||||
function makeResumeData(overrides?: {
|
||||
experiences?: ResumeData["sections"]["experience"]["items"];
|
||||
skills?: ResumeData["sections"]["skills"]["items"];
|
||||
references?: ResumeData["sections"]["references"]["items"];
|
||||
}): ResumeData {
|
||||
return {
|
||||
...defaultResumeData,
|
||||
summary: { ...defaultResumeData.summary, content: "<p>Original summary</p>" },
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
experience: {
|
||||
...defaultResumeData.sections.experience,
|
||||
items: overrides?.experiences ?? [
|
||||
{
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "TechCorp",
|
||||
position: "Engineer",
|
||||
location: "NYC",
|
||||
period: "2020-2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Original description</p>",
|
||||
roles: [
|
||||
{ id: "role-1", position: "Senior", period: "2022-2024", description: "<p>Senior role</p>" },
|
||||
{ id: "role-2", position: "Junior", period: "2020-2022", description: "<p>Junior role</p>" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "exp-2",
|
||||
hidden: false,
|
||||
company: "StartupInc",
|
||||
position: "Developer",
|
||||
location: "Remote",
|
||||
period: "2018-2020",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Startup description</p>",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
...defaultResumeData.sections.skills,
|
||||
items: overrides?.skills ?? [
|
||||
{
|
||||
id: "skill-0",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "JavaScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["React", "Node.js"],
|
||||
},
|
||||
{
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "Python",
|
||||
proficiency: "Intermediate",
|
||||
level: 3,
|
||||
keywords: ["Django"],
|
||||
},
|
||||
{
|
||||
id: "skill-2",
|
||||
hidden: true,
|
||||
icon: "",
|
||||
name: "Photography",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
references: {
|
||||
...defaultResumeData.sections.references,
|
||||
items: overrides?.references ?? [
|
||||
{
|
||||
id: "ref-1",
|
||||
hidden: false,
|
||||
name: "Jane Smith",
|
||||
position: "Engineering Manager",
|
||||
website: { url: "", label: "" },
|
||||
phone: "555-1234",
|
||||
description: "<p>Jane was my direct manager at TechCorp.</p>",
|
||||
},
|
||||
{
|
||||
id: "ref-2",
|
||||
hidden: false,
|
||||
name: "Bob Jones",
|
||||
position: "CTO at StartupInc",
|
||||
website: { url: "", label: "" },
|
||||
phone: "555-5678",
|
||||
description: "<p>Bob can speak to my contributions.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyTailorOutput: TailorOutput = {
|
||||
summary: { content: "" },
|
||||
experiences: [],
|
||||
references: [],
|
||||
skills: [],
|
||||
};
|
||||
|
||||
// --- sanitizeText ---
|
||||
|
||||
describe("sanitizeText", () => {
|
||||
it("replaces emdash and endash with hyphen", () => {
|
||||
expect(sanitizeText("2020\u20132024")).toBe("2020-2024");
|
||||
expect(sanitizeText("skills \u2014 leadership")).toBe("skills - leadership");
|
||||
});
|
||||
|
||||
it("replaces curly quotes with straight quotes", () => {
|
||||
expect(sanitizeText("\u201CHello\u201D")).toBe('"Hello"');
|
||||
expect(sanitizeText("\u2018world\u2019")).toBe("'world'");
|
||||
});
|
||||
|
||||
it("replaces ellipsis character with three dots", () => {
|
||||
expect(sanitizeText("and more\u2026")).toBe("and more...");
|
||||
});
|
||||
|
||||
it("replaces non-breaking and special whitespace with standard space", () => {
|
||||
expect(sanitizeText("hello\u00A0world")).toBe("hello world");
|
||||
expect(sanitizeText("thin\u2009space")).toBe("thin space");
|
||||
});
|
||||
|
||||
it("removes Unicode bullet characters", () => {
|
||||
expect(sanitizeText("\u2022 item one")).toBe(" item one");
|
||||
expect(sanitizeText("\u2219 item two")).toBe(" item two");
|
||||
});
|
||||
|
||||
it("leaves normal ASCII text unchanged", () => {
|
||||
expect(sanitizeText("<p>Normal text - with hyphens.</p>")).toBe("<p>Normal text - with hyphens.</p>");
|
||||
});
|
||||
});
|
||||
|
||||
// --- tailorOutputToPatches ---
|
||||
|
||||
describe("tailorOutputToPatches", () => {
|
||||
it("generates summary replace patch", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
summary: { content: "<p>Tailored summary</p>" },
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/summary/content",
|
||||
value: "<p>Tailored summary</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes summary content", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
summary: { content: "<p>Expert \u2014 Full Stack Developer</p>" },
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const summaryOp = operations.find((op) => op.path === "/summary/content");
|
||||
expect(opValue(summaryOp)).toBe("<p>Expert - Full Stack Developer</p>");
|
||||
});
|
||||
|
||||
it("skips summary patch when content is empty", () => {
|
||||
const { operations } = tailorOutputToPatches(emptyTailorOutput, makeResumeData());
|
||||
|
||||
const summaryOps = operations.filter((op) => op.path === "/summary/content");
|
||||
expect(summaryOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("generates experience description patches", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Tailored exp</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/0/description",
|
||||
value: "<p>Tailored exp</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes experience descriptions", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Led team \u2013 built \u201Cgreat\u201D things</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const expOp = operations.find((op) => op.path === "/sections/experience/items/0/description");
|
||||
expect(opValue(expOp)).toBe('<p>Led team - built "great" things</p>');
|
||||
});
|
||||
|
||||
it("handles role progression with nested role patches", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [
|
||||
{
|
||||
index: 0,
|
||||
description: "<p>Main desc</p>",
|
||||
roles: [
|
||||
{ index: 0, description: "<p>Tailored senior</p>" },
|
||||
{ index: 1, description: "<p>Tailored junior</p>" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/0/roles/0/description",
|
||||
value: "<p>Tailored senior</p>",
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/0/roles/1/description",
|
||||
value: "<p>Tailored junior</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("generates reference description patches", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [
|
||||
{ index: 0, description: "<p>Jane supervised my engineering work and can speak to my technical skills.</p>" },
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/references/items/0/description",
|
||||
value: "<p>Jane supervised my engineering work and can speak to my technical skills.</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("generates patches for multiple references", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [
|
||||
{ index: 0, description: "<p>Ref 0 tailored</p>" },
|
||||
{ index: 1, description: "<p>Ref 1 tailored</p>" },
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/references/items/0/description",
|
||||
value: "<p>Ref 0 tailored</p>",
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/references/items/1/description",
|
||||
value: "<p>Ref 1 tailored</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes reference descriptions", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: 0, description: "<p>Jane\u2019s team \u2014 excellent work</p>" }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const refOp = operations.find((op) => op.path === "/sections/references/items/0/description");
|
||||
expect(opValue(refOp)).toBe("<p>Jane's team - excellent work</p>");
|
||||
});
|
||||
|
||||
it("ignores out-of-bounds reference indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: 99, description: "<p>Bad ref</p>" }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const refOps = operations.filter((op) => op.path.includes("/references/"));
|
||||
expect(refOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hides all existing skills and adds curated skills", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "React", keywords: ["Hooks", "JSX"], proficiency: "Developer", icon: "code", isNew: false }],
|
||||
};
|
||||
|
||||
const resumeData = makeResumeData();
|
||||
const { operations } = tailorOutputToPatches(output, resumeData);
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/skills/items/0/hidden",
|
||||
value: true,
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/skills/items/1/hidden",
|
||||
value: true,
|
||||
});
|
||||
|
||||
// Already-hidden skill (index 2) should not get a redundant hide op
|
||||
const hideSkill2 = operations.find((op) => op.path === "/sections/skills/items/2/hidden");
|
||||
expect(hideSkill2).toBeUndefined();
|
||||
|
||||
const addOp = operations.find((op) => op.op === "add" && op.path === "/sections/skills/items/-");
|
||||
expect(addOp).toBeDefined();
|
||||
expect(opValue(addOp)).toMatchObject({
|
||||
name: "React",
|
||||
keywords: ["Hooks", "JSX"],
|
||||
proficiency: "Developer",
|
||||
icon: "code",
|
||||
hidden: false,
|
||||
level: 0,
|
||||
});
|
||||
expect(opValue(addOp).id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sanitizes skill names and keywords", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [
|
||||
{
|
||||
name: "Full\u2013Stack Development",
|
||||
keywords: ["React \u2014 Frontend", "Node\u2019s Server"],
|
||||
proficiency: "\u201CAdvanced\u201D",
|
||||
icon: "code",
|
||||
isNew: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const addOp = operations.find((op) => op.op === "add" && op.path === "/sections/skills/items/-");
|
||||
expect(opValue(addOp).name).toBe("Full-Stack Development");
|
||||
expect(opValue(addOp).keywords).toEqual(["React - Frontend", "Node's Server"]);
|
||||
expect(opValue(addOp).proficiency).toBe('"Advanced"');
|
||||
});
|
||||
|
||||
it("returns newSkills only for skills marked isNew", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [
|
||||
{ name: "React", keywords: ["Hooks"], proficiency: "Developer", icon: "code", isNew: false },
|
||||
{ name: "Docker", keywords: ["K8s"], proficiency: "Intermediate", icon: "cloud", isNew: true },
|
||||
{ name: "AWS", keywords: ["EC2", "S3"], proficiency: "Advanced", icon: "cloud", isNew: true },
|
||||
],
|
||||
};
|
||||
|
||||
const { newSkills } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(newSkills).toEqual([
|
||||
{ name: "Docker", keywords: ["K8s"], proficiency: "Intermediate" },
|
||||
{ name: "AWS", keywords: ["EC2", "S3"], proficiency: "Advanced" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return newSkills when all skills are existing", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "React", keywords: ["Hooks"], proficiency: "Developer", icon: "code", isNew: false }],
|
||||
};
|
||||
|
||||
const { newSkills } = tailorOutputToPatches(output, makeResumeData());
|
||||
expect(newSkills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty skills array without modifying existing skills", () => {
|
||||
const { operations } = tailorOutputToPatches(emptyTailorOutput, makeResumeData());
|
||||
|
||||
const skillOps = operations.filter((op) => op.path.includes("/skills/"));
|
||||
expect(skillOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty experiences, references, and skills gracefully", () => {
|
||||
const { operations, newSkills } = tailorOutputToPatches(emptyTailorOutput, makeResumeData());
|
||||
|
||||
expect(operations).toHaveLength(0);
|
||||
expect(newSkills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores out-of-bounds experience indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 99, description: "<p>Bad</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const expOps = operations.filter((op) => op.path.includes("/experience/"));
|
||||
expect(expOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores out-of-bounds role indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Ok</p>", roles: [{ index: 99, description: "<p>Bad role</p>" }] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const roleOps = operations.filter((op) => op.path.includes("/roles/"));
|
||||
expect(roleOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("generates multiple experience patches at different indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [
|
||||
{ index: 0, description: "<p>First</p>", roles: [] },
|
||||
{ index: 1, description: "<p>Second</p>", roles: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/0/description",
|
||||
value: "<p>First</p>",
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
op: "replace",
|
||||
path: "/sections/experience/items/1/description",
|
||||
value: "<p>Second</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses skill icon from AI output", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "Cloud", keywords: ["AWS"], proficiency: "Advanced", icon: "cloud", isNew: false }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const addOp = operations.find((op) => op.op === "add" && op.path === "/sections/skills/items/-");
|
||||
expect(opValue(addOp).icon).toBe("cloud");
|
||||
});
|
||||
|
||||
it("defaults empty icon to empty string", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "Cloud", keywords: ["AWS"], proficiency: "Advanced", icon: "", isNew: false }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
|
||||
const addOp = operations.find((op) => op.op === "add" && op.path === "/sections/skills/items/-");
|
||||
expect(opValue(addOp).icon).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// --- validateTailorOutput ---
|
||||
|
||||
describe("validateTailorOutput", () => {
|
||||
it("returns empty array for valid output", () => {
|
||||
const output: TailorOutput = {
|
||||
summary: { content: "<p>Summary</p>" },
|
||||
experiences: [{ index: 0, description: "<p>Desc</p>", roles: [] }],
|
||||
references: [{ index: 0, description: "<p>Ref desc</p>" }],
|
||||
skills: [{ name: "React", keywords: ["Hooks"], proficiency: "Advanced", icon: "code", isNew: false }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects out-of-bounds experience index", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 10, description: "<p>Bad</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]).toContain("Experience index 10 out of bounds");
|
||||
});
|
||||
|
||||
it("detects out-of-bounds role index", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Ok</p>", roles: [{ index: 5, description: "<p>Bad role</p>" }] }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]).toContain("Role index 5");
|
||||
});
|
||||
|
||||
it("detects out-of-bounds reference index", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: 50, description: "<p>Bad ref</p>" }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]).toContain("Reference index 50 out of bounds");
|
||||
});
|
||||
|
||||
it("handles resume with no items gracefully", () => {
|
||||
const emptyResume = makeResumeData({ experiences: [], skills: [], references: [] });
|
||||
|
||||
const output: TailorOutput = {
|
||||
summary: { content: "<p>Summary</p>" },
|
||||
experiences: [],
|
||||
references: [],
|
||||
skills: [{ name: "New Skill", keywords: [], proficiency: "", icon: "", isNew: true }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, emptyResume);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects negative indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: -1, description: "<p>Negative</p>", roles: [] }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects negative reference indices", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: -1, description: "<p>Negative ref</p>" }],
|
||||
};
|
||||
|
||||
const errors = validateTailorOutput(output, makeResumeData());
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- buildSkillSyncOperations ---
|
||||
|
||||
describe("buildSkillSyncOperations", () => {
|
||||
it("generates add operations for each skill", () => {
|
||||
const skills = [
|
||||
{ name: "React", keywords: ["Hooks", "JSX"], proficiency: "Advanced" },
|
||||
{ name: "Go", keywords: ["Goroutines"], proficiency: "" },
|
||||
];
|
||||
|
||||
const operations = buildSkillSyncOperations(skills);
|
||||
|
||||
expect(operations).toHaveLength(2);
|
||||
expect(operations[0].op).toBe("add");
|
||||
expect(operations[0].path).toBe("/sections/skills/items/-");
|
||||
expect(opValue(operations[0])).toMatchObject({
|
||||
name: "React",
|
||||
keywords: ["Hooks", "JSX"],
|
||||
proficiency: "Advanced",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
level: 0,
|
||||
});
|
||||
expect(opValue(operations[0]).id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("generates unique IDs for each skill", () => {
|
||||
const skills = [
|
||||
{ name: "A", keywords: [], proficiency: "" },
|
||||
{ name: "B", keywords: [], proficiency: "" },
|
||||
];
|
||||
|
||||
const operations = buildSkillSyncOperations(skills);
|
||||
|
||||
expect(opValue(operations[0]).id).not.toBe(opValue(operations[1]).id);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
const operations = buildSkillSyncOperations([]);
|
||||
expect(operations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
import type { NewSkillInfo, TailorOutput } from "@/schema/tailor";
|
||||
import type { jsonPatchOperationSchema } from "@/utils/resume/patch";
|
||||
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
export type JsonPatchOperation = z.infer<typeof jsonPatchOperationSchema>;
|
||||
|
||||
/**
|
||||
* Sanitizes text output from AI to ensure consistent character formatting.
|
||||
* Replaces smart quotes, emdashes, endashes, special whitespace, and
|
||||
* other Unicode characters with their standard ASCII equivalents.
|
||||
*/
|
||||
export function sanitizeText(text: string): string {
|
||||
return (
|
||||
text
|
||||
// Emdashes and endashes → hyphen
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
// Curly single quotes → straight
|
||||
.replace(/[\u2018\u2019\u201A]/g, "'")
|
||||
// Curly double quotes → straight
|
||||
.replace(/[\u201C\u201D\u201E]/g, '"')
|
||||
// Ellipsis character → three dots
|
||||
.replace(/\u2026/g, "...")
|
||||
// Non-breaking space and other special whitespace → standard space
|
||||
.replace(/[\u00A0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F]/g, " ")
|
||||
// Unicode bullet → empty (should use HTML <li> instead)
|
||||
.replace(/[\u2022\u2023\u25E6\u2043\u2219]/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a TailorOutput from the AI into JSON Patch (RFC 6902) operations
|
||||
* that can be applied to a resume's data.
|
||||
*
|
||||
* Returns both the patch operations and metadata about newly added skills
|
||||
* (for the skill sync confirmation dialog).
|
||||
*/
|
||||
export function tailorOutputToPatches(
|
||||
output: TailorOutput,
|
||||
resumeData: ResumeData,
|
||||
): { operations: JsonPatchOperation[]; newSkills: NewSkillInfo[] } {
|
||||
const operations: JsonPatchOperation[] = [];
|
||||
|
||||
// 1. Summary
|
||||
if (output.summary?.content) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: "/summary/content",
|
||||
value: sanitizeText(output.summary.content),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Experience descriptions
|
||||
for (const exp of output.experiences) {
|
||||
if (exp.index < 0 || exp.index >= resumeData.sections.experience.items.length) continue;
|
||||
|
||||
const basePath = `/sections/experience/items/${exp.index}`;
|
||||
|
||||
if (exp.description) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `${basePath}/description`,
|
||||
value: sanitizeText(exp.description),
|
||||
});
|
||||
}
|
||||
|
||||
if (exp.roles) {
|
||||
const rolesCount = resumeData.sections.experience.items[exp.index].roles?.length ?? 0;
|
||||
for (const role of exp.roles) {
|
||||
if (role.index < 0 || role.index >= rolesCount) continue;
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `${basePath}/roles/${role.index}/description`,
|
||||
value: sanitizeText(role.description),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reference descriptions
|
||||
for (const ref of output.references) {
|
||||
if (ref.index < 0 || ref.index >= resumeData.sections.references.items.length) continue;
|
||||
|
||||
if (ref.description) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `/sections/references/items/${ref.index}/description`,
|
||||
value: sanitizeText(ref.description),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Skills — full replacement approach
|
||||
// The AI provides the complete curated skills list.
|
||||
// First, hide ALL existing skills (they remain in the data but won't show).
|
||||
// Then add the curated set as new visible items.
|
||||
const newSkills: NewSkillInfo[] = [];
|
||||
|
||||
if (output.skills.length > 0) {
|
||||
// Hide all existing skills on the tailored copy
|
||||
for (let i = 0; i < resumeData.sections.skills.items.length; i++) {
|
||||
if (!resumeData.sections.skills.items[i].hidden) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `/sections/skills/items/${i}/hidden`,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the curated skills as new visible items
|
||||
for (const skill of output.skills) {
|
||||
operations.push({
|
||||
op: "add",
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: generateId(),
|
||||
hidden: false,
|
||||
icon: skill.icon || "",
|
||||
name: sanitizeText(skill.name),
|
||||
proficiency: sanitizeText(skill.proficiency || ""),
|
||||
level: 0,
|
||||
keywords: skill.keywords.map(sanitizeText),
|
||||
},
|
||||
});
|
||||
|
||||
// Track newly inferred skills for sync-back to original resume
|
||||
if (skill.isNew) {
|
||||
newSkills.push({
|
||||
name: sanitizeText(skill.name),
|
||||
keywords: skill.keywords.map(sanitizeText),
|
||||
proficiency: sanitizeText(skill.proficiency || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { operations, newSkills };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the AI-generated TailorOutput references valid indices
|
||||
* within the actual resume data. Returns an array of error messages.
|
||||
* An empty array means the output is valid.
|
||||
*/
|
||||
export function validateTailorOutput(output: TailorOutput, resumeData: ResumeData): string[] {
|
||||
const errors: string[] = [];
|
||||
const experienceCount = resumeData.sections.experience.items.length;
|
||||
const referencesCount = resumeData.sections.references.items.length;
|
||||
|
||||
for (const exp of output.experiences) {
|
||||
if (exp.index < 0 || exp.index >= experienceCount) {
|
||||
errors.push(`Experience index ${exp.index} out of bounds (max: ${experienceCount - 1})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exp.roles) {
|
||||
const rolesCount = resumeData.sections.experience.items[exp.index].roles?.length ?? 0;
|
||||
for (const role of exp.roles) {
|
||||
if (role.index < 0 || role.index >= rolesCount) {
|
||||
errors.push(`Role index ${role.index} in experience ${exp.index} out of bounds (max: ${rolesCount - 1})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of output.references) {
|
||||
if (ref.index < 0 || ref.index >= referencesCount) {
|
||||
errors.push(`Reference index ${ref.index} out of bounds (max: ${referencesCount - 1})`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds JSON Patch add operations for syncing new skills back to
|
||||
* the original (source) resume.
|
||||
*/
|
||||
export function buildSkillSyncOperations(skills: NewSkillInfo[]): JsonPatchOperation[] {
|
||||
return skills.map((skill) => ({
|
||||
op: "add" as const,
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: generateId(),
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: skill.name,
|
||||
proficiency: skill.proficiency,
|
||||
level: 0,
|
||||
keywords: skill.keywords,
|
||||
},
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user