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:
Luka Fagundes
2026-03-19 01:48:02 -07:00
committed by GitHub
parent 56a1838315
commit 3e16586d7a
150 changed files with 28149 additions and 388 deletions
+1 -1
View File
@@ -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 }}
>
+7 -4
View File
@@ -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>
+15 -10
View File
@@ -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>
+6 -4
View File
@@ -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>
+2 -2
View File
@@ -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 />
+17 -9
View File
@@ -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>
+2 -1
View File
@@ -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>
+7 -4
View File
@@ -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>
+6 -4
View File
@@ -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>
+16 -3
View File
@@ -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,
};
}
+308
View File
@@ -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>
+4 -3
View File
@@ -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>
+18 -9
View File
@@ -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 />
+12 -8
View File
@@ -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>
+8 -5
View File
@@ -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}>