mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
feat(jobs): add job listings with AI-powered resume tailoring (#2788)
* feat: add job listings feature with JSearch API integration, resume tailoring, and per-user rate limiting * feat(jobs): add search filters UI, filter helper functions with tests, and job_search_quota DB migration * feat(jobs): add pagination with 30 results per page and prev/next navigation * refactor(job-detail): Adjust sheet width and scroll area height * feat(ai): Add resume tailoring feature and prompt * refactor(ai): Revise tailoring prompts and schema for full skill rewrite * feat(ai): Add reference tailoring and output sanitization * feat(testing): Add Vitest testing framework * fix: address PR review - atomic rate limiting, calendar-month quota, skill sync warning, gitignore routeTree.gen.ts * feat(jobs): Add location filter to job listings * feat(job-listings): Add DOCX document generation * feat(job-listings): Enable search by location and on Enter key * feat(job-listings): Split location filter into city, state, and country * feat(jobs): Implement job search adapter and JSearch * Update 'locale/' directory * feat(resume): Simplify filename generation and add tests * fix(JSearch): reduce JSearch API usage to 1 request per search to prevent quota exhaustion * fix(JSearch): Displayed quota amounts on Job Search functionality and settings fixed to pull from RapidAPI/JSearch response * fix(internal rate limit): Removed internal rate limit and .env.example addition, cloud based implementation handles. * style(job-filters): Adjust layout of switch filters * fix(typecheck): Fixed typecheck issues introduced to sync with origin * feat(jobs): Enhance tailor dialog with apply link and tags * feat(locale files): updated locale files with the latest build * feat(jobs): Add job search provider and integrate testing functionality - Introduced `createJobSearchProvider` function to instantiate a JSearchProvider. - Enhanced job search provider with methods for searching jobs, retrieving job details, and testing connection. - Updated `vite.config.ts` to include new testing configurations and plugins. - Added new dependencies in `package.json` for testing and document generation. - Removed obsolete `vitest.config.ts` file. - Improved job search provider tests for better coverage and reliability. * refactor: Update job search routes and remove obsolete test configurations - Removed the test configuration from `vite.config.ts`. - Updated localization files to reflect changes in job search routes, renaming references from `jobs` to `job-search` across multiple languages. - Adjusted autofix workflow to run formatting without the `--fix` flag for better control over code style adjustments. * chore: Update dependencies and improve animation performance - Added `jsdom` as a new dependency in `package.json`. - Updated `vite-plus` and `vitest` to the latest versions for better compatibility. - Enhanced animation components with `willChange` styles to optimize rendering performance. - Adjusted various UI components to improve responsiveness and visual effects. - Removed obsolete job details functionality from the job search provider and related tests. * chore(locales): Update localization files for job search improvements - Modified job search related strings to remove references to "this month" for a more concise format. - Updated file references in localization entries to reflect changes in the job search component structure. - Added new strings for API usage, quota remaining, and job fetching error messages across multiple languages. - Removed obsolete "Monthly Usage" string from localization files. * chore(dependencies): Update @typescript/native-preview to version 7.0.0-dev.20260319.1 --------- Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
@@ -109,7 +109,7 @@ const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"
|
||||
className="flex size-12 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"
|
||||
whileHover={{ rotate: [0, -10, 10, 0] }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ const getFaqItems = (): FAQItemData[] => [
|
||||
<a
|
||||
href={crowdinUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonVariants({ variant: "link", className: "h-auto px-0!" })}
|
||||
>
|
||||
contribute to the translations on Crowdin
|
||||
@@ -76,7 +76,8 @@ export function FAQ() {
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans context="Every word needs to be wrapped in a tag">
|
||||
<span>Frequently</span>
|
||||
@@ -90,7 +91,8 @@ export function FAQ() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
transition={{ duration: 0.45, delay: 0.08 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Accordion multiple>
|
||||
{faqItems.map((item, index) => (
|
||||
@@ -114,7 +116,8 @@ function FAQItemComponent({ item, index }: FAQItemComponentProps) {
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||
transition={{ duration: 0.24, delay: Math.min(0.16, index * 0.03) }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<AccordionItem value={item.question} className="group border-t">
|
||||
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TranslateIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
@@ -30,9 +31,7 @@ type Feature = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
type FeatureCardProps = Feature & {
|
||||
index: number;
|
||||
};
|
||||
type FeatureCardProps = Feature;
|
||||
|
||||
const getFeatures = (): Feature[] => [
|
||||
{
|
||||
@@ -133,7 +132,7 @@ const getFeatures = (): Feature[] => [
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps) {
|
||||
function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
@@ -141,10 +140,13 @@ function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps
|
||||
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
|
||||
"hover:bg-secondary/30",
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.03, ease: "easeOut" }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
whileHover={{ y: -3, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
{/* Hover gradient overlay */}
|
||||
<div
|
||||
@@ -154,7 +156,7 @@ function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps
|
||||
|
||||
{/* Icon */}
|
||||
<div aria-hidden="true" className="relative">
|
||||
<div className="inline-flex rounded-lg bg-primary/5 p-2.5 text-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
|
||||
<div className="inline-flex rounded-md bg-primary/5 p-2.5 text-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
|
||||
<Icon size={24} weight="thin" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +171,8 @@ function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps
|
||||
}
|
||||
|
||||
export function Features() {
|
||||
const features = useMemo(() => getFeatures(), []);
|
||||
|
||||
return (
|
||||
<section id="features">
|
||||
{/* Header */}
|
||||
@@ -177,7 +181,8 @@ export function Features() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Features</Trans>
|
||||
@@ -193,8 +198,8 @@ export function Features() {
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="xs:grid-cols-2 grid grid-cols-1 border-t xl:grid-cols-4">
|
||||
{getFeatures().map((feature, index) => (
|
||||
<FeatureCard key={feature.id} {...feature} index={index} />
|
||||
{features.map((feature) => (
|
||||
<FeatureCard key={feature.id} {...feature} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -54,7 +54,8 @@ export function Footer() {
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "opacity" }}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Brand Column */}
|
||||
@@ -83,7 +84,7 @@ export function Footer() {
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${social.label} (${t`opens in new tab`})`}
|
||||
>
|
||||
<social.icon aria-hidden="true" size={18} />
|
||||
@@ -141,8 +142,9 @@ function FooterLink({ url, label }: FooterLinkItem) {
|
||||
aria-hidden="true"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded bg-primary"
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary"
|
||||
style={{ willChange: "width, opacity" }}
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Header() {
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
}, [y]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
@@ -49,7 +49,7 @@ export function Header() {
|
||||
className="fixed inset-x-0 top-0 z-50 border-b border-transparent bg-background/80 backdrop-blur-lg transition-colors"
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
>
|
||||
<ProductHuntBanner />
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ export function Hero() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||
transition={{ duration: 1.1, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<CometCard glareOpacity={0} className="3xl:max-w-7xl relative -mb-12 max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
|
||||
<video
|
||||
@@ -32,7 +33,7 @@ export function Hero() {
|
||||
fetchPriority="high"
|
||||
src="/videos/timelapse.mp4"
|
||||
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
|
||||
className="pointer-events-none size-full rounded-lg border object-cover"
|
||||
className="pointer-events-none size-full rounded-md border object-cover"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -47,9 +48,12 @@ export function Hero() {
|
||||
<motion.a
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
transition={{ duration: 0.45, delay: 0.55 }}
|
||||
whileHover={{ y: -2, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.985 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.rxresu.me/getting-started"
|
||||
>
|
||||
<Badge variant="secondary" className="h-auto gap-1.5 px-3 py-0.5">
|
||||
@@ -62,7 +66,8 @@ export function Hero() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1 }}
|
||||
transition={{ duration: 0.45, delay: 0.7 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans>
|
||||
<p className="font-medium tracking-tight text-muted-foreground md:text-lg">Finally,</p>
|
||||
@@ -77,7 +82,8 @@ export function Hero() {
|
||||
className="max-w-xl text-base leading-relaxed text-muted-foreground md:text-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1.2 }}
|
||||
transition={{ duration: 0.45, delay: 0.82 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans>
|
||||
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
|
||||
@@ -90,7 +96,8 @@ export function Hero() {
|
||||
className="flex flex-col items-center gap-3 sm:flex-row sm:gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1.4 }}
|
||||
transition={{ duration: 0.45, delay: 0.95 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -115,7 +122,7 @@ export function Hero() {
|
||||
className="gap-2 px-4"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer">
|
||||
<BookIcon aria-hidden="true" className="size-4" />
|
||||
<Trans>Learn More</Trans>
|
||||
<span className="sr-only">
|
||||
@@ -134,12 +141,13 @@ export function Hero() {
|
||||
className="absolute inset-s-1/2 bottom-8 -translate-x-1/2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 2, duration: 1 }}
|
||||
transition={{ delay: 1.25, duration: 0.7 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5"
|
||||
animate={{ y: [0, 5, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
|
||||
</motion.div>
|
||||
|
||||
@@ -20,7 +20,8 @@ export function Prefooter() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold tracking-tight md:text-4xl">
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
|
||||
@@ -15,10 +15,12 @@ function TemplateItem({ metadata }: TemplateItemProps) {
|
||||
<motion.div
|
||||
className="group relative shrink-0"
|
||||
initial={{ scale: 1, zIndex: 10 }}
|
||||
whileHover={{ scale: 1.08, zIndex: 20 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
whileHover={{ scale: 1.06, zIndex: 20 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 26 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<div className="relative aspect-page w-48 overflow-hidden rounded-lg border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
|
||||
<div className="relative aspect-page w-48 overflow-hidden rounded-md border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
|
||||
{/* Subtle overlay on hover */}
|
||||
@@ -88,7 +90,8 @@ export function Templates() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Templates</Trans>
|
||||
|
||||
@@ -37,10 +37,12 @@ function TestimonialCard({ testimonial }: TestimonialCardProps) {
|
||||
<motion.div
|
||||
className="group relative w-[320px] shrink-0 sm:w-[360px] md:w-[400px]"
|
||||
initial={{ scale: 1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
whileHover={{ y: -3, scale: 1.02 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 24 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<div className="relative flex h-full flex-col rounded-xl border bg-card p-5 shadow-sm transition-shadow duration-300 group-hover:shadow-xl">
|
||||
<div className="relative flex h-full flex-col rounded-md border bg-card p-5 shadow-sm transition-shadow duration-300 group-hover:shadow-xl">
|
||||
<p className="flex-1 leading-relaxed text-muted-foreground">"{testimonial}"</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -103,7 +105,7 @@ export function Testimonials() {
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-foreground underline underline-offset-2 transition-colors hover:text-primary"
|
||||
>
|
||||
{email}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrowUUpRightIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
FilePdfIcon,
|
||||
type Icon,
|
||||
@@ -27,6 +28,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/file";
|
||||
import { buildDocx } from "@/utils/resume/docx";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
export function BuilderDock() {
|
||||
@@ -66,17 +68,29 @@ export function BuilderDock() {
|
||||
|
||||
const onDownloadJSON = useCallback(async () => {
|
||||
if (!resume?.data) return;
|
||||
const filename = generateFilename(resume.data.basics.name, "json");
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume?.data]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume?.data) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume?.data]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume?.id) return;
|
||||
|
||||
const filename = generateFilename(resume.data.basics.name, "pdf");
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`, {
|
||||
description: t`This may take a while depending on the server capacity. Please do not close the window or refresh the page.`,
|
||||
});
|
||||
@@ -89,15 +103,16 @@ export function BuilderDock() {
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume?.id, resume?.data.basics.name, printResumeAsPDF]);
|
||||
}, [resume?.id, resume?.name, printResumeAsPDF]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-4 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 0.5, y: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
initial={{ opacity: 0, y: -18 }}
|
||||
animate={{ opacity: 0.6, y: 0 }}
|
||||
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center rounded-l-full rounded-r-full bg-popover px-2 shadow-xl"
|
||||
>
|
||||
<DockIcon
|
||||
@@ -126,6 +141,7 @@ export function BuilderDock() {
|
||||
<AIChat />
|
||||
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
|
||||
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
|
||||
<DockIcon icon={FileDocIcon} title={t`Download DOCX`} onClick={() => onDownloadDOCX()} />
|
||||
<DockIcon
|
||||
title={t`Download PDF`}
|
||||
disabled={isPrinting}
|
||||
@@ -151,9 +167,16 @@ function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockI
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
|
||||
whileTap={disabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ function CustomSectionContainer({ section }: { section: CustomSection }) {
|
||||
section.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Badge variant="secondary" className="mb-1.5 rounded-sm">
|
||||
<Badge variant="secondary" className="mb-1.5 rounded-md">
|
||||
{getSectionTitle(section.type)}
|
||||
</Badge>
|
||||
<span className="line-clamp-1 text-base font-medium text-wrap">{section.title}</span>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ExperienceSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="experience" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{section.items.map((item) => {
|
||||
return (
|
||||
<SectionItem
|
||||
|
||||
@@ -24,7 +24,7 @@ export function SkillsSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="skills" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="skills" item={item} title={item.name} subtitle={item.proficiency} />
|
||||
))}
|
||||
|
||||
@@ -241,9 +241,11 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
value={item}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 1, y: -10 }}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="group relative flex h-18 border-b select-none"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -191,6 +191,8 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center"
|
||||
>
|
||||
<div className="size-4 rounded-md bg-foreground" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -9,6 +9,7 @@ import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/file";
|
||||
import { buildDocx } from "@/utils/resume/docx";
|
||||
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -20,15 +21,26 @@ export function ExportSectionBuilder() {
|
||||
);
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
const filename = generateFilename(resume.data.basics.name, "json");
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
const filename = generateFilename(resume.data.basics.name, "pdf");
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`, {
|
||||
description: t`This may take a while depending on the server capacity. Please do not close the window or refresh the page.`,
|
||||
});
|
||||
@@ -62,6 +74,23 @@ export function ExportSectionBuilder() {
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadDOCX}
|
||||
className="h-auto gap-x-4 p-4! text-start font-normal whitespace-normal active:scale-98"
|
||||
>
|
||||
<FileDocIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">DOCX</h6>
|
||||
<p className="text-xs leading-normal text-muted-foreground">
|
||||
<Trans>
|
||||
Download a copy of your resume as a Word document. Use this file to further customize your resume in
|
||||
Microsoft Word or Google Docs.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
|
||||
@@ -112,9 +112,9 @@ export function SharingSectionBuilder() {
|
||||
/>
|
||||
|
||||
<Label htmlFor="sharing-switch" className="my-2 flex flex-col items-start gap-y-1 font-normal">
|
||||
<p className="font-medium">
|
||||
<span className="font-medium">
|
||||
<Trans>Allow Public Access</Trans>
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans>Anyone with the link can view and download the resume.</Trans>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
GearSixIcon,
|
||||
KeyIcon,
|
||||
ReadCvLogoIcon,
|
||||
@@ -48,6 +49,11 @@ const appSidebarItems = [
|
||||
label: msg`Resumes`,
|
||||
href: "/dashboard/resumes",
|
||||
},
|
||||
{
|
||||
icon: <BriefcaseIcon />,
|
||||
label: msg`Job Search`,
|
||||
href: "/dashboard/job-search",
|
||||
},
|
||||
] as const satisfies SidebarItem[];
|
||||
|
||||
const settingsSidebarItems = [
|
||||
@@ -76,6 +82,11 @@ const settingsSidebarItems = [
|
||||
label: msg`Artificial Intelligence`,
|
||||
href: "/dashboard/settings/ai",
|
||||
},
|
||||
{
|
||||
icon: <BriefcaseIcon />,
|
||||
label: msg`Job Search API`,
|
||||
href: "/dashboard/settings/job-search",
|
||||
},
|
||||
{
|
||||
icon: <WarningIcon />,
|
||||
label: msg`Danger Zone`,
|
||||
@@ -183,9 +194,11 @@ export function DashboardSidebar() {
|
||||
{state === "expanded" && (
|
||||
<motion.div
|
||||
key="copyright"
|
||||
initial={{ y: 50, height: 0, opacity: 0 }}
|
||||
animate={{ y: 0, height: "auto", opacity: 1 }}
|
||||
exit={{ y: 50, height: 0, opacity: 0 }}
|
||||
initial={{ y: 12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 12, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Copyright className="shrink-0 p-2 wrap-break-word whitespace-normal" />
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PostFilterOptions, SearchParams } from "@/schema/jobs";
|
||||
|
||||
// --- Pagination ---
|
||||
|
||||
export const RESULTS_PER_PAGE = 10;
|
||||
export const FETCH_NUM_PAGES = 1;
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type FilterState = {
|
||||
// API-level (SearchParams)
|
||||
datePosted: string | null;
|
||||
remoteOnly: boolean;
|
||||
employmentType: string | null;
|
||||
jobRequirements: string | null;
|
||||
countryCode: string;
|
||||
// Post-filters (PostFilterOptions)
|
||||
minSalary: string;
|
||||
maxSalary: string;
|
||||
includeKeywords: string[];
|
||||
excludeKeywords: string[];
|
||||
excludeCompanies: string[];
|
||||
directApplyOnly: boolean;
|
||||
};
|
||||
|
||||
export const initialFilterState: FilterState = {
|
||||
datePosted: null,
|
||||
remoteOnly: false,
|
||||
employmentType: null,
|
||||
jobRequirements: null,
|
||||
countryCode: "US",
|
||||
minSalary: "",
|
||||
maxSalary: "",
|
||||
includeKeywords: [],
|
||||
excludeKeywords: [],
|
||||
excludeCompanies: [],
|
||||
directApplyOnly: false,
|
||||
};
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
export function buildSearchParams(query: string, filters: FilterState, page?: number): SearchParams {
|
||||
const effectiveQuery = query.trim();
|
||||
const countryCode = filters.countryCode.trim().toUpperCase() || initialFilterState.countryCode;
|
||||
|
||||
const params: SearchParams = { query: effectiveQuery, num_pages: FETCH_NUM_PAGES };
|
||||
if (page && page > 1) params.page = page;
|
||||
if (filters.datePosted && filters.datePosted !== "all") {
|
||||
params.date_posted = filters.datePosted as SearchParams["date_posted"];
|
||||
}
|
||||
params.country = countryCode;
|
||||
if (filters.remoteOnly) params.remote_jobs_only = true;
|
||||
if (filters.employmentType) params.employment_types = filters.employmentType;
|
||||
if (filters.jobRequirements) params.job_requirements = filters.jobRequirements;
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildPostFilters(filters: FilterState): PostFilterOptions {
|
||||
const result: PostFilterOptions = {};
|
||||
const minSalaryInput = filters.minSalary.trim();
|
||||
const maxSalaryInput = filters.maxSalary.trim();
|
||||
const minSal = Number(minSalaryInput);
|
||||
const maxSal = Number(maxSalaryInput);
|
||||
|
||||
if (minSalaryInput !== "" && Number.isFinite(minSal) && minSal >= 0) result.minSalary = minSal;
|
||||
if (maxSalaryInput !== "" && Number.isFinite(maxSal) && maxSal >= 0) result.maxSalary = maxSal;
|
||||
if (result.minSalary != null && result.maxSalary != null && result.minSalary > result.maxSalary) {
|
||||
[result.minSalary, result.maxSalary] = [result.maxSalary, result.minSalary];
|
||||
}
|
||||
|
||||
if (filters.includeKeywords.length > 0) result.includeKeywords = filters.includeKeywords;
|
||||
if (filters.excludeKeywords.length > 0) result.excludeKeywords = filters.excludeKeywords;
|
||||
if (filters.excludeCompanies.length > 0) result.excludeCompanies = filters.excludeCompanies;
|
||||
if (filters.directApplyOnly) result.directApplyOnly = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hasActiveFilters(filters: FilterState): boolean {
|
||||
return (
|
||||
(filters.datePosted !== null && filters.datePosted !== "all") ||
|
||||
filters.remoteOnly ||
|
||||
filters.employmentType !== null ||
|
||||
filters.jobRequirements !== null ||
|
||||
filters.countryCode !== initialFilterState.countryCode ||
|
||||
filters.minSalary !== "" ||
|
||||
filters.maxSalary !== "" ||
|
||||
filters.includeKeywords.length > 0 ||
|
||||
filters.excludeKeywords.length > 0 ||
|
||||
filters.excludeCompanies.length > 0 ||
|
||||
filters.directApplyOnly
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
BriefcaseIcon,
|
||||
BuildingsIcon,
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
MapPinIcon,
|
||||
MoneyIcon,
|
||||
StarIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
|
||||
import { formatSalary, isValidExternalUrl } from "./job-utils";
|
||||
import { TailorDialog } from "./tailor-dialog";
|
||||
|
||||
type Props = {
|
||||
job: JobResult | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
const isAIEnabled = useAIStore((s) => s.enabled);
|
||||
const [tailorOpen, setTailorOpen] = useState(false);
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
|
||||
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
|
||||
const hasApplyLink = isValidExternalUrl(job.job_apply_link);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="data-[side=right]:sm:w-[30vw] data-[side=right]:sm:max-w-none data-[side=right]:sm:min-w-[400px]"
|
||||
>
|
||||
<SheetHeader>
|
||||
<div className="flex items-start gap-x-3">
|
||||
{job.employer_logo ? (
|
||||
<img
|
||||
src={job.employer_logo}
|
||||
alt={job.employer_name}
|
||||
className="size-12 shrink-0 rounded-md object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<BuildingsIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="text-lg">{job.job_title}</SheetTitle>
|
||||
<SheetDescription>{job.employer_name}</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 px-4">
|
||||
<div className="flex flex-col gap-y-4 pb-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{location && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MapPinIcon className="size-3" />
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_is_remote && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<GlobeIcon className="size-3" />
|
||||
<Trans>Remote</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_employment_type && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<BriefcaseIcon className="size-3" />
|
||||
{job.job_employment_type.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{salary && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MoneyIcon className="size-3" />
|
||||
{salary}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_posted_at_datetime_utc && (
|
||||
<Badge variant="outline" className="gap-x-1">
|
||||
<ClockIcon className="size-3" />
|
||||
{new Date(job.job_posted_at_datetime_utc).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
disabled={!hasApplyLink}
|
||||
render={
|
||||
<a href={hasApplyLink ? job.job_apply_link : "#"} target="_blank" rel="noopener noreferrer" />
|
||||
}
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!isAIEnabled}
|
||||
onClick={() => setTailorOpen(true)}
|
||||
>
|
||||
<StarIcon />
|
||||
<Trans>Tailor Resume</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{job.job_description && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium">
|
||||
<Trans>Description</Trans>
|
||||
</h4>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{job.job_description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.job_highlights && Object.keys(job.job_highlights).length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
{Object.entries(job.job_highlights).map(([category, items]) => (
|
||||
<div key={category} className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium capitalize">{category.replaceAll("_", " ")}</h4>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{(items as string[]).map((item: string, i: number) => (
|
||||
<li key={i} className="text-sm text-muted-foreground">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.job_required_skills && job.job_required_skills.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium">
|
||||
<Trans>Required Skills</Trans>
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{job.job_required_skills.map((skill) => (
|
||||
<Badge key={skill} variant="outline">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.job_benefits && job.job_benefits.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium">
|
||||
<Trans>Benefits</Trans>
|
||||
</h4>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{job.job_benefits.map((benefit) => (
|
||||
<li key={benefit} className="text-sm text-muted-foreground">
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.apply_options.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4 className="font-medium">
|
||||
<Trans>Apply Via</Trans>
|
||||
</h4>
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
{job.apply_options.map((option, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={option.apply_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
|
||||
{option.publisher || t`Apply Link`}
|
||||
{option.is_direct && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Trans>Direct</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<TailorDialog job={job} open={tailorOpen} onOpenChange={setTailorOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import type { RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
export function formatSalary(
|
||||
min: number | null,
|
||||
max: number | null,
|
||||
currency: string | null,
|
||||
period: string | null,
|
||||
): string {
|
||||
if (!min && !max) return "";
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
const resolvedCurrency = currency ?? "USD";
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: resolvedCurrency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
return `${resolvedCurrency} ${amount.toLocaleString()}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (min && max) return `${formatCurrency(min)} - ${formatCurrency(max)}${period ? ` / ${period}` : ""}`;
|
||||
if (min) return `${formatCurrency(min)}+${period ? ` / ${period}` : ""}`;
|
||||
if (!max) return "";
|
||||
return `Up to ${formatCurrency(max)}${period ? ` / ${period}` : ""}`;
|
||||
}
|
||||
|
||||
export function formatPostedDate(timestamp: number | null): string {
|
||||
if (!timestamp) return "";
|
||||
|
||||
const postedDate = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - postedDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 0) return t`Today`;
|
||||
if (diffDays === 1) return t`Yesterday`;
|
||||
if (diffDays < 7) return t`${diffDays} days ago`;
|
||||
if (diffDays < 30) return t`${Math.floor(diffDays / 7)} weeks ago`;
|
||||
return t`${Math.floor(diffDays / 30)} months ago`;
|
||||
}
|
||||
|
||||
export function getQuotaStatus(quota: RapidApiQuota): "healthy" | "warning" | "critical" {
|
||||
if (quota.limit <= 0) return "healthy";
|
||||
const usage = quota.used / quota.limit;
|
||||
if (usage >= 0.9) return "critical";
|
||||
if (usage >= 0.75) return "warning";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
export function isValidExternalUrl(url: string | null | undefined): boolean {
|
||||
if (!url?.trim()) return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildPostFilters,
|
||||
buildSearchParams,
|
||||
FETCH_NUM_PAGES,
|
||||
type FilterState,
|
||||
hasActiveFilters,
|
||||
initialFilterState,
|
||||
RESULTS_PER_PAGE,
|
||||
} from "./filter-helpers";
|
||||
|
||||
// --- buildSearchParams ---
|
||||
|
||||
describe("buildSearchParams", () => {
|
||||
it("returns query and num_pages when all filters are default", () => {
|
||||
const result = buildSearchParams("react developer", initialFilterState);
|
||||
expect(result).toEqual({ query: "react developer", num_pages: FETCH_NUM_PAGES, country: "US" });
|
||||
});
|
||||
|
||||
it("trims the query string", () => {
|
||||
const result = buildSearchParams(" react developer ", initialFilterState);
|
||||
expect(result.query).toBe("react developer");
|
||||
});
|
||||
|
||||
it("includes date_posted when datePosted is set", () => {
|
||||
const filters: FilterState = { ...initialFilterState, datePosted: "week" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.date_posted).toBe("week");
|
||||
});
|
||||
|
||||
it("omits date_posted when datePosted is all", () => {
|
||||
const filters: FilterState = { ...initialFilterState, datePosted: "all" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.date_posted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes remote_jobs_only when remoteOnly is true", () => {
|
||||
const filters: FilterState = { ...initialFilterState, remoteOnly: true };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.remote_jobs_only).toBe(true);
|
||||
});
|
||||
|
||||
it("omits remote_jobs_only when remoteOnly is false", () => {
|
||||
const result = buildSearchParams("engineer", initialFilterState);
|
||||
expect(result.remote_jobs_only).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes employment_types when employmentType is set", () => {
|
||||
const filters: FilterState = { ...initialFilterState, employmentType: "FULLTIME" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.employment_types).toBe("FULLTIME");
|
||||
});
|
||||
|
||||
it("includes job_requirements when jobRequirements is set", () => {
|
||||
const filters: FilterState = { ...initialFilterState, jobRequirements: "under_3_years_experience" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.job_requirements).toBe("under_3_years_experience");
|
||||
});
|
||||
|
||||
it("includes country using the default countryCode", () => {
|
||||
const result = buildSearchParams("engineer", initialFilterState);
|
||||
expect(result.country).toBe("US");
|
||||
});
|
||||
|
||||
it("includes country using a custom countryCode", () => {
|
||||
const filters: FilterState = { ...initialFilterState, countryCode: "DE" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.country).toBe("DE");
|
||||
});
|
||||
|
||||
it("normalizes countryCode to uppercase", () => {
|
||||
const filters: FilterState = { ...initialFilterState, countryCode: "de" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.country).toBe("DE");
|
||||
});
|
||||
|
||||
it("falls back to US when countryCode is empty", () => {
|
||||
const filters: FilterState = { ...initialFilterState, countryCode: "" };
|
||||
const result = buildSearchParams("engineer", filters);
|
||||
expect(result.country).toBe("US");
|
||||
});
|
||||
|
||||
it("keeps query focused on user text", () => {
|
||||
const filters: FilterState = { ...initialFilterState, countryCode: "DE" };
|
||||
const result = buildSearchParams("engineer in berlin", filters);
|
||||
expect(result.query).toBe("engineer in berlin");
|
||||
});
|
||||
|
||||
it("includes all filter params when all are set", () => {
|
||||
const filters: FilterState = {
|
||||
...initialFilterState,
|
||||
datePosted: "today",
|
||||
remoteOnly: true,
|
||||
employmentType: "CONTRACTOR",
|
||||
jobRequirements: "no_experience",
|
||||
countryCode: "CA",
|
||||
};
|
||||
const result = buildSearchParams("designer", filters);
|
||||
expect(result).toEqual({
|
||||
query: "designer",
|
||||
num_pages: FETCH_NUM_PAGES,
|
||||
date_posted: "today",
|
||||
country: "CA",
|
||||
remote_jobs_only: true,
|
||||
employment_types: "CONTRACTOR",
|
||||
job_requirements: "no_experience",
|
||||
});
|
||||
});
|
||||
it("always includes num_pages", () => {
|
||||
const result = buildSearchParams("test", initialFilterState);
|
||||
expect(result.num_pages).toBe(FETCH_NUM_PAGES);
|
||||
});
|
||||
|
||||
it("omits page param when page is undefined", () => {
|
||||
const result = buildSearchParams("test", initialFilterState);
|
||||
expect(result.page).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits page param when page is 1", () => {
|
||||
const result = buildSearchParams("test", initialFilterState, 1);
|
||||
expect(result.page).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes page param when page > 1", () => {
|
||||
const result = buildSearchParams("test", initialFilterState, 2);
|
||||
expect(result.page).toBe(2);
|
||||
});
|
||||
|
||||
it("includes page param for higher page numbers", () => {
|
||||
const result = buildSearchParams("test", initialFilterState, 5);
|
||||
expect(result.page).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Pagination constants ---
|
||||
|
||||
describe("pagination constants", () => {
|
||||
it("RESULTS_PER_PAGE is 10", () => {
|
||||
expect(RESULTS_PER_PAGE).toBe(10);
|
||||
});
|
||||
|
||||
it("FETCH_NUM_PAGES is 1", () => {
|
||||
expect(FETCH_NUM_PAGES).toBe(1);
|
||||
});
|
||||
|
||||
it("FETCH_NUM_PAGES matches RESULTS_PER_PAGE worth of results", () => {
|
||||
// Each API page returns ~10 results, so FETCH_NUM_PAGES * 10 should equal RESULTS_PER_PAGE
|
||||
expect(FETCH_NUM_PAGES * 10).toBe(RESULTS_PER_PAGE);
|
||||
});
|
||||
});
|
||||
|
||||
// --- buildPostFilters ---
|
||||
|
||||
describe("buildPostFilters", () => {
|
||||
it("returns empty object when all filters are default", () => {
|
||||
const result = buildPostFilters(initialFilterState);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("includes minSalary when valid positive number", () => {
|
||||
const filters: FilterState = { ...initialFilterState, minSalary: "50000" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.minSalary).toBe(50000);
|
||||
});
|
||||
|
||||
it("includes minSalary when value is zero", () => {
|
||||
const filters: FilterState = { ...initialFilterState, minSalary: "0" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.minSalary).toBe(0);
|
||||
});
|
||||
|
||||
it("includes maxSalary when valid positive number", () => {
|
||||
const filters: FilterState = { ...initialFilterState, maxSalary: "150000" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.maxSalary).toBe(150000);
|
||||
});
|
||||
|
||||
it("omits minSalary for empty string", () => {
|
||||
const result = buildPostFilters(initialFilterState);
|
||||
expect(result.minSalary).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits minSalary for non-numeric string", () => {
|
||||
const filters: FilterState = { ...initialFilterState, minSalary: "abc" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.minSalary).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes includeKeywords when non-empty", () => {
|
||||
const filters: FilterState = { ...initialFilterState, includeKeywords: ["react", "typescript"] };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.includeKeywords).toEqual(["react", "typescript"]);
|
||||
});
|
||||
|
||||
it("omits includeKeywords when empty array", () => {
|
||||
const result = buildPostFilters(initialFilterState);
|
||||
expect(result.includeKeywords).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes excludeKeywords when non-empty", () => {
|
||||
const filters: FilterState = { ...initialFilterState, excludeKeywords: ["senior"] };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.excludeKeywords).toEqual(["senior"]);
|
||||
});
|
||||
|
||||
it("includes excludeCompanies when non-empty", () => {
|
||||
const filters: FilterState = { ...initialFilterState, excludeCompanies: ["Spam Inc"] };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.excludeCompanies).toEqual(["Spam Inc"]);
|
||||
});
|
||||
|
||||
it("includes directApplyOnly when true", () => {
|
||||
const filters: FilterState = { ...initialFilterState, directApplyOnly: true };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.directApplyOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("omits directApplyOnly when false", () => {
|
||||
const result = buildPostFilters(initialFilterState);
|
||||
expect(result.directApplyOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes all post-filters when all are set", () => {
|
||||
const filters: FilterState = {
|
||||
...initialFilterState,
|
||||
minSalary: "80000",
|
||||
maxSalary: "200000",
|
||||
includeKeywords: ["react"],
|
||||
excludeKeywords: ["java"],
|
||||
excludeCompanies: ["Acme"],
|
||||
directApplyOnly: true,
|
||||
};
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result).toEqual({
|
||||
minSalary: 80000,
|
||||
maxSalary: 200000,
|
||||
includeKeywords: ["react"],
|
||||
excludeKeywords: ["java"],
|
||||
excludeCompanies: ["Acme"],
|
||||
directApplyOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes salary range when min is greater than max", () => {
|
||||
const filters: FilterState = { ...initialFilterState, minSalary: "150000", maxSalary: "80000" };
|
||||
const result = buildPostFilters(filters);
|
||||
expect(result.minSalary).toBe(80000);
|
||||
expect(result.maxSalary).toBe(150000);
|
||||
});
|
||||
});
|
||||
|
||||
// --- hasActiveFilters ---
|
||||
|
||||
describe("hasActiveFilters", () => {
|
||||
it("returns false for initial/default filter state", () => {
|
||||
expect(hasActiveFilters(initialFilterState)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when datePosted is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, datePosted: "week" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when datePosted is all", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, datePosted: "all" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when remoteOnly is true", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, remoteOnly: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when employmentType is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, employmentType: "FULLTIME" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when jobRequirements is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, jobRequirements: "no_experience" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when countryCode differs from default", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, countryCode: "DE" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when minSalary is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, minSalary: "50000" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when maxSalary is set", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, maxSalary: "150000" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when includeKeywords has items", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, includeKeywords: ["react"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when excludeKeywords has items", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, excludeKeywords: ["java"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when excludeCompanies has items", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, excludeCompanies: ["Acme"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when directApplyOnly is true", () => {
|
||||
expect(hasActiveFilters({ ...initialFilterState, directApplyOnly: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when multiple filters are active", () => {
|
||||
expect(
|
||||
hasActiveFilters({
|
||||
...initialFilterState,
|
||||
remoteOnly: true,
|
||||
employmentType: "FULLTIME",
|
||||
minSalary: "60000",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { msg, t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CaretRightIcon, FunnelIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { ChipInput } from "@/components/input/chip-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ISO_COUNTRIES } from "@/constants/iso-countries";
|
||||
|
||||
import { type FilterState, hasActiveFilters, initialFilterState } from "./filter-helpers";
|
||||
|
||||
export {
|
||||
buildPostFilters,
|
||||
buildSearchParams,
|
||||
type FilterState,
|
||||
hasActiveFilters,
|
||||
initialFilterState,
|
||||
RESULTS_PER_PAGE,
|
||||
} from "./filter-helpers";
|
||||
|
||||
// --- Combobox option constants ---
|
||||
|
||||
const datePostedOptions = [
|
||||
{ value: "all", label: msg`Any time` },
|
||||
{ value: "today", label: msg`Today` },
|
||||
{ value: "3days", label: msg`Last 3 days` },
|
||||
{ value: "week", label: msg`This week` },
|
||||
{ value: "month", label: msg`This month` },
|
||||
] as const;
|
||||
|
||||
const employmentTypeOptions = [
|
||||
{ value: "FULLTIME", label: msg`Full-time` },
|
||||
{ value: "PARTTIME", label: msg`Part-time` },
|
||||
{ value: "CONTRACTOR", label: msg`Contractor` },
|
||||
{ value: "INTERN", label: msg`Intern` },
|
||||
] as const;
|
||||
|
||||
const experienceOptions = [
|
||||
{ value: "no_experience", label: msg`No experience` },
|
||||
{ value: "under_3_years_experience", label: msg`Under 3 years` },
|
||||
{ value: "more_than_3_years_experience", label: msg`More than 3 years` },
|
||||
{ value: "no_degree", label: msg`No degree required` },
|
||||
] as const;
|
||||
|
||||
// --- Component ---
|
||||
|
||||
type SearchFiltersProps = {
|
||||
filters: FilterState;
|
||||
onFiltersChange: (filters: FilterState) => void;
|
||||
};
|
||||
|
||||
export function SearchFilters({ filters, onFiltersChange }: SearchFiltersProps) {
|
||||
const { i18n } = useLingui();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const _employmentTypeOptions = useMemo(() => {
|
||||
return employmentTypeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: i18n.t(option.label),
|
||||
}));
|
||||
}, [i18n.locale]);
|
||||
|
||||
const _datePostedOptions = useMemo(() => {
|
||||
return datePostedOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: i18n.t(option.label),
|
||||
}));
|
||||
}, [i18n.locale]);
|
||||
|
||||
const _experienceOptions = useMemo(() => {
|
||||
return experienceOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: i18n.t(option.label),
|
||||
}));
|
||||
}, [i18n.locale]);
|
||||
|
||||
const _countryOptions = useMemo(() => {
|
||||
return ISO_COUNTRIES.map((country) => ({
|
||||
value: country.code,
|
||||
label: country.name,
|
||||
keywords: [country.code],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
onFiltersChange({ ...filters, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Quick Filters */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="filter-remote" className="text-xs text-muted-foreground">
|
||||
<Trans>Remote</Trans>
|
||||
</Label>
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
id="filter-remote"
|
||||
checked={filters.remoteOnly}
|
||||
onCheckedChange={(v) => updateFilter("remoteOnly", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="filter-direct-apply" className="text-xs text-muted-foreground">
|
||||
<Trans>Direct Apply</Trans>
|
||||
</Label>
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
id="filter-direct-apply"
|
||||
checked={filters.directApplyOnly}
|
||||
onCheckedChange={(v) => updateFilter("directApplyOnly", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
<Trans>Type</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
options={_employmentTypeOptions}
|
||||
value={filters.employmentType}
|
||||
onValueChange={(v) => updateFilter("employmentType", v)}
|
||||
placeholder={t`Any type`}
|
||||
className="h-9 w-[140px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
<Trans>Date Posted</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
options={_datePostedOptions}
|
||||
value={filters.datePosted}
|
||||
onValueChange={(v) => updateFilter("datePosted", v)}
|
||||
placeholder={t`Any time`}
|
||||
className="h-9 w-[140px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
<Trans>Experience</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
options={_experienceOptions}
|
||||
value={filters.jobRequirements}
|
||||
onValueChange={(v) => updateFilter("jobRequirements", v)}
|
||||
placeholder={t`Any level`}
|
||||
className="h-9 w-[160px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
<Trans>Country</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
options={_countryOptions}
|
||||
value={filters.countryCode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
updateFilter("countryCode", value);
|
||||
}}
|
||||
placeholder={t`Select country`}
|
||||
searchPlaceholder={t`Search countries`}
|
||||
className="h-9 w-[260px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters(filters) && (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-x-1" onClick={() => onFiltersChange(initialFilterState)}>
|
||||
<XIcon className="size-3.5" />
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters Toggle */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-x-1.5 text-muted-foreground"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
>
|
||||
<FunnelIcon className="size-3.5" />
|
||||
<Trans>Advanced Filters</Trans>
|
||||
<CaretRightIcon
|
||||
className="size-3 transition-transform"
|
||||
style={{ transform: showAdvanced ? "rotate(90deg)" : "rotate(0deg)" }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Advanced Filters Panel */}
|
||||
<AnimatePresence initial={false}>
|
||||
{showAdvanced && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Minimum Salary</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={filters.minSalary}
|
||||
onChange={(e) => updateFilter("minSalary", e.target.value)}
|
||||
placeholder={t`e.g. 50000`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Maximum Salary</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={filters.maxSalary}
|
||||
onChange={(e) => updateFilter("maxSalary", e.target.value)}
|
||||
placeholder={t`e.g. 150000`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Include Keywords</Trans>
|
||||
</Label>
|
||||
<ChipInput
|
||||
hideDescription
|
||||
value={filters.includeKeywords}
|
||||
onChange={(v) => updateFilter("includeKeywords", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Exclude Keywords</Trans>
|
||||
</Label>
|
||||
<ChipInput
|
||||
hideDescription
|
||||
value={filters.excludeKeywords}
|
||||
onChange={(v) => updateFilter("excludeKeywords", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm">
|
||||
<Trans>Exclude Companies</Trans>
|
||||
</Label>
|
||||
<ChipInput
|
||||
hideDescription
|
||||
value={filters.excludeCompanies}
|
||||
onChange={(v) => updateFilter("excludeCompanies", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
import type { NewSkillInfo } from "@/schema/tailor";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { client, orpc } from "@/integrations/orpc/client";
|
||||
import { buildSkillSyncOperations, tailorOutputToPatches, validateTailorOutput } from "@/utils/resume/tailor";
|
||||
import { slugify } from "@/utils/string";
|
||||
|
||||
type Props = {
|
||||
job: JobResult;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type DialogPhase =
|
||||
| { step: "select" }
|
||||
| { step: "tailoring" }
|
||||
| { step: "skill-sync"; newResumeId: string; newSkills: NewSkillInfo[]; sourceResumeId: string };
|
||||
|
||||
export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { data: resumes, isLoading } = useQuery(orpc.resume.list.queryOptions());
|
||||
|
||||
const [phase, setPhase] = useState<DialogPhase>({ step: "select" });
|
||||
const [selectedSkills, setSelectedSkills] = useState<Set<number>>(new Set());
|
||||
|
||||
const aiEnabled = useAIStore((s) => s.enabled);
|
||||
const aiProvider = useAIStore((s) => s.provider);
|
||||
const aiModel = useAIStore((s) => s.model);
|
||||
const aiApiKey = useAIStore((s) => s.apiKey);
|
||||
const aiBaseURL = useAIStore((s) => s.baseURL);
|
||||
|
||||
const { mutate: duplicateResume, isPending: isDuplicating } = useMutation(orpc.resume.duplicate.mutationOptions());
|
||||
|
||||
const resetDialog = () => {
|
||||
setPhase({ step: "select" });
|
||||
setSelectedSkills(new Set());
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) resetDialog();
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const navigateToBuilder = (resumeId: string) => {
|
||||
if (job.job_apply_link) {
|
||||
window.open(job.job_apply_link, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
handleOpenChange(false);
|
||||
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
|
||||
};
|
||||
|
||||
const handleSelectResume = async (resumeId: string, resumeName: string) => {
|
||||
const tailorName = `${resumeName} - ${job.job_title}`;
|
||||
const tailorSlug = slugify(`${tailorName}-${Date.now()}`);
|
||||
|
||||
// If AI is not enabled, fall back to duplicating and navigating to builder
|
||||
if (!aiEnabled) {
|
||||
duplicateResume(
|
||||
{ id: resumeId, name: tailorName, slug: tailorSlug, tags: ["tailored"] },
|
||||
{
|
||||
onSuccess: (newResumeId) => navigateToBuilder(newResumeId),
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to duplicate resume`, { description: error.message });
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// AI-powered tailoring pipeline
|
||||
setPhase({ step: "tailoring" });
|
||||
|
||||
try {
|
||||
// Step 1: Duplicate the resume
|
||||
const newResumeId = await client.resume.duplicate({
|
||||
id: resumeId,
|
||||
name: tailorName,
|
||||
slug: tailorSlug,
|
||||
tags: ["tailored"],
|
||||
});
|
||||
|
||||
// Step 2: Fetch the full resume data
|
||||
const resume = await client.resume.getById({ id: newResumeId });
|
||||
|
||||
// Step 3: Call AI tailor endpoint
|
||||
const tailorOutput = await client.ai.tailorResume({
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
apiKey: aiApiKey,
|
||||
baseURL: aiBaseURL,
|
||||
resumeData: resume.data,
|
||||
job,
|
||||
});
|
||||
|
||||
// Step 4: Validate AI output
|
||||
const errors = validateTailorOutput(tailorOutput, resume.data);
|
||||
if (errors.length > 0) {
|
||||
toast.error(t`AI returned some invalid references`, {
|
||||
description: errors.join("; "),
|
||||
});
|
||||
}
|
||||
|
||||
// Step 5: Convert to patches and apply
|
||||
const { operations, newSkills } = tailorOutputToPatches(tailorOutput, resume.data);
|
||||
|
||||
if (operations.length > 0) {
|
||||
await client.resume.patch({ id: newResumeId, operations });
|
||||
}
|
||||
|
||||
// Step 6: If new skills were found, show sync dialog
|
||||
if (newSkills.length > 0) {
|
||||
setSelectedSkills(new Set(newSkills.map((_, i) => i)));
|
||||
setPhase({
|
||||
step: "skill-sync",
|
||||
newResumeId,
|
||||
newSkills,
|
||||
sourceResumeId: resumeId,
|
||||
});
|
||||
} else {
|
||||
toast.success(t`Resume tailored successfully`);
|
||||
navigateToBuilder(newResumeId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(t`Tailoring failed`, { description: message });
|
||||
setPhase({ step: "select" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkillSync = async () => {
|
||||
if (phase.step !== "skill-sync") return;
|
||||
|
||||
const { newResumeId, newSkills, sourceResumeId } = phase;
|
||||
const skillsToSync = Array.from(selectedSkills).map((i) => newSkills[i]);
|
||||
|
||||
if (skillsToSync.length > 0) {
|
||||
try {
|
||||
const operations = buildSkillSyncOperations(skillsToSync);
|
||||
await client.resume.patch({ id: sourceResumeId, operations });
|
||||
toast.success(t`Added ${skillsToSync.length} new skills to your original resume`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(t`Failed to sync skills`, { description: message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t`Resume tailored successfully`);
|
||||
navigateToBuilder(newResumeId);
|
||||
};
|
||||
|
||||
const handleSkipSync = () => {
|
||||
if (phase.step !== "skill-sync") return;
|
||||
toast.success(t`Resume tailored successfully`);
|
||||
navigateToBuilder(phase.newResumeId);
|
||||
};
|
||||
|
||||
const toggleSkill = (index: number) => {
|
||||
setSelectedSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
{phase.step === "select" && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Tailor Resume</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Select a resume to tailor for "{job.job_title}" at {job.employer_name}. A copy will be created
|
||||
{aiEnabled ? " and the AI will optimize it for this position." : "."}
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-80">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : resumes && resumes.length > 0 ? (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{resumes.map((resume) => (
|
||||
<Button
|
||||
key={resume.id}
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-x-3 py-3"
|
||||
disabled={isDuplicating}
|
||||
onClick={() => handleSelectResume(resume.id, resume.name)}
|
||||
>
|
||||
<ReadCvLogoIcon className="size-5 shrink-0" />
|
||||
<div className="min-w-0 text-start">
|
||||
<p className="truncate font-medium">{resume.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{new Date(resume.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
{resume.tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{resume.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDuplicating && <Spinner className="ms-auto" />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>No resumes found. Create a resume first.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>
|
||||
<Trans>Cancel</Trans>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase.step === "tailoring" && (
|
||||
<div className="flex flex-col items-center gap-y-4 py-12">
|
||||
<Spinner className="size-10" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">
|
||||
<Trans>Tailoring your resume...</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>Optimizing summary, experience, and skills for {job.job_title}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.step === "skill-sync" && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>New Skills Detected</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
The AI identified new skills from your experience that match this job. Select which ones to save back
|
||||
to your original resume for future applications. This will permanently modify your original resume and
|
||||
cannot be undone.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-80">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{phase.newSkills.map((skill, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${skill.name}-${index}`}
|
||||
className="flex items-center gap-x-3 rounded-md border p-3 text-start hover:bg-muted/50"
|
||||
onClick={() => toggleSkill(index)}
|
||||
>
|
||||
<Switch checked={selectedSkills.has(index)} onCheckedChange={() => toggleSkill(index)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{skill.name}</p>
|
||||
{skill.proficiency && <p className="text-xs text-muted-foreground">{skill.proficiency}</p>}
|
||||
{skill.keywords.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{skill.keywords.map((kw) => (
|
||||
<Badge key={kw} variant="secondary" className="text-[10px]">
|
||||
{kw}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleSkipSync}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button onClick={handleSkillSync}>
|
||||
<Trans>Save {selectedSkills.size} Skills</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { JobResult, RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
import {
|
||||
buildPostFilters,
|
||||
buildSearchParams,
|
||||
type FilterState,
|
||||
initialFilterState,
|
||||
RESULTS_PER_PAGE,
|
||||
} from "./search-filters";
|
||||
|
||||
type ActiveFilterChip = { key: keyof FilterState; label: string; value?: string };
|
||||
|
||||
export function useJobSearch() {
|
||||
const rapidApiKey = useJobsStore((state) => state.rapidApiKey);
|
||||
const testStatus = useJobsStore((state) => state.testStatus);
|
||||
const setJobsStore = useJobsStore((state) => state.set);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [filters, setFilters] = useState<FilterState>(initialFilterState);
|
||||
const [jobs, setJobs] = useState<JobResult[]>([]);
|
||||
const [quota, setQuota] = useState<RapidApiQuota | null>(null);
|
||||
const [selectedJob, setSelectedJob] = useState<JobResult | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const { mutate: searchJobs, isPending } = useMutation(orpc.jobs.search.mutationOptions());
|
||||
|
||||
const isConfigured = Boolean(rapidApiKey && testStatus === "success");
|
||||
|
||||
const executeSearch = useCallback(
|
||||
(page: number) => {
|
||||
if (!rapidApiKey) return;
|
||||
|
||||
const requestId = ++requestIdRef.current;
|
||||
const effectiveQuery = query.trim() || "jobs";
|
||||
const params = buildSearchParams(effectiveQuery, filters, page);
|
||||
const postFilters = buildPostFilters(filters);
|
||||
setError(null);
|
||||
|
||||
searchJobs(
|
||||
{ apiKey: rapidApiKey, params, filters: postFilters },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
setHasMore(data.data.length >= RESULTS_PER_PAGE);
|
||||
setJobs(data.data.slice(0, RESULTS_PER_PAGE));
|
||||
setQuota(data.rapidApiQuota ?? null);
|
||||
|
||||
const rapidApiQuota = data.rapidApiQuota;
|
||||
if (rapidApiQuota) {
|
||||
setJobsStore((draft) => {
|
||||
draft.rapidApiQuota = rapidApiQuota;
|
||||
});
|
||||
}
|
||||
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setError(error.message);
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[filters, query, rapidApiKey, searchJobs, setJobsStore],
|
||||
);
|
||||
|
||||
const handleSearch = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setHasSearched(true);
|
||||
setCurrentPage(1);
|
||||
executeSearch(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
executeSearch(page);
|
||||
};
|
||||
|
||||
const handleJobClick = (job: JobResult) => {
|
||||
setSelectedJob(job);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const removeFilter = (key: keyof FilterState, value?: string) => {
|
||||
if (key === "includeKeywords" || key === "excludeKeywords" || key === "excludeCompanies") {
|
||||
const currentValues = filters[key];
|
||||
if (!Array.isArray(currentValues)) return;
|
||||
const nextValues = value ? currentValues.filter((item) => item !== value) : [];
|
||||
setFilters({ ...filters, [key]: nextValues });
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "remoteOnly" || key === "directApplyOnly") {
|
||||
setFilters({ ...filters, [key]: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setFilters({ ...filters, [key]: initialFilterState[key] as never });
|
||||
};
|
||||
|
||||
const activeFilterChips: ActiveFilterChip[] = [
|
||||
...(filters.remoteOnly ? [{ key: "remoteOnly" as const, label: t`Remote only` }] : []),
|
||||
...(filters.directApplyOnly ? [{ key: "directApplyOnly" as const, label: t`Direct apply only` }] : []),
|
||||
...(filters.employmentType ? [{ key: "employmentType" as const, label: filters.employmentType }] : []),
|
||||
...(filters.jobRequirements ? [{ key: "jobRequirements" as const, label: filters.jobRequirements }] : []),
|
||||
...(filters.datePosted && filters.datePosted !== "all"
|
||||
? [{ key: "datePosted" as const, label: filters.datePosted }]
|
||||
: []),
|
||||
...filters.includeKeywords.map((value) => ({ key: "includeKeywords" as const, label: `+${value}`, value })),
|
||||
...filters.excludeKeywords.map((value) => ({ key: "excludeKeywords" as const, label: `-${value}`, value })),
|
||||
...filters.excludeCompanies.map((value) => ({
|
||||
key: "excludeCompanies" as const,
|
||||
label: t`Exclude ${value}`,
|
||||
value,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
activeFilterChips,
|
||||
currentPage,
|
||||
error,
|
||||
executeSearch,
|
||||
filters,
|
||||
handleJobClick,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
hasMore,
|
||||
hasSearched,
|
||||
isConfigured,
|
||||
isPending,
|
||||
jobs,
|
||||
query,
|
||||
quota,
|
||||
removeFilter,
|
||||
scrollRef,
|
||||
selectedJob,
|
||||
setFilters,
|
||||
setQuery,
|
||||
setSheetOpen,
|
||||
sheetOpen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BriefcaseIcon,
|
||||
BuildingsIcon,
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MapPinIcon,
|
||||
MoneyIcon,
|
||||
WarningCircleIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
import { JobDetailSheet } from "./-components/job-detail";
|
||||
import { formatPostedDate, formatSalary, getQuotaStatus } from "./-components/job-utils";
|
||||
import { hasActiveFilters, initialFilterState, SearchFilters } from "./-components/search-filters";
|
||||
import { useJobSearch } from "./-components/use-job-search";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/job-search/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function JobCard({ job, onClick }: { job: JobResult; onClick: () => void }) {
|
||||
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
|
||||
const posted = formatPostedDate(job.job_posted_at_timestamp);
|
||||
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col gap-y-3 rounded-md border bg-card p-4 text-start transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<div className="flex items-start gap-x-3">
|
||||
{job.employer_logo ? (
|
||||
<img src={job.employer_logo} alt={job.employer_name} className="size-10 shrink-0 rounded-md object-contain" />
|
||||
) : (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<BuildingsIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-medium">{job.job_title}</h3>
|
||||
<p className="truncate text-sm text-muted-foreground">{job.employer_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{location && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MapPinIcon className="size-3" />
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_is_remote && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<GlobeIcon className="size-3" />
|
||||
<Trans>Remote</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_employment_type && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<BriefcaseIcon className="size-3" />
|
||||
{job.job_employment_type.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{salary && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MoneyIcon className="size-3" />
|
||||
{salary}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{posted && (
|
||||
<Badge variant="outline" className="gap-x-1">
|
||||
<ClockIcon className="size-3" />
|
||||
{posted}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const {
|
||||
activeFilterChips,
|
||||
currentPage,
|
||||
error,
|
||||
executeSearch,
|
||||
filters,
|
||||
handleJobClick,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
hasMore,
|
||||
hasSearched,
|
||||
isConfigured,
|
||||
isPending,
|
||||
jobs,
|
||||
query,
|
||||
quota,
|
||||
removeFilter,
|
||||
scrollRef,
|
||||
selectedJob,
|
||||
setFilters,
|
||||
setQuery,
|
||||
setSheetOpen,
|
||||
sheetOpen,
|
||||
} = useJobSearch();
|
||||
|
||||
const showFilterChips = useMemo(() => hasActiveFilters(filters), [filters]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={BriefcaseIcon} title={t`Job Search`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
{!isConfigured ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex max-w-xl flex-col items-center gap-y-4 py-12 text-center"
|
||||
>
|
||||
<MagnifyingGlassIcon className="size-12 text-muted-foreground" weight="light" />
|
||||
<h2 className="text-lg font-medium">
|
||||
<Trans>Configure Job Search</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>To search for job listings, you need to configure your RapidAPI key in settings.</Trans>
|
||||
</p>
|
||||
<Button nativeButton={false} variant="outline" render={<Link to="/dashboard/settings/job-search" />}>
|
||||
<Trans>Go to Settings</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSearch} className="flex items-end gap-x-3">
|
||||
<div className="flex flex-1 flex-col gap-y-2">
|
||||
<Label htmlFor="job-query">
|
||||
<Trans>Search</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="job-query"
|
||||
name="job-query"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t`e.g. frontend developer jobs in Berlin`}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? <Spinner /> : <MagnifyingGlassIcon />}
|
||||
<Trans>Search</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div ref={scrollRef} />
|
||||
|
||||
<SearchFilters filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
{showFilterChips && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{activeFilterChips.map((chip) => (
|
||||
<button
|
||||
key={`${chip.key}-${chip.value ?? chip.label}`}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border bg-background px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => removeFilter(chip.key, chip.value)}
|
||||
>
|
||||
{chip.label}
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
))}
|
||||
<Button size="sm" variant="ghost" onClick={() => setFilters(initialFilterState)}>
|
||||
<Trans>Clear all</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quota && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
getQuotaStatus(quota) === "healthy" && "text-emerald-600",
|
||||
getQuotaStatus(quota) === "warning" && "text-amber-600",
|
||||
getQuotaStatus(quota) === "critical" && "text-red-600",
|
||||
)}
|
||||
>
|
||||
<Trans>Quota: {quota.remaining} remaining</Trans>
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
{quota.used} / {quota.limit} requests used
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && jobs.length === 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="flex animate-pulse flex-col gap-y-3 rounded-md border bg-card p-4">
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-5/6 rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isPending && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningCircleIcon className="mt-0.5 size-4 text-destructive" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
<Trans>Could not fetch jobs</Trans>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => executeSearch(currentPage)}>
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{jobs.map((job) => (
|
||||
<JobCard key={job.job_id} job={job} onClick={() => handleJobClick(job)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1 || isPending}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<CaretLeftIcon className="size-4" />
|
||||
<Trans>Previous</Trans>
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<Trans>Page {currentPage}</Trans>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || isPending}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<Trans>Next</Trans>
|
||||
<CaretRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasSearched && !isPending && jobs.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>No jobs found. Try a different search query.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JobDetailSheet job={selectedJob} open={sheetOpen} onOpenChange={setSheetOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,24 +31,31 @@ export function ResumeCard({ resume }: ResumeCardProps) {
|
||||
return (
|
||||
<ResumeContextMenu resume={resume}>
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
|
||||
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
|
||||
{match({ isLoading, imageSrc: screenshotData?.url })
|
||||
.with({ isLoading: true }, () => (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<CircleNotchIcon weight="thin" className="size-12 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
.with({ imageSrc: P.string }, ({ imageSrc }) => (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={resume.name}
|
||||
className={cn("size-full object-cover transition-all", resume.isLocked && "blur-xs")}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
<motion.div
|
||||
whileHover={{ y: -2, scale: 1.005 }}
|
||||
whileTap={{ scale: 0.998 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
|
||||
{match({ isLoading, imageSrc: screenshotData?.url })
|
||||
.with({ isLoading: true }, () => (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<CircleNotchIcon weight="thin" className="size-12 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
.with({ imageSrc: P.string }, ({ imageSrc }) => (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={resume.name}
|
||||
className={cn("size-full object-cover transition-all", resume.isLocked && "blur-xs")}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
<ResumeLockOverlay isLocked={resume.isLocked} />
|
||||
</BaseCard>
|
||||
<ResumeLockOverlay isLocked={resume.isLocked} />
|
||||
</BaseCard>
|
||||
</motion.div>
|
||||
</Link>
|
||||
</ResumeContextMenu>
|
||||
);
|
||||
@@ -63,6 +70,8 @@ function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ willChange: "opacity" }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-full bg-popover p-6">
|
||||
|
||||
@@ -15,28 +15,40 @@ type Props = {
|
||||
export function GridView({ resumes }: Props) {
|
||||
return (
|
||||
<div className="3xl:grid-cols-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<motion.div initial={{ opacity: 0, x: -50 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -50 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<CreateResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{resumes?.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, y: -50, filter: "blur(12px)" }}
|
||||
transition={{ delay: (index + 2) * 0.05 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
filter: "blur(8px)",
|
||||
}}
|
||||
transition={{ duration: 0.2, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<ResumeCard resume={resume} />
|
||||
</motion.div>
|
||||
|
||||
@@ -31,7 +31,13 @@ export function ListView({ resumes }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<motion.div initial={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -50 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
@@ -50,10 +56,11 @@ export function ListView({ resumes }: Props) {
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -73,15 +80,16 @@ export function ListView({ resumes }: Props) {
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{resumes?.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -50, filter: "blur(12px)" }}
|
||||
transition={{ delay: (index + 2) * 0.05 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<ResumeListItem resume={resume} />
|
||||
</motion.div>
|
||||
|
||||
@@ -221,11 +221,12 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-sm border bg-popover p-6">
|
||||
<div className="rounded-sm bg-primary/10 p-2.5">
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -66,11 +66,12 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-sm border bg-popover p-6">
|
||||
<div className="rounded-sm bg-primary/10 p-2.5">
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<BookOpenIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
@@ -90,7 +91,7 @@ function RouteComponent() {
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me/api-reference" target="_blank" rel="noopener">
|
||||
<a href="https://docs.rxresu.me/api-reference" target="_blank" rel="noopener noreferrer">
|
||||
<LinkSimpleIcon />
|
||||
<Trans>API Reference</Trans>
|
||||
</a>
|
||||
@@ -111,7 +112,7 @@ function RouteComponent() {
|
||||
<Trans>Create a new API key</Trans>
|
||||
</Button>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{apiKeys.map((key, index) => (
|
||||
<motion.div
|
||||
key={key.id}
|
||||
@@ -119,7 +120,8 @@ function RouteComponent() {
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ delay: index * 0.08 }}
|
||||
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<KeyIcon />
|
||||
|
||||
@@ -130,9 +132,16 @@ function RouteComponent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.03 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -29,7 +29,8 @@ export function PasswordSection() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center justify-between gap-x-4"
|
||||
>
|
||||
<h2 className="flex items-center gap-x-3 text-base font-medium">
|
||||
@@ -39,21 +40,35 @@ export function PasswordSection() {
|
||||
|
||||
{match(hasPassword)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleUpdatePassword}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleUpdatePassword}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Set Password</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Set Password</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</motion.div>
|
||||
|
||||
@@ -40,7 +40,8 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: animationDelay }}
|
||||
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
@@ -52,16 +53,30 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
|
||||
{match(isConnected)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleUnlink}>
|
||||
<LinkBreakIcon />
|
||||
<Trans>Disconnect</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleUnlink}>
|
||||
<LinkBreakIcon />
|
||||
<Trans>Disconnect</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button variant="outline" onClick={handleLink}>
|
||||
<LinkIcon />
|
||||
<Trans>Connect</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleLink}>
|
||||
<LinkIcon />
|
||||
<Trans>Connect</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,8 @@ export function TwoFactorSection() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
@@ -45,16 +46,30 @@ export function TwoFactorSection() {
|
||||
|
||||
{match(hasTwoFactor)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleLeftIcon />
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleLeftIcon />
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleRightIcon />
|
||||
<Trans>Enable 2FA</Trans>
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleRightIcon />
|
||||
<Trans>Enable 2FA</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,8 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-4"
|
||||
>
|
||||
<PasswordSection />
|
||||
|
||||
@@ -62,7 +62,8 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<p className="leading-relaxed">
|
||||
@@ -76,15 +77,18 @@ function RouteComponent() {
|
||||
placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`}
|
||||
/>
|
||||
|
||||
<Button
|
||||
<motion.div
|
||||
className="justify-self-end"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={!isConfirmationValid}
|
||||
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
|
||||
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BriefcaseIcon, CheckCircleIcon, InfoIcon, LinkSimpleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/job-search")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RapidAPIKeyForm() {
|
||||
const { set, rapidApiKey, testStatus } = useJobsStore();
|
||||
|
||||
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.jobs.testConnection.mutationOptions());
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.rapidApiKey = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
testConnection(
|
||||
{ apiKey: rapidApiKey },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = data.success ? "success" : "failure";
|
||||
draft.rapidApiQuota = data.rapidApiQuota ?? null;
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
draft.rapidApiQuota = null;
|
||||
});
|
||||
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="rapidapi-key">
|
||||
<Trans>RapidAPI Key</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="rapidapi-key"
|
||||
name="rapidapi-key"
|
||||
type="password"
|
||||
value={rapidApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder={t`Enter your RapidAPI key`}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoCapitalize="off"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Get your API key from RapidAPI by subscribing to the JSearch API.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" disabled={isTesting || !rapidApiKey} onClick={handleTestConnection}>
|
||||
{match({ isTesting, testStatus })
|
||||
.with({ isTesting: true }, () => <Spinner />)
|
||||
.with({ isTesting: false, testStatus: "success" }, () => <CheckCircleIcon className="text-success" />)
|
||||
.with({ isTesting: false, testStatus: "failure" }, () => <XCircleIcon className="text-destructive" />)
|
||||
.otherwise(() => null)}
|
||||
<Trans>Test Connection</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RapidAPIQuotaDisplay() {
|
||||
const { testStatus, rapidApiQuota } = useJobsStore();
|
||||
|
||||
if (!rapidApiQuota || testStatus !== "success") return null;
|
||||
|
||||
const { used, limit, remaining } = rapidApiQuota;
|
||||
const percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Progress value={percent} id="jobs-quota-progress" className="w-full max-w-md">
|
||||
<ProgressLabel>
|
||||
<Trans>API Usage</Trans>
|
||||
</ProgressLabel>
|
||||
<ProgressValue />
|
||||
</Progress>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
{used} of {limit} requests used ({remaining} remaining)
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const isClient = useIsClient();
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={BriefcaseIcon} title={t`Job Search API`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>What is JSearch API?</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
JSearch aggregates job listings from multiple job boards using Google for Jobs. You can filter by
|
||||
country (ISO alpha-2 code), date posted, job type, remote options, and experience level.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
href="https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkSimpleIcon />
|
||||
<Trans>JSearch API Documentation</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
<Trans>
|
||||
Your RapidAPI key is stored locally on your browser. It is only sent to the server when making a request
|
||||
to search for jobs, and is never stored or logged on our servers.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<RapidAPIKeyForm />
|
||||
<RapidAPIQuotaDisplay />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,8 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
@@ -47,7 +48,7 @@ function RouteComponent() {
|
||||
nativeButton={false}
|
||||
className="h-5 justify-start text-xs text-muted-foreground active:scale-100"
|
||||
render={
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener">
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Help translate the app to your language</Trans>
|
||||
<ArrowRightIcon className="size-3" />
|
||||
</a>
|
||||
|
||||
@@ -123,7 +123,8 @@ function RouteComponent() {
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
@@ -214,12 +215,14 @@ function RouteComponent() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center gap-x-4 justify-self-end"
|
||||
>
|
||||
<Button type="reset" variant="ghost" onClick={onCancel}>
|
||||
|
||||
Reference in New Issue
Block a user