feat: combine settings pages into a single integrations page

This commit is contained in:
Amruth Pillai
2026-04-27 12:56:15 +02:00
parent 8566a9fd0f
commit 5d8126d4b0
68 changed files with 3361 additions and 3280 deletions
@@ -95,12 +95,12 @@ export function NavigationCommandGroup() {
</CommandItem>
<CommandItem
keywords={[t`Artificial Intelligence`]}
value="navigation.settings.ai"
onSelect={() => onNavigate("/dashboard/settings/ai")}
keywords={[t`Integrations`, t`Artificial Intelligence`, t`Job Search API`]}
value="navigation.settings.integrations"
onSelect={() => onNavigate("/dashboard/settings/integrations")}
>
<OpenAiLogoIcon />
<Trans>Artificial Intelligence</Trans>
<Trans>Integrations</Trans>
</CommandItem>
<CommandItem
+23
View File
@@ -49,6 +49,7 @@ import { Route as ApiAuthSplatRouteImport } from "./routes/api/auth.$";
import { Route as DotwellKnownOauthProtectedResourceSplatRouteImport } from "./routes/[.]well-known/oauth-protected-resource.$";
import { Route as DotwellKnownOauthAuthorizationServerSplatRouteImport } from "./routes/[.]well-known/oauth-authorization-server.$";
import { Route as DotwellKnownMcpServerCardDotjsonRouteImport } from "./routes/[.]well-known/mcp/server-card[.]json";
import { Route as DashboardSettingsIntegrationsRouteRouteImport } from "./routes/dashboard/settings/integrations/route";
import { Route as DashboardSettingsAuthenticationIndexRouteImport } from "./routes/dashboard/settings/authentication/index";
const SchemaDotjsonRoute = SchemaDotjsonRouteImport.update({
@@ -261,6 +262,12 @@ const DotwellKnownMcpServerCardDotjsonRoute =
path: "/.well-known/mcp/server-card.json",
getParentRoute: () => rootRouteImport,
} as any);
const DashboardSettingsIntegrationsRouteRoute =
DashboardSettingsIntegrationsRouteRouteImport.update({
id: "/settings/integrations",
path: "/settings/integrations",
getParentRoute: () => DashboardRouteRoute,
} as any);
const DashboardSettingsAuthenticationIndexRoute =
DashboardSettingsAuthenticationIndexRouteImport.update({
id: "/settings/authentication/",
@@ -292,6 +299,7 @@ export interface FileRoutesByFullPath {
"/auth/": typeof AuthIndexRoute;
"/dashboard/": typeof DashboardIndexRoute;
"/mcp/": typeof McpIndexRoute;
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
@@ -331,6 +339,7 @@ export interface FileRoutesByTo {
"/auth": typeof AuthIndexRoute;
"/dashboard": typeof DashboardIndexRoute;
"/mcp": typeof McpIndexRoute;
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
@@ -375,6 +384,7 @@ export interface FileRoutesById {
"/auth/": typeof AuthIndexRoute;
"/dashboard/": typeof DashboardIndexRoute;
"/mcp/": typeof McpIndexRoute;
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
@@ -419,6 +429,7 @@ export interface FileRouteTypes {
| "/auth/"
| "/dashboard/"
| "/mcp/"
| "/dashboard/settings/integrations"
| "/.well-known/mcp/server-card.json"
| "/.well-known/oauth-authorization-server/$"
| "/.well-known/oauth-protected-resource/$"
@@ -458,6 +469,7 @@ export interface FileRouteTypes {
| "/auth"
| "/dashboard"
| "/mcp"
| "/dashboard/settings/integrations"
| "/.well-known/mcp/server-card.json"
| "/.well-known/oauth-authorization-server/$"
| "/.well-known/oauth-protected-resource/$"
@@ -501,6 +513,7 @@ export interface FileRouteTypes {
| "/auth/"
| "/dashboard/"
| "/mcp/"
| "/dashboard/settings/integrations"
| "/.well-known/mcp/server-card.json"
| "/.well-known/oauth-authorization-server/$"
| "/.well-known/oauth-protected-resource/$"
@@ -823,6 +836,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DotwellKnownMcpServerCardDotjsonRouteImport;
parentRoute: typeof rootRouteImport;
};
"/dashboard/settings/integrations": {
id: "/dashboard/settings/integrations";
path: "/settings/integrations";
fullPath: "/dashboard/settings/integrations";
preLoaderRoute: typeof DashboardSettingsIntegrationsRouteRouteImport;
parentRoute: typeof DashboardRouteRoute;
};
"/dashboard/settings/authentication/": {
id: "/dashboard/settings/authentication/";
path: "/settings/authentication";
@@ -875,6 +895,7 @@ const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
interface DashboardRouteRouteChildren {
DashboardIndexRoute: typeof DashboardIndexRoute;
DashboardSettingsIntegrationsRouteRoute: typeof DashboardSettingsIntegrationsRouteRoute;
DashboardSettingsAiRoute: typeof DashboardSettingsAiRoute;
DashboardSettingsApiKeysRoute: typeof DashboardSettingsApiKeysRoute;
DashboardSettingsDangerZoneRoute: typeof DashboardSettingsDangerZoneRoute;
@@ -888,6 +909,8 @@ interface DashboardRouteRouteChildren {
const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
DashboardIndexRoute: DashboardIndexRoute,
DashboardSettingsIntegrationsRouteRoute:
DashboardSettingsIntegrationsRouteRoute,
DashboardSettingsAiRoute: DashboardSettingsAiRoute,
DashboardSettingsApiKeysRoute: DashboardSettingsApiKeysRoute,
DashboardSettingsDangerZoneRoute: DashboardSettingsDangerZoneRoute,
@@ -266,8 +266,8 @@ function DisabledState() {
variant="outline"
nativeButton={false}
render={
<Link to="/dashboard/settings/ai">
<Trans>Open AI Settings</Trans>
<Link to="/dashboard/settings/integrations">
<Trans>Open Integrations Settings</Trans>
<ArrowRightIcon />
</Link>
}
+2 -7
View File
@@ -79,13 +79,8 @@ const settingsSidebarItems = [
},
{
icon: <BrainIcon />,
label: msg`Artificial Intelligence`,
href: "/dashboard/settings/ai",
},
{
icon: <BriefcaseIcon />,
label: msg`Job Search API`,
href: "/dashboard/settings/job-search",
label: msg`Integrations`,
href: "/dashboard/settings/integrations",
},
{
icon: <WarningIcon />,
+2 -2
View File
@@ -79,8 +79,8 @@ function RouteComponent() {
<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 nativeButton={false} variant="outline" render={<Link to="/dashboard/settings/integrations" />}>
<Trans>Go to Integrations</Trans>
</Button>
</motion.div>
) : (
+4 -308
View File
@@ -1,311 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { BrainIcon, CheckCircleIcon, InfoIcon, XCircleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useMemo } from "react";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import type { AIProvider } from "@/integrations/ai/types";
import { Button } from "@/components/ui/button";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
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 { Switch } from "@/components/ui/switch";
import { useAIStore } from "@/integrations/ai/store";
import { orpc } from "@/integrations/orpc/client";
import { getOrpcErrorMessage } from "@/utils/error-message";
import { cn } from "@/utils/style";
import { DashboardHeader } from "../-components/header";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/settings/ai")({
component: RouteComponent,
beforeLoad: () => {
throw redirect({ to: "/dashboard/settings/integrations", replace: true });
},
});
const providerOptions: (ComboboxOption<AIProvider> & { defaultBaseURL: string })[] = [
{
value: "openai",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "OpenAI",
}),
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: "https://api.openai.com/v1",
},
{
value: "anthropic",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Anthropic Claude",
}),
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: "https://api.anthropic.com/v1",
},
{
value: "gemini",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Google Gemini",
}),
keywords: ["gemini", "google", "bard"],
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta",
},
{
value: "vercel-ai-gateway",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Vercel AI Gateway",
}),
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: "https://ai-gateway.vercel.sh/v1/ai",
},
{
value: "openrouter",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "OpenRouter",
}),
keywords: ["openrouter", "router", "multi", "proxy"],
defaultBaseURL: "https://openrouter.ai/api/v1",
},
{
value: "ollama",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Ollama",
}),
keywords: ["ollama", "ai", "local"],
defaultBaseURL: "https://ollama.com/api",
},
];
function AIForm() {
const { set, model, apiKey, baseURL, provider, enabled, testStatus } = useAIStore();
const selectedOption = useMemo(() => {
return providerOptions.find((option) => option.value === provider);
}, [provider]);
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.ai.testConnection.mutationOptions());
const handleProviderChange = (value: AIProvider | null) => {
if (!value) return;
set((draft) => {
draft.provider = value;
});
};
const handleModelChange = (value: string) => {
set((draft) => {
draft.model = value;
});
};
const handleApiKeyChange = (value: string) => {
set((draft) => {
draft.apiKey = value;
});
};
const handleBaseURLChange = (value: string) => {
set((draft) => {
draft.baseURL = value;
});
};
const handleTestConnection = () => {
testConnection(
{ provider, model, apiKey, baseURL },
{
onSuccess: (data) => {
set((draft) => {
draft.testStatus = data ? "success" : "failure";
});
},
onError: (error) => {
set((draft) => {
draft.testStatus = "failure";
});
toast.error(
getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t({
comment: "Error shown when AI provider credentials or base URL are invalid in AI settings",
message: "Invalid AI provider configuration. Please check your settings.",
}),
BAD_GATEWAY: t({
comment: "Error shown when the configured AI provider cannot be reached during connection test",
message: "Could not reach the AI provider. Please try again.",
}),
},
fallback: t({
comment: "Fallback toast when testing AI provider connection fails",
message: "Failed to test AI provider connection. Please try again.",
}),
}),
);
},
},
);
};
return (
<div className="grid gap-6 sm:grid-cols-2">
<div className="flex flex-col gap-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
</Label>
<Combobox
id="ai-provider"
value={provider}
disabled={enabled}
options={providerOptions}
onValueChange={handleProviderChange}
/>
</div>
<div className="flex flex-col gap-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
name="ai-model"
type="text"
value={model}
disabled={enabled}
onChange={(e) => handleModelChange(e.target.value)}
placeholder={t({
comment: "Example model-name placeholder in AI settings",
message: "e.g., gpt-4, claude-3-opus, gemini-pro",
})}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
<Input
id="ai-api-key"
name="ai-api-key"
type="password"
value={apiKey}
disabled={enabled}
onChange={(e) => handleApiKeyChange(e.target.value)}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
// ignore password managers
data-lpignore="true"
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL (Optional)</Trans>
</Label>
<Input
id="ai-base-url"
name="ai-base-url"
type="url"
value={baseURL}
disabled={enabled}
placeholder={selectedOption?.defaultBaseURL}
onChange={(e) => handleBaseURLChange(e.target.value)}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
/>
</div>
<div>
<Button variant="outline" disabled={isTesting || enabled} onClick={handleTestConnection}>
{isTesting ? (
<Spinner />
) : testStatus === "success" ? (
<CheckCircleIcon className="text-success" />
) : testStatus === "failure" ? (
<XCircleIcon className="text-destructive" />
) : null}
<Trans>Test Connection</Trans>
</Button>
</div>
</div>
);
}
function RouteComponent() {
const isClient = useIsClient();
const enabled = useAIStore((state) => state.enabled);
const canEnable = useAIStore((state) => state.canEnable());
const setEnabled = useAIStore((state) => state.setEnabled);
if (!isClient) return null;
return (
<div className="space-y-4">
<DashboardHeader icon={BrainIcon} title={t`Artificial Intelligence`} />
<Separator />
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<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>Your data is stored locally</Trans>
</h3>
<p className="leading-relaxed text-muted-foreground">
<Trans>
Everything entered here is stored locally on your browser. Your data is only sent to the server when
making a request to the AI provider, and is never stored or logged on our servers.
</Trans>
</p>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<Label htmlFor="enable-ai">
<Trans>Enable AI Features</Trans>
</Label>
<Switch id="enable-ai" checked={enabled} disabled={!canEnable} onCheckedChange={setEnabled} />
</div>
<p className={cn("flex items-center gap-x-2", enabled ? "text-success" : "text-destructive")}>
{enabled ? <CheckCircleIcon /> : <XCircleIcon />}
{enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</p>
<AIForm />
</motion.div>
</div>
);
}
@@ -0,0 +1,284 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckCircleIcon, InfoIcon, XCircleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { toast } from "sonner";
import type { AIProvider } from "@/integrations/ai/types";
import { Button } from "@/components/ui/button";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { useAIStore } from "@/integrations/ai/store";
import { orpc } from "@/integrations/orpc/client";
import { getOrpcErrorMessage } from "@/utils/error-message";
import { cn } from "@/utils/style";
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
const providerOptions: AIProviderOption[] = [
{
value: "openai",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "OpenAI",
}),
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: "https://api.openai.com/v1",
},
{
value: "anthropic",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Anthropic Claude",
}),
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: "https://api.anthropic.com/v1",
},
{
value: "gemini",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Google Gemini",
}),
keywords: ["gemini", "google", "bard"],
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta",
},
{
value: "vercel-ai-gateway",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Vercel AI Gateway",
}),
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: "https://ai-gateway.vercel.sh/v1/ai",
},
{
value: "openrouter",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "OpenRouter",
}),
keywords: ["openrouter", "router", "multi", "proxy"],
defaultBaseURL: "https://openrouter.ai/api/v1",
},
{
value: "ollama",
label: t({
comment: "AI provider option label in dashboard AI settings",
message: "Ollama",
}),
keywords: ["ollama", "ai", "local"],
defaultBaseURL: "https://ollama.com/api",
},
];
function AIForm() {
const { set, model, apiKey, baseURL, provider, enabled, testStatus } = useAIStore();
const selectedOption = useMemo(() => {
return providerOptions.find((option) => option.value === provider);
}, [provider]);
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.ai.testConnection.mutationOptions());
const handleProviderChange = (value: AIProvider | null) => {
if (!value) return;
set((draft) => {
draft.provider = value;
});
};
const handleTestConnection = () => {
testConnection(
{ provider, model, apiKey, baseURL },
{
onSuccess: (data) => {
set((draft) => {
draft.testStatus = data ? "success" : "failure";
});
},
onError: (error) => {
set((draft) => {
draft.testStatus = "failure";
});
toast.error(
getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t({
comment: "Error shown when AI provider credentials or base URL are invalid in AI settings",
message: "Invalid AI provider configuration. Please check your settings.",
}),
BAD_GATEWAY: t({
comment: "Error shown when the configured AI provider cannot be reached during connection test",
message: "Could not reach the AI provider. Please try again.",
}),
},
fallback: t({
comment: "Fallback toast when testing AI provider connection fails",
message: "Failed to test AI provider connection. Please try again.",
}),
}),
);
},
},
);
};
return (
<div className="grid gap-6 sm:grid-cols-2">
<div className="flex flex-col gap-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
</Label>
<Combobox
id="ai-provider"
value={provider}
disabled={enabled}
options={providerOptions}
onValueChange={handleProviderChange}
/>
</div>
<div className="flex flex-col gap-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
name="ai-model"
type="text"
value={model}
disabled={enabled}
onChange={(e) =>
set((draft) => {
draft.model = e.target.value;
})
}
placeholder={t({
comment: "Example model-name placeholder in AI settings",
message: "e.g., gpt-4, claude-3-opus, gemini-pro",
})}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
<Input
id="ai-api-key"
name="ai-api-key"
type="password"
value={apiKey}
disabled={enabled}
onChange={(e) =>
set((draft) => {
draft.apiKey = e.target.value;
})
}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
data-lpignore="true"
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
<div className="flex flex-col gap-y-2 sm:col-span-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL (Optional)</Trans>
</Label>
<Input
id="ai-base-url"
name="ai-base-url"
type="url"
value={baseURL}
disabled={enabled}
placeholder={selectedOption?.defaultBaseURL}
onChange={(e) =>
set((draft) => {
draft.baseURL = e.target.value;
})
}
autoCorrect="off"
autoComplete="off"
spellCheck="false"
autoCapitalize="off"
/>
</div>
<div>
<Button variant="outline" disabled={isTesting || enabled} onClick={handleTestConnection}>
{isTesting ? (
<Spinner />
) : testStatus === "success" ? (
<CheckCircleIcon className="text-success" />
) : testStatus === "failure" ? (
<XCircleIcon className="text-destructive" />
) : null}
<Trans>Test Connection</Trans>
</Button>
</div>
</div>
);
}
export function AISettingsSection() {
const aiEnabled = useAIStore((state) => state.enabled);
const canEnableAI = useAIStore((state) => state.canEnable());
const setAIEnabled = useAIStore((state) => state.setEnabled);
return (
<section className="grid gap-6">
<h2 className="text-lg font-semibold">
<Trans>Artificial Intelligence</Trans>
</h2>
<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>Your data is stored locally</Trans>
</h3>
<p className="leading-relaxed text-muted-foreground">
<Trans>
Everything entered here is stored locally on your browser. Your data is only sent to the server when
making a request to the AI provider, and is never stored or logged on our servers.
</Trans>
</p>
</div>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="enable-ai">
<Trans>Enable AI Features</Trans>
</Label>
<Switch id="enable-ai" checked={aiEnabled} disabled={!canEnableAI} onCheckedChange={setAIEnabled} />
</div>
<p className={cn("flex items-center gap-x-2", aiEnabled ? "text-success" : "text-destructive")}>
{aiEnabled ? <CheckCircleIcon /> : <XCircleIcon />}
{aiEnabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</p>
<AIForm />
</section>
);
}
@@ -0,0 +1,182 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckCircleIcon, InfoIcon, LinkSimpleIcon, XCircleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
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 { Spinner } from "@/components/ui/spinner";
import { useJobsStore } from "@/integrations/jobs/store";
import { orpc } from "@/integrations/orpc/client";
import { getOrpcErrorMessage } from "@/utils/error-message";
function RapidAPIKeyForm() {
const { set, rapidApiKey, testStatus } = useJobsStore();
const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.jobs.testConnection.mutationOptions());
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(
getOrpcErrorMessage(error, {
byCode: {
BAD_GATEWAY: t({
comment: "Error shown when JSearch API connection test fails in job search settings",
message: "Could not reach JSearch API. Check your API key and try again.",
}),
},
fallback: t({
comment: "Fallback toast when testing RapidAPI job search connection fails",
message: "Failed to test RapidAPI connection. Please try again.",
}),
}),
);
},
},
);
};
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) =>
set((draft) => {
draft.rapidApiKey = 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}>
{isTesting ? (
<Spinner />
) : testStatus === "success" ? (
<CheckCircleIcon className="text-success" />
) : testStatus === "failure" ? (
<XCircleIcon className="text-destructive" />
) : 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>
);
}
export function JobSearchSettingsSection() {
return (
<section className="grid gap-6">
<h2 className="text-xl font-semibold">
<Trans>Job Search</Trans>
</h2>
<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. You can filter by country, 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>API Reference</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>
<RapidAPIKeyForm />
<RapidAPIQuotaDisplay />
</section>
);
}
@@ -0,0 +1,42 @@
import { t } from "@lingui/core/macro";
import { BrainIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useIsClient } from "usehooks-ts";
import { Separator } from "@/components/ui/separator";
import { DashboardHeader } from "../../-components/header";
import { AISettingsSection } from "./-components/ai-section";
import { JobSearchSettingsSection } from "./-components/job-search-section";
export const Route = createFileRoute("/dashboard/settings/integrations")({
component: RouteComponent,
});
function RouteComponent() {
const isClient = useIsClient();
if (!isClient) return null;
return (
<div className="space-y-4">
<DashboardHeader icon={BrainIcon} title={t`Integrations`} />
<Separator />
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-8 will-change-[transform,opacity]"
>
<AISettingsSection />
<Separator />
<JobSearchSettingsSection />
</motion.div>
</div>
);
}
+4 -203
View File
@@ -1,206 +1,7 @@
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 { getOrpcErrorMessage } from "@/utils/error-message";
import { DashboardHeader } from "../-components/header";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/settings/job-search")({
component: RouteComponent,
beforeLoad: () => {
throw redirect({ to: "/dashboard/settings/integrations", replace: true });
},
});
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(
getOrpcErrorMessage(error, {
byCode: {
BAD_GATEWAY: t({
comment: "Error shown when JSearch API connection test fails in job search settings",
message: "Could not reach JSearch API. Check your API key and try again.",
}),
},
fallback: t({
comment: "Fallback toast when testing RapidAPI job search connection fails",
message: "Failed to test RapidAPI connection. Please try again.",
}),
}),
);
},
},
);
};
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>
);
}