try.jobops.app

This commit is contained in:
DaKheera47 2026-03-10 16:35:46 +00:00
parent 11d1e9820b
commit daca4d2bd4
3 changed files with 101 additions and 35 deletions

View File

@ -5,9 +5,12 @@
[![GHCR](https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker&logoColor=white)](https://github.com/DaKheera47/job-ops/pkgs/container/job-ops) [![GHCR](https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker&logoColor=white)](https://github.com/DaKheera47/job-ops/pkgs/container/job-ops)
[![Release](https://github.com/DaKheera47/job-ops/actions/workflows/ghcr.yml/badge.svg)](https://github.com/DaKheera47/job-ops/actions/workflows/ghcr.yml) [![Release](https://github.com/DaKheera47/job-ops/actions/workflows/ghcr.yml/badge.svg)](https://github.com/DaKheera47/job-ops/actions/workflows/ghcr.yml)
[![Contributors](https://img.shields.io/github/contributors-anon/dakheera47/job-ops)](Contributors) [![Contributors](https://img.shields.io/github/contributors-anon/dakheera47/job-ops)](Contributors)
[![Cloud Waitlist](https://img.shields.io/badge/☁_Cloud-Join_Waitlist-orange?style=flat-square)](https://try.jobops.app?utm_source=github&utm_medium=badge&utm_campaign=waitlist)
<img width="1200" height="600" alt="Jobops-banner-900" src="https://github.com/user-attachments/assets/e929e389-2ebb-4de1-82c6-8e136b849b78" /> <img width="1200" height="600" alt="Jobops-banner-900" src="https://github.com/user-attachments/assets/e929e389-2ebb-4de1-82c6-8e136b849b78" />
Stop applying blind.
Scrapes major job boards (LinkedIn, Indeed, Glassdoor & more), AI-scores suitability, tailors resumes (RxResume), and tracks application emails automatically. Scrapes major job boards (LinkedIn, Indeed, Glassdoor & more), AI-scores suitability, tailors resumes (RxResume), and tracks application emails automatically.
You still apply to every job yourself. JobOps just finds jobs, makes sure you're applying to the right ones with a tailored CV, and not losing track of where you're at. You still apply to every job yourself. JobOps just finds jobs, makes sure you're applying to the right ones with a tailored CV, and not losing track of where you're at.
@ -46,12 +49,6 @@ If you want the serious view of the project, start here:
- [Extractor System](https://jobops.dakheera47.com/docs/extractors/overview) - [Extractor System](https://jobops.dakheera47.com/docs/extractors/overview)
- [Troubleshooting](https://jobops.dakheera47.com/docs/troubleshooting/common-problems) - [Troubleshooting](https://jobops.dakheera47.com/docs/troubleshooting/common-problems)
## Contributing
Want to contribute code, docs, or extractors? Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md).
That guide is intentionally link-first so contributor workflow lives in one place while setup and feature docs stay in the canonical docs site.
## Quick Start (10 Min) ## Quick Start (10 Min)
Prefer guided setup? Follow the [Self-Hosting Guide](https://jobops.dakheera47.com/docs/getting-started/self-hosting). Prefer guided setup? Follow the [Self-Hosting Guide](https://jobops.dakheera47.com/docs/getting-started/self-hosting).
@ -110,6 +107,23 @@ See [post-application tracking docs](https://jobops.dakheera47.com/docs/features
**Note on Analytics**: The alpha version includes anonymous analytics (Umami) to help debug performance. To opt-out, block `umami.dakheera47.com` in your firewall/DNS. **Note on Analytics**: The alpha version includes anonymous analytics (Umami) to help debug performance. To opt-out, block `umami.dakheera47.com` in your firewall/DNS.
## ☁️ Cloud Version (Coming Soon)
Self-hosting not your thing? A hosted version of JobOps is coming.
- No Docker required
- Up and running in 2 minutes
- Managed updates
- Self-hosted will always be free and open source
👉 Join the waitlist at https://try.jobops.app?utm_source=github&utm_medium=readme&utm_campaign=waitlist
## Contributing
Want to contribute code, docs, or extractors? Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md).
That guide is intentionally link-first so contributor workflow lives in one place while setup and feature docs stay in the canonical docs site.
## Star History ## Star History
<a href="https://www.star-history.com/#DaKheera47/job-ops&type=date&legend=top-left"> <a href="https://www.star-history.com/#DaKheera47/job-ops&type=date&legend=top-left">

View File

@ -2,7 +2,6 @@ import { fireEvent, render, screen } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { trackEvent } from "@/lib/analytics";
import { App } from "./App"; import { App } from "./App";
import { useDemoInfo } from "./hooks/useDemoInfo"; import { useDemoInfo } from "./hooks/useDemoInfo";
@ -10,10 +9,6 @@ vi.mock("./hooks/useDemoInfo", () => ({
useDemoInfo: vi.fn(), useDemoInfo: vi.fn(),
})); }));
vi.mock("@/lib/analytics", () => ({
trackEvent: vi.fn(),
}));
vi.mock("react-transition-group", () => ({ vi.mock("react-transition-group", () => ({
SwitchTransition: ({ children }: { children: React.ReactNode }) => children, SwitchTransition: ({ children }: { children: React.ReactNode }) => children,
CSSTransition: ({ children }: { children: React.ReactNode }) => children, CSSTransition: ({ children }: { children: React.ReactNode }) => children,
@ -66,9 +61,10 @@ vi.mock("./pages/VisaSponsorsPage", () => ({
describe("App demo banner", () => { describe("App demo banner", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear();
}); });
it("shows a Star repo link in demo mode and tracks click", () => { it("shows a waitlist link in demo mode", () => {
vi.mocked(useDemoInfo).mockReturnValue({ vi.mocked(useDemoInfo).mockReturnValue({
demoMode: true, demoMode: true,
resetCadenceHours: 6, resetCadenceHours: 6,
@ -84,18 +80,14 @@ describe("App demo banner", () => {
</MemoryRouter>, </MemoryRouter>,
); );
const link = screen.getByRole("link", { name: /star .*repo/i }); const link = screen.getByRole("link", { name: "try.jobops.app" });
expect(link).toHaveAttribute( expect(link).toHaveAttribute(
"href", "href",
"https://github.com/DaKheera47/job-ops", "https://try.jobops.app?utm_source=demo&utm_medium=banner&utm_campaign=waitlist",
); );
fireEvent.click(link);
expect(trackEvent).toHaveBeenCalledWith("star_repo_click", {
location: "demo_mode_banner",
});
}); });
it("does not render the demo banner CTA when demo mode is disabled", () => { it("does not render the demo banner waitlist link when demo mode is disabled", () => {
vi.mocked(useDemoInfo).mockReturnValue({ vi.mocked(useDemoInfo).mockReturnValue({
demoMode: false, demoMode: false,
resetCadenceHours: 6, resetCadenceHours: 6,
@ -111,6 +103,32 @@ describe("App demo banner", () => {
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.queryByRole("link", { name: /star .*repo/i })).toBeNull(); expect(screen.queryByRole("link", { name: "try.jobops.app" })).toBeNull();
});
it("lets the user dismiss the waitlist banner and keeps it hidden", () => {
vi.mocked(useDemoInfo).mockReturnValue({
demoMode: true,
resetCadenceHours: 6,
lastResetAt: null,
nextResetAt: null,
baselineVersion: null,
baselineName: null,
});
render(
<MemoryRouter initialEntries={["/overview"]}>
<App />
</MemoryRouter>,
);
fireEvent.click(
screen.getByRole("button", { name: /dismiss demo waitlist banner/i }),
);
expect(screen.queryByRole("link", { name: "try.jobops.app" })).toBeNull();
expect(localStorage.getItem("jobops.demoWaitlistBannerDismissed")).toBe(
"1",
);
}); });
}); });

View File

@ -2,12 +2,13 @@
* Main App component. * Main App component.
*/ */
import React, { useRef } from "react"; import { X } from "lucide-react";
import React, { useRef, useState } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { CSSTransition, SwitchTransition } from "react-transition-group"; import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Button } from "@/components/ui/button";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { trackEvent } from "@/lib/analytics";
import { BasicAuthPrompt } from "./components/BasicAuthPrompt"; import { BasicAuthPrompt } from "./components/BasicAuthPrompt";
import { OnboardingGate } from "./components/OnboardingGate"; import { OnboardingGate } from "./components/OnboardingGate";
import { useDemoInfo } from "./hooks/useDemoInfo"; import { useDemoInfo } from "./hooks/useDemoInfo";
@ -39,10 +40,20 @@ const REDIRECTS: Array<{ from: string; to: string }> = [
{ from: "/all/:jobId", to: "/jobs/all/:jobId" }, { from: "/all/:jobId", to: "/jobs/all/:jobId" },
]; ];
const DEMO_WAITLIST_BANNER_DISMISSED_KEY = "jobops.demoWaitlistBannerDismissed";
export const App: React.FC = () => { export const App: React.FC = () => {
const location = useLocation(); const location = useLocation();
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const demoInfo = useDemoInfo(); const demoInfo = useDemoInfo();
const [demoWaitlistBannerDismissed, setDemoWaitlistBannerDismissed] =
useState(() => {
try {
return localStorage.getItem(DEMO_WAITLIST_BANNER_DISMISSED_KEY) === "1";
} catch {
return false;
}
});
// Determine a stable key for transitions to avoid unnecessary unmounts when switching sub-tabs // Determine a stable key for transitions to avoid unnecessary unmounts when switching sub-tabs
const pageKey = React.useMemo(() => { const pageKey = React.useMemo(() => {
@ -57,22 +68,45 @@ export const App: React.FC = () => {
<> <>
<OnboardingGate /> <OnboardingGate />
<BasicAuthPrompt /> <BasicAuthPrompt />
{demoInfo?.demoMode && !demoWaitlistBannerDismissed && (
<div className="sticky top-0 z-50 w-full border-b border-orange-400/60 bg-orange-500 px-4 py-2 text-xs text-orange-950 shadow-sm">
<div className="mx-auto flex max-w-7xl items-center justify-center gap-3">
<p className="flex-1 text-center font-medium">
This is a read-only demo. Want JobOps without the Docker setup? {" "}
Cloud version coming soon join the waitlist at{" "}
<a
className="font-semibold underline underline-offset-2 hover:text-orange-900"
href="https://try.jobops.app?utm_source=demo&utm_medium=banner&utm_campaign=waitlist"
target="_blank"
rel="noreferrer"
>
try.jobops.app
</a>
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 rounded-full text-orange-950 hover:bg-orange-400/30 hover:text-orange-950"
onClick={() => {
setDemoWaitlistBannerDismissed(true);
try {
localStorage.setItem(DEMO_WAITLIST_BANNER_DISMISSED_KEY, "1");
} catch {
// Ignore storage errors in restricted browser contexts.
}
}}
>
<X className="h-4 w-4" />
<span className="sr-only">Dismiss demo waitlist banner</span>
</Button>
</div>
</div>
)}
{demoInfo?.demoMode && ( {demoInfo?.demoMode && (
<div className="w-full border-b border-amber-400/50 bg-amber-500/20 px-4 py-2 text-center text-xs text-amber-100 backdrop-blur"> <div className="w-full border-b border-amber-400/50 bg-amber-500/20 px-4 py-2 text-center text-xs text-amber-100 backdrop-blur">
Demo mode: integrations are simulated and data resets every{" "} Demo mode: integrations are simulated and data resets every{" "}
{demoInfo.resetCadenceHours} hours.{" "} {demoInfo.resetCadenceHours} hours.
<a
className="font-semibold underline underline-offset-2 hover:text-amber-50"
href="https://github.com/DaKheera47/job-ops"
target="_blank"
rel="noreferrer"
onClick={() =>
trackEvent("star_repo_click", { location: "demo_mode_banner" })
}
>
Star the repo on GitHub
</a>
.
</div> </div>
)} )}
<div> <div>