mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
refactor(web): let the homepage, command palette and ats checker compile with the react compiler
The ATS sample fetch checks the response and reports failures, the ats-pdf chunk loads alongside extraction, and the feature explorer and command palette pages are split into one component per view.
This commit is contained in:
@@ -138,6 +138,15 @@ export function AtsChecker({ signedIn, importPending }: AtsCheckerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchSample() {
|
||||
try {
|
||||
const res = await fetch(SAMPLE.url);
|
||||
return res.ok ? await res.blob() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type IdleProps = {
|
||||
error?: string | undefined;
|
||||
posting: string;
|
||||
@@ -150,9 +159,13 @@ function Idle({ error, posting, onPosting, onFile }: IdleProps) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const [sampleError, setSampleError] = useState<string>();
|
||||
|
||||
const sample = async () => {
|
||||
const blob = await (await fetch(SAMPLE.url)).blob();
|
||||
onFile(new File([blob], SAMPLE.name, { type: "application/pdf" }));
|
||||
setSampleError(undefined);
|
||||
const blob = await fetchSample();
|
||||
if (blob) onFile(new File([blob], SAMPLE.name, { type: "application/pdf" }));
|
||||
else setSampleError(t`Couldn't load the sample file. Try again.`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -217,9 +230,9 @@ function Idle({ error, posting, onPosting, onFile }: IdleProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{(error ?? sampleError) && (
|
||||
<p role="alert" className="rounded-lg bg-danger-soft px-3 py-2.5 text-danger-text text-sm">
|
||||
{error}
|
||||
{error ?? sampleError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -278,7 +291,7 @@ const verdict = (score: number) =>
|
||||
type Row = { key: string; title: string; count: string; tone: "danger" | "warn" | "accent"; body: React.ReactNode };
|
||||
|
||||
/** One row per category with something to fix, worst first, plus the posting's terms when one was pasted. */
|
||||
export function issueRows(report: PdfAtsReport): Row[] {
|
||||
function issueRows(report: PdfAtsReport): Row[] {
|
||||
const byCategory = new Map<PdfCategory, PdfAtsReport["findings"]>();
|
||||
for (const finding of report.findings)
|
||||
byCategory.set(finding.category, [...(byCategory.get(finding.category) ?? []), finding]);
|
||||
|
||||
@@ -21,12 +21,15 @@ export type RunAtsCheckOptions = {
|
||||
* who never uploads a file never downloads either.
|
||||
*/
|
||||
export async function runAtsCheck(file: File, options: RunAtsCheckOptions = {}): Promise<AtsCheckResult> {
|
||||
const engine = import("@reactive-resume/resume/ats-pdf");
|
||||
// Awaited below; this only keeps a failed chunk load from surfacing as unhandled when extraction throws first.
|
||||
engine.catch(() => {});
|
||||
const raw = await extractPdf(file, {
|
||||
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
|
||||
const { analyzePdfResume, buildExtractedDocument } = await import("@reactive-resume/resume/ats-pdf");
|
||||
const { analyzePdfResume, buildExtractedDocument } = await engine;
|
||||
|
||||
const report = analyzePdfResume(raw, {
|
||||
...(options.jobDescription?.trim() ? { jobDescription: options.jobDescription } : {}),
|
||||
|
||||
@@ -173,7 +173,6 @@ describe("ResumesCommandGroup", () => {
|
||||
renderGroup();
|
||||
|
||||
expect(mocks.resumeQueryOptions).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
input: { sort: "lastUpdatedAt", tags: [] },
|
||||
});
|
||||
expect(screen.getByText("Evil Apricot Pike")).toBeInTheDocument();
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BaseCommandGroup } from "./base";
|
||||
type Application = RouterOutput["applications"]["list"][number];
|
||||
type Thread = RouterOutput["agent"]["threads"]["list"][number];
|
||||
type SearchPage = "resumes" | "applications" | "threads";
|
||||
type SearchPageProps = { page: SearchPage };
|
||||
|
||||
const isSearchPage = (page: string | undefined): page is SearchPage =>
|
||||
page === "resumes" || page === "applications" || page === "threads";
|
||||
@@ -26,81 +27,12 @@ const matchesSearch = (search: string, values: Array<string | null | undefined>)
|
||||
};
|
||||
|
||||
export function ResumesCommandGroup() {
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
const { session } = useRouteContext({ strict: false });
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const peekPage = useCommandPaletteStore((state) => state.peekPage);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
const search = useCommandPaletteStore((state) => state.search);
|
||||
|
||||
const commandPage = peekPage();
|
||||
const commandSearchPage = isSearchPage(commandPage) ? commandPage : undefined;
|
||||
const searchPage = commandSearchPage;
|
||||
|
||||
const { data: resumes, isLoading } = useQuery(
|
||||
orpc.resume.list.queryOptions({
|
||||
enabled: !!session && searchPage === "resumes",
|
||||
input: { sort: "lastUpdatedAt", tags: [] },
|
||||
}),
|
||||
);
|
||||
const { data: applications, isLoading: isLoadingApplications } = useQuery({
|
||||
...applicationsListQueryOptions(),
|
||||
enabled: !!session && searchPage === "applications",
|
||||
});
|
||||
const { data: threads, isLoading: isLoadingThreads } = useQuery(
|
||||
orpc.agent.threads.list.queryOptions({
|
||||
enabled: !!session && searchPage === "threads",
|
||||
}),
|
||||
);
|
||||
|
||||
const filteredResumes = (resumes ?? []).filter((resume) => matchesSearch(search, [resume.name, resume.slug]));
|
||||
const filteredApplications = (applications ?? []).filter((application) =>
|
||||
matchesSearch(search, [application.company, application.role]),
|
||||
);
|
||||
const filteredThreads = (threads ?? []).filter((thread) =>
|
||||
matchesSearch(search, [thread.title, thread.resumeName, thread.coverLetterName]),
|
||||
);
|
||||
|
||||
const onCreate = () => {
|
||||
reset();
|
||||
openDialog("document.new", undefined);
|
||||
};
|
||||
|
||||
const onNavigate = async (path: string) => {
|
||||
await navigate({ to: path });
|
||||
reset();
|
||||
};
|
||||
|
||||
const onCreateApplication = async () => {
|
||||
await navigate({ to: "/dashboard/applications", search: { create: true } });
|
||||
reset();
|
||||
};
|
||||
|
||||
const onOpenApplication = async (application: Application) => {
|
||||
await navigate({
|
||||
to: "/dashboard/applications",
|
||||
search: { applicationId: application.id },
|
||||
});
|
||||
reset();
|
||||
};
|
||||
|
||||
// A conversation opens its document with the assistant showing it.
|
||||
const onOpenThread = async (thread: Thread) => {
|
||||
if (thread.coverLetterId)
|
||||
await navigate({
|
||||
to: "/builder/letter/$coverLetterId",
|
||||
params: { coverLetterId: thread.coverLetterId },
|
||||
search: { assistant: thread.id },
|
||||
});
|
||||
else if (thread.workingResumeId)
|
||||
await navigate({
|
||||
to: "/builder/$resumeId",
|
||||
params: { resumeId: thread.workingResumeId },
|
||||
search: { assistant: thread.id },
|
||||
});
|
||||
reset();
|
||||
};
|
||||
const searchPage = isSearchPage(commandPage) ? commandPage : undefined;
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
@@ -127,93 +59,168 @@ export function ResumesCommandGroup() {
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
{searchPage === "resumes" ? (
|
||||
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Resumes</Trans>}>
|
||||
<CommandItem value="resumes.create" onSelect={onCreate}>
|
||||
<Icon name="add" size={16} />
|
||||
<Trans>Create a new resume</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading resumes…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredResumes.map((resume) => (
|
||||
<CommandItem
|
||||
key={resume.id}
|
||||
value={`resume.${resume.id}`}
|
||||
keywords={[resume.name, resume.slug]}
|
||||
onSelect={() => onNavigate(`/builder/${resume.id}`)}
|
||||
>
|
||||
<Icon name="description" size={16} />
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 group-data-[selected=true]/command-item:opacity-100">
|
||||
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
) : null}
|
||||
|
||||
{searchPage === "applications" ? (
|
||||
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Applications</Trans>}>
|
||||
<CommandItem value="applications.create" onSelect={onCreateApplication}>
|
||||
<Icon name="add" size={16} />
|
||||
<Trans>New Application</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoadingApplications ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading applications…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredApplications.map((application) => (
|
||||
<CommandItem
|
||||
key={application.id}
|
||||
value={`application.${application.id}`}
|
||||
keywords={[application.company, application.role]}
|
||||
onSelect={() => onOpenApplication(application)}
|
||||
>
|
||||
<Icon name="work" size={16} />
|
||||
<span className="min-w-0 truncate">{application.company}</span>
|
||||
<span className="truncate text-ink-3 text-xs">{application.role}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
) : null}
|
||||
|
||||
{searchPage === "threads" ? (
|
||||
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Assistant conversations</Trans>}>
|
||||
{isLoadingThreads ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading conversations…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const documentName = thread.coverLetterName ?? thread.resumeName ?? "";
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={thread.id}
|
||||
value={`thread.${thread.id}`}
|
||||
keywords={[thread.title, documentName]}
|
||||
onSelect={() => onOpenThread(thread)}
|
||||
>
|
||||
<Icon name="chat" size={16} />
|
||||
<span className="min-w-0 truncate">{thread.title}</span>
|
||||
<span className="truncate text-ink-3 text-xs">{documentName}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
) : null}
|
||||
{searchPage === "resumes" ? <ResumesPage page={searchPage} /> : null}
|
||||
{searchPage === "applications" ? <ApplicationsPage page={searchPage} /> : null}
|
||||
{searchPage === "threads" ? <ThreadsPage page={searchPage} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumesPage({ page }: SearchPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const search = useCommandPaletteStore((state) => state.search);
|
||||
const { data: resumes, isLoading } = useQuery(
|
||||
orpc.resume.list.queryOptions({ input: { sort: "lastUpdatedAt", tags: [] } }),
|
||||
);
|
||||
const filteredResumes = (resumes ?? []).filter((resume) => matchesSearch(search, [resume.name, resume.slug]));
|
||||
|
||||
const onCreate = () => {
|
||||
reset();
|
||||
openDialog("document.new", undefined);
|
||||
};
|
||||
|
||||
const onNavigate = async (path: string) => {
|
||||
await navigate({ to: path });
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCommandGroup page={page} heading={<Trans>Resumes</Trans>}>
|
||||
<CommandItem value="resumes.create" onSelect={onCreate}>
|
||||
<Icon name="add" size={16} />
|
||||
<Trans>Create a new resume</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading resumes…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredResumes.map((resume) => (
|
||||
<CommandItem
|
||||
key={resume.id}
|
||||
value={`resume.${resume.id}`}
|
||||
keywords={[resume.name, resume.slug]}
|
||||
onSelect={() => onNavigate(`/builder/${resume.id}`)}
|
||||
>
|
||||
<Icon name="description" size={16} />
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 group-data-[selected=true]/command-item:opacity-100">
|
||||
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplicationsPage({ page }: SearchPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const search = useCommandPaletteStore((state) => state.search);
|
||||
const { data: applications, isLoading } = useQuery(applicationsListQueryOptions());
|
||||
const filteredApplications = (applications ?? []).filter((application) =>
|
||||
matchesSearch(search, [application.company, application.role]),
|
||||
);
|
||||
|
||||
const onCreateApplication = async () => {
|
||||
await navigate({ to: "/dashboard/applications", search: { create: true } });
|
||||
reset();
|
||||
};
|
||||
|
||||
const onOpenApplication = async (application: Application) => {
|
||||
await navigate({
|
||||
to: "/dashboard/applications",
|
||||
search: { applicationId: application.id },
|
||||
});
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCommandGroup page={page} heading={<Trans>Applications</Trans>}>
|
||||
<CommandItem value="applications.create" onSelect={onCreateApplication}>
|
||||
<Icon name="add" size={16} />
|
||||
<Trans>New Application</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading applications…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredApplications.map((application) => (
|
||||
<CommandItem
|
||||
key={application.id}
|
||||
value={`application.${application.id}`}
|
||||
keywords={[application.company, application.role]}
|
||||
onSelect={() => onOpenApplication(application)}
|
||||
>
|
||||
<Icon name="work" size={16} />
|
||||
<span className="min-w-0 truncate">{application.company}</span>
|
||||
<span className="truncate text-ink-3 text-xs">{application.role}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadsPage({ page }: SearchPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const search = useCommandPaletteStore((state) => state.search);
|
||||
const { data: threads, isLoading } = useQuery(orpc.agent.threads.list.queryOptions());
|
||||
const filteredThreads = (threads ?? []).filter((thread) =>
|
||||
matchesSearch(search, [thread.title, thread.resumeName, thread.coverLetterName]),
|
||||
);
|
||||
|
||||
// A conversation opens its document with the assistant showing it.
|
||||
const onOpenThread = async (thread: Thread) => {
|
||||
if (thread.coverLetterId)
|
||||
await navigate({
|
||||
to: "/builder/letter/$coverLetterId",
|
||||
params: { coverLetterId: thread.coverLetterId },
|
||||
search: { assistant: thread.id },
|
||||
});
|
||||
else if (thread.workingResumeId)
|
||||
await navigate({
|
||||
to: "/builder/$resumeId",
|
||||
params: { resumeId: thread.workingResumeId },
|
||||
search: { assistant: thread.id },
|
||||
});
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCommandGroup page={page} heading={<Trans>Assistant conversations</Trans>}>
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading conversations…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const documentName = thread.coverLetterName ?? thread.resumeName ?? "";
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={thread.id}
|
||||
value={`thread.${thread.id}`}
|
||||
keywords={[thread.title, documentName]}
|
||||
onSelect={() => onOpenThread(thread)}
|
||||
>
|
||||
<Icon name="chat" size={16} />
|
||||
<span className="min-w-0 truncate">{thread.title}</span>
|
||||
<span className="truncate text-ink-3 text-xs">{documentName}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,21 @@ import { useEffect, useId, useRef, useState } from "react";
|
||||
const focusRing = "focus-visible:outline-2 focus-visible:outline-(--home-accent) focus-visible:outline-offset-4";
|
||||
const message = "max-w-[300px] text-[14px] leading-[1.8]";
|
||||
|
||||
/** The extracted text of the chosen file, or of the bundled sample; null when the read was cancelled midway. */
|
||||
async function readPdf(selected: File | null, signal: AbortSignal) {
|
||||
let source = selected;
|
||||
if (!source) {
|
||||
const response = await fetch("/templates/pdf/rhyhorn.pdf", { signal });
|
||||
if (!response.ok) throw new Error("Sample PDF unavailable");
|
||||
source = new File([await response.blob()], "rhyhorn.pdf", { type: "application/pdf" });
|
||||
}
|
||||
if (signal.aborted) return null;
|
||||
const { runAtsCheck } = await import("@/features/ats-checker/run-ats-check");
|
||||
if (signal.aborted) return null;
|
||||
const result = await runAtsCheck(source, { signal });
|
||||
return signal.aborted ? null : result.fullText;
|
||||
}
|
||||
|
||||
export default function AtsPlayground() {
|
||||
const inputId = useId();
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
@@ -38,18 +53,9 @@ export default function AtsPlayground() {
|
||||
}
|
||||
setPhase("reading");
|
||||
try {
|
||||
let source = selected;
|
||||
if (!source) {
|
||||
const response = await fetch("/templates/pdf/rhyhorn.pdf", { signal: controller.signal });
|
||||
if (!response.ok) throw new Error("Sample PDF unavailable");
|
||||
source = new File([await response.blob()], "rhyhorn.pdf", { type: "application/pdf" });
|
||||
}
|
||||
if (controller.signal.aborted) return;
|
||||
const { runAtsCheck } = await import("@/features/ats-checker/run-ats-check");
|
||||
if (controller.signal.aborted) return;
|
||||
const result = await runAtsCheck(source, { signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setText(result.fullText);
|
||||
const fullText = await readPdf(selected, controller.signal);
|
||||
if (fullText === null) return;
|
||||
setText(fullText);
|
||||
setPhase("done");
|
||||
} catch (caught) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
@@ -164,14 +164,14 @@ export default function ExportPlayground({ name, accent, typeface, template }: E
|
||||
]);
|
||||
|
||||
const download = async () => {
|
||||
const action = actions.get(format);
|
||||
setBusy(true);
|
||||
try {
|
||||
await actions.get(format)?.();
|
||||
if (action) await action();
|
||||
} catch {
|
||||
toast.add({ type: "error", description: t`Could not prepare the sample. Please try again.` });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,19 +41,37 @@ export function FeatureExplorer() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const visible = useInView(ref);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const revisedText = t`Designed a shared component library that six product teams use to build consistent interfaces.`;
|
||||
const sharingOptions = [
|
||||
{ id: "public", label: t`Public` },
|
||||
{ id: "password", label: t`Password` },
|
||||
{ id: "private", label: t`Private` },
|
||||
];
|
||||
// State lives here so a demo keeps its place when you switch away and back.
|
||||
const features = [
|
||||
{ id: "writing", label: t`A hand with the writing`, hint: t`AI, when you want it`, icon: PencilSimpleIcon },
|
||||
{ id: "sharing", label: t`A link worth sharing`, hint: t`Choose who can see it`, icon: GlobeIcon },
|
||||
{ id: "tracking", label: t`Know where things stand`, hint: t`Your applications, together`, icon: KanbanIcon },
|
||||
{ id: "tools", label: t`Fits into your workflow`, hint: t`API and MCP access`, icon: CodeIcon },
|
||||
{
|
||||
id: "writing",
|
||||
label: t`A hand with the writing`,
|
||||
hint: t`AI, when you want it`,
|
||||
icon: PencilSimpleIcon,
|
||||
demo: <WritingDemo accepted={accepted} onAcceptedChange={setAccepted} />,
|
||||
},
|
||||
{
|
||||
id: "sharing",
|
||||
label: t`A link worth sharing`,
|
||||
hint: t`Choose who can see it`,
|
||||
icon: GlobeIcon,
|
||||
demo: <SharingDemo sharing={sharing} onSharingChange={setSharing} />,
|
||||
},
|
||||
{
|
||||
id: "tracking",
|
||||
label: t`Know where things stand`,
|
||||
hint: t`Your applications, together`,
|
||||
icon: KanbanIcon,
|
||||
demo: <TrackingDemo stage={stage} onStageChange={setStage} />,
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: t`Fits into your workflow`,
|
||||
hint: t`API and MCP access`,
|
||||
icon: CodeIcon,
|
||||
demo: <ToolsDemo playing={flowPlaying} onPlayingChange={setFlowPlaying} visible={visible} />,
|
||||
},
|
||||
];
|
||||
const stages = [t`Applied`, t`Interview`, t`Offer`];
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -91,285 +109,306 @@ export function FeatureExplorer() {
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="min-h-[492px] overflow-hidden rounded-[4px] border border-(--home-line) bg-[#19191b]">
|
||||
{feature === "writing" && (
|
||||
<div className={demoClass}>
|
||||
<div className={demoTopClass}>
|
||||
<span>
|
||||
<Trans>Example edit</Trans>
|
||||
</span>
|
||||
<span>Alex Morgan</span>
|
||||
</div>
|
||||
<div className="transform-[perspective(1000px)_rotateX(3deg)_rotateY(-3deg)] mx-auto mt-[25px] min-h-[224px] max-w-[430px] rounded-[2px] border border-[#d9d6d0] bg-[#efeee9] px-[30px] pt-7 pb-[38px] text-[#343239] shadow-[0_10px_18px_#0002] max-[540px]:px-6 max-[540px]:pt-[23px] max-[540px]:pb-8">
|
||||
<span className="text-[#735c9a] text-[11px]">
|
||||
<Trans>Experience</Trans>
|
||||
</span>
|
||||
<h3 className="mt-[9px] font-[Manrope_Variable,sans-serif] font-semibold text-[18px] leading-[1.3] tracking-[-0.03em]">
|
||||
<Trans>Product designer</Trans>
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"-mx-[5px] mt-3 rounded-[2px] px-[5px] py-1 text-[12px] leading-[1.8]",
|
||||
accepted && "bg-[#d6e5d6]",
|
||||
)}
|
||||
>
|
||||
{accepted ? (
|
||||
<>
|
||||
<span className="sr-only" role="status">
|
||||
{revisedText}
|
||||
</span>
|
||||
<span aria-hidden="true">
|
||||
{revisedText.split(" ").map((word, index) => (
|
||||
<span
|
||||
key={`${index}-${word}`}
|
||||
className="animate-[home-word-arrive_180ms_ease-out_both] group-data-[instant=true]/explorer:animate-none"
|
||||
style={{ animationDelay: `${index * 45}ms` }}
|
||||
>
|
||||
{word}{" "}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
t`Responsible for working on the design system and helping other teams with their designs.`
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-[15px] h-[3px] w-[95%] bg-[#dad8d4]" />
|
||||
<div className="mt-[7px] h-[3px] w-[66%] bg-[#dad8d4]" />
|
||||
</div>
|
||||
<div className="relative -mt-4 flex items-start gap-3 rounded-[4px] border border-[#514b59] bg-[#2c2732] px-[17px] py-[19px] shadow-[0_10px_20px_#0003] max-[1100px]:flex-wrap max-[540px]:flex-wrap max-[900px]:flex-nowrap max-[540px]:gap-[9px] max-[540px]:px-3 max-[540px]:py-4">
|
||||
<PencilSimpleIcon size={20} aria-hidden="true" className="mt-[2px] shrink-0 text-[#c9b7dc]" />
|
||||
<div className="max-[540px]:flex-[1_0_calc(100%_-_30px)]">
|
||||
<strong className="block font-medium text-[13px]">
|
||||
{accepted ? t`Your edit is in.` : t`Say what you actually did.`}
|
||||
</strong>
|
||||
<p className="mt-[5px] max-w-[250px] text-[#b8b0c0] text-[12px]">
|
||||
{accepted
|
||||
? t`You can always go back to the original.`
|
||||
: t`Lead with the work. Add a concrete detail so someone can picture it.`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`ml-auto flex min-h-11 shrink-0 items-center gap-[9px] rounded-[4px] border border-[#655971] bg-[#3e3449] px-2.5 py-2 text-[#efebf2] text-[12px] ${pressTransition} max-[1100px]:ml-[31px] max-[540px]:ml-[29px] max-[900px]:ml-auto`}
|
||||
onClick={() => setAccepted(!accepted)}
|
||||
>
|
||||
{accepted ? t`Undo` : t`Use this wording`}
|
||||
{accepted ? (
|
||||
<CheckIcon size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowRightIcon size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className={demoCaptionClass}>
|
||||
<Trans>
|
||||
This is a sample edit. In the app, connect your own AI provider. Turn on Review edits to approve changes
|
||||
before they’re applied.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{feature === "sharing" && (
|
||||
<div className={demoClass}>
|
||||
<div className={demoTopClass}>
|
||||
<span>
|
||||
<Trans>Sharing preview</Trans>
|
||||
</span>
|
||||
<GlobeIcon size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-[25px] mb-5 flex justify-center gap-[3px]">
|
||||
{sharingOptions.map(({ id, label }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={`min-h-11 rounded-[4px] border border-transparent bg-transparent px-[18px] py-2 text-(--home-muted) text-[13px] ${pressTransition} aria-pressed:border-[#47474a] aria-pressed:bg-[#323235] aria-pressed:text-(--home-ink) group-data-[instant=true]/explorer:transition-none`}
|
||||
aria-pressed={sharing === id}
|
||||
onClick={() => setSharing(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="m-auto max-w-[430px] overflow-hidden rounded-[4px] border border-[#404043] shadow-[0_12px_30px_#0002]">
|
||||
<div className="flex items-center justify-center gap-2 bg-[#252527] p-2.5 text-[#939399] text-[10px]">
|
||||
<LockSimpleIcon size={13} aria-hidden="true" />
|
||||
<Trans>Your resume link</Trans>
|
||||
</div>
|
||||
<div className="grid bg-[#202022]">
|
||||
{sharingOptions.map(({ id }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="invisible col-start-1 row-start-1 flex min-h-[215px] flex-col items-center justify-center p-6 text-center opacity-0 [transition:opacity_180ms_ease,visibility_180ms] data-[active=true]:visible data-[active=true]:opacity-100 group-data-[instant=true]/explorer:transition-none"
|
||||
data-active={sharing === id}
|
||||
aria-hidden={sharing !== id}
|
||||
>
|
||||
{id === "public" ? (
|
||||
<>
|
||||
<div className="grid size-11 place-items-center rounded-full bg-[#45382e] text-[#decfbf] text-[12px]">
|
||||
AM
|
||||
</div>
|
||||
<h3 className="mt-3 font-medium text-[19px] tracking-[-0.03em]">Alex Morgan</h3>
|
||||
<p className="mt-1.5 text-(--home-muted) text-[12px]">
|
||||
<Trans>Product designer</Trans>
|
||||
</p>
|
||||
<div className="mt-[15px] grid w-[110px] gap-[5px]">
|
||||
<i className="h-[2px] bg-[#444447]" />
|
||||
<i className="h-[2px] bg-[#444447]" />
|
||||
<i className="h-[2px] w-[70%] bg-[#444447]" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LockSimpleIcon size={34} weight="light" aria-hidden="true" />
|
||||
<h3 className="mt-3 font-medium text-[19px] tracking-[-0.03em]">
|
||||
{id === "password" ? t`Password required` : t`Resume not found`}
|
||||
</h3>
|
||||
<p className="mt-1.5 text-(--home-muted) text-[12px]">
|
||||
{id === "password"
|
||||
? t`Visitors need your password to open the resume.`
|
||||
: t`A private resume does not appear at its public link.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className={demoCaptionClass}>
|
||||
<Trans>
|
||||
Try the settings to see what visitors can access. When you create your resume, you decide how to share
|
||||
it.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{feature === "tracking" && (
|
||||
<div className={demoClass}>
|
||||
<div className={demoTopClass}>
|
||||
<span>
|
||||
<Trans>Example application</Trans>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="-my-1.5 min-h-8 px-2 py-1 text-(--home-muted)"
|
||||
onClick={() => setStage(0)}
|
||||
>
|
||||
<Trans>Reset</Trans>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-[34px] [--home-board-gap:12px] max-[540px]:[--home-board-gap:7px]">
|
||||
<div className="grid grid-cols-3 gap-(--home-board-gap)">
|
||||
{stages.map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center gap-1.5 text-(--home-muted) text-[11px] max-[540px]:gap-1 max-[540px]:text-[9px]"
|
||||
>
|
||||
<i className={cn("size-1.5 rounded-full", stageDotColors[index])} />
|
||||
{label}
|
||||
<span className="ml-auto text-[#85858c]">{stage === index ? 1 : 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="relative mt-[15px] grid grid-cols-3 gap-(--home-board-gap)"
|
||||
style={{ "--stage": stage } as CSSProperties}
|
||||
>
|
||||
{stages.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`${jobCardClass} border-[#323237] border-dashed text-transparent`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`${jobCardClass} transform-[translateX(calc(var(--stage)_*_(100%_+_var(--home-board-gap))))_rotate(calc(var(--stage)_*_3deg_-_2deg))] absolute top-0 left-0 w-[calc((100%_-_2_*_var(--home-board-gap))_/_3)] border-[#4b4b51] bg-[#2a2a2e] text-(--home-ink) shadow-[0_5px_12px_#0002] [transition:transform_460ms_var(--ease-out-strong)] group-data-[instant=true]/explorer:transition-none`}
|
||||
>
|
||||
<div className="mb-4 grid size-[30px] place-items-center rounded-[4px] bg-[#d8c4ac] font-[Georgia,serif] font-semibold text-[#34302a] text-[19px] leading-none">
|
||||
n.
|
||||
</div>
|
||||
<strong className="block font-medium text-[12px] max-[540px]:text-[10px]">
|
||||
<Trans>Product designer</Trans>
|
||||
</strong>
|
||||
<p className="mt-[5px] text-(--home-muted) text-[10px] max-[540px]:text-[9px]">Northstar Studio</p>
|
||||
<span className="mt-[18px] block text-[#bab4c7] text-[9px] max-[540px]:text-[8px]">
|
||||
<Trans>Resume attached</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center justify-between text-(--home-muted) text-[12px]">
|
||||
<span role="status">{stages[stage]}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex min-h-11 items-center gap-2.5 rounded-[4px] border border-[#4b4b51] bg-[#2b2b2e] px-[13px] py-2 text-(--home-ink) text-[12px] ${pressTransition}`}
|
||||
onClick={() => setStage((stage + 1) % stages.length)}
|
||||
>
|
||||
{stage === 0 ? t`Move to interview` : stage === 1 ? t`Move to offer` : t`Start again`}
|
||||
<ArrowRightIcon size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className={demoCaptionClass}>
|
||||
<Trans>
|
||||
Keep the company, role, resume, and follow-up date together. Switch between a board, table, and insights
|
||||
in the app.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{feature === "tools" && (
|
||||
<div className={demoClass}>
|
||||
<div className={demoTopClass}>
|
||||
<span>
|
||||
<Trans>Your tools, connected</Trans>
|
||||
</span>
|
||||
{!reducedMotion && (
|
||||
<button
|
||||
type="button"
|
||||
className={`-my-3 grid min-h-11 min-w-11 place-items-center rounded-[4px] px-2 py-1 text-(--home-muted) ${pressTransition} hover:bg-[#323235]`}
|
||||
aria-label={flowPlaying ? t`Pause data flow animation` : t`Play data flow animation`}
|
||||
onClick={() => setFlowPlaying(!flowPlaying)}
|
||||
>
|
||||
{flowPlaying ? <PauseIcon size={16} aria-hidden="true" /> : <PlayIcon size={16} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-auto mt-[55px] mb-[38px] flex max-w-[410px] items-center">
|
||||
<div className={toolBoxClass}>
|
||||
<CodeIcon size={32} weight="light" aria-hidden="true" />
|
||||
<span className={toolLabelClass}>
|
||||
<Trans>Your AI app</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className="group/wire relative h-px flex-1 bg-[#66666d]" data-playing={flowPlaying && visible}>
|
||||
<span className={`absolute -top-[25px] w-full text-center text-(--home-muted) ${toolLabelClass}`}>
|
||||
MCP
|
||||
</span>
|
||||
<i
|
||||
className="absolute inset-x-0 -top-[3px] h-[7px] animate-[home-data-flow_3.5s_cubic-bezier(0.45,0,0.55,1)_infinite_alternate_paused] after:-ml-[3px] after:block after:size-[7px] after:rounded-full after:bg-[#ddc8eb] after:shadow-[0_0_0_4px_#c5a4db14,0_0_11px_#c5a4db50] after:content-[''] group-data-[playing=true]/wire:[animation-play-state:running]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className={toolBoxClass}>
|
||||
<img src="/icon/dark.svg" width="38" height="38" alt="" />
|
||||
<span className={toolLabelClass}>Reactive Resume</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-[Manrope_Variable,sans-serif] font-medium text-[23px] leading-[1.3] tracking-[-0.035em] max-[540px]:text-[22px]">
|
||||
<Trans>Work on your resume from your AI app.</Trans>
|
||||
</h3>
|
||||
<p className="mt-[15px] text-(--home-muted) text-[14px] leading-[1.7]">
|
||||
<Trans>
|
||||
Connect through MCP to create and update resumes in a conversation. Or use the API to build your own
|
||||
workflow.
|
||||
</Trans>
|
||||
</p>
|
||||
<a href="https://docs.rxresu.me/guides/using-the-mcp-server" className={cn(textLink, "mt-4")}>
|
||||
<Trans>See how to connect</Trans>
|
||||
<ArrowUpRightIcon size={17} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{features.find((item) => item.id === feature)?.demo}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type WritingDemoProps = { accepted: boolean; onAcceptedChange: (accepted: boolean) => void };
|
||||
|
||||
function WritingDemo({ accepted, onAcceptedChange }: WritingDemoProps) {
|
||||
const revisedText = t`Designed a shared component library that six product teams use to build consistent interfaces.`;
|
||||
return (
|
||||
<div className={demoClass}>
|
||||
<div className={demoTopClass}>
|
||||
<span>
|
||||
<Trans>Example edit</Trans>
|
||||
</span>
|
||||
<span>Alex Morgan</span>
|
||||
</div>
|
||||
<div className="transform-[perspective(1000px)_rotateX(3deg)_rotateY(-3deg)] mx-auto mt-[25px] min-h-[224px] max-w-[430px] rounded-[2px] border border-[#d9d6d0] bg-[#efeee9] px-[30px] pt-7 pb-[38px] text-[#343239] shadow-[0_10px_18px_#0002] max-[540px]:px-6 max-[540px]:pt-[23px] max-[540px]:pb-8">
|
||||
<span className="text-[#735c9a] text-[11px]">
|
||||
<Trans>Experience</Trans>
|
||||
</span>
|
||||
<h3 className="mt-[9px] font-[Manrope_Variable,sans-serif] font-semibold text-[18px] leading-[1.3] tracking-[-0.03em]">
|
||||
<Trans>Product designer</Trans>
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"-mx-[5px] mt-3 rounded-[2px] px-[5px] py-1 text-[12px] leading-[1.8]",
|
||||
accepted && "bg-[#d6e5d6]",
|
||||
)}
|
||||
>
|
||||
{accepted ? (
|
||||
<>
|
||||
<span className="sr-only" role="status">
|
||||
{revisedText}
|
||||
</span>
|
||||
<span aria-hidden="true">
|
||||
{revisedText.split(" ").map((word, index) => (
|
||||
<span
|
||||
key={`${index}-${word}`}
|
||||
className="animate-[home-word-arrive_180ms_ease-out_both] group-data-[instant=true]/explorer:animate-none"
|
||||
style={{ animationDelay: `${index * 45}ms` }}
|
||||
>
|
||||
{word}{" "}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
t`Responsible for working on the design system and helping other teams with their designs.`
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-[15px] h-[3px] w-[95%] bg-[#dad8d4]" />
|
||||
<div className="mt-[7px] h-[3px] w-[66%] bg-[#dad8d4]" />
|
||||
</div>
|
||||
<div className="relative -mt-4 flex items-start gap-3 rounded-[4px] border border-[#514b59] bg-[#2c2732] px-[17px] py-[19px] shadow-[0_10px_20px_#0003] max-[1100px]:flex-wrap max-[540px]:flex-wrap max-[900px]:flex-nowrap max-[540px]:gap-[9px] max-[540px]:px-3 max-[540px]:py-4">
|
||||
<PencilSimpleIcon size={20} aria-hidden="true" className="mt-[2px] shrink-0 text-[#c9b7dc]" />
|
||||
<div className="max-[540px]:flex-[1_0_calc(100%_-_30px)]">
|
||||
<strong className="block font-medium text-[13px]">
|
||||
{accepted ? t`Your edit is in.` : t`Say what you actually did.`}
|
||||
</strong>
|
||||
<p className="mt-[5px] max-w-[250px] text-[#b8b0c0] text-[12px]">
|
||||
{accepted
|
||||
? t`You can always go back to the original.`
|
||||
: t`Lead with the work. Add a concrete detail so someone can picture it.`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`ml-auto flex min-h-11 shrink-0 items-center gap-[9px] rounded-[4px] border border-[#655971] bg-[#3e3449] px-2.5 py-2 text-[#efebf2] text-[12px] ${pressTransition} max-[1100px]:ml-[31px] max-[540px]:ml-[29px] max-[900px]:ml-auto`}
|
||||
onClick={() => onAcceptedChange(!accepted)}
|
||||
>
|
||||
{accepted ? t`Undo` : t`Use this wording`}
|
||||
{accepted ? <CheckIcon size={16} aria-hidden="true" /> : <ArrowRightIcon size={16} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className={demoCaptionClass}>
|
||||
<Trans>
|
||||
This is a sample edit. In the app, connect your own AI provider. Turn on Review edits to approve changes
|
||||
before they’re applied.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SharingDemoProps = { sharing: string; onSharingChange: (sharing: string) => void };
|
||||
|
||||
function SharingDemo({ sharing, onSharingChange }: SharingDemoProps) {
|
||||
const sharingOptions = [
|
||||
{ id: "public", label: t`Public` },
|
||||
{ id: "password", label: t`Password` },
|
||||
{ id: "private", label: t`Private` },
|
||||
];
|
||||
return (
|
||||
<div className={demoClass}>
|
||||
<div className={demoTopClass}>
|
||||
<span>
|
||||
<Trans>Sharing preview</Trans>
|
||||
</span>
|
||||
<GlobeIcon size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-[25px] mb-5 flex justify-center gap-[3px]">
|
||||
{sharingOptions.map(({ id, label }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={`min-h-11 rounded-[4px] border border-transparent bg-transparent px-[18px] py-2 text-(--home-muted) text-[13px] ${pressTransition} aria-pressed:border-[#47474a] aria-pressed:bg-[#323235] aria-pressed:text-(--home-ink) group-data-[instant=true]/explorer:transition-none`}
|
||||
aria-pressed={sharing === id}
|
||||
onClick={() => onSharingChange(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="m-auto max-w-[430px] overflow-hidden rounded-[4px] border border-[#404043] shadow-[0_12px_30px_#0002]">
|
||||
<div className="flex items-center justify-center gap-2 bg-[#252527] p-2.5 text-[#939399] text-[10px]">
|
||||
<LockSimpleIcon size={13} aria-hidden="true" />
|
||||
<Trans>Your resume link</Trans>
|
||||
</div>
|
||||
<div className="grid bg-[#202022]">
|
||||
{sharingOptions.map(({ id }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="invisible col-start-1 row-start-1 flex min-h-[215px] flex-col items-center justify-center p-6 text-center opacity-0 [transition:opacity_180ms_ease,visibility_180ms] data-[active=true]:visible data-[active=true]:opacity-100 group-data-[instant=true]/explorer:transition-none"
|
||||
data-active={sharing === id}
|
||||
aria-hidden={sharing !== id}
|
||||
>
|
||||
{id === "public" ? (
|
||||
<>
|
||||
<div className="grid size-11 place-items-center rounded-full bg-[#45382e] text-[#decfbf] text-[12px]">
|
||||
AM
|
||||
</div>
|
||||
<h3 className="mt-3 font-medium text-[19px] tracking-[-0.03em]">Alex Morgan</h3>
|
||||
<p className="mt-1.5 text-(--home-muted) text-[12px]">
|
||||
<Trans>Product designer</Trans>
|
||||
</p>
|
||||
<div className="mt-[15px] grid w-[110px] gap-[5px]">
|
||||
<i className="h-[2px] bg-[#444447]" />
|
||||
<i className="h-[2px] bg-[#444447]" />
|
||||
<i className="h-[2px] w-[70%] bg-[#444447]" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LockSimpleIcon size={34} weight="light" aria-hidden="true" />
|
||||
<h3 className="mt-3 font-medium text-[19px] tracking-[-0.03em]">
|
||||
{id === "password" ? t`Password required` : t`Resume not found`}
|
||||
</h3>
|
||||
<p className="mt-1.5 text-(--home-muted) text-[12px]">
|
||||
{id === "password"
|
||||
? t`Visitors need your password to open the resume.`
|
||||
: t`A private resume does not appear at its public link.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className={demoCaptionClass}>
|
||||
<Trans>
|
||||
Try the settings to see what visitors can access. When you create your resume, you decide how to share it.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TrackingDemoProps = { stage: number; onStageChange: (stage: number) => void };
|
||||
|
||||
function TrackingDemo({ stage, onStageChange }: TrackingDemoProps) {
|
||||
const stages = [t`Applied`, t`Interview`, t`Offer`];
|
||||
return (
|
||||
<div className={demoClass}>
|
||||
<div className={demoTopClass}>
|
||||
<span>
|
||||
<Trans>Example application</Trans>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="-my-1.5 min-h-8 px-2 py-1 text-(--home-muted)"
|
||||
onClick={() => onStageChange(0)}
|
||||
>
|
||||
<Trans>Reset</Trans>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-[34px] [--home-board-gap:12px] max-[540px]:[--home-board-gap:7px]">
|
||||
<div className="grid grid-cols-3 gap-(--home-board-gap)">
|
||||
{stages.map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center gap-1.5 text-(--home-muted) text-[11px] max-[540px]:gap-1 max-[540px]:text-[9px]"
|
||||
>
|
||||
<i className={cn("size-1.5 rounded-full", stageDotColors[index])} />
|
||||
{label}
|
||||
<span className="ml-auto text-[#85858c]">{stage === index ? 1 : 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="relative mt-[15px] grid grid-cols-3 gap-(--home-board-gap)"
|
||||
style={{ "--stage": stage } as CSSProperties}
|
||||
>
|
||||
{stages.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`${jobCardClass} border-[#323237] border-dashed text-transparent`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={`${jobCardClass} transform-[translateX(calc(var(--stage)_*_(100%_+_var(--home-board-gap))))_rotate(calc(var(--stage)_*_3deg_-_2deg))] absolute top-0 left-0 w-[calc((100%_-_2_*_var(--home-board-gap))_/_3)] border-[#4b4b51] bg-[#2a2a2e] text-(--home-ink) shadow-[0_5px_12px_#0002] [transition:transform_460ms_var(--ease-out-strong)] group-data-[instant=true]/explorer:transition-none`}
|
||||
>
|
||||
<div className="mb-4 grid size-[30px] place-items-center rounded-[4px] bg-[#d8c4ac] font-[Georgia,serif] font-semibold text-[#34302a] text-[19px] leading-none">
|
||||
n.
|
||||
</div>
|
||||
<strong className="block font-medium text-[12px] max-[540px]:text-[10px]">
|
||||
<Trans>Product designer</Trans>
|
||||
</strong>
|
||||
<p className="mt-[5px] text-(--home-muted) text-[10px] max-[540px]:text-[9px]">Northstar Studio</p>
|
||||
<span className="mt-[18px] block text-[#bab4c7] text-[9px] max-[540px]:text-[8px]">
|
||||
<Trans>Resume attached</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-7 flex items-center justify-between text-(--home-muted) text-[12px]">
|
||||
<span role="status">{stages[stage]}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex min-h-11 items-center gap-2.5 rounded-[4px] border border-[#4b4b51] bg-[#2b2b2e] px-[13px] py-2 text-(--home-ink) text-[12px] ${pressTransition}`}
|
||||
onClick={() => onStageChange((stage + 1) % stages.length)}
|
||||
>
|
||||
{stage === 0 ? t`Move to interview` : stage === 1 ? t`Move to offer` : t`Start again`}
|
||||
<ArrowRightIcon size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className={demoCaptionClass}>
|
||||
<Trans>
|
||||
Keep the company, role, resume, and follow-up date together. Switch between a board, table, and insights in
|
||||
the app.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolsDemoProps = { playing: boolean; onPlayingChange: (playing: boolean) => void; visible: boolean };
|
||||
|
||||
function ToolsDemo({ playing, onPlayingChange, visible }: ToolsDemoProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
return (
|
||||
<div className={demoClass}>
|
||||
<div className={demoTopClass}>
|
||||
<span>
|
||||
<Trans>Your tools, connected</Trans>
|
||||
</span>
|
||||
{!reducedMotion && (
|
||||
<button
|
||||
type="button"
|
||||
className={`-my-3 grid min-h-11 min-w-11 place-items-center rounded-[4px] px-2 py-1 text-(--home-muted) ${pressTransition} hover:bg-[#323235]`}
|
||||
aria-label={playing ? t`Pause data flow animation` : t`Play data flow animation`}
|
||||
onClick={() => onPlayingChange(!playing)}
|
||||
>
|
||||
{playing ? <PauseIcon size={16} aria-hidden="true" /> : <PlayIcon size={16} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-auto mt-[55px] mb-[38px] flex max-w-[410px] items-center">
|
||||
<div className={toolBoxClass}>
|
||||
<CodeIcon size={32} weight="light" aria-hidden="true" />
|
||||
<span className={toolLabelClass}>
|
||||
<Trans>Your AI app</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className="group/wire relative h-px flex-1 bg-[#66666d]" data-playing={playing && visible}>
|
||||
<span className={`absolute -top-[25px] w-full text-center text-(--home-muted) ${toolLabelClass}`}>MCP</span>
|
||||
<i
|
||||
className="absolute inset-x-0 -top-[3px] h-[7px] animate-[home-data-flow_3.5s_cubic-bezier(0.45,0,0.55,1)_infinite_alternate_paused] after:-ml-[3px] after:block after:size-[7px] after:rounded-full after:bg-[#ddc8eb] after:shadow-[0_0_0_4px_#c5a4db14,0_0_11px_#c5a4db50] after:content-[''] group-data-[playing=true]/wire:[animation-play-state:running]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className={toolBoxClass}>
|
||||
<img src="/icon/dark.svg" width="38" height="38" alt="" />
|
||||
<span className={toolLabelClass}>Reactive Resume</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-[Manrope_Variable,sans-serif] font-medium text-[23px] leading-[1.3] tracking-[-0.035em] max-[540px]:text-[22px]">
|
||||
<Trans>Work on your resume from your AI app.</Trans>
|
||||
</h3>
|
||||
<p className="mt-[15px] text-(--home-muted) text-[14px] leading-[1.7]">
|
||||
<Trans>
|
||||
Connect through MCP to create and update resumes in a conversation. Or use the API to build your own workflow.
|
||||
</Trans>
|
||||
</p>
|
||||
<a href="https://docs.rxresu.me/guides/using-the-mcp-server" className={cn(textLink, "mt-4")}>
|
||||
<Trans>See how to connect</Trans>
|
||||
<ArrowUpRightIcon size={17} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CSSProperties, PointerEvent } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { m, useMotionTemplate, useReducedMotion, useSpring } from "motion/react";
|
||||
import { m, useReducedMotion, useSpring } from "motion/react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import "./resume-sculpture.css";
|
||||
@@ -60,7 +60,6 @@ export function ResumeSculpture({
|
||||
const reducedMotion = useReducedMotion();
|
||||
const rotateX = useSpring(10, { stiffness: 180, damping: 26 });
|
||||
const rotateY = useSpring(-19, { stiffness: 180, damping: 26 });
|
||||
const transform = useMotionTemplate`rotateX(${rotateX}deg) rotateY(${rotateY}deg) rotateZ(-7deg)`;
|
||||
|
||||
function startDrag(event: PointerEvent<HTMLDivElement>) {
|
||||
if (event.pointerType === "touch" || event.button !== 0 || dragOrigin.current || reducedMotion) return;
|
||||
@@ -114,7 +113,7 @@ export function ResumeSculpture({
|
||||
className="sculpture-rig group/rig transform-3d absolute inset-0"
|
||||
data-spread={spread}
|
||||
data-instant={keyboardAction}
|
||||
style={{ transform }}
|
||||
style={{ rotateX, rotateY, rotateZ: -7 }}
|
||||
>
|
||||
{templates.map((paperTemplate, index) => {
|
||||
const position = (index - templates.indexOf(template) + templates.length) % templates.length;
|
||||
|
||||
Reference in New Issue
Block a user