Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,15 @@
"@vercel/blob": "^2.6.1",
"graphql": "^16.10.0",
"graphql-request": "^7.2.0",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.545.0",
"nitro": "3.0.260610-beta",
"nprogress": "^0.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.57.0",
"react-i18next": "^17.0.11",
"recharts": "^3.9.2",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.18",
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/__tests__/lib/i18n.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { LocaleProvider, useLocale } from '#/lib/i18n';

function Probe() {
const { locale, t, formatNumber } = useLocale();
return (
<output>
{locale}|{t('nav.dashboard')}|{formatNumber(1234567)}
</output>
);
}

describe('i18n', () => {
it('falls back to English when no locale is stored', () => {
localStorage.removeItem('locale');
render(
<LocaleProvider>
<Probe />
</LocaleProvider>,
);
expect(screen.getByText(/\|Dashboard\|/)).toBeInTheDocument();
});

it('uses a URL locale and updates document language', () => {
window.history.replaceState({}, '', '/?locale=zh-CN');
render(
<LocaleProvider>
<Probe />
</LocaleProvider>,
);
expect(screen.getByText(/zh-CN\|仪表盘\|/)).toBeInTheDocument();
expect(document.documentElement.lang).toBe('zh-CN');
});
});
20 changes: 12 additions & 8 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
UserIcon,
PlusIcon,
} from 'lucide-react';
import { useLocale } from '#/lib/i18n';

