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:
Amruth Pillai
2026-04-25 15:31:13 +02:00
parent a42dbcd452
commit 08e9c80037
56 changed files with 1271 additions and 391 deletions
+40 -7
View File
@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useRef } from "react";
import { useHotkeys } from "react-hotkeys-hook";
@@ -81,33 +82,65 @@ export function CommandPalette() {
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogHeader className="sr-only print:hidden">
<DialogTitle>
<Trans>Builder Command Palette</Trans>
<Trans comment="Screen-reader dialog title for the command palette in the resume builder">
Builder Command Palette
</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Type a command or search...</Trans>
<Trans comment="Screen-reader dialog description instructing users how to use the command palette">
Type a command or search...
</Trans>
</DialogDescription>
</DialogHeader>
<DialogContent
className="overflow-hidden p-0"
aria-label={isFirstPage ? "Command Palette" : `Command Palette - ${currentPage}`}
aria-label={
isFirstPage
? t({
comment: "Accessible label for the command palette dialog",
message: "Command Palette",
})
: t({
comment: "Accessible label for command palette dialog when browsing a nested command page",
message: `Command Palette - ${currentPage}`,
})
}
>
<Command
loop
aria-label="Command Palette"
aria-label={t({
comment: "Accessible label for command list region inside command palette",
message: "Command Palette",
})}
className="[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
>
<CommandInput
ref={inputRef}
value={search}
onValueChange={handleSearchChange}
placeholder={isFirstPage ? "Type a command or search..." : "Search..."}
aria-label="Search commands"
placeholder={
isFirstPage
? t({
comment: "Placeholder in command palette input on root page",
message: "Type a command or search...",
})
: t({
comment: "Placeholder in command palette input on nested pages",
message: "Search...",
})
}
aria-label={t({
comment: "Accessible label for command palette search input",
message: "Search commands",
})}
/>
<CommandList>
<CommandEmpty>
<Trans>The command you're looking for doesn't exist.</Trans>
<Trans comment="Empty-state message when no command palette results match the search query">
The command you're looking for doesn't exist.
</Trans>
</CommandEmpty>
<ResumesCommandGroup />
@@ -73,7 +73,9 @@ export function ResumesCommandGroup() {
{resume.name}
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
Press <Kbd>Enter</Kbd> to open
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
Press <Kbd>Enter</Kbd> to open
</Trans>
</CommandShortcut>
</CommandItem>
))
+14 -4
View File
@@ -14,6 +14,7 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
@@ -78,7 +79,11 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
<button
type="button"
tabIndex={-1}
aria-label={`Edit ${chip}`}
aria-label={t({
comment:
"Screen reader label for button that edits a keyword chip. Variable is the current keyword text.",
message: `Edit ${chip}`,
})}
onClick={(e) => {
e.stopPropagation();
onEdit(index);
@@ -90,7 +95,11 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
<button
type="button"
tabIndex={-1}
aria-label={`Remove ${chip}`}
aria-label={t({
comment:
"Screen reader label for button that removes a keyword chip. Variable is the current keyword text.",
message: `Remove ${chip}`,
})}
onClick={(e) => {
e.stopPropagation();
onRemove(index);
@@ -122,6 +131,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
const [input, setInput] = React.useState("");
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const isEditingKeyword = editingIndex !== null;
const addChip = React.useCallback(
(chip: string) => {
@@ -289,8 +299,8 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
type="text"
value={input}
autoComplete="off"
aria-label={editingIndex !== null ? "Edit keyword" : "Add keyword"}
placeholder={editingIndex !== null ? "Editing keyword..." : "Add a keyword..."}
aria-label={isEditingKeyword ? t`Edit keyword` : t`Add keyword`}
placeholder={isEditingKeyword ? t`Editing keyword...` : t`Add a keyword...`}
onKeyDown={handleKeyDown}
onChange={handleInputChange}
/>
+8 -3
View File
@@ -24,12 +24,17 @@ type IconSearchInputProps = {
function _IconSearchInput(props: IconSearchInputProps) {
return (
<Input
autoFocus
spellCheck={false}
inputMode="search"
value={props.value}
aria-label={t`Search for an icon`}
placeholder={t`Search for an icon`}
aria-label={t({
comment: "Accessible label for icon picker search input",
message: "Search for an icon",
})}
placeholder={t({
comment: "Placeholder text in icon picker search input",
message: "Search for an icon",
})}
onChange={(e) => props.onChange(e.currentTarget.value)}
className={cn("rounded-none border-0 focus-visible:ring-0", props.className)}
/>
+10 -2
View File
@@ -172,8 +172,16 @@ export function RichInput({ value, onChange, style, className, editorClassName,
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent className="flex h-[95svh] max-h-none! w-[95svw] max-w-none! flex-col p-4 sm:max-w-none! 2xl:max-w-none!">
<div className="sr-only">
<DialogTitle>Fullscreen Editor</DialogTitle>
<DialogDescription>Edit content in fullscreen mode</DialogDescription>
<DialogTitle>
<Trans comment="Screen reader title for the fullscreen rich-text editor dialog">
Fullscreen Editor
</Trans>
</DialogTitle>
<DialogDescription>
<Trans comment="Screen reader description for the fullscreen rich-text editor dialog">
Edit content in fullscreen mode
</Trans>
</DialogDescription>
</div>
{editorElement}
</DialogContent>
+9 -3
View File
@@ -69,16 +69,22 @@ export function URLInput({ value, onChange, hideLabelButton, ...props }: Props)
<Popover>
<PopoverTrigger
render={
<InputGroupButton size="icon-sm" title={t`Add a label to the URL`}>
<InputGroupButton
size="icon-sm"
title={t({
comment: "Tooltip for action button that opens URL label editor",
message: "Add a label to the URL",
})}
>
<TagIcon />
</InputGroupButton>
}
/>
<PopoverContent className="pt-3">
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
<div role="presentation" className="grid gap-2" onMouseDown={(e) => e.stopPropagation()}>
<Label htmlFor="url-label">
<Trans>Label</Trans>
<Trans comment="Short field label for custom display text associated with a URL">Label</Trans>
</Label>
<Input id="url-label" name="url-label" value={value.label} onChange={handleLabelChange} />
</div>
+5 -2
View File
@@ -16,8 +16,11 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
return (
<div
role="presentation"
aria-label={t`Level ${level} of 5`}
role="img"
aria-label={t({
comment: "Accessible label for skill/proficiency level indicator, where level is current value out of 5",
message: `Level ${level} of 5`,
})}
className={cn(
"flex items-center gap-x-1.5",
type === "progress-bar" && "gap-x-0",
+8 -2
View File
@@ -37,14 +37,20 @@ export function useWebfonts(typography: z.infer<typeof typographySchema>) {
for (const { url, weight, style } of fontUrls) {
const fontFace = new FontFace(family, `url("${url}")`, { style, weight, display: "swap" });
if (!document.fonts.has(fontFace)) document.fonts.add(await fontFace.load());
if (!document.fonts.has(fontFace)) {
try {
document.fonts.add(await fontFace.load());
} catch {
// Fail open for printer/headless environments where remote fonts may be blocked by CSP.
}
}
}
}
const bodyTypography = typography.body;
const headingTypography = typography.heading;
void Promise.all([
void Promise.allSettled([
loadFont(bodyTypography.fontFamily, bodyTypography.fontWeights),
loadFont(headingTypography.fontFamily, headingTypography.fontWeights),
]).then(() => {
+33 -27
View File
@@ -36,8 +36,29 @@ export type ExtendedIconProps = IconProps & {
hidden?: boolean;
};
const CSS_RULE_SPLIT_PATTERN = /\n(?=\s*[.#a-zA-Z])/;
const CSS_SELECTOR_PATTERN = /^([^{]+)(\{)/;
function scopeCustomCssSelectors(css: string): string {
// keep @keyframes blocks unchanged, scope the remaining rule selectors
const keyframes: string[] = [];
const withoutKeyframes = css.replace(/@(-webkit-)?keyframes\s+[^{]+\{[\s\S]*?\}\s*\}/gi, (block) => {
keyframes.push(block);
return `__RR_KEYFRAMES_${keyframes.length - 1}__`;
});
const scoped = withoutKeyframes.replace(/(^|})\s*([^@{}][^{]+)\{/g, (_match, prefix, rawSelectors) => {
const selectors = rawSelectors
.split(",")
.map((selector: string) => selector.trim())
.filter(Boolean)
.map((selector: string) => `.resume-preview-container ${selector}`)
.join(", ");
if (!selectors) return `${prefix}${rawSelectors}{`;
return `${prefix} ${selectors}{`;
});
const restored = scoped.replace(/__RR_KEYFRAMES_(\d+)__/g, (_match, index) => keyframes[Number(index)] ?? "");
return restored;
}
function getTemplateComponent(template: Template) {
return match(template)
@@ -82,24 +103,7 @@ export const ResumePreview = ({ showPageNumbers = false, pageClassName, classNam
if (!metadata.css.enabled || !metadata.css.value.trim()) return null;
const sanitizedCss = sanitizeCss(metadata.css.value);
const scoped = sanitizedCss
.split(CSS_RULE_SPLIT_PATTERN)
.map((rule) => {
const trimmed = rule.trim();
if (!trimmed || trimmed.startsWith("@")) return trimmed;
return trimmed.replace(CSS_SELECTOR_PATTERN, (_match, selectors, brace) => {
const prefixed = selectors
.split(",")
.map((selector: string) => `.resume-preview-container ${selector.trim()} `)
.join(", ");
return `${prefixed}${brace}`;
});
})
.join("\n");
return scoped;
return scopeCustomCssSelectors(sanitizedCss);
}, [metadata.css.enabled, metadata.css.value]);
return (
@@ -134,10 +138,10 @@ function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers =
const metadata = useResumeStore((state) => state.resume.data.metadata);
const pageNumber = useMemo(() => pageIndex + 1, [pageIndex]);
const maxPageHeight = useMemo(() => pageDimensionsAsPixels[metadata.page.format].height, [metadata.page.format]);
const totalNumberOfPages = useMemo(() => metadata.layout.pages.length, [metadata.layout.pages]);
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
const pageNumber = pageIndex + 1;
const maxPageHeight = pageDimensionsAsPixels[metadata.page.format].height;
const totalNumberOfPages = metadata.layout.pages.length;
const TemplateComponent = getTemplateComponent(metadata.template);
useResizeObserver({
ref: pageRef as RefObject<HTMLDivElement>,
@@ -152,7 +156,7 @@ function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers =
{showPageNumbers && totalNumberOfPages > 1 && (
<div className="absolute inset-s-0 -top-6 print:hidden">
<span className="text-xs font-medium text-foreground">
<Trans>
<Trans comment="Page counter label shown above resume preview pages">
Page {pageNumber} of {totalNumberOfPages}
</Trans>
</span>
@@ -174,12 +178,14 @@ function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers =
<Alert className="max-w-sm text-yellow-600">
<WarningIcon color="currentColor" />
<AlertTitle>
<Trans>
<Trans comment="Warning shown when resume content exceeds printable page height">
The content is too tall for this page, this may cause undesirable results when exporting to PDF.
</Trans>
</AlertTitle>
<AlertDescription className="text-xs underline-offset-2 group-hover/link:underline">
<Trans>Learn more about how to fit content on a page</Trans>
<Trans comment="Help link text to documentation about fitting resume content onto a page">
Learn more about how to fit content on a page
</Trans>
<ArrowRightIcon color="currentColor" className="ms-1 inline size-3" />
</AlertDescription>
</Alert>
+12 -3
View File
@@ -10,7 +10,12 @@ export function Copyright({ className, ...props }: Props) {
<p>
<Trans>
Licensed under{" "}
<a href="#" target="_blank" rel="noopener" className="font-medium underline underline-offset-2">
<a
href="https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE"
target="_blank"
rel="noopener"
className="font-medium underline underline-offset-2"
>
MIT
</a>
.
@@ -18,7 +23,7 @@ export function Copyright({ className, ...props }: Props) {
</p>
<p>
<Trans>By the community, for the community.</Trans>
<Trans comment="Tagline shown in app footer/about area">By the community, for the community.</Trans>
</p>
<p>
@@ -36,7 +41,11 @@ export function Copyright({ className, ...props }: Props) {
</Trans>
</p>
<p className="mt-4">Reactive Resume v{__APP_VERSION__}</p>
<p className="mt-4">
<Trans comment="App version label in footer; includes semantic version variable">
Reactive Resume v{__APP_VERSION__}
</Trans>
</p>
</div>
);
}
+2 -1
View File
@@ -50,10 +50,11 @@ function InputGroupAddon({
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
onMouseDown={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.preventDefault();
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
+5 -2
View File
@@ -2,16 +2,19 @@ import type * as React from "react";
import { cn } from "@/utils/style";
function Label({ className, ...props }: React.ComponentProps<"label">) {
function Label({ className, children, htmlFor, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
htmlFor={htmlFor}
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
>
{children}
</label>
);
}
+20 -6
View File
@@ -1,5 +1,7 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { SidebarIcon } from "@phosphor-icons/react";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
@@ -176,8 +178,12 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>
<Trans comment="Dialog title for the mobile navigation sidebar panel">Sidebar</Trans>
</SheetTitle>
<SheetDescription>
<Trans comment="Dialog description for the mobile sidebar panel">Displays the mobile sidebar.</Trans>
</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -210,7 +216,7 @@ function Sidebar({
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:-left-(--sidebar-width) data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:-right-(--sidebar-width) md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+2px)]"
@@ -248,7 +254,9 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
{...props}
>
<SidebarIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className="sr-only">
<Trans comment="Screen-reader-only label for button that opens or closes the app sidebar">Toggle Sidebar</Trans>
</span>
</Button>
);
}
@@ -260,10 +268,16 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label={t({
comment: "Accessible label for draggable/clickable sidebar rail that toggles sidebar visibility",
message: "Toggle Sidebar",
})}
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title={t({
comment: "Tooltip text for sidebar rail toggle action",
message: "Toggle Sidebar",
})}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:inset-s-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
+12 -1
View File
@@ -1,9 +1,20 @@
import { t } from "@lingui/core/macro";
import { SpinnerIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return <SpinnerIcon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />;
return (
<SpinnerIcon
role="status"
aria-label={t({
comment: "Accessible label for loading spinner icon",
message: "Loading",
})}
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
}
export { Spinner };
+16 -6
View File
@@ -22,6 +22,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/integrations/auth/client";
import { getReadableErrorMessage } from "@/utils/error-message";
import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/utils/locale";
import { isTheme } from "@/utils/theme";
@@ -56,7 +57,16 @@ export function UserDropdownMenu({ children }: Props) {
void router.invalidate();
},
onError: ({ error }) => {
toast.error(error.message, { id: toastId });
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when signing out fails",
message: "Failed to sign out. Please try again.",
}),
),
{ id: toastId },
);
},
},
});
@@ -73,7 +83,7 @@ export function UserDropdownMenu({ children }: Props) {
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<TranslateIcon />
<Trans>Language</Trans>
<Trans comment="Menu item that opens language selection submenu">Language</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[400px] overflow-y-auto">
<DropdownMenuRadioGroup value={i18n.locale} onValueChange={handleLocaleChange}>
@@ -89,15 +99,15 @@ export function UserDropdownMenu({ children }: Props) {
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PaletteIcon />
<Trans>Theme</Trans>
<Trans comment="Menu item that opens appearance theme selection submenu">Theme</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>
<DropdownMenuRadioItem value="light">
<Trans>Light</Trans>
<Trans comment="Appearance theme option for light mode">Light</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Trans>Dark</Trans>
<Trans comment="Appearance theme option for dark mode">Dark</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
@@ -108,7 +118,7 @@ export function UserDropdownMenu({ children }: Props) {
<DropdownMenuItem onClick={handleLogout}>
<SignOutIcon />
<Trans>Logout</Trans>
<Trans comment="User menu action to sign out of current account">Logout</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+13 -3
View File
@@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { getReadableErrorMessage } from "@/utils/error-message";
import { type DialogProps, useDialogStore } from "../store";
@@ -59,7 +60,16 @@ const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
});
if (error) {
toast.error(error.message, { id: toastId });
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when creating an API key fails",
message: "Failed to create API key. Please try again.",
}),
),
{ id: toastId },
);
return;
}
@@ -149,7 +159,7 @@ const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
<DialogFooter>
<Button type="submit">
<Trans>Create</Trans>
<Trans comment="Create API key dialog submit action">Create</Trans>
</Button>
</DialogFooter>
</form>
@@ -206,7 +216,7 @@ const CopyApiKeyForm = ({ apiKey }: CopyApiKeyFormProps) => {
<DialogFooter>
<Button onClick={onConfirm}>
<Trans>Confirm</Trans>
<Trans comment="Create API key dialog acknowledgment action after copying">Confirm</Trans>
</Button>
</DialogFooter>
</DialogContent>
+34 -2
View File
@@ -14,6 +14,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { getReadableErrorMessage } from "@/utils/error-message";
import { type DialogProps, useDialogStore } from "../store";
@@ -55,7 +56,16 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
});
if (error) {
toast.error(error.message, { id: toastId });
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when changing account password fails",
message: "Failed to update your password. Please try again.",
}),
),
{ id: toastId },
);
return;
}
@@ -100,6 +110,17 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowCurrentPassword}>
<span className="sr-only">
{showCurrentPassword
? t({
comment: "Accessible label for toggle button that hides the visible current password",
message: "Hide password",
})
: t({
comment: "Accessible label for toggle button that reveals the masked current password",
message: "Show password",
})}
</span>
{showCurrentPassword ? <EyeIcon /> : <EyeSlashIcon />}
</Button>
</div>
@@ -130,6 +151,17 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowNewPassword}>
<span className="sr-only">
{showNewPassword
? t({
comment: "Accessible label for toggle button that hides the visible new password",
message: "Hide password",
})
: t({
comment: "Accessible label for toggle button that reveals the masked new password",
message: "Show password",
})}
</span>
{showNewPassword ? <EyeIcon /> : <EyeSlashIcon />}
</Button>
</div>
@@ -140,7 +172,7 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
<DialogFooter>
<Button type="submit">
<Trans>Update Password</Trans>
<Trans comment="Primary action button to submit changed password">Update Password</Trans>
</Button>
</DialogFooter>
</form>
+25 -2
View File
@@ -14,6 +14,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { getReadableErrorMessage } from "@/utils/error-message";
import { type DialogProps, useDialogStore } from "../store";
@@ -43,7 +44,16 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
const { error } = await authClient.twoFactor.disable({ password: data.password });
if (error) {
toast.error(error.message, { id: toastId });
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when disabling two-factor authentication fails",
message: "Failed to disable two-factor authentication. Please try again.",
}),
),
{ id: toastId },
);
return;
}
@@ -92,6 +102,19 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
<span className="sr-only">
{showPassword
? t({
comment:
"Accessible label for toggle button that hides the visible password in two-factor disable dialog",
message: "Hide password",
})
: t({
comment:
"Accessible label for toggle button that reveals the masked password in two-factor disable dialog",
message: "Show password",
})}
</span>
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
</Button>
</div>
@@ -102,7 +125,7 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
<DialogFooter>
<Button type="submit" variant="destructive">
<Trans>Disable 2FA</Trans>
<Trans comment="Destructive action button to turn off two-factor authentication">Disable 2FA</Trans>
</Button>
</DialogFooter>
</form>
+45 -8
View File
@@ -17,6 +17,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { getReadableErrorMessage } from "@/utils/error-message";
import { type DialogProps, useDialogStore } from "../store";
@@ -76,7 +77,16 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
});
if (error) {
toast.error(error.message, { id: toastId });
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when enabling two-factor authentication fails",
message: "Failed to enable two-factor authentication. Please try again.",
}),
),
{ id: toastId },
);
return;
}
@@ -96,7 +106,16 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
const { error } = await authClient.twoFactor.verifyTotp({ code: data.code });
if (error) {
toast.error(error.message, { id: toastId });
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when verifying two-factor setup code fails",
message: "Failed to verify your code. Please try again.",
}),
),
{ id: toastId },
);
return;
}
@@ -202,6 +221,19 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
<span className="sr-only">
{showPassword
? t({
comment:
"Accessible label for toggle button that hides the visible password in two-factor setup",
message: "Hide password",
})
: t({
comment:
"Accessible label for toggle button that reveals the masked password in two-factor setup",
message: "Show password",
})}
</span>
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
</Button>
</div>
@@ -264,10 +296,12 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
<DialogFooter className="gap-x-2">
<Button type="button" variant="outline" onClick={requestClose}>
<Trans>Cancel</Trans>
<Trans comment="Secondary action button to close two-factor setup dialog">Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Continue</Trans>
<Trans comment="Primary action button to proceed to next step in two-factor setup">
Continue
</Trans>
</Button>
</DialogFooter>
</form>
@@ -290,11 +324,11 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
<div className="flex items-center gap-x-2">
<Button type="button" variant="outline" onClick={handleDownloadBackupCodes} className="flex-1">
<ArrowDownIcon className="me-2 size-4" />
<Trans>Download</Trans>
<Trans comment="Action button to download two-factor backup codes as a text file">Download</Trans>
</Button>
<Button type="button" variant="ghost" onClick={handleCopyBackupCodes} className="flex-1">
<CopyIcon className="me-2 size-4" />
<Trans>Copy</Trans>
<Trans comment="Action button to copy two-factor backup codes to clipboard">Copy</Trans>
</Button>
</div>
</div>
@@ -302,7 +336,7 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
<DialogFooter>
<Button type="button" onClick={onConfirmBackup}>
<Trans>Continue</Trans>
<Trans comment="Final action button after saving backup codes">Continue</Trans>
</Button>
</DialogFooter>
</div>
@@ -328,7 +362,10 @@ function TwoFactorQRCode({ totpUri }: { totpUri: string }) {
size={256}
marginSize={2}
className="rounded-md"
title="Two-Factor Authentication QR Code"
title={t({
comment: "Accessible title for QR code image shown during two-factor setup",
message: "Two-Factor Authentication QR Code",
})}
/>
);
}
+59 -11
View File
@@ -24,6 +24,7 @@ import { JSONResumeImporter } from "@/integrations/import/json-resume";
import { ReactiveResumeJSONImporter } from "@/integrations/import/reactive-resume-json";
import { ReactiveResumeV4JSONImporter } from "@/integrations/import/reactive-resume-v4-json";
import { client, orpc } from "@/integrations/orpc/client";
import { getOrpcErrorMessage } from "@/utils/error-message";
import { cn } from "@/utils/style";
import { type DialogProps, useDialogStore } from "../store";
@@ -186,18 +187,39 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
});
}
if (!data) throw new Error("No data was returned from the AI provider.");
if (!data) {
throw new Error(
t({
comment: "Error shown when AI import endpoint returns no parsed resume data",
message: "No data was returned from the AI provider.",
}),
);
}
const id = await importResume({ data });
toast.success(t`Your resume has been imported successfully.`, { id: toastId, description: null });
closeDialog();
void navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId, description: null });
} else {
toast.error(t`An unknown error occurred while importing your resume.`, { id: toastId, description: null });
}
toast.error(
getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t({
comment: "Error shown when AI parsing returns invalid resume structure during import",
message: "The imported file could not be parsed into a valid resume.",
}),
BAD_GATEWAY: t({
comment: "Error shown when AI provider is unreachable during PDF/DOCX resume import",
message: "Could not reach the AI provider. Please try again.",
}),
},
fallback: t({
comment: "Fallback toast when importing a resume fails for an unknown reason",
message: "An unknown error occurred while importing your resume.",
}),
}),
{ id: toastId, description: null },
);
} finally {
setIsLoading(false);
}
@@ -235,14 +257,36 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "reactive-resume-json", label: "Reactive Resume (JSON)" },
{ value: "reactive-resume-v4-json", label: "Reactive Resume v4 (JSON)" },
{ value: "json-resume-json", label: "JSON Resume" },
{
value: "reactive-resume-json",
label: t({
comment: "Import source option for current Reactive Resume JSON format",
message: "Reactive Resume (JSON)",
}),
},
{
value: "reactive-resume-v4-json",
label: t({
comment: "Import source option for legacy Reactive Resume v4 JSON format",
message: "Reactive Resume v4 (JSON)",
}),
},
{
value: "json-resume-json",
label: t({
comment: "Import source option for standard JSON Resume format",
message: "JSON Resume",
}),
},
{
value: "pdf",
label: (
<div className="flex items-center gap-x-2">
PDF <Badge>{t`AI`}</Badge>
{t({
comment: "File format label in import source selector",
message: "PDF",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
@@ -250,7 +294,11 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
value: "docx",
label: (
<div className="flex items-center gap-x-2">
Microsoft Word <Badge>{t`AI`}</Badge>
{t({
comment: "File format label in import source selector",
message: "Microsoft Word",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
+12 -15
View File
@@ -25,6 +25,7 @@ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/integrations/auth/client";
import { orpc, type RouterInput } from "@/integrations/orpc/client";
import { getResumeErrorMessage } from "@/utils/error-message";
import { generateId, generateRandomName, slugify } from "@/utils/string";
import { type DialogProps, useDialogStore } from "../store";
@@ -70,12 +71,7 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
closeDialog();
},
onError: (error) => {
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
toast.error(t`A resume with this slug already exists.`, { id: toastId });
return;
}
toast.error(error.message, { id: toastId });
toast.error(getResumeErrorMessage(error), { id: toastId });
},
});
};
@@ -99,7 +95,7 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
closeDialog();
},
onError: (error) => {
toast.error(error.message, { id: toastId });
toast.error(getResumeErrorMessage(error), { id: toastId });
},
});
};
@@ -121,7 +117,13 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
<ResumeForm />
<DialogFooter>
<ButtonGroup aria-label="Create Resume with Options" className="gap-x-px rtl:flex-row-reverse">
<ButtonGroup
aria-label={t({
comment: "Accessible label for create-resume split button group",
message: "Create resume with options",
})}
className="gap-x-px rtl:flex-row-reverse"
>
<Button type="submit" disabled={isPending}>
<Trans>Create</Trans>
</Button>
@@ -183,12 +185,7 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
closeDialog();
},
onError: (error) => {
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
toast.error(t`A resume with this slug already exists.`, { id: toastId });
return;
}
toast.error(error.message, { id: toastId });
toast.error(getResumeErrorMessage(error), { id: toastId });
},
});
};
@@ -257,7 +254,7 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
void navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
},
onError: (error) => {
toast.error(error.message, { id: toastId });
toast.error(getResumeErrorMessage(error), { id: toastId });
},
});
};
+20 -29
View File
@@ -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>
</>
);
}
+7 -2
View File
@@ -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>
+28 -28
View File
@@ -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>
);
+24 -6
View File
@@ -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
View File
@@ -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>
+51 -12
View File
@@ -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>
}
/>
+26 -4
View File
@@ -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>
+29 -4
View File
@@ -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>
+10 -3
View File
@@ -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>
+13 -4
View File
@@ -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);
},
},
);
+8 -2
View File
@@ -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>
+43 -7
View File
@@ -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"
+19 -3
View File
@@ -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>
))
+19 -3
View File
@@ -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 },
);
},
});
};
+15 -1
View File
@@ -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.",
}),
}),
);
},
},
);
+50 -7
View File
@@ -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">
+36 -58
View File
@@ -1,27 +1,18 @@
import DOMPurify, { type Config } from "dompurify";
/**
* DOMPurify configuration for sanitizing rich text content.
* This configuration allows safe HTML tags used in the rich text editor
* while stripping all potentially dangerous content like scripts and event handlers.
*/
const RICH_TEXT_CONFIG: Config = {
// Allow safe HTML tags used by the TipTap editor
ALLOWED_TAGS: [
// Text formatting
"p",
"br",
"hr",
"span",
"div",
// Headings
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
// Text styling
"strong",
"b",
"em",
@@ -32,11 +23,9 @@ const RICH_TEXT_CONFIG: Config = {
"mark",
"code",
"pre",
// Lists
"ul",
"ol",
"li",
// Tables
"table",
"thead",
"tbody",
@@ -46,76 +35,65 @@ const RICH_TEXT_CONFIG: Config = {
"td",
"colgroup",
"col",
// Links
"a",
// Quotes
"blockquote",
],
// Allow safe attributes
ALLOWED_ATTR: ["class", "style", "href", "target", "rel", "colspan", "rowspan", "data-type", "data-label"],
// Only allow http and https protocols in links
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// Force all links to open in new tab with safe rel attributes
ADD_ATTR: ["target", "rel"],
// Don't allow data: URIs which can be used for XSS
ALLOW_DATA_ATTR: false,
};
/**
* Sanitizes HTML content to prevent XSS attacks.
* Uses DOMPurify with a strict configuration that only allows
* safe HTML tags and attributes used by the rich text editor.
*
* @param html - The HTML string to sanitize
* @returns Sanitized HTML string safe for rendering
*/
const PLAIN_TEXT_SANITIZE_CONFIG: Config = {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
};
function sanitizePlainTextContent(value: string): string {
return DOMPurify.sanitize(value, PLAIN_TEXT_SANITIZE_CONFIG) as string;
}
function stripCssComments(value: string): string {
if (!value) return "";
return value.replace(/\/\*[\s\S]*?\*\//g, "");
}
function decodeCssEscapes(value: string): string {
if (!value) return "";
return value.replace(/\\([0-9a-fA-F]{1,6})(?:\r\n|[ \t\r\n\f])?|\\(.)/g, (_match, hex, escapedChar) => {
if (hex) return String.fromCodePoint(parseInt(hex, 16));
return escapedChar ?? "";
});
}
export function sanitizeHtml(html: string): string {
if (!html) return "";
return DOMPurify.sanitize(html, { ...RICH_TEXT_CONFIG, RETURN_TRUSTED_TYPE: false }) as string;
}
/**
* Sanitizes CSS content to prevent CSS injection attacks.
* Only allows CSS rules, stripping any JavaScript or HTML that might be embedded.
*
* Note: This is a basic sanitization. For more robust CSS sanitization,
* consider using a dedicated CSS parser/sanitizer library.
*/
export function sanitizeCss(css: string): string {
if (!css) return "";
// Remove any JavaScript expressions
let sanitized = css
// Remove javascript: URLs
const normalized = decodeCssEscapes(stripCssComments(css));
const preSanitized = normalized
.replace(/javascript\s*:/gi, "")
// Remove expression() which can execute JS in older IE
.replace(/expression\s*\(/gi, "")
// Remove url() with data: or javascript:
.replace(/url\s*\(\s*["']?\s*(?:javascript|data):/gi, "url(")
// Remove behavior: property (IE-specific, can run scripts)
.replace(/behavior\s*:/gi, "")
// Remove -moz-binding (Firefox-specific, can run scripts)
.replace(/-moz-binding\s*:/gi, "")
// Remove @import with javascript or data URLs
.replace(/@import\s+(?:url\s*\()?["']?\s*(?:javascript|data):/gi, "");
.replace(/behavior\s*:[^;}]*/gi, "")
.replace(/-moz-binding\s*:[^;}]*/gi, "");
// Use DOMPurify to clean any HTML that might be in the CSS
sanitized = DOMPurify.sanitize(sanitized, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
}) as string;
const sanitized = preSanitized
.replace(/@import[^;]*;/gi, "")
.replace(/@font-face\s*\{[^}]*\}/gi, "")
.replace(/\b(?:url|image-set|cross-fade)\s*\([^)]*\)/gi, "")
.replace(/^\s*src\s*:[^;]*;?/gim, "");
return sanitized;
return sanitizePlainTextContent(sanitized);
}
/**
* Checks if the given value is a plain JSON object (not null, not array, not other types).
*
* @param value - The value to check.
* @returns True if the value is a plain object, false otherwise.
*/
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}