mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
refactor(ui): improve error handling and input safety in app flows
Normalize frontend error rendering and tighten input/path handling across auth, builder, dashboard, and shared components for more resilient UX behavior. Made-with: Cursor
This commit is contained in:
@@ -1,26 +1,25 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl } from "@/utils/file";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type LoaderData = Omit<RouterOutput["resume"]["getBySlug"], "data"> & { data: ResumeData };
|
||||
|
||||
export const Route = createFileRoute("/$username/$slug")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context, params: { username, slug } }) => {
|
||||
loader: async ({ context, params, ...rest }) => {
|
||||
console.log("$username/$slug loader", JSON.stringify({ params, context, rest }, null, 2));
|
||||
|
||||
const { username, slug } = params;
|
||||
const resume = await context.queryClient.ensureQueryData(
|
||||
orpc.resume.getBySlug.queryOptions({ input: { username, slug } }),
|
||||
);
|
||||
@@ -28,7 +27,19 @@ export const Route = createFileRoute("/$username/$slug")({
|
||||
return { resume: resume as LoaderData };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [{ title: loaderData ? `${loaderData.resume.name} - Reactive Resume` : "Reactive Resume" }],
|
||||
meta: [
|
||||
{
|
||||
title: loaderData
|
||||
? `${loaderData.resume.name} - ${t({
|
||||
comment: "Brand name suffix in browser tab title for public resume pages",
|
||||
message: "Reactive Resume",
|
||||
})}`
|
||||
: t({
|
||||
comment: "Browser tab title before the public resume finishes loading",
|
||||
message: "Reactive Resume",
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "NEED_PASSWORD") {
|
||||
@@ -54,9 +65,6 @@ function RouteComponent() {
|
||||
const initialize = useResumeStore((state) => state.initialize);
|
||||
|
||||
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
|
||||
const { mutateAsync: printResumeAsPDF, isPending: isPrinting } = useMutation(
|
||||
orpc.printer.printResumeAsPDF.mutationOptions(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resume) return;
|
||||
@@ -64,12 +72,6 @@ function RouteComponent() {
|
||||
return () => initialize(null);
|
||||
}, [resume, initialize]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const { url } = await printResumeAsPDF({ id: resume.id });
|
||||
await downloadFromUrl(url, `${resume.name}.pdf`);
|
||||
}, [resume, printResumeAsPDF]);
|
||||
|
||||
if (!isReady) return <LoadingScreen />;
|
||||
|
||||
return (
|
||||
@@ -79,17 +81,6 @@ function RouteComponent() {
|
||||
>
|
||||
<ResumePreview className="space-y-4" pageClassName="print:w-full! w-full max-w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
disabled={isPrinting}
|
||||
className="fixed inset-e-4 bottom-4 z-50 hidden rounded-full px-4 md:inline-flex print:hidden"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{isPrinting ? <Spinner /> : <DownloadSimpleIcon />}
|
||||
{isPrinting ? <Trans>Downloading...</Trans> : <Trans>Download</Trans>}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,12 @@ const getFaqItems = (): FAQItemData[] => [
|
||||
className={buttonVariants({ variant: "link", className: "h-auto px-0!" })}
|
||||
>
|
||||
contribute to the translations on Crowdin
|
||||
<span className="sr-only"> (opens in new tab)</span>
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen reader hint indicating the FAQ translation contribution link opens in a new browser tab">
|
||||
{" "}
|
||||
(opens in new tab)
|
||||
</Trans>
|
||||
</span>
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
@@ -78,7 +83,7 @@ export function FAQ() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<Trans context="Every word needs to be wrapped in a tag">
|
||||
<Trans context="Home page FAQ section heading with each word visually separated into individual spans">
|
||||
<span>Frequently</span>
|
||||
<span>Asked</span>
|
||||
<span>Questions</span>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { FingerprintIcon, GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { RouterOutput } from "@/integrations/orpc/client";
|
||||
@@ -51,7 +50,6 @@ type SocialAuthButtonsProps = {
|
||||
|
||||
function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
const router = useRouter();
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
@@ -62,7 +60,14 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when social sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,7 +84,14 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when custom OAuth sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +105,14 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: false });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when passkey sign-in fails without an error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,25 +120,6 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!("passkey" in providers)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("PublicKeyCredential" in window)) return;
|
||||
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
||||
if (hasStartedConditionalPasskeyRef.current) return;
|
||||
|
||||
hasStartedConditionalPasskeyRef.current = true;
|
||||
|
||||
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: true });
|
||||
if (error) return;
|
||||
|
||||
await router.invalidate();
|
||||
});
|
||||
}, [providers, router]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
@@ -137,7 +137,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
className={cn("hidden", "passkey" in providers && "inline-flex")}
|
||||
>
|
||||
<FingerprintIcon />
|
||||
Passkey
|
||||
<Trans comment="Label for passkey sign-in button">Passkey</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -148,7 +148,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
)}
|
||||
>
|
||||
<GoogleLogoIcon />
|
||||
Google
|
||||
<Trans comment="Brand name label for Google social sign-in button">Google</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -159,7 +159,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
)}
|
||||
>
|
||||
<GithubLogoIcon />
|
||||
GitHub
|
||||
<Trans comment="Brand name label for GitHub social sign-in button">GitHub</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -170,7 +170,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
)}
|
||||
>
|
||||
<LinkedinLogoIcon />
|
||||
LinkedIn
|
||||
<Trans comment="Brand name label for LinkedIn social sign-in button">LinkedIn</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,14 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when requesting password reset email fails without backend message",
|
||||
message: "Failed to send password reset email. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,7 +78,8 @@ function RouteComponent() {
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
Sign in now <ArrowRightIcon />
|
||||
<Trans comment="Call-to-action link from forgot-password page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -87,10 +95,20 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
<Trans comment="Label for email input on forgot-password form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={<Input type="email" autoComplete="email" placeholder="john.doe@example.com" {...field} />}
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on forgot-password form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -98,7 +116,7 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Send Password Reset Email</Trans>
|
||||
<Trans comment="Primary action button label on forgot-password form">Send Password Reset Email</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -122,7 +140,7 @@ function PostForgotPasswordScreen() {
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="mailto:">
|
||||
<Trans>Open Email Client</Trans>
|
||||
<Trans comment="Button label to open the user's default email app">Open Email Client</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
+66
-11
@@ -2,7 +2,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
import { SocialAuth } from "./-components/social-auth";
|
||||
|
||||
@@ -33,9 +36,13 @@ type FormValues = z.infer<typeof formSchema>;
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const { flags } = Route.useRouteContext();
|
||||
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { data: providers = {} } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -55,7 +62,14 @@ function RouteComponent() {
|
||||
: await authClient.signIn.username({ username: data.identifier, password: data.password });
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, { id: toastId });
|
||||
toast.error(
|
||||
result.error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when sign-in fails and no server error message is available",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,11 +95,30 @@ function RouteComponent() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!("passkey" in providers)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("PublicKeyCredential" in window)) return;
|
||||
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
||||
if (hasStartedConditionalPasskeyRef.current) return;
|
||||
|
||||
hasStartedConditionalPasskeyRef.current = true;
|
||||
|
||||
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: true });
|
||||
if (error) return;
|
||||
|
||||
await router.invalidate();
|
||||
});
|
||||
}, [providers, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<Trans>Sign in to your account</Trans>
|
||||
<Trans comment="Title on the login page">Sign in to your account</Trans>
|
||||
</h1>
|
||||
|
||||
{!flags.disableSignups && (
|
||||
@@ -98,7 +131,10 @@ function RouteComponent() {
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/register">
|
||||
Create one now <ArrowRightIcon />
|
||||
<Trans comment="Call-to-action link from login page to account registration page">
|
||||
Create one now
|
||||
</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -116,14 +152,18 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
<Trans comment="Label for login identifier input that accepts email or username">
|
||||
Email Address
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
autoFocus
|
||||
autoComplete="section-login username webauthn"
|
||||
placeholder="john.doe@example.com"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder for login identifier field",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -144,7 +184,7 @@ function RouteComponent() {
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
<Trans comment="Label for password input on login form">Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Button
|
||||
@@ -154,7 +194,7 @@ function RouteComponent() {
|
||||
className="h-auto p-0 text-xs leading-none"
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Forgot Password?</Trans>
|
||||
<Trans comment="Link label to password reset page from login form">Forgot Password?</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -172,7 +212,22 @@ function RouteComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides the password in login form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals the password in login form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -182,7 +237,7 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Sign in</Trans>
|
||||
<Trans comment="Primary action button label on login form">Sign in</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -71,7 +71,14 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when account registration fails without a server error message",
|
||||
message: "Failed to create your account. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,7 +104,8 @@ function RouteComponent() {
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
Sign in now <ArrowRightIcon />
|
||||
<Trans comment="Call-to-action link from registration page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -114,11 +122,20 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
<Trans comment="Label for full name input on registration form">Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input min={3} max={64} autoComplete="section-register name" placeholder="John Doe" {...field} />
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on registration form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
@@ -132,7 +149,7 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Username</Trans>
|
||||
<Trans comment="Label for username input on registration form">Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
@@ -140,7 +157,10 @@ function RouteComponent() {
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register username"
|
||||
placeholder="john.doe"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on registration form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -157,14 +177,17 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
<Trans comment="Label for email input on registration form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="section-register email"
|
||||
placeholder="john.doe@example.com"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on registration form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -181,7 +204,7 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
<Trans comment="Label for password input on registration form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
@@ -196,7 +219,22 @@ function RouteComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in registration form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in registration form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -206,7 +244,7 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Sign up</Trans>
|
||||
<Trans comment="Primary action button label on registration form">Sign up</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -242,7 +280,8 @@ function PostSignupScreen() {
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard">
|
||||
<Trans>Continue</Trans> <ArrowRightIcon />
|
||||
<Trans comment="Button label to continue to dashboard after successful registration">Continue</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -53,7 +53,14 @@ function RouteComponent() {
|
||||
const { error } = await authClient.resetPassword({ token, newPassword: data.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when resetting password fails and no backend message is available",
|
||||
message: "Failed to reset your password. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +91,7 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>New Password</Trans>
|
||||
<Trans comment="Label for new password input on reset-password form">New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
@@ -99,7 +106,22 @@ function RouteComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in reset-password form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in reset-password form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -109,7 +131,7 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Reset Password</Trans>
|
||||
<Trans comment="Primary action button label on reset-password form">Reset Password</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z
|
||||
@@ -75,7 +76,16 @@ function RouteComponent() {
|
||||
toast.dismiss(toastId);
|
||||
form.setError("password", { message: t`The password you entered is incorrect` });
|
||||
} else {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resume password verification fails unexpectedly",
|
||||
message: "Failed to verify the password. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -102,7 +112,7 @@ function RouteComponent() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
<Trans comment="Label for password input on protected resume access form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
@@ -117,7 +127,22 @@ function RouteComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password on protected resume screen",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password on protected resume screen",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -128,7 +153,7 @@ function RouteComponent() {
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<LockOpenIcon />
|
||||
<Trans>Unlock</Trans>
|
||||
<Trans comment="Primary action button label to unlock a password-protected resume">Unlock</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -43,7 +43,14 @@ function RouteComponent() {
|
||||
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
|
||||
message: "Failed to verify your backup code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,14 +93,14 @@ function RouteComponent() {
|
||||
render={
|
||||
<Link to="/auth/verify-2fa">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans>Verify</Trans>
|
||||
<Trans comment="Primary action button to submit backup code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -44,7 +44,14 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a two-factor authentication code fails",
|
||||
message: "Failed to verify your code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,14 +102,14 @@ function RouteComponent() {
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Back to Login</Trans>
|
||||
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans>Verify</Trans>
|
||||
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -113,7 +120,9 @@ function RouteComponent() {
|
||||
className="h-auto justify-self-center p-0 text-sm"
|
||||
render={
|
||||
<Link to="/auth/verify-2fa-backup">
|
||||
<Trans>Lost access to your authenticator?</Trans>
|
||||
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
|
||||
Lost access to your authenticator?
|
||||
</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getResumeErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { useBuilderSidebar } from "../-store/sidebar";
|
||||
|
||||
@@ -38,12 +39,21 @@ export function BuilderHeader() {
|
||||
<div className="absolute inset-x-0 top-0 z-10 flex h-14 items-center justify-between border-b bg-popover px-1.5">
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("left")}>
|
||||
<SidebarSimpleIcon />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the left sidebar in resume builder">
|
||||
Toggle left sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for button navigating from builder to resumes dashboard",
|
||||
message: "Go to resumes dashboard",
|
||||
})}
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
|
||||
@@ -59,6 +69,11 @@ export function BuilderHeader() {
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("right")}>
|
||||
<SidebarSimpleIcon className="-scale-x-100" />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the right sidebar in resume builder">
|
||||
Toggle right sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -99,7 +114,7 @@ function BuilderHeaderDropdown() {
|
||||
{ id, isLocked: !isLocked },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -122,7 +137,7 @@ function BuilderHeaderDropdown() {
|
||||
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type z from "zod";
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, LinkIcon, ListPlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
@@ -119,7 +120,10 @@ export function CustomFieldsSection({ onSubmit }: Props) {
|
||||
type="url"
|
||||
value={field.value}
|
||||
id={`customFields.${index}.link`}
|
||||
placeholder="Must start with https://"
|
||||
placeholder={t({
|
||||
comment: "Placeholder text for custom link URL field in resume builder",
|
||||
message: "Must start with https://",
|
||||
})}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ColumnsIcon,
|
||||
@@ -46,9 +47,18 @@ function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): str
|
||||
.with("summary", () => {
|
||||
if ("content" in item) {
|
||||
const stripped = stripHtml(item.content);
|
||||
return stripped.length > 50 ? `${stripped.slice(0, 50)}...` : stripped || "Summary";
|
||||
return stripped.length > 50
|
||||
? `${stripped.slice(0, 50)}...`
|
||||
: stripped ||
|
||||
t({
|
||||
comment: "Fallback title for a custom summary item in resume builder when content is empty",
|
||||
message: "Summary",
|
||||
});
|
||||
}
|
||||
return "Summary";
|
||||
return t({
|
||||
comment: "Fallback title for a custom summary item in resume builder when content is unavailable",
|
||||
message: "Summary",
|
||||
});
|
||||
})
|
||||
.with("profiles", () => ("network" in item ? item.network : ""))
|
||||
.with("experience", () => ("company" in item ? item.company : ""))
|
||||
@@ -65,9 +75,18 @@ function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): str
|
||||
.with("cover-letter", () => {
|
||||
if ("recipient" in item) {
|
||||
const stripped = stripHtml(item.recipient);
|
||||
return stripped.length > 50 ? `${stripped.slice(0, 50)}...` : stripped || "Cover Letter";
|
||||
return stripped.length > 50
|
||||
? `${stripped.slice(0, 50)}...`
|
||||
: stripped ||
|
||||
t({
|
||||
comment: "Fallback title for a custom cover letter item in resume builder when recipient is empty",
|
||||
message: "Cover Letter",
|
||||
});
|
||||
}
|
||||
return "Cover Letter";
|
||||
return t({
|
||||
comment: "Fallback title for a custom cover letter item in resume builder when recipient is unavailable",
|
||||
message: "Cover Letter",
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
}
|
||||
@@ -216,9 +235,15 @@ function CustomSectionDropdownMenu({ section }: { section: CustomSection }) {
|
||||
};
|
||||
|
||||
const onDeleteSection = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to delete this custom section?", {
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this custom section?`, {
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when deleting a custom section in resume builder",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort deleting a custom section in resume builder",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { pictureSchema } from "@/schema/resume/data";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -59,9 +60,10 @@ function PictureSectionForm() {
|
||||
if (!picture.url) return;
|
||||
|
||||
const appOrigin = window.location.origin;
|
||||
const pictureOrigin = new URL(picture.url).origin;
|
||||
const pictureUrl = new URL(picture.url, appOrigin);
|
||||
const pictureOrigin = pictureUrl.origin;
|
||||
|
||||
const filename = picture.url.split("/").pop();
|
||||
const filename = pictureUrl.pathname.split("/").pop();
|
||||
if (!filename) return;
|
||||
|
||||
// If the picture is from the same origin, attempt to delete it
|
||||
@@ -85,7 +87,16 @@ function PictureSectionForm() {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when uploading profile picture for resume fails",
|
||||
message: "Failed to upload picture. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -96,8 +107,10 @@ function PictureSectionForm() {
|
||||
<div className="flex items-center gap-x-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={picture.url ? onDeletePicture : onSelectPicture}
|
||||
aria-label={picture.url ? t`Delete picture` : t`Upload picture`}
|
||||
className="group/picture relative size-18 cursor-pointer overflow-hidden rounded-md bg-secondary transition-colors hover:bg-secondary/50"
|
||||
>
|
||||
{picture.url && (
|
||||
@@ -111,7 +124,7 @@ function PictureSectionForm() {
|
||||
<div className="absolute inset-0 z-0 flex size-full items-center justify-center">
|
||||
{picture.url ? <TrashSimpleIcon className="size-6" /> : <UploadSimpleIcon className="size-6" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -122,7 +135,7 @@ function PictureSectionForm() {
|
||||
<Trans>URL</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormControl render={<Input {...field} readOnly />} />
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -235,7 +248,10 @@ function PictureSectionForm() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Square`}
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to square",
|
||||
message: "Square",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.onChange(1);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
@@ -246,7 +262,10 @@ function PictureSectionForm() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Landscape`}
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to landscape orientation",
|
||||
message: "Landscape",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.onChange(1.5);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
@@ -257,7 +276,10 @@ function PictureSectionForm() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Portrait`}
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to portrait orientation",
|
||||
message: "Portrait",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.onChange(0.5);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowBendUpRightIcon,
|
||||
@@ -210,9 +211,15 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to delete this item?", {
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this item?`, {
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when deleting a section item in resume builder",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort deleting a section item in resume builder",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -88,10 +88,16 @@ export function SectionDropdownMenu({ type }: Props) {
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to reset this section?", {
|
||||
description: "This will remove all items from this section.",
|
||||
confirmText: "Reset",
|
||||
cancelText: "Cancel",
|
||||
const confirmed = await confirm(t`Are you sure you want to reset this section?`, {
|
||||
description: t`This will remove all items from this section.`,
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when resetting a resume section",
|
||||
message: "Reset",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort resetting a resume section",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { type CSSProperties, forwardRef, type HTMLAttributes, useCallback, useState } from "react";
|
||||
import { type CSSProperties, forwardRef, type HTMLAttributes, useCallback, useId, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
import type { SectionType } from "@/schema/resume/data";
|
||||
@@ -30,8 +30,18 @@ type ColumnId = "main" | "sidebar";
|
||||
|
||||
const getColumnLabel = (columnId: ColumnId): string => {
|
||||
return match(columnId)
|
||||
.with("main", () => t`Main`)
|
||||
.with("sidebar", () => t`Sidebar`)
|
||||
.with("main", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the primary content area",
|
||||
message: "Main",
|
||||
}),
|
||||
)
|
||||
.with("sidebar", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the secondary sidebar area",
|
||||
message: "Sidebar",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@@ -257,20 +267,25 @@ function PageContainer({
|
||||
onToggleFullWidth,
|
||||
}: PageContainerProps) {
|
||||
const isFullWidth = page.fullWidth;
|
||||
const fullWidthSwitchId = useId();
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-dashed bg-background/40">
|
||||
<div className="flex items-center justify-between bg-secondary/50 px-4 py-3">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<span className="text-xs font-medium">
|
||||
<Trans>Page {pageIndex + 1}</Trans>
|
||||
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
|
||||
</span>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Switch checked={page.fullWidth} onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)} />
|
||||
<label htmlFor={fullWidthSwitchId} className="flex cursor-pointer items-center gap-2">
|
||||
<Switch
|
||||
id={fullWidthSwitchId}
|
||||
checked={page.fullWidth}
|
||||
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
|
||||
/>
|
||||
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Full Width</Trans>
|
||||
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
@@ -24,6 +25,29 @@ function impactCircleClass(impact: "high" | "medium" | "low") {
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function impactLabel(impact: "high" | "medium" | "low") {
|
||||
return match(impact)
|
||||
.with("high", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "High",
|
||||
}),
|
||||
)
|
||||
.with("medium", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Medium",
|
||||
}),
|
||||
)
|
||||
.with("low", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Low",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function ResumeAnalysisSectionBuilder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -43,7 +67,24 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
toast.success(t`Resume analysis complete.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to analyze resume.`, { description: error.message });
|
||||
toast.error(t`Failed to analyze resume.`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error description when AI returns invalid resume analysis format",
|
||||
message: "The AI returned an invalid analysis format. Please try again.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error description when AI provider cannot be reached during resume analysis",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when resume analysis request fails",
|
||||
message: "Something went wrong while analyzing your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -179,9 +220,10 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
<div key={`${suggestion.title}-${index}`} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
className={`size-2.5 shrink-0 rounded-full ring-1 ring-border ${impactCircleClass(suggestion.impact)}`}
|
||||
title={suggestion.impact}
|
||||
aria-label={suggestion.impact}
|
||||
title={impactLabel(suggestion.impact)}
|
||||
aria-label={impactLabel(suggestion.impact)}
|
||||
/>
|
||||
<div className="text-sm font-semibold tracking-tight">{suggestion.title}</div>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,9 @@ export function SharingSectionBuilder() {
|
||||
{resume.isPublic && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sharing-url">URL</Label>
|
||||
<Label htmlFor="sharing-url">
|
||||
<Trans comment="Form field label for the generated public resume link in sharing settings">URL</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly id="sharing-url" value={publicUrl} />
|
||||
|
||||
@@ -106,17 +106,27 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
disabled={!hasApplyLink}
|
||||
render={
|
||||
<a href={hasApplyLink ? job.job_apply_link : "#"} target="_blank" rel="noopener noreferrer" />
|
||||
}
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
{hasApplyLink ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href={job.job_apply_link} target="_blank" rel="noopener noreferrer">
|
||||
<span className="sr-only">
|
||||
<Trans>Apply for this job</Trans>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1" disabled>
|
||||
<ArrowSquareOutIcon />
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="flex-1" onClick={() => setTailorOpen(true)}>
|
||||
<StarIcon />
|
||||
@@ -211,7 +221,11 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
|
||||
{option.publisher || t`Apply Link`}
|
||||
{option.publisher ||
|
||||
t({
|
||||
comment: "Fallback publisher name for a third-party job application link",
|
||||
message: "Apply Link",
|
||||
})}
|
||||
{option.is_direct && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Trans>Direct</Trans>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { plural, t } from "@lingui/core/macro";
|
||||
|
||||
import type { RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
@@ -13,7 +13,7 @@ export function formatSalary(
|
||||
const formatCurrency = (amount: number) => {
|
||||
const resolvedCurrency = currency ?? "USD";
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: resolvedCurrency,
|
||||
maximumFractionDigits: 0,
|
||||
@@ -23,10 +23,15 @@ export function formatSalary(
|
||||
}
|
||||
};
|
||||
|
||||
if (min && max) return `${formatCurrency(min)} - ${formatCurrency(max)}${period ? ` / ${period}` : ""}`;
|
||||
if (min) return `${formatCurrency(min)}+${period ? ` / ${period}` : ""}`;
|
||||
const periodSuffix = period ? ` / ${period}` : "";
|
||||
|
||||
if (min && max) return `${formatCurrency(min)} - ${formatCurrency(max)}${periodSuffix}`;
|
||||
if (min) return `${formatCurrency(min)}+${periodSuffix}`;
|
||||
if (!max) return "";
|
||||
return `Up to ${formatCurrency(max)}${period ? ` / ${period}` : ""}`;
|
||||
return t({
|
||||
comment: "Salary label when only an upper salary bound is available",
|
||||
message: `Up to ${formatCurrency(max)}${periodSuffix}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPostedDate(timestamp: number | null): string {
|
||||
@@ -38,9 +43,26 @@ export function formatPostedDate(timestamp: number | null): string {
|
||||
|
||||
if (diffDays <= 0) return t`Today`;
|
||||
if (diffDays === 1) return t`Yesterday`;
|
||||
if (diffDays < 7) return t`${diffDays} days ago`;
|
||||
if (diffDays < 30) return t`${Math.floor(diffDays / 7)} weeks ago`;
|
||||
return t`${Math.floor(diffDays / 30)} months ago`;
|
||||
if (diffDays < 7) {
|
||||
return plural(diffDays, {
|
||||
one: "# day ago",
|
||||
other: "# days ago",
|
||||
});
|
||||
}
|
||||
|
||||
if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return plural(weeks, {
|
||||
one: "# week ago",
|
||||
other: "# weeks ago",
|
||||
});
|
||||
}
|
||||
|
||||
const months = Math.floor(diffDays / 30);
|
||||
return plural(months, {
|
||||
one: "# month ago",
|
||||
other: "# months ago",
|
||||
});
|
||||
}
|
||||
|
||||
export function getQuotaStatus(quota: RapidApiQuota): "healthy" | "warning" | "critical" {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { client, orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
import { buildSkillSyncOperations, tailorOutputToPatches, validateTailorOutput } from "@/utils/resume/tailor";
|
||||
import { slugify } from "@/utils/string";
|
||||
|
||||
@@ -77,7 +78,20 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
{
|
||||
onSuccess: (newResumeId) => navigateToBuilder(newResumeId),
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to duplicate resume`, { description: error.message });
|
||||
toast.error(t`Failed to duplicate resume`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
RESUME_SLUG_ALREADY_EXISTS: t({
|
||||
comment: "Error description when generated slug for duplicated tailored resume already exists",
|
||||
message: "A resume with this slug already exists. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when duplicating a resume for tailoring fails",
|
||||
message: "Could not duplicate your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -138,8 +152,28 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
navigateToBuilder(newResumeId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(t`Tailoring failed`, { description: message });
|
||||
toast.error(t`Tailoring failed`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error description when AI tailoring output is invalid",
|
||||
message: "The AI returned invalid tailoring data. Please try again.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error description when AI provider is unreachable during resume tailoring",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
INVALID_PATCH_OPERATIONS: t({
|
||||
comment: "Error description when generated patch operations cannot be applied to tailored resume",
|
||||
message: "Generated resume changes were invalid. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when tailoring pipeline fails",
|
||||
message: "Something went wrong while tailoring your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
setPhase({ step: "select" });
|
||||
}
|
||||
};
|
||||
@@ -156,8 +190,21 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
await client.resume.patch({ id: sourceResumeId, operations });
|
||||
toast.success(t`Added ${skillsToSync.length} new skills to your original resume`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
toast.error(t`Failed to sync skills`, { description: message });
|
||||
toast.error(t`Failed to sync skills`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
INVALID_PATCH_OPERATIONS: t({
|
||||
comment:
|
||||
"Error description when syncing newly detected skills to source resume fails due to invalid patch",
|
||||
message: "Could not apply skill updates to your original resume.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when syncing newly detected skills fails",
|
||||
message: "Something went wrong while syncing skills.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -195,9 +242,9 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Select a resume to tailor for "{job.job_title}" at {job.employer_name}. A copy will be created
|
||||
{aiEnabled ? " and the AI will optimize it for this position." : "."}
|
||||
Select a resume to tailor for "{job.job_title}" at {job.employer_name}. A copy will be created.
|
||||
</Trans>
|
||||
{aiEnabled && <Trans>The AI will optimize it for this position.</Trans>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -315,7 +362,9 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button onClick={handleSkillSync}>
|
||||
<Trans>Save {selectedSkills.size} Skills</Trans>
|
||||
<Trans comment="Button label to save the selected number of detected skills to the original resume">
|
||||
Save {selectedSkills.size} Skills
|
||||
</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { JobResult, RapidApiQuota } from "@/schema/jobs";
|
||||
|
||||
import { useJobsStore } from "@/integrations/jobs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getOrpcErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import {
|
||||
buildPostFilters,
|
||||
@@ -71,8 +72,21 @@ export function useJobSearch() {
|
||||
},
|
||||
onError: (error) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setError(error.message);
|
||||
toast.error(error.message);
|
||||
const message = getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error shown when job search API is unavailable while searching jobs",
|
||||
message: "Could not fetch jobs from JSearch API. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error shown when job search request fails",
|
||||
message: "Failed to search jobs. Please try again.",
|
||||
}),
|
||||
});
|
||||
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -96,11 +96,17 @@ function RouteComponent() {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t`e.g. frontend developer jobs in Berlin`}
|
||||
placeholder={t({
|
||||
comment: "Example placeholder text in job search input field",
|
||||
message: "e.g. frontend developer jobs in Berlin",
|
||||
})}
|
||||
aria-label={t({
|
||||
comment: "Accessible label for free-text job search query field",
|
||||
message: "Job search query",
|
||||
})}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import { getResumeErrorMessage } from "@/utils/error-message";
|
||||
|
||||
type Props = {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
@@ -56,7 +57,7 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
{ id: resume.id, isLocked: !resume.isLocked },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -78,7 +79,7 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -93,7 +94,7 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
render={
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
|
||||
<FolderOpenIcon />
|
||||
<Trans>Open</Trans>
|
||||
<Trans comment="Resume card context menu action to open the resume editor">Open</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -102,24 +103,28 @@ export function ResumeContextMenu({ resume, children }: Props) {
|
||||
|
||||
<ContextMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
<Trans comment="Resume card context menu action to edit resume metadata">Update</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
<Trans comment="Resume card context menu action to create a copy">Duplicate</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onClick={handleToggleLock}>
|
||||
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
|
||||
{resume.isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
{resume.isLocked ? (
|
||||
<Trans comment="Resume card context menu action to remove edit lock">Unlock</Trans>
|
||||
) : (
|
||||
<Trans comment="Resume card context menu action to prevent edits">Lock</Trans>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
<Trans comment="Resume card context menu destructive action to remove a resume">Delete</Trans>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import { getResumeErrorMessage } from "@/utils/error-message";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children"> & {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
@@ -56,7 +57,7 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
{ id: resume.id, isLocked: !resume.isLocked },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -78,7 +79,7 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -92,7 +93,7 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
|
||||
<DropdownMenuItem>
|
||||
<FolderOpenIcon />
|
||||
<Trans>Open</Trans>
|
||||
<Trans comment="Resume card dropdown action to open the resume editor">Open</Trans>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
|
||||
@@ -100,24 +101,28 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
|
||||
<DropdownMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
<Trans comment="Resume card dropdown action to edit resume metadata">Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
<Trans comment="Resume card dropdown action to create a copy">Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleToggleLock}>
|
||||
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
|
||||
{resume.isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
{resume.isLocked ? (
|
||||
<Trans comment="Resume card dropdown action to remove edit lock">Unlock</Trans>
|
||||
) : (
|
||||
<Trans comment="Resume card dropdown action to prevent edits">Lock</Trans>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
<Trans comment="Resume card dropdown destructive action to remove a resume">Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type AIProvider, 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";
|
||||
@@ -28,31 +29,46 @@ export const Route = createFileRoute("/dashboard/settings/ai")({
|
||||
const providerOptions: (ComboboxOption<AIProvider> & { defaultBaseURL: string })[] = [
|
||||
{
|
||||
value: "openai",
|
||||
label: "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: "ollama",
|
||||
label: "Ollama",
|
||||
label: t({
|
||||
comment: "AI provider option label in dashboard AI settings",
|
||||
message: "Ollama",
|
||||
}),
|
||||
keywords: ["ollama", "ai", "local"],
|
||||
defaultBaseURL: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
label: "Anthropic Claude",
|
||||
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: "vercel-ai-gateway",
|
||||
label: "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: "gemini",
|
||||
label: "Google 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",
|
||||
},
|
||||
@@ -106,7 +122,24 @@ function AIForm() {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
|
||||
toast.error(error.message);
|
||||
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.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -138,7 +171,10 @@ function AIForm() {
|
||||
value={model}
|
||||
disabled={enabled}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
placeholder="e.g., gpt-4, claude-3-opus, gemini-pro"
|
||||
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"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
@@ -38,8 +39,14 @@ function RouteComponent() {
|
||||
const onDelete = async (id: string) => {
|
||||
const confirmation = await confirm(t`Are you sure you want to delete this API key?`, {
|
||||
description: t`The API key will no longer be able to access your data after deletion. This action cannot be undone.`,
|
||||
confirmText: t`Delete`,
|
||||
cancelText: t`Cancel`,
|
||||
confirmText: t({
|
||||
comment: "API key deletion confirmation dialog confirm action in settings",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "API key deletion confirmation dialog cancel action in settings",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
@@ -49,7 +56,16 @@ function RouteComponent() {
|
||||
const { error } = await authClient.apiKey.delete({ keyId: id });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when deleting an API key fails",
|
||||
message: "Failed to delete the API key. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,18 +18,49 @@ import type { AuthProvider } from "@/integrations/auth/types";
|
||||
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
/**
|
||||
* Get the display name for a social provider
|
||||
*/
|
||||
export function getProviderName(providerId: AuthProvider): string {
|
||||
return match(providerId)
|
||||
.with("credential", () => "Password")
|
||||
.with("passkey", () => "Passkey")
|
||||
.with("google", () => "Google")
|
||||
.with("github", () => "GitHub")
|
||||
.with("linkedin", () => "LinkedIn")
|
||||
.with("custom", () => "Custom OAuth")
|
||||
.with("credential", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Password",
|
||||
}),
|
||||
)
|
||||
.with("passkey", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Passkey",
|
||||
}),
|
||||
)
|
||||
.with("google", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Google",
|
||||
}),
|
||||
)
|
||||
.with("github", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "GitHub",
|
||||
}),
|
||||
)
|
||||
.with("linkedin", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "LinkedIn",
|
||||
}),
|
||||
)
|
||||
.with("custom", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Custom OAuth",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
@@ -85,7 +116,16 @@ export function useAuthProviderActions() {
|
||||
const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/settings/authentication" });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when linking a social authentication provider fails",
|
||||
message: "Failed to link provider. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,7 +139,16 @@ export function useAuthProviderActions() {
|
||||
const { error } = await authClient.unlinkAccount({ providerId: provider, accountId });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when unlinking a social authentication provider fails",
|
||||
message: "Failed to unlink provider. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
export function PasskeysSection() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -26,7 +27,15 @@ export function PasskeysSection() {
|
||||
},
|
||||
onSuccess: async ({ data, error }) => {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when passkey registration fails",
|
||||
message: "Failed to register passkey. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +45,10 @@ export function PasskeysSection() {
|
||||
const name = await prompt(t`Enter a name for your passkey.`, {
|
||||
description: t`This will help you identify it later, if you plan to have multiple passkeys.`,
|
||||
defaultValue: "",
|
||||
confirmText: t`Save`,
|
||||
confirmText: t({
|
||||
comment: "Passkey rename prompt confirm action in authentication settings",
|
||||
message: "Save",
|
||||
}),
|
||||
});
|
||||
if (name === null) return;
|
||||
|
||||
@@ -46,7 +58,15 @@ export function PasskeysSection() {
|
||||
|
||||
const { error: renameError } = await authClient.passkey.updatePasskey({ id: passkeyId, name: passkeyName });
|
||||
if (renameError) {
|
||||
toast.error(renameError.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
renameError,
|
||||
t({
|
||||
comment: "Fallback toast when renaming a passkey fails",
|
||||
message: "Failed to rename passkey. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,7 +83,15 @@ export function PasskeysSection() {
|
||||
},
|
||||
onSuccess: async ({ error }) => {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when deleting a passkey fails",
|
||||
message: "Failed to delete passkey. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,7 +159,7 @@ export function PasskeysSection() {
|
||||
disabled={deletePasskeyMutation.isPending}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
<Trans comment="Passkey row action to remove the selected passkey">Delete</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,9 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
>
|
||||
<Button variant="outline" onClick={handleUnlink}>
|
||||
<LinkBreakIcon />
|
||||
<Trans>Disconnect</Trans>
|
||||
<Trans comment="Authentication settings action to unlink a connected social login provider">
|
||||
Disconnect
|
||||
</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
@@ -74,7 +76,7 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
>
|
||||
<Button variant="outline" onClick={handleLink}>
|
||||
<LinkIcon />
|
||||
<Trans>Connect</Trans>
|
||||
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
@@ -33,8 +34,14 @@ function RouteComponent() {
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
|
||||
description: t`This action cannot be undone. All your data will be permanently deleted.`,
|
||||
confirmText: t`Confirm`,
|
||||
cancelText: t`Cancel`,
|
||||
confirmText: t({
|
||||
comment: "Account deletion confirmation dialog confirm action in danger zone",
|
||||
message: "Confirm",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Account deletion confirmation dialog cancel action in danger zone",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
@@ -48,7 +55,16 @@ function RouteComponent() {
|
||||
void navigate({ to: "/" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when account deletion fails",
|
||||
message: "Failed to delete your account. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
|
||||
@@ -50,7 +51,20 @@ function RapidAPIKeyForm() {
|
||||
draft.rapidApiQuota = null;
|
||||
});
|
||||
|
||||
toast.error(error.message);
|
||||
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.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { getReadableErrorMessage } from "@/utils/error-message";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
@@ -66,7 +67,15 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when updating profile details fails",
|
||||
message: "Failed to update your profile. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,7 +90,15 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when requesting email change confirmation fails",
|
||||
message: "Failed to request email change. Please try again.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,7 +119,16 @@ function RouteComponent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resending account verification email fails",
|
||||
message: "Failed to resend verification email. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +162,18 @@ function RouteComponent() {
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={<Input min={3} max={64} autoComplete="name" placeholder="John Doe" {...field} />}
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on profile settings form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -157,7 +194,10 @@ function RouteComponent() {
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="username"
|
||||
placeholder="john.doe"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on profile settings form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -181,7 +221,10 @@ function RouteComponent() {
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="john.doe@example.com"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on profile settings form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
@@ -224,7 +267,7 @@ function RouteComponent() {
|
||||
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
|
||||
>
|
||||
<Button type="reset" variant="ghost" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
<Trans comment="Profile settings form action to discard unsaved edits">Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
|
||||
Reference in New Issue
Block a user