interface CommandItem {
id: string;
Expand All @@ -23,42 +24,43 @@ export function CommandPalette() {
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const { t } = useLocale();

const commands: CommandItem[] = useMemo(
() => [
{
id: 'dashboard',
label: 'Go to Dashboard',
label: t('commands.dashboard'),
icon: <LayoutDashboardIcon size={16} />,
action: () => navigate({ to: '/dashboard' }),
},
{
id: 'applications',
label: 'Go to Applications',
label: t('commands.applications'),
icon: <BriefcaseIcon size={16} />,
action: () => navigate({ to: '/applications' }),
},
{
id: 'new-application',
label: 'New Application',
label: t('commands.newApplication'),
shortcut: '⌘N',
icon: <PlusIcon size={16} />,
action: () => navigate({ to: '/applications/new' }),
},
{
id: 'analytics',
label: 'Go to Analytics',
label: t('commands.analytics'),
icon: <BarChart2Icon size={16} />,
action: () => navigate({ to: '/analytics' }),
},
{
id: 'account',
label: 'Go to Account',
label: t('commands.account'),
icon: <UserIcon size={16} />,
action: () => navigate({ to: '/account' }),
},
],
[navigate],
[navigate, t],
);

const filtered = useMemo(() => {
Expand Down Expand Up @@ -114,7 +116,7 @@ export function CommandPalette() {
<input
ref={inputRef}
type="text"
placeholder="Type a command..."
placeholder={t('commands.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
Expand All @@ -126,7 +128,9 @@ export function CommandPalette() {
</div>
<ul className="max-h-64 overflow-auto py-2">
{filtered.length === 0 && (
<li className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">No results</li>
<li className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{t('commands.noResults')}
</li>
)}
{filtered.map((cmd, i) => (
<li
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/ErrorState.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AlertTriangleIcon, RefreshCwIcon } from 'lucide-react';
import { getErrorMessage } from '#/lib/errors';
import { useLocale } from '#/lib/i18n';

interface ErrorStateProps {
error: unknown;
Expand All @@ -13,17 +14,19 @@ interface ErrorStateProps {
* distinctly different from a genuinely empty result.
*/
export function ErrorState({ error, onRetry }: ErrorStateProps) {
const { t } = useLocale();
return (
<div className="text-center py-12">
<AlertTriangleIcon size={40} className="mx-auto mb-3 text-red-500" />
<p className="text-gray-700 dark:text-gray-300">{getErrorMessage(error)}</p>
{onRetry && (
<button
onClick={onRetry}
aria-label="Try again"
aria-label={t('common.tryAgain')}
className="mt-3 flex items-center gap-1.5 mx-auto text-sm text-blue-600 hover:underline"
>
<RefreshCwIcon size={14} /> <span className="hidden sm:inline">Try again</span>
<RefreshCwIcon size={14} />{' '}
<span className="hidden sm:inline">{t('common.tryAgain')}</span>
</button>
)}
</div>
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/OAuthButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ interface OAuthButtonsProps {
}

export function OAuthButtons({ label }: OAuthButtonsProps) {
const { t } = useLocale();
return (
<div className="space-y-3">
<div className="relative">
Expand All @@ -11,7 +12,7 @@ export function OAuthButtons({ label }: OAuthButtonsProps) {
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-white dark:bg-gray-800 px-2 text-gray-500 dark:text-gray-400">
or
{t('common.or')}
</span>
</div>
</div>
Expand All @@ -20,14 +21,15 @@ export function OAuthButtons({ label }: OAuthButtonsProps) {
href="/auth/oauth/google/start"
className="flex w-full items-center justify-center gap-2 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
{label} with Google
{label} {t('common.withGoogle')}
</a>
<a
href="/auth/oauth/github/start"
className="flex w-full items-center justify-center gap-2 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
{label} with GitHub
{label} {t('common.withGitHub')}
</a>
</div>
);
}
import { useLocale } from '#/lib/i18n';
39 changes: 39 additions & 0 deletions apps/web/src/i18n/en-GB.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"nav.dashboard": "Dashboard",
"nav.applications": "Applications",
"nav.calendar": "Calendar",
"nav.analytics": "Analytics",
"nav.assistant": "Assistant",
"nav.settings": "Settings",
"nav.signOut": "Sign out",
"settings.language": "Language",
"settings.languageDescription": "Choose the language used across the web app.",
"settings.profile": "Profile",
"settings.experience": "Experience",
"settings.security": "Security",
"settings.integrations": "Integrations",
"settings.notifications": "Notifications",
"settings.data": "Data",
"errors.notFound": "Page not found",
"errors.goDashboard": "Go to dashboard",
"errors.somethingWrong": "Something went wrong",
"errors.reloadHelp": "Try reloading the page. If this keeps happening, let us know.",
"errors.reload": "Reload",
"auth.signIn": "Sign in",
"auth.signUp": "Sign up",
"auth.email": "Email",
"auth.password": "Password",
"auth.forgotPassword": "Forgot password?",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.loading": "Loading…",
"auth.noAccount": "Don't have an account?",
"auth.register": "Register",
"auth.twoFactor": "Two-factor authentication",
"auth.twoFactorHelp": "Enter the 6-digit code from your authenticator app, or one of your backup codes.",
"auth.code": "Code",
"auth.signingIn": "Signing in…",
"auth.noAccountFound": "No account found with this email.",
"auth.createOne": "to create one."
}
87 changes: 87 additions & 0 deletions apps/web/src/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
"nav.dashboard": "Dashboard",
"nav.applications": "Applications",
"nav.calendar": "Calendar",
"nav.analytics": "Analytics",
"nav.assistant": "Assistant",
"nav.settings": "Settings",
"nav.signOut": "Sign out",
"settings.language": "Language",
"settings.languageDescription": "Choose the language used across the web app.",
"settings.profile": "Profile",
"settings.experience": "Experience",
"settings.security": "Security",
"settings.integrations": "Integrations",
"settings.notifications": "Notifications",
"settings.data": "Data",
"errors.notFound": "Page not found",
"errors.goDashboard": "Go to dashboard",
"errors.somethingWrong": "Something went wrong",
"errors.reloadHelp": "Try reloading the page. If this keeps happening, let us know.",
"errors.reload": "Reload",
"errors.userNotFound": "No account found with this email. Please register first.",
"errors.itemNotFound": "That item couldn't be found — it may have been deleted.",
"errors.forbidden": "You don't have permission to do that.",
"errors.generic": "Something went wrong. Please try again.",
"errors.network": "Can't reach the server. Check your connection and try again.",
"auth.signIn": "Sign in",
"auth.signUp": "Sign up",
"auth.email": "Email",
"auth.password": "Password",
"auth.forgotPassword": "Forgot password?",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.loading": "Loading…",
"common.tryAgain": "Try again",
"common.undo": "Undo",
"common.or": "or",
"common.withGoogle": "with Google",
"common.withGitHub": "with GitHub",
"commands.dashboard": "Go to Dashboard",
"commands.applications": "Go to Applications",
"commands.newApplication": "New Application",
"commands.analytics": "Go to Analytics",
"commands.account": "Go to Account",
"commands.placeholder": "Type a command...",
"commands.noResults": "No results",
"status.draft": "Draft",
"status.applied": "Applied",
"status.interviewing": "Interviewing",
"status.offered": "Offered",
"status.accepted": "Accepted",
"status.rejected": "Rejected",
"status.withdrawn": "Withdrawn",
"auth.noAccount": "Don't have an account?",
"auth.register": "Register",
"auth.twoFactor": "Two-factor authentication",
"auth.twoFactorHelp": "Enter the 6-digit code from your authenticator app, or one of your backup codes.",
"auth.code": "Code",
"auth.signingIn": "Signing in…",
"auth.noAccountFound": "No account found with this email.",
"auth.createOne": "to create one.",
"landing.signIn": "Sign in",
"landing.getStarted": "Get started",
"landing.startFree": "Start for free",
"landing.heroTitle": "Your job search, organized and powered by AI",
"landing.heroDescription": "Track applications, get AI-generated cover letters and resume feedback, visualize your pipeline, and never miss an interview. Everything you need to land your next role.",
"landing.tracking": "Application tracking",
"landing.trackingDescription": "Track every application from first apply to offer. Use a kanban board or list view, add notes, attach documents, and never lose sight of where you stand.",
"landing.assistant": "AI assistant",
"landing.assistantDescription": "Get personalized cover letters, resume match scores, and real-time answers about your applications — powered by the data you already have in Job Finder.",
"landing.analytics": "Analytics & insights",
"landing.analyticsDescription": "Visualize your pipeline with charts and stats. See conversion rates, time-to-response, and which sources deliver the best results.",
"landing.calendar": "Calendar",
"landing.calendarDescription": "Keep interviews and deadlines in one place. View your schedule by month, week, or day and never double-book again.",
"landing.documents": "Document management",
"landing.documentsDescription": "Upload and organize resumes, cover letters, and offer letters. Attach documents directly to applications for easy access.",
"landing.notifications": "Smart notifications",
"landing.notificationsDescription": "Stay on top of follow-ups and deadlines. Browser push notifications keep you informed even when the app is closed.",
"landing.everything": "Everything you need to land your next job",
"landing.everythingDescription": "Job Finder combines application tracking, AI-powered tools, and analytics into one seamless experience.",
"landing.howItWorks": "How it works",
"landing.step1": "Sign up for free in seconds",
"landing.step2": "Add your applications and documents",
"landing.step3": "Get AI-powered insights and recommendations",
"landing.step4": "Track progress from first apply to offer"
}
46 changes: 46 additions & 0 deletions apps/web/src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"nav.dashboard": "仪表盘",
"nav.applications": "求职申请",
"nav.calendar": "日历",
"nav.analytics": "分析",
"nav.assistant": "助手",
"nav.settings": "设置",
"nav.signOut": "退出登录",
"settings.language": "语言",
"settings.languageDescription": "选择网页应用使用的语言。",
"settings.profile": "个人资料",
"settings.experience": "经历",
"settings.security": "安全",
"settings.integrations": "集成",
"settings.notifications": "通知",
"settings.data": "数据",
"errors.notFound": "页面未找到",
"errors.goDashboard": "前往仪表盘",
"errors.somethingWrong": "发生错误",
"errors.reloadHelp": "请重新加载页面。如果问题持续,请通知我们。",
"errors.reload": "重新加载",
"auth.signIn": "登录",
"auth.signUp": "注册",
"auth.email": "电子邮箱",
"auth.password": "密码",
"auth.forgotPassword": "忘记密码?",
"common.save": "保存",
"common.cancel": "取消",
"common.delete": "删除",
"common.loading": "加载中…",
"status.draft": "草稿",
"status.applied": "已申请",
"status.interviewing": "面试中",
"status.offered": "已获得录用",
"status.accepted": "已接受",
"status.rejected": "已拒绝",
"status.withdrawn": "已撤回",
"auth.noAccount": "还没有账户?",
"auth.register": "注册",
"auth.twoFactor": "双重身份验证",
"auth.twoFactorHelp": "输入身份验证器应用中的六位数验证码,或一组备用验证码。",
"auth.code": "验证码",
"auth.signingIn": "登录中…",
"auth.noAccountFound": "未找到使用此邮箱的账户。",
"auth.createOne": "以创建账户。"
}
Loading
Loading