diff --git a/apps/start/package.json b/apps/start/package.json index 1714f66bc..e292f1bbd 100644 --- a/apps/start/package.json +++ b/apps/start/package.json @@ -109,6 +109,8 @@ "flag-icons": "^7.1.0", "framer-motion": "^11.0.28", "hamburger-react": "^2.5.0", + "i18next": "^26.3.1", + "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.2.4", "javascript-time-ago": "^2.5.9", "katex": "^0.16.21", @@ -132,6 +134,7 @@ "react-dom": "catalog:", "react-grid-layout": "^1.5.2", "react-hook-form": "^7.50.1", + "react-i18next": "^17.0.8", "react-in-viewport": "1.0.0-beta.8", "react-markdown": "^10.1.0", "react-redux": "^8.1.3", @@ -190,4 +193,4 @@ "web-vitals": "^4.2.4", "wrangler": "4.85.0" } -} \ No newline at end of file +} diff --git a/apps/start/src/components/auth/or.tsx b/apps/start/src/components/auth/or.tsx index 7463afdc9..51415dee4 100644 --- a/apps/start/src/components/auth/or.tsx +++ b/apps/start/src/components/auth/or.tsx @@ -1,10 +1,14 @@ import { cn } from '@/utils/cn'; +import { useTranslation } from 'react-i18next'; export function Or({ className }: { className?: string }) { + const { t } = useTranslation(); return (
- OR + + {t('common.or')} +
); diff --git a/apps/start/src/components/auth/reset-password-form.tsx b/apps/start/src/components/auth/reset-password-form.tsx index a4d6dd4ca..533d23956 100644 --- a/apps/start/src/components/auth/reset-password-form.tsx +++ b/apps/start/src/components/auth/reset-password-form.tsx @@ -4,6 +4,7 @@ import { zResetPassword } from '@openpanel/validation'; import { useMutation } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import type { z } from 'zod'; import { InputWithLabel } from '../forms/input-with-label'; @@ -13,12 +14,13 @@ const validator = zResetPassword; type IForm = z.infer; export function ResetPasswordForm({ token }: { token: string }) { + const { t } = useTranslation(); const navigate = useNavigate(); const trpc = useTRPC(); const mutation = useMutation( trpc.auth.resetPassword.mutationOptions({ onSuccess() { - toast.success('Password reset successfully'); + toast.success(t('auth.password_reset_successfully')); navigate({ to: '/login', }); @@ -45,23 +47,23 @@ export function ResetPasswordForm({ token }: { token: string }) {

- Reset your password + {t('auth.reset_your_password')}

- Already have an account?{' '} + {t('auth.already_have_account')}{' '} - Sign in + {t('auth.sign_in')}

- +
); diff --git a/apps/start/src/components/auth/share-enter-password.tsx b/apps/start/src/components/auth/share-enter-password.tsx index 9f1e7ce7a..29a1aa5ee 100644 --- a/apps/start/src/components/auth/share-enter-password.tsx +++ b/apps/start/src/components/auth/share-enter-password.tsx @@ -3,6 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { type ISignInShare, zSignInShare } from '@openpanel/validation'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { PublicPageCard } from '../public-page-card'; import { Button } from '../ui/button'; @@ -15,6 +16,7 @@ export function ShareEnterPassword({ shareId: string; shareType?: 'overview' | 'dashboard' | 'report'; }) { + const { t } = useTranslation(); const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInShare.mutationOptions({ @@ -22,7 +24,7 @@ export function ShareEnterPassword({ window.location.reload(); }, onError() { - toast.error('Incorrect password'); + toast.error(t('auth.incorrect_password')); }, }), ); @@ -45,24 +47,26 @@ export function ShareEnterPassword({ const typeLabel = shareType === 'dashboard' - ? 'Dashboard' + ? t('auth.share_type_dashboard') : shareType === 'report' - ? 'Report' - : 'Overview'; + ? t('auth.share_type_report') + : t('auth.share_type_overview'); return (
- +
); diff --git a/apps/start/src/components/auth/sign-in-email-form.tsx b/apps/start/src/components/auth/sign-in-email-form.tsx index aec9a789a..d4a0c83f9 100644 --- a/apps/start/src/components/auth/sign-in-email-form.tsx +++ b/apps/start/src/components/auth/sign-in-email-form.tsx @@ -5,6 +5,7 @@ import { zSignInEmail } from '@openpanel/validation'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useRouter } from '@tanstack/react-router'; import { type SubmitHandler, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import type { z } from 'zod'; import { InputWithLabel } from '../forms/input-with-label'; @@ -17,6 +18,7 @@ export function SignInEmailForm({ isLastUsed, inviteId, }: { isLastUsed?: boolean; inviteId?: string }) { + const { t } = useTranslation(); const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInEmail.mutationOptions({ @@ -25,7 +27,7 @@ export function SignInEmailForm({ window.location.href = '/verify'; return; } - toast.success('Successfully signed in'); + toast.success(t('auth.successfully_signed_in')); window.location.href = '/'; }, onError(error) { @@ -52,23 +54,23 @@ export function SignInEmailForm({
{isLastUsed && ( - Used last time + {t('auth.used_last_time')} )}
@@ -81,7 +83,7 @@ export function SignInEmailForm({ } className="text-sm text-muted-foreground hover:text-highlight hover:underline transition-colors duration-200 text-center mt-2" > - Forgot password? + {t('auth.forgot_password')} ); diff --git a/apps/start/src/components/auth/sign-in-github.tsx b/apps/start/src/components/auth/sign-in-github.tsx index f4e7e99d9..5181eec9c 100644 --- a/apps/start/src/components/auth/sign-in-github.tsx +++ b/apps/start/src/components/auth/sign-in-github.tsx @@ -1,5 +1,6 @@ import { useTRPC } from '@/integrations/trpc/react'; import { useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { Button } from '../ui/button'; export function SignInGithub({ @@ -7,6 +8,7 @@ export function SignInGithub({ inviteId, isLastUsed, }: { type: 'sign-in' | 'sign-up'; inviteId?: string; isLastUsed?: boolean }) { + const { t } = useTranslation(); const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInOAuth.mutationOptions({ @@ -18,8 +20,8 @@ export function SignInGithub({ }), ); const title = () => { - if (type === 'sign-in') return 'Sign in with Github'; - if (type === 'sign-up') return 'Sign up with Github'; + if (type === 'sign-in') return t('auth.sign_in_with_github'); + if (type === 'sign-up') return t('auth.sign_up_with_github'); }; return (
@@ -47,7 +49,7 @@ export function SignInGithub({ {isLastUsed && ( - Used last time + {t('auth.used_last_time')} )}
diff --git a/apps/start/src/components/auth/sign-in-google.tsx b/apps/start/src/components/auth/sign-in-google.tsx index 35125873f..e60344621 100644 --- a/apps/start/src/components/auth/sign-in-google.tsx +++ b/apps/start/src/components/auth/sign-in-google.tsx @@ -1,4 +1,5 @@ import { useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { Button } from '../ui/button'; import { useTRPC } from '@/integrations/trpc/react'; @@ -11,6 +12,7 @@ export function SignInGoogle({ inviteId?: string; isLastUsed?: boolean; }) { + const { t } = useTranslation(); const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInOAuth.mutationOptions({ @@ -23,10 +25,10 @@ export function SignInGoogle({ ); const title = () => { if (type === 'sign-in') { - return 'Sign in with Google'; + return t('auth.sign_in_with_google'); } if (type === 'sign-up') { - return 'Sign up with Google'; + return t('auth.sign_up_with_google'); } }; return ( @@ -67,7 +69,7 @@ export function SignInGoogle({ {isLastUsed && ( - Used last time + {t('auth.used_last_time')} )}
diff --git a/apps/start/src/components/auth/sign-up-email-form.tsx b/apps/start/src/components/auth/sign-up-email-form.tsx index f85df49ff..d186c9ce0 100644 --- a/apps/start/src/components/auth/sign-up-email-form.tsx +++ b/apps/start/src/components/auth/sign-up-email-form.tsx @@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; import { type SubmitHandler, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import type { z } from 'zod'; import { InputWithLabel } from '../forms/input-with-label'; @@ -16,11 +17,12 @@ type IForm = z.infer; export function SignUpEmailForm({ inviteId, }: { inviteId: string | undefined }) { + const { t } = useTranslation(); const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signUpEmail.mutationOptions({ async onSuccess() { - toast.success('Successfully signed up'); + toast.success(t('auth.successfully_signed_up')); window.location.href = '/'; }, onError(error) { @@ -41,14 +43,14 @@ export function SignUpEmailForm({
); diff --git a/apps/start/src/components/chat/chat-context-widget.tsx b/apps/start/src/components/chat/chat-context-widget.tsx index eb3be33b5..134064c7d 100644 --- a/apps/start/src/components/chat/chat-context-widget.tsx +++ b/apps/start/src/components/chat/chat-context-widget.tsx @@ -1,6 +1,6 @@ import { usePageContextValue } from '@/contexts/page-context'; import { cn } from '@/utils/cn'; -import { timeWindows } from '@openpanel/constants'; +import { getTimeWindowLabelKey } from '@/utils/time-window-label'; import { Building2Icon, GanttChartIcon, @@ -14,6 +14,7 @@ import { UsersIcon, WallpaperIcon, } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; /** * Small banner shown at the top of the chat body explaining what @@ -23,6 +24,7 @@ import { * Renders nothing when there's no useful context to surface. */ export function ChatContextWidget() { + const { t } = useTranslation(); const ctx = usePageContextValue(); if (!ctx) return null; @@ -37,7 +39,7 @@ export function ChatContextWidget() { if (range === 'custom' && ctx.filters?.startDate && ctx.filters?.endDate) { chips.push(`${ctx.filters.startDate} → ${ctx.filters.endDate}`); } else if (range) { - chips.push(formatRange(range)); + chips.push(formatRange(range, t)); } // Event-name filter @@ -123,6 +125,7 @@ const PAGE_META: Record< dashboard: { label: 'Dashboard', icon: LayoutDashboardIcon }, }; -function formatRange(range: string): string { - return timeWindows[range as keyof typeof timeWindows]?.label ?? range; +function formatRange(range: string, t: (key: string) => string): string { + const key = getTimeWindowLabelKey(range); + return key ? t(key) : range; } diff --git a/apps/start/src/components/chat/chat-drawer-body.tsx b/apps/start/src/components/chat/chat-drawer-body.tsx index 103f7614f..fcc220129 100644 --- a/apps/start/src/components/chat/chat-drawer-body.tsx +++ b/apps/start/src/components/chat/chat-drawer-body.tsx @@ -6,6 +6,7 @@ import { StickToBottom, useStickToBottomContext, } from 'use-stick-to-bottom'; +import { useTranslation } from 'react-i18next'; import { ChatContextWidget } from './chat-context-widget'; import { ChatDrawerEmpty } from './chat-drawer-empty'; import { ChatMessage } from './chat-message'; @@ -20,6 +21,7 @@ import { useChatRuntime } from './chat-runtime'; * up — courtesy of `use-stick-to-bottom`. Zero refs, zero effects. */ export function ChatDrawerBody() { + const { t } = useTranslation(); const { messages, isLoading, isStreaming, status, error } = useChatRuntime(); const hasContext = usePageContextValue(); @@ -66,14 +68,16 @@ export function ChatDrawerBody() { ))} {isLoading && !isStreaming && (
- Thinking… + + {t('chat.thinking')} +
)} {status === 'error' && (
- {error?.message ?? 'Something went wrong. Try again.'} + {error?.message ?? t('chat.generic_error')}
)} @@ -85,6 +89,7 @@ export function ChatDrawerBody() { } function ScrollToBottomButton() { + const { t } = useTranslation(); const { isAtBottom, scrollToBottom } = useStickToBottomContext(); if (isAtBottom) return null; return ( @@ -94,10 +99,10 @@ function ScrollToBottomButton() { variant="secondary" className="absolute bottom-3 left-1/2 -translate-x-1/2 h-7 px-2 shadow-md gap-1" onClick={() => scrollToBottom()} - aria-label="Scroll to bottom" + aria-label={t('chat.scroll_to_bottom')} > - Latest + {t('chat.latest')} ); } diff --git a/apps/start/src/components/chat/chat-drawer-empty.tsx b/apps/start/src/components/chat/chat-drawer-empty.tsx index dd4001a76..323b29751 100644 --- a/apps/start/src/components/chat/chat-drawer-empty.tsx +++ b/apps/start/src/components/chat/chat-drawer-empty.tsx @@ -1,6 +1,7 @@ import CopyInput from '@/components/forms/copy-input'; import { usePageContextValue } from '@/contexts/page-context'; import { ExternalLinkIcon, KeyRoundIcon, SparklesIcon } from 'lucide-react'; +import { Trans, useTranslation } from 'react-i18next'; import { useChatRuntime } from './chat-runtime'; /** @@ -11,6 +12,7 @@ import { useChatRuntime } from './chat-runtime'; * it doesn't depend on an agent being available. */ export function ChatDrawerEmpty() { + const { t } = useTranslation(); const ctx = usePageContextValue(); const { send } = useChatRuntime(); const suggestions = getSuggestionsForContext(ctx); @@ -21,40 +23,52 @@ export function ChatDrawerEmpty() {
-

{suggestions.headline}

+

+ {t(suggestions.headlineKey)} +

- {suggestions.description} + {t(suggestions.descriptionKey)}

- {suggestions.prompts.map((prompt) => ( - - ))} + {suggestions.promptKeys.map((promptKey) => { + const prompt = t(promptKey); + return ( + + ); + })}
); } export function ChatDrawerNotConfigured() { + const { t } = useTranslation(); + return (
-

AI chat isn't configured

+

+ {t('chat.ai_not_configured')} +

- Set OPENAI_API_KEY and/or{' '} - ANTHROPIC_API_KEY on the - API service to enable AI chat, then restart it. The model picker - shows only providers with a configured key. + , + anthropic: , + }} + i18nKey="chat.ai_not_configured_setup" + />

@@ -67,7 +81,7 @@ export function ChatDrawerNotConfigured() { rel="noopener" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground" > - View setup docs + {t('chat.view_setup_docs')}
@@ -75,9 +89,9 @@ export function ChatDrawerNotConfigured() { } type Suggestions = { - headline: string; - description: string; - prompts: string[]; + headlineKey: string; + descriptionKey: string; + promptKeys: string[]; }; function getSuggestionsForContext( @@ -85,13 +99,12 @@ function getSuggestionsForContext( ): Suggestions { if (!ctx) { return { - headline: 'Ask about your data', - description: - 'I can answer questions about your analytics, generate reports, and dig into users, sessions, or pages.', - prompts: [ - 'What happened yesterday?', - 'Show me last 7 days of traffic', - 'Which pages have the highest bounce rate?', + headlineKey: 'chat.empty_no_context_headline', + descriptionKey: 'chat.empty_no_context_description', + promptKeys: [ + 'chat.empty_no_context_prompt_what_happened_yesterday', + 'chat.empty_no_context_prompt_last_seven_days_traffic', + 'chat.empty_no_context_prompt_highest_bounce_rate', ], }; } @@ -99,142 +112,131 @@ function getSuggestionsForContext( switch (ctx.page) { case 'overview': return { - headline: 'Ask about this overview', - description: - 'I can explain trends, compare periods, or filter the page. Try a question or pick a starter.', - prompts: [ - 'What changed compared to the previous period?', - 'Filter to mobile only', - 'Show me last 7 days of traffic', - 'Which referrers drove the most sessions?', + headlineKey: 'chat.empty_overview_headline', + descriptionKey: 'chat.empty_overview_description', + promptKeys: [ + 'chat.empty_overview_prompt_compare_previous_period', + 'chat.empty_overview_prompt_filter_mobile_only', + 'chat.empty_overview_prompt_last_seven_days_traffic', + 'chat.empty_overview_prompt_top_referrers', ], }; case 'insights': return { - headline: 'Explore your insights', - description: - 'I can explain why an insight fired, find related ones, or walk you through the most important.', - prompts: [ - 'What are the most important insights right now?', - 'Explain the biggest anomaly this week', - 'Any insights related to device trends?', + headlineKey: 'chat.empty_insights_headline', + descriptionKey: 'chat.empty_insights_description', + promptKeys: [ + 'chat.empty_insights_prompt_most_important', + 'chat.empty_insights_prompt_biggest_anomaly', + 'chat.empty_insights_prompt_device_trends', ], }; case 'pages': return { - headline: 'Ask about your pages', - description: - 'I can rank pages by performance, find declining ones, or identify entry/exit patterns.', - prompts: [ - 'Which pages are declining vs last month?', - 'Show pages with the highest bounce rate', - 'Top entry pages in the last 7 days', - 'Find pages with underperforming SEO', + headlineKey: 'chat.empty_pages_headline', + descriptionKey: 'chat.empty_pages_description', + promptKeys: [ + 'chat.empty_pages_prompt_declining_pages', + 'chat.empty_pages_prompt_highest_bounce_rate', + 'chat.empty_pages_prompt_top_entry_pages', + 'chat.empty_pages_prompt_underperforming_seo', ], }; case 'seo': return { - headline: 'Dig into your SEO', - description: - 'I can surface high-opportunity queries, check for cannibalization, and correlate SEO with on-site engagement.', - prompts: [ - 'Queries on page 2 with high impressions (easy wins)', - 'Any query cannibalization I should fix?', - 'Which pages bring GSC clicks but bounce hard?', - 'Top SEO queries last 30 days', + headlineKey: 'chat.empty_seo_headline', + descriptionKey: 'chat.empty_seo_description', + promptKeys: [ + 'chat.empty_seo_prompt_page_two_queries', + 'chat.empty_seo_prompt_query_cannibalization', + 'chat.empty_seo_prompt_gsc_clicks_bounce_hard', + 'chat.empty_seo_prompt_top_seo_queries', ], }; case 'events': return { - headline: 'Analyze your events', - description: - 'I can analyze distribution, correlate events, and drill into properties.', - prompts: [ - 'Which events often happen together?', - 'Analyze event distribution by country', - 'Filter to signup events only', - 'What are the most common event properties?', + headlineKey: 'chat.empty_events_headline', + descriptionKey: 'chat.empty_events_description', + promptKeys: [ + 'chat.empty_events_prompt_events_together', + 'chat.empty_events_prompt_distribution_by_country', + 'chat.empty_events_prompt_signup_events_only', + 'chat.empty_events_prompt_common_properties', ], }; case 'profileDetail': return { - headline: 'Ask about this user', - description: - 'I can summarize this profile, build a journey, or compare them to the average user.', - prompts: [ - "Tell me about this user's journey", - 'How does this user compare to average?', - 'What was their last session?', - 'Do they have unusual behavior?', + headlineKey: 'chat.empty_profile_detail_headline', + descriptionKey: 'chat.empty_profile_detail_description', + promptKeys: [ + 'chat.empty_profile_detail_prompt_user_journey', + 'chat.empty_profile_detail_prompt_compare_average', + 'chat.empty_profile_detail_prompt_last_session', + 'chat.empty_profile_detail_prompt_unusual_behavior', ], }; case 'sessionDetail': return { - headline: 'Ask about this session', - description: - 'I can walk through the path, compare to typical sessions, or explain the referrer context.', - prompts: [ - 'Walk me through this session', - 'How does this compare to typical sessions?', - 'Where did this traffic come from?', - 'Are there similar sessions today?', + headlineKey: 'chat.empty_session_detail_headline', + descriptionKey: 'chat.empty_session_detail_description', + promptKeys: [ + 'chat.empty_session_detail_prompt_walk_through', + 'chat.empty_session_detail_prompt_compare_typical', + 'chat.empty_session_detail_prompt_traffic_source', + 'chat.empty_session_detail_prompt_similar_sessions', ], }; case 'groupDetail': return { - headline: 'Ask about this group', - description: - 'I can show metrics, list members, and compare to peer groups.', - prompts: [ - "Summarize this group's activity", - 'Compare to other groups', - 'Who are the most active members?', - "What's this group's engagement trend?", + headlineKey: 'chat.empty_group_detail_headline', + descriptionKey: 'chat.empty_group_detail_description', + promptKeys: [ + 'chat.empty_group_detail_prompt_summarize_activity', + 'chat.empty_group_detail_prompt_compare_groups', + 'chat.empty_group_detail_prompt_active_members', + 'chat.empty_group_detail_prompt_engagement_trend', ], }; case 'reportEditor': return { - headline: 'Edit this report with me', - description: - 'I can preview changes, suggest breakdowns, or compare to the previous period.', - prompts: [ - 'Suggest useful breakdowns for this report', - 'Compare to the previous period', - 'Find anomalies in the current data', - 'Add a country breakdown', + headlineKey: 'chat.empty_report_editor_headline', + descriptionKey: 'chat.empty_report_editor_description', + promptKeys: [ + 'chat.empty_report_editor_prompt_useful_breakdowns', + 'chat.empty_report_editor_prompt_compare_previous_period', + 'chat.empty_report_editor_prompt_find_anomalies', + 'chat.empty_report_editor_prompt_country_breakdown', ], }; case 'dashboard': return { - headline: 'Ask about this dashboard', - description: - 'I can summarize every report on this dashboard at once, flag what changed, or zoom into a single chart.', - prompts: [ - 'Summarize this dashboard', - "What's underperforming here?", - 'Compare this dashboard to the previous period', - 'Which report has the biggest change?', + headlineKey: 'chat.empty_dashboard_headline', + descriptionKey: 'chat.empty_dashboard_description', + promptKeys: [ + 'chat.empty_dashboard_prompt_summarize_dashboard', + 'chat.empty_dashboard_prompt_underperforming', + 'chat.empty_dashboard_prompt_compare_previous_period', + 'chat.empty_dashboard_prompt_biggest_change', ], }; default: return { - headline: 'Ask about your data', - description: - 'I can answer questions about the page you\'re viewing, generate reports, and dig into specific users, sessions, or pages.', - prompts: [ - "What's our visitor count this week?", - 'Top traffic sources right now', - 'Show me top pages by bounce rate', + headlineKey: 'chat.empty_default_context_headline', + descriptionKey: 'chat.empty_default_context_description', + promptKeys: [ + 'chat.empty_default_context_prompt_visitor_count', + 'chat.empty_default_context_prompt_top_traffic_sources', + 'chat.empty_default_context_prompt_top_pages_bounce_rate', ], }; } diff --git a/apps/start/src/components/chat/chat-drawer-footer.tsx b/apps/start/src/components/chat/chat-drawer-footer.tsx index 0c59260b4..707b3ad14 100644 --- a/apps/start/src/components/chat/chat-drawer-footer.tsx +++ b/apps/start/src/components/chat/chat-drawer-footer.tsx @@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/utils/cn'; import { ArrowUpIcon, StopCircleIcon } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useChatRuntime } from './chat-runtime'; import { ModelPicker } from './model-picker'; @@ -13,6 +14,7 @@ import { ModelPicker } from './model-picker'; * input state — `useChat` v5+ doesn't manage input. */ export function ChatDrawerFooter() { + const { t } = useTranslation(); const { send, stop, status } = useChatRuntime(); const [text, setText] = useState(''); const isStreaming = status === 'streaming' || status === 'submitted'; @@ -61,7 +63,7 @@ export function ChatDrawerFooter() { ref={textareaRef} value={text} onChange={(e) => setText(e.target.value)} - placeholder="Ask anything about your data…" + placeholder={t('chat.ask_anything_placeholder')} rows={2} className={cn( 'block w-full bg-transparent text-sm leading-[1.5] text-foreground', @@ -86,7 +88,7 @@ export function ChatDrawerFooter() { variant="secondary" className="size-7 rounded-md shrink-0" onClick={stop} - aria-label="Stop generating" + aria-label={t('chat.stop_generating')} > @@ -97,7 +99,7 @@ export function ChatDrawerFooter() { variant="default" className="size-7 rounded-md shrink-0" disabled={!text.trim()} - aria-label="Send message" + aria-label={t('chat.send_message')} > diff --git a/apps/start/src/components/chat/chat-drawer-header.tsx b/apps/start/src/components/chat/chat-drawer-header.tsx index 73cbeeb26..9513375b5 100644 --- a/apps/start/src/components/chat/chat-drawer-header.tsx +++ b/apps/start/src/components/chat/chat-drawer-header.tsx @@ -11,6 +11,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { MessageSquarePlusIcon, Trash2Icon, XIcon } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useChatState } from './chat-context'; /** @@ -28,6 +29,7 @@ export function ChatDrawerHeader({ projectId: string; onClose: () => void; }) { + const { t } = useTranslation(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { conversationId, switchConversation, newConversation, streamingTitle } = @@ -55,7 +57,7 @@ export function ChatDrawerHeader({ // 3. "New chat" fallback when nothing is known yet const persistedTitle = conversations?.find((c) => c.id === conversationId)?.title ?? null; - const activeTitle = streamingTitle ?? persistedTitle ?? 'New chat'; + const activeTitle = streamingTitle ?? persistedTitle ?? t('chat.new_chat'); return (
@@ -74,11 +76,11 @@ export function ChatDrawerHeader({ align="start" className="w-72 max-h-96 overflow-y-auto" > - Conversations + {t('chat.conversations')} {(!conversations || conversations.length === 0) && (
- No conversations yet. Start typing to create one. + {t('chat.no_conversations')}
)} {conversations?.map((c) => ( @@ -87,7 +89,9 @@ export function ChatDrawerHeader({ className="flex items-center justify-between gap-2" onSelect={() => switchConversation(c.id)} > - {c.title ?? 'Untitled chat'} + + {c.title ?? t('chat.untitled_chat')} + deleteMutation.mutate({ id: c.id })} /> @@ -101,8 +105,8 @@ export function ChatDrawerHeader({ variant="ghost" size="sm" onClick={() => newConversation()} - aria-label="New conversation" - title="New conversation" + aria-label={t('chat.new_conversation')} + title={t('chat.new_conversation')} > @@ -110,7 +114,7 @@ export function ChatDrawerHeader({ variant="ghost" size="sm" onClick={onClose} - aria-label="Close chat" + aria-label={t('chat.close_chat')} > @@ -128,6 +132,7 @@ export function ChatDrawerHeader({ * time-based auto-revert applies. */ function InlineDeleteButton({ onConfirm }: { onConfirm: () => void }) { + const { t } = useTranslation(); const [armed, setArmed] = useState(false); const timerRef = useRef(null); @@ -167,8 +172,8 @@ function InlineDeleteButton({ onConfirm }: { onConfirm: () => void }) { ? 'shrink-0 rounded bg-destructive p-1 text-white hover:bg-destructive/90 transition-colors' : 'shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive transition-colors' } - aria-label={armed ? 'Confirm delete' : 'Delete conversation'} - title={armed ? 'Click again to confirm' : 'Delete'} + aria-label={armed ? t('chat.confirm_delete') : t('chat.delete_conversation')} + title={armed ? t('chat.click_again_to_confirm') : t('chat.delete')} > {armed ? ( diff --git a/apps/start/src/components/chat/chat-drawer.tsx b/apps/start/src/components/chat/chat-drawer.tsx index 7c6256539..87a6277a7 100644 --- a/apps/start/src/components/chat/chat-drawer.tsx +++ b/apps/start/src/components/chat/chat-drawer.tsx @@ -1,6 +1,7 @@ import { useAppParams } from '@/hooks/use-app-params'; import { useResizableDrawer } from '@/hooks/use-resizable-drawer'; import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { useChatState } from './chat-context'; import { ChatDrawerBody } from './chat-drawer-body'; import { ChatDrawerNotConfigured } from './chat-drawer-empty'; @@ -25,6 +26,7 @@ const MAX_WIDTH = 720; * thin `useChatState()` context (conversation list, new chat button). */ export function ChatDrawer() { + const { t } = useTranslation(); const { projectId } = useAppParams(); const { agentName, @@ -82,7 +84,7 @@ export function ChatDrawer() { >
diff --git a/apps/start/src/components/chat/chat-message.tsx b/apps/start/src/components/chat/chat-message.tsx index 37258be67..d268e631f 100644 --- a/apps/start/src/components/chat/chat-message.tsx +++ b/apps/start/src/components/chat/chat-message.tsx @@ -2,6 +2,7 @@ import { cn } from '@/utils/cn'; import type { UIMessage } from '@better-agent/client'; import { ChevronRightIcon } from 'lucide-react'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ChatMarkdown } from './chat-markdown'; import { chatToolRenderers, DefaultToolResult } from './tool-results/registry'; import type { ToolResultPart } from './tool-results/types'; @@ -30,6 +31,7 @@ export function ChatMessage({ */ runStillActive?: boolean; }) { + const { t } = useTranslation(); const isUser = message.role === 'user'; // Index tool results by callId so we can attach them to their call. @@ -113,7 +115,9 @@ export function ChatMessage({ key={part.callId} className="flex items-center gap-2 py-1.5 text-sm" > - Thinking… + + {t('chat.thinking')} +
); } @@ -127,7 +131,7 @@ export function ChatMessage({ input: part.args ? safeParse(part.args) : undefined, output: matched?.result, errorText: - matched?.status === 'error' ? 'Tool failed' : undefined, + matched?.status === 'error' ? t('chat.tool_failed') : undefined, }; return (
@@ -160,6 +164,7 @@ function ReasoningBlock({ text: string; complete: boolean; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const trimmed = text.trim(); // Latest non-empty line for the live preview. @@ -174,7 +179,7 @@ function ReasoningBlock({ return (
- Thinking… + {t('chat.thinking')}
{latestLine && (
@@ -195,7 +200,7 @@ function ReasoningBlock({ - Thought + {t('chat.thought')} {open && (
diff --git a/apps/start/src/components/chat/model-picker.tsx b/apps/start/src/components/chat/model-picker.tsx index f880ec629..525cb884b 100644 --- a/apps/start/src/components/chat/model-picker.tsx +++ b/apps/start/src/components/chat/model-picker.tsx @@ -9,6 +9,7 @@ import { } from '@/components/ui/dropdown-menu'; import { type ChatModelOption, getModelLabel } from '@/agents/models'; import { CheckIcon, ChevronDownIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { useChatState } from './chat-context'; /** @@ -17,6 +18,7 @@ import { useChatState } from './chat-context'; * different agent name on the next render. */ export function ModelPicker() { + const { t } = useTranslation(); const { agentName, setAgent, models } = useChatState(); const grouped = models.reduce>( @@ -34,8 +36,8 @@ export function ModelPicker() { variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 text-muted-foreground" - aria-label="Select model" - title="Select model" + aria-label={t('chat.select_model')} + title={t('chat.select_model')} > {getModelLabel(agentName)} diff --git a/apps/start/src/components/chat/sidebar-chat-composer.tsx b/apps/start/src/components/chat/sidebar-chat-composer.tsx index b309cb571..4f47bfc26 100644 --- a/apps/start/src/components/chat/sidebar-chat-composer.tsx +++ b/apps/start/src/components/chat/sidebar-chat-composer.tsx @@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/utils/cn'; import { ArrowUpIcon, SparklesIcon } from 'lucide-react'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useChatState } from './chat-context'; /** @@ -17,6 +18,7 @@ import { useChatState } from './chat-context'; * pending message once `useAgent` is ready. */ export function SidebarChatComposer() { + const { t } = useTranslation(); const { setPendingMessage, openNewChat } = useChatState(); const [text, setText] = useState(''); const hasText = text.trim().length > 0; @@ -43,7 +45,7 @@ export function SidebarChatComposer() { type="text" value={text} onChange={(e) => setText(e.target.value)} - placeholder="Ask AI anything…" + placeholder={t('chat.ask_ai_placeholder')} className={cn( 'min-w-0 flex-1 bg-transparent py-2 text-[13px] font-medium text-foreground', 'placeholder:font-normal placeholder:text-muted-foreground', @@ -63,7 +65,7 @@ export function SidebarChatComposer() { size="icon" variant="default" className="size-6 shrink-0 rounded-sm" - aria-label="Send to AI" + aria-label={t('chat.send_to_ai')} > @@ -73,7 +75,7 @@ export function SidebarChatComposer() { 'shrink-0 rounded border border-border bg-def-200 px-1.5 py-0.5 font-mono text-[11px]', 'text-muted-foreground cursor-pointer', )} - aria-label="Keyboard shortcut: Cmd+J" + aria-label={t('chat.keyboard_shortcut')} onClick={() => openNewChat()} > ⌘J diff --git a/apps/start/src/components/chat/tool-results/chat-profile-result.tsx b/apps/start/src/components/chat/tool-results/chat-profile-result.tsx index f775f9354..1299f47a5 100644 --- a/apps/start/src/components/chat/tool-results/chat-profile-result.tsx +++ b/apps/start/src/components/chat/tool-results/chat-profile-result.tsx @@ -3,6 +3,7 @@ import { isProfileFullError, type ProfileFullSuccess, } from './output-types'; +import { useTranslation } from 'react-i18next'; import { ResultCard, ResultLabel, @@ -29,12 +30,13 @@ export function ChatProfileFullResult({ part }: ToolResultProps) { } function Inner({ output }: { output: unknown }) { + const { t } = useTranslation(); const value = asProfileFullOutput(output); if (!value) { return (
- No profile + {t('chat.result_no_profile')}
); @@ -54,6 +56,7 @@ function Inner({ output }: { output: unknown }) { } function SuccessCard({ value }: { value: ProfileFullSuccess }) { + const { t } = useTranslation(); const { profile, metrics, dashboard_url } = value; const title = profile && @@ -62,32 +65,35 @@ function SuccessCard({ value }: { value: ProfileFullSuccess }) { profile.id); return ( - + {metrics && (
{typeof metrics.sessions === 'number' && ( - Sessions + {t('chat.result_sessions')} {metrics.sessions} )} {typeof metrics.totalEvents === 'number' && ( - Total events + {t('chat.result_total_events')} {metrics.totalEvents} )} {typeof metrics.avgSessionDurationMin === 'number' && ( - Avg session + {t('chat.result_avg_session')} - {metrics.avgSessionDurationMin.toFixed(1)} min + {t('chat.result_minutes', { + count: metrics.avgSessionDurationMin, + value: metrics.avgSessionDurationMin.toFixed(1), + })} )} {typeof metrics.bounceRate === 'number' && ( - Bounce rate + {t('chat.result_bounce_rate')} {(metrics.bounceRate * 100).toFixed(0)}% @@ -95,7 +101,7 @@ function SuccessCard({ value }: { value: ProfileFullSuccess }) { )} {typeof metrics.revenue === 'number' && metrics.revenue > 0 && ( - Revenue + {t('chat.result_revenue')} ${metrics.revenue.toFixed(2)} )} @@ -108,7 +114,7 @@ function SuccessCard({ value }: { value: ProfileFullSuccess }) { rel="noopener noreferrer" className="block px-3 py-1.5 text-sm text-muted-foreground hover:underline" > - Open profile → + {t('chat.result_open_profile')} )} diff --git a/apps/start/src/components/chat/tool-results/chat-report-result.tsx b/apps/start/src/components/chat/tool-results/chat-report-result.tsx index 8d119f70e..1256456c1 100644 --- a/apps/start/src/components/chat/tool-results/chat-report-result.tsx +++ b/apps/start/src/components/chat/tool-results/chat-report-result.tsx @@ -3,10 +3,25 @@ import { Button } from '@/components/ui/button'; import { pushModal } from '@/modals'; import type { IReport, IReportInput } from '@openpanel/validation'; import { SaveIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { asReportOutput } from './output-types'; import { ResultCard, ToolStateGuard } from './shared'; import type { ToolResultProps } from './types'; +const CHART_TYPE_LABEL_KEYS = { + linear: 'chat.result_chart_type_linear', + bar: 'chat.result_chart_type_bar', + area: 'chat.result_chart_type_area', + pie: 'chat.result_chart_type_pie', + funnel: 'chat.result_chart_type_funnel', + metric: 'chat.result_chart_type_metric', + retention: 'chat.result_chart_type_retention', + histogram: 'chat.result_chart_type_histogram', + sankey: 'chat.result_chart_type_sankey', + map: 'chat.result_chart_type_map', + conversion: 'chat.result_chart_type_conversion', +} as const; + /** * Renders the result of `get_report_data` / `generate_report` / * `preview_report_with_changes` / `get_funnel` / `get_rolling_active_users` — @@ -42,12 +57,13 @@ function ChatReportInner({ input: unknown; toolType: string; }) { + const { t } = useTranslation(); const value = asReportOutput(output); if (!value || value.error) { return (
- {value?.error ?? 'No data'} + {value?.error ?? t('chat.result_no_data')}
); @@ -56,18 +72,38 @@ function ChatReportInner({ const report = value.report; if (!report || !report.chartType) { return ( - +
- Report data returned but no renderable config. + {t('chat.result_no_renderable_report_config')}
); } + const translateTitleLabel = (label: TitleLabel | null): string | null => { + if (!label) return null; + if ('text' in label) return label.text; + if ('suffix' in label) { + const kind = translateTitleLabel(label.kind); + return kind ? `${kind}: ${label.suffix}` : label.suffix; + } + const values: Record = Object.fromEntries( + Object.entries(label.values ?? {}).map(([key, value]) => [ + key, + typeof value === 'object' && value !== null + ? translateTitleLabel(value) + : value, + ]), + ); + return t(label.key, values); + }; + const title = value.name ?? - deriveTitleFromInput(toolType, input) ?? - `${humanizeChartType(String(report.chartType))} report`; + translateTitleLabel(deriveTitleFromInput(toolType, input)) ?? + t('chat.result_chart_report_title', { + chart: translateTitleLabel(humanizeChartType(String(report.chartType))), + }); return ( @@ -91,7 +127,7 @@ function ChatReportInner({ rel="noopener noreferrer" className="text-sm text-muted-foreground hover:underline" > - Open in dashboard → + {t('chat.result_open_in_dashboard')} {!report.id && ( )}
@@ -121,7 +157,15 @@ function ChatReportInner({ * — we can do better than `"LINEAR chart"` by reading what the * user actually asked for. */ -function deriveTitleFromInput(toolType: string, input: unknown): string | null { +type TitleLabel = + | { + key: string; + values?: Record; + } + | { text: string } + | { kind: TitleLabel; suffix: string }; + +function deriveTitleFromInput(toolType: string, input: unknown): TitleLabel | null { if (!input || typeof input !== 'object') return null; const args = input as { steps?: unknown; @@ -135,16 +179,31 @@ function deriveTitleFromInput(toolType: string, input: unknown): string | null { case 'tool-get_funnel': { if (Array.isArray(args.steps) && args.steps.length > 0) { const names = args.steps.filter((s): s is string => typeof s === 'string'); - if (names.length > 0) return `Funnel: ${names.join(' → ')}`; + if (names.length > 0) { + return { + key: 'chat.result_funnel_title', + values: { steps: names.join(' → ') }, + }; + } } - return 'Funnel'; + return { key: 'chat.result_chart_type_funnel' }; } case 'tool-get_rolling_active_users': { const w = args.windowDays ?? 1; - const label = w === 1 ? 'DAU' : w === 7 ? 'WAU' : w === 30 ? 'MAU' : `${w}-day active users`; + const label = + w === 1 + ? 'DAU' + : w === 7 + ? 'WAU' + : w === 30 + ? 'MAU' + : { key: 'chat.result_day_active_users', values: { count: w } }; const days = args.days ?? 30; - return `${label} — last ${days} days`; + return { + key: 'chat.result_active_users_title', + values: { label: typeof label === 'string' ? label : label, count: days }, + }; } case 'tool-generate_report': { @@ -155,10 +214,9 @@ function deriveTitleFromInput(toolType: string, input: unknown): string | null { : []; const kind = args.chartType ? humanizeChartType(args.chartType) - : 'Report'; + : { key: 'chat.result_report' }; if (events.length === 0) return kind; - if (events.length === 1) return `${kind}: ${events[0]}`; - return `${kind}: ${events.join(', ')}`; + return { kind, suffix: events.join(', ') }; } default: @@ -166,31 +224,12 @@ function deriveTitleFromInput(toolType: string, input: unknown): string | null { } } -function humanizeChartType(type: string): string { - switch (type) { - case 'linear': - return 'Line chart'; - case 'bar': - return 'Bar chart'; - case 'area': - return 'Area chart'; - case 'pie': - return 'Pie chart'; - case 'funnel': - return 'Funnel'; - case 'metric': - return 'Metric'; - case 'retention': - return 'Retention'; - case 'histogram': - return 'Histogram'; - case 'sankey': - return 'Sankey'; - case 'map': - return 'Map'; - case 'conversion': - return 'Conversion'; - default: - return type.charAt(0).toUpperCase() + type.slice(1); +function humanizeChartType(type: string): TitleLabel { + const key = + CHART_TYPE_LABEL_KEYS[type as keyof typeof CHART_TYPE_LABEL_KEYS]; + if (key) { + return { key }; } + + return { text: type.charAt(0).toUpperCase() + type.slice(1) }; } diff --git a/apps/start/src/components/chat/tool-results/chat-table-result.tsx b/apps/start/src/components/chat/tool-results/chat-table-result.tsx index 2895c1036..38e08da4e 100644 --- a/apps/start/src/components/chat/tool-results/chat-table-result.tsx +++ b/apps/start/src/components/chat/tool-results/chat-table-result.tsx @@ -2,6 +2,7 @@ import { DefaultToolResult } from './default-tool-result'; import { normalizeTableOutput, type TableRow } from './output-types'; import { ResultCard, ToolStateGuard } from './shared'; import type { ToolResultProps } from './types'; +import { useTranslation } from 'react-i18next'; /** * Generic compact table for top-pages / top-referrers / breakdowns / etc. @@ -36,6 +37,7 @@ export function ChatTableResult(props: ToolResultProps) { } function ChatTableInner({ output }: { output: unknown }) { + const { t } = useTranslation(); const { rows, total, truncated } = normalizeTableOutput(output); // Empty case is handled at the wrapper level (falls through to // DefaultToolResult) — this function only runs with rows present. @@ -86,8 +88,11 @@ function ChatTableInner({ output }: { output: unknown }) { {rows.length > 15 && (
- Showing 15 of {total ?? rows.length} - {truncated && ' (truncated)'} + {t('chat.table_showing_limited_rows', { + shown: 15, + total: total ?? rows.length, + })} + {truncated && ` ${t('chat.table_truncated_suffix')}`}
)}
diff --git a/apps/start/src/components/chat/tool-results/chat-ui-apply-result.tsx b/apps/start/src/components/chat/tool-results/chat-ui-apply-result.tsx index 935a260b2..ded2b33e6 100644 --- a/apps/start/src/components/chat/tool-results/chat-ui-apply-result.tsx +++ b/apps/start/src/components/chat/tool-results/chat-ui-apply-result.tsx @@ -1,7 +1,8 @@ -import { timeWindows } from '@openpanel/constants'; import { CheckIcon } from 'lucide-react'; import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import type { ToolResultProps } from './types'; +import { getTimeWindowLabelKey } from '@/utils/time-window-label'; /** * Renderers for the client-side UI-mutator tools: @@ -17,6 +18,7 @@ import type { ToolResultProps } from './types'; */ export function ApplyFiltersResult({ part }: ToolResultProps) { + const { t } = useTranslation(); const input = (part.input ?? {}) as { range?: string; startDate?: string; @@ -25,18 +27,28 @@ export function ApplyFiltersResult({ part }: ToolResultProps) { }; const parts: string[] = []; if (input.startDate && input.endDate) { - parts.push(`date range: ${input.startDate} → ${input.endDate}`); + parts.push( + t('chat.ui_apply_date_range_summary', { + value: `${input.startDate} → ${input.endDate}`, + }), + ); } else if (input.range) { - parts.push(`date range: ${formatRange(input.range)}`); + parts.push( + t('chat.ui_apply_date_range_summary', { + value: formatRange(input.range, t), + }), + ); } if (input.interval) { - parts.push(`interval: ${input.interval}`); + parts.push(t('chat.ui_apply_interval_summary', { value: input.interval })); } - const summary = parts.length > 0 ? parts.join(' · ') : 'filters'; - return Applied {summary}; + const summary = + parts.length > 0 ? parts.join(' · ') : t('chat.ui_apply_filters_summary'); + return {t('chat.ui_apply_applied_summary', { summary })}; } export function SetPropertyFiltersResult({ part }: ToolResultProps) { + const { t } = useTranslation(); const input = (part.input ?? {}) as { filters?: Array<{ name?: string; @@ -46,7 +58,7 @@ export function SetPropertyFiltersResult({ part }: ToolResultProps) { }; const filters = Array.isArray(input.filters) ? input.filters : []; if (filters.length === 0) { - return Cleared property filters; + return {t('chat.ui_apply_cleared_property_filters')}; } const summary = filters .map((f) => { @@ -55,18 +67,22 @@ export function SetPropertyFiltersResult({ part }: ToolResultProps) { return `${f.name}${op}${value ? ` = ${value}` : ''}`; }) .join(' · '); - return Applied filters: {summary}; + return {t('chat.ui_apply_applied_filters', { summary })}; } export function SetEventNamesFilterResult({ part }: ToolResultProps) { + const { t } = useTranslation(); const input = (part.input ?? {}) as { eventNames?: string[] }; const names = Array.isArray(input.eventNames) ? input.eventNames : []; if (names.length === 0) { - return Cleared event-name filter; + return {t('chat.ui_apply_cleared_event_filter')}; } return ( - Filtered to {names.length === 1 ? 'event' : 'events'}: {names.join(', ')} + {t('chat.ui_apply_filtered_events', { + count: names.length, + names: names.join(', '), + })} ); } @@ -80,6 +96,7 @@ function AppliedChip({ children }: { children: ReactNode }) { ); } -function formatRange(range: string): string { - return timeWindows[range as keyof typeof timeWindows]?.label ?? range; +function formatRange(range: string, t: (key: string) => string): string { + const key = getTimeWindowLabelKey(range); + return key ? t(key) : range; } diff --git a/apps/start/src/components/chat/tool-results/default-tool-result.tsx b/apps/start/src/components/chat/tool-results/default-tool-result.tsx index df79311c0..bc7119370 100644 --- a/apps/start/src/components/chat/tool-results/default-tool-result.tsx +++ b/apps/start/src/components/chat/tool-results/default-tool-result.tsx @@ -1,6 +1,12 @@ import { ChevronRightIcon } from 'lucide-react'; import { useState } from 'react'; -import { ResultCard, ToolDoneBadge, ToolStateGuard } from './shared'; +import { useTranslation } from 'react-i18next'; +import { + ResultCard, + ToolDoneBadge, + ToolStateGuard, + useToolPhraseTranslator, +} from './shared'; import { getToolPhrase } from './tool-labels'; import type { ToolResultProps } from './types'; @@ -29,13 +35,15 @@ function DefaultInner({ toolName: string; output: unknown; }) { + const { t } = useTranslation(); + const translateToolPhrase = useToolPhraseTranslator(); const [expanded, setExpanded] = useState(false); if (output == null) { return ( - +
- No result. + {t('chat.tool_result_empty')}
); @@ -43,7 +51,7 @@ function DefaultInner({ if (typeof output === 'string') { return ( - +
{output}
); @@ -64,8 +72,8 @@ function DefaultInner({ /> {Array.isArray(output) - ? `${output.length} item${output.length === 1 ? '' : 's'}` - : 'View raw output'} + ? t('chat.tool_result_item_count', { count: output.length }) + : t('chat.tool_result_view_raw_output')} {expanded && ( diff --git a/apps/start/src/components/chat/tool-results/shared.tsx b/apps/start/src/components/chat/tool-results/shared.tsx index b85c77717..88964a902 100644 --- a/apps/start/src/components/chat/tool-results/shared.tsx +++ b/apps/start/src/components/chat/tool-results/shared.tsx @@ -2,7 +2,8 @@ import { Skeleton } from '@/components/skeleton'; import { cn } from '@/utils/cn'; import { AlertCircleIcon, CheckIcon, SparklesIcon } from 'lucide-react'; import type { ReactNode } from 'react'; -import { getToolPhrase } from './tool-labels'; +import { useTranslation } from 'react-i18next'; +import { getToolPhrase, type ToolPhraseLabel } from './tool-labels'; /** * Wrapper that handles the tool-part state machine. Shows a shimmer @@ -23,6 +24,8 @@ export function ToolStateGuard({ pending?: ReactNode; children: ReactNode; }) { + const { t } = useTranslation(); + if (state === 'input-streaming' || state === 'input-available') { return pending ?? ; } @@ -30,7 +33,7 @@ export function ToolStateGuard({ return (
- {errorText ?? 'Tool failed'} + {errorText ?? t('chat.tool_failed')}
); } @@ -45,7 +48,11 @@ export function ToolStateGuard({ * while a tool's input is streaming or it's executing on the server. */ export function ToolActivityBadge({ toolName }: { toolName?: string }) { - const label = toolName ? getToolPhrase(toolName, 'active') : 'Working'; + const { t } = useTranslation(); + const translateToolPhrase = useToolPhraseTranslator(); + const label = toolName + ? translateToolPhrase(getToolPhrase(toolName, 'active')) + : t('chat.tool_working'); return (
@@ -66,12 +73,14 @@ export function ToolDoneBadge({ toolName: string; children?: ReactNode; }) { + const translateToolPhrase = useToolPhraseTranslator(); + return (
- {getToolPhrase(toolName, 'done')} + {translateToolPhrase(getToolPhrase(toolName, 'done'))} {children &&
{children}
} @@ -119,3 +128,25 @@ export function ResultValue({ children, mono = true }: { children: ReactNode; mo } export { Skeleton }; + +export function useToolPhraseTranslator() { + const { t } = useTranslation(); + + const translateToolPhrase = (label: ToolPhraseLabel): string => { + if ('text' in label) return label.text; + const values: Record = Object.fromEntries( + Object.entries(label.values ?? {}).map(([key, value]) => [ + key, + typeof value === 'object' && value !== null + ? translateToolPhrase(value) + : value, + ]), + ); + return t(label.key, { + defaultValue: label.fallback, + ...values, + }); + }; + + return translateToolPhrase; +} diff --git a/apps/start/src/components/chat/tool-results/tool-labels.ts b/apps/start/src/components/chat/tool-results/tool-labels.ts index be70cd4a3..af2a6813a 100644 --- a/apps/start/src/components/chat/tool-results/tool-labels.ts +++ b/apps/start/src/components/chat/tool-results/tool-labels.ts @@ -3,271 +3,770 @@ * * Each tool gets a tuple: (verb-form for in-progress, noun-form for * completed). E.g. - * list_event_names → ["Looking up event names", "Event names"] + * list_event_names -> ["Looking up event names", "Event names"] * * Falls back to a generic transformation when a tool isn't in the - * map: snake_case → "Snake case", with verb-prefix heuristics - * ("get_" → "Loading", "list_" → "Looking up", "find_" → "Finding"). + * map: snake_case -> "Snake case", with verb-prefix heuristics + * ("get_" -> "Loading", "list_" -> "Looking up", "find_" -> "Finding"). */ -const VERB_PREFIXES: Array<[string, string]> = [ - ['list_', 'Looking up'], - ['get_', 'Loading'], - ['find_', 'Finding'], - ['query_', 'Querying'], - ['analyze_', 'Analyzing'], - ['compare_', 'Comparing'], - ['correlate_', 'Correlating'], - ['explain_', 'Explaining'], - ['suggest_', 'Suggesting'], - ['preview_', 'Previewing'], - ['generate_', 'Generating'], - ['apply_', 'Applying'], - ['set_', 'Updating'], - ['gsc_', 'Loading SEO'], -]; +const VERB_PREFIXES = [ + ['list_', 'chat.tool_fallback_verb_list', 'Looking up'], + ['get_', 'chat.tool_fallback_verb_get', 'Loading'], + ['find_', 'chat.tool_fallback_verb_find', 'Finding'], + ['query_', 'chat.tool_fallback_verb_query', 'Querying'], + ['analyze_', 'chat.tool_fallback_verb_analyze', 'Analyzing'], + ['compare_', 'chat.tool_fallback_verb_compare', 'Comparing'], + ['correlate_', 'chat.tool_fallback_verb_correlate', 'Correlating'], + ['explain_', 'chat.tool_fallback_verb_explain', 'Explaining'], + ['suggest_', 'chat.tool_fallback_verb_suggest', 'Suggesting'], + ['preview_', 'chat.tool_fallback_verb_preview', 'Previewing'], + ['generate_', 'chat.tool_fallback_verb_generate', 'Generating'], + ['apply_', 'chat.tool_fallback_verb_apply', 'Applying'], + ['set_', 'chat.tool_fallback_verb_set', 'Updating'], + ['gsc_', 'chat.tool_fallback_verb_gsc', 'Loading SEO'], +] as const; -const PHRASES: Record< - string, - { active: string; done: string } -> = { +const PHRASES = { // Discovery - list_event_names: { active: 'Looking up event names', done: 'Event names' }, + list_event_names: { + active: { + key: 'chat.tool_list_event_names_active', + fallback: 'Looking up event names', + }, + done: { + key: 'chat.tool_list_event_names_done', + fallback: 'Event names', + }, + }, list_event_properties: { - active: 'Looking up properties', - done: 'Event properties', + active: { + key: 'chat.tool_list_event_properties_active', + fallback: 'Looking up properties', + }, + done: { + key: 'chat.tool_list_event_properties_done', + fallback: 'Event properties', + }, }, get_event_property_values: { - active: 'Loading property values', - done: 'Property values', + active: { + key: 'chat.tool_get_event_property_values_active', + fallback: 'Loading property values', + }, + done: { + key: 'chat.tool_get_event_property_values_done', + fallback: 'Property values', + }, }, // Saved reports & dashboards - list_dashboards: { active: 'Loading dashboards', done: 'Dashboards' }, - list_reports: { active: 'Loading reports', done: 'Reports' }, - get_report_data: { active: 'Running report', done: 'Report' }, - generate_report: { active: 'Building report', done: 'Report' }, + list_dashboards: { + active: { + key: 'chat.tool_list_dashboards_active', + fallback: 'Loading dashboards', + }, + done: { + key: 'chat.tool_list_dashboards_done', + fallback: 'Dashboards', + }, + }, + list_reports: { + active: { + key: 'chat.tool_list_reports_active', + fallback: 'Loading reports', + }, + done: { + key: 'chat.tool_list_reports_done', + fallback: 'Reports', + }, + }, + get_report_data: { + active: { + key: 'chat.tool_get_report_data_active', + fallback: 'Running report', + }, + done: { + key: 'chat.tool_get_report_data_done', + fallback: 'Report', + }, + }, + generate_report: { + active: { + key: 'chat.tool_generate_report_active', + fallback: 'Building report', + }, + done: { + key: 'chat.tool_generate_report_done', + fallback: 'Report', + }, + }, // Aggregate analytics - get_analytics_overview: { active: 'Loading overview', done: 'Overview' }, - get_top_pages: { active: 'Loading top pages', done: 'Top pages' }, + get_analytics_overview: { + active: { + key: 'chat.tool_get_analytics_overview_active', + fallback: 'Loading overview', + }, + done: { + key: 'chat.tool_get_analytics_overview_done', + fallback: 'Overview', + }, + }, + get_top_pages: { + active: { + key: 'chat.tool_get_top_pages_active', + fallback: 'Loading top pages', + }, + done: { + key: 'chat.tool_get_top_pages_done', + fallback: 'Top pages', + }, + }, get_top_referrers: { - active: 'Loading top referrers', - done: 'Top referrers', + active: { + key: 'chat.tool_get_top_referrers_active', + fallback: 'Loading top referrers', + }, + done: { + key: 'chat.tool_get_top_referrers_done', + fallback: 'Top referrers', + }, }, get_country_breakdown: { - active: 'Loading geo breakdown', - done: 'Geo breakdown', + active: { + key: 'chat.tool_get_country_breakdown_active', + fallback: 'Loading geo breakdown', + }, + done: { + key: 'chat.tool_get_country_breakdown_done', + fallback: 'Geo breakdown', + }, }, get_device_breakdown: { - active: 'Loading device breakdown', - done: 'Device breakdown', + active: { + key: 'chat.tool_get_device_breakdown_active', + fallback: 'Loading device breakdown', + }, + done: { + key: 'chat.tool_get_device_breakdown_done', + fallback: 'Device breakdown', + }, }, get_rolling_active_users: { - active: 'Loading active users', - done: 'Active users', + active: { + key: 'chat.tool_get_rolling_active_users_active', + fallback: 'Loading active users', + }, + done: { + key: 'chat.tool_get_rolling_active_users_done', + fallback: 'Active users', + }, + }, + get_funnel: { + active: { + key: 'chat.tool_get_funnel_active', + fallback: 'Building funnel', + }, + done: { + key: 'chat.tool_get_funnel_done', + fallback: 'Funnel', + }, }, - get_funnel: { active: 'Building funnel', done: 'Funnel' }, get_retention_cohort: { - active: 'Building retention cohort', - done: 'Retention', + active: { + key: 'chat.tool_get_retention_cohort_active', + fallback: 'Building retention cohort', + }, + done: { + key: 'chat.tool_get_retention_cohort_done', + fallback: 'Retention', + }, + }, + get_user_flow: { + active: { + key: 'chat.tool_get_user_flow_active', + fallback: 'Building user flow', + }, + done: { + key: 'chat.tool_get_user_flow_done', + fallback: 'User flow', + }, }, - get_user_flow: { active: 'Building user flow', done: 'User flow' }, // Free-form - query_events: { active: 'Querying events', done: 'Events' }, - query_sessions: { active: 'Querying sessions', done: 'Sessions' }, - find_profiles: { active: 'Finding profiles', done: 'Profiles' }, + query_events: { + active: { + key: 'chat.tool_query_events_active', + fallback: 'Querying events', + }, + done: { + key: 'chat.tool_query_events_done', + fallback: 'Events', + }, + }, + query_sessions: { + active: { + key: 'chat.tool_query_sessions_active', + fallback: 'Querying sessions', + }, + done: { + key: 'chat.tool_query_sessions_done', + fallback: 'Sessions', + }, + }, + find_profiles: { + active: { + key: 'chat.tool_find_profiles_active', + fallback: 'Finding profiles', + }, + done: { + key: 'chat.tool_find_profiles_done', + fallback: 'Profiles', + }, + }, // Profile - get_profile_full: { active: 'Loading profile', done: 'Profile' }, + get_profile_full: { + active: { + key: 'chat.tool_get_profile_full_active', + fallback: 'Loading profile', + }, + done: { + key: 'chat.tool_get_profile_full_done', + fallback: 'Profile', + }, + }, get_profile_events: { - active: 'Loading profile events', - done: 'Profile events', + active: { + key: 'chat.tool_get_profile_events_active', + fallback: 'Loading profile events', + }, + done: { + key: 'chat.tool_get_profile_events_done', + fallback: 'Profile events', + }, }, get_profile_sessions: { - active: 'Loading profile sessions', - done: 'Profile sessions', + active: { + key: 'chat.tool_get_profile_sessions_active', + fallback: 'Loading profile sessions', + }, + done: { + key: 'chat.tool_get_profile_sessions_done', + fallback: 'Profile sessions', + }, }, get_profile_metrics: { - active: 'Loading profile metrics', - done: 'Profile metrics', + active: { + key: 'chat.tool_get_profile_metrics_active', + fallback: 'Loading profile metrics', + }, + done: { + key: 'chat.tool_get_profile_metrics_done', + fallback: 'Profile metrics', + }, }, get_profile_journey: { - active: 'Loading user journey', - done: 'User journey', + active: { + key: 'chat.tool_get_profile_journey_active', + fallback: 'Loading user journey', + }, + done: { + key: 'chat.tool_get_profile_journey_done', + fallback: 'User journey', + }, }, get_profile_groups: { - active: 'Loading profile groups', - done: 'Profile groups', + active: { + key: 'chat.tool_get_profile_groups_active', + fallback: 'Loading profile groups', + }, + done: { + key: 'chat.tool_get_profile_groups_done', + fallback: 'Profile groups', + }, }, compare_profile_to_average: { - active: 'Comparing to average', - done: 'Profile comparison', + active: { + key: 'chat.tool_compare_profile_to_average_active', + fallback: 'Comparing to average', + }, + done: { + key: 'chat.tool_compare_profile_to_average_done', + fallback: 'Profile comparison', + }, }, // Session - get_session_full: { active: 'Loading session', done: 'Session' }, - get_session_path: { active: 'Loading session path', done: 'Session path' }, + get_session_full: { + active: { + key: 'chat.tool_get_session_full_active', + fallback: 'Loading session', + }, + done: { + key: 'chat.tool_get_session_full_done', + fallback: 'Session', + }, + }, + get_session_path: { + active: { + key: 'chat.tool_get_session_path_active', + fallback: 'Loading session path', + }, + done: { + key: 'chat.tool_get_session_path_done', + fallback: 'Session path', + }, + }, get_session_events: { - active: 'Loading session events', - done: 'Session events', + active: { + key: 'chat.tool_get_session_events_active', + fallback: 'Loading session events', + }, + done: { + key: 'chat.tool_get_session_events_done', + fallback: 'Session events', + }, }, get_similar_sessions: { - active: 'Finding similar sessions', - done: 'Similar sessions', + active: { + key: 'chat.tool_get_similar_sessions_active', + fallback: 'Finding similar sessions', + }, + done: { + key: 'chat.tool_get_similar_sessions_done', + fallback: 'Similar sessions', + }, }, compare_session_to_typical: { - active: 'Comparing to typical', - done: 'Session comparison', + active: { + key: 'chat.tool_compare_session_to_typical_active', + fallback: 'Comparing to typical', + }, + done: { + key: 'chat.tool_compare_session_to_typical_done', + fallback: 'Session comparison', + }, }, get_session_referrer_context: { - active: 'Loading referrer context', - done: 'Referrer context', + active: { + key: 'chat.tool_get_session_referrer_context_active', + fallback: 'Loading referrer context', + }, + done: { + key: 'chat.tool_get_session_referrer_context_done', + fallback: 'Referrer context', + }, }, get_session_replay_summary: { - active: 'Loading session replay', - done: 'Session replay', + active: { + key: 'chat.tool_get_session_replay_summary_active', + fallback: 'Loading session replay', + }, + done: { + key: 'chat.tool_get_session_replay_summary_done', + fallback: 'Session replay', + }, }, // Pages get_page_performance: { - active: 'Loading page performance', - done: 'Page performance', + active: { + key: 'chat.tool_get_page_performance_active', + fallback: 'Loading page performance', + }, + done: { + key: 'chat.tool_get_page_performance_done', + fallback: 'Page performance', + }, }, get_page_conversions: { - active: 'Loading page conversions', - done: 'Page conversions', + active: { + key: 'chat.tool_get_page_conversions_active', + fallback: 'Loading page conversions', + }, + done: { + key: 'chat.tool_get_page_conversions_done', + fallback: 'Page conversions', + }, }, get_entry_exit_pages: { - active: 'Loading entry/exit pages', - done: 'Entry/exit pages', + active: { + key: 'chat.tool_get_entry_exit_pages_active', + fallback: 'Loading entry/exit pages', + }, + done: { + key: 'chat.tool_get_entry_exit_pages_done', + fallback: 'Entry/exit pages', + }, }, find_declining_pages: { - active: 'Finding declining pages', - done: 'Declining pages', + active: { + key: 'chat.tool_find_declining_pages_active', + fallback: 'Finding declining pages', + }, + done: { + key: 'chat.tool_find_declining_pages_done', + fallback: 'Declining pages', + }, }, // SEO / GSC - gsc_get_overview: { active: 'Loading SEO overview', done: 'SEO overview' }, - gsc_get_top_queries: { active: 'Loading top queries', done: 'Top queries' }, + gsc_get_overview: { + active: { + key: 'chat.tool_gsc_get_overview_active', + fallback: 'Loading SEO overview', + }, + done: { + key: 'chat.tool_gsc_get_overview_done', + fallback: 'SEO overview', + }, + }, + gsc_get_top_queries: { + active: { + key: 'chat.tool_gsc_get_top_queries_active', + fallback: 'Loading top queries', + }, + done: { + key: 'chat.tool_gsc_get_top_queries_done', + fallback: 'Top queries', + }, + }, gsc_get_top_pages: { - active: 'Loading top SEO pages', - done: 'Top SEO pages', + active: { + key: 'chat.tool_gsc_get_top_pages_active', + fallback: 'Loading top SEO pages', + }, + done: { + key: 'chat.tool_gsc_get_top_pages_done', + fallback: 'Top SEO pages', + }, }, gsc_get_query_details: { - active: 'Loading query details', - done: 'Query details', + active: { + key: 'chat.tool_gsc_get_query_details_active', + fallback: 'Loading query details', + }, + done: { + key: 'chat.tool_gsc_get_query_details_done', + fallback: 'Query details', + }, }, gsc_get_page_details: { - active: 'Loading page details', - done: 'Page details', + active: { + key: 'chat.tool_gsc_get_page_details_active', + fallback: 'Loading page details', + }, + done: { + key: 'chat.tool_gsc_get_page_details_done', + fallback: 'Page details', + }, }, gsc_get_query_opportunities: { - active: 'Finding query opportunities', - done: 'Query opportunities', + active: { + key: 'chat.tool_gsc_get_query_opportunities_active', + fallback: 'Finding query opportunities', + }, + done: { + key: 'chat.tool_gsc_get_query_opportunities_done', + fallback: 'Query opportunities', + }, }, gsc_get_cannibalization: { - active: 'Checking cannibalization', - done: 'Cannibalization', + active: { + key: 'chat.tool_gsc_get_cannibalization_active', + fallback: 'Checking cannibalization', + }, + done: { + key: 'chat.tool_gsc_get_cannibalization_done', + fallback: 'Cannibalization', + }, }, correlate_seo_with_traffic: { - active: 'Correlating SEO with traffic', - done: 'SEO/traffic correlation', + active: { + key: 'chat.tool_correlate_seo_with_traffic_active', + fallback: 'Correlating SEO with traffic', + }, + done: { + key: 'chat.tool_correlate_seo_with_traffic_done', + fallback: 'SEO/traffic correlation', + }, }, // Events analyze_event_distribution: { - active: 'Analyzing event distribution', - done: 'Event distribution', + active: { + key: 'chat.tool_analyze_event_distribution_active', + fallback: 'Analyzing event distribution', + }, + done: { + key: 'chat.tool_analyze_event_distribution_done', + fallback: 'Event distribution', + }, + }, + correlate_events: { + active: { + key: 'chat.tool_correlate_events_active', + fallback: 'Correlating events', + }, + done: { + key: 'chat.tool_correlate_events_done', + fallback: 'Event correlation', + }, }, - correlate_events: { active: 'Correlating events', done: 'Event correlation' }, get_event_property_distribution: { - active: 'Loading property distribution', - done: 'Property distribution', + active: { + key: 'chat.tool_get_event_property_distribution_active', + fallback: 'Loading property distribution', + }, + done: { + key: 'chat.tool_get_event_property_distribution_done', + fallback: 'Property distribution', + }, }, list_properties_for_event: { - active: 'Loading properties', - done: 'Event properties', + active: { + key: 'chat.tool_list_properties_for_event_active', + fallback: 'Loading properties', + }, + done: { + key: 'chat.tool_list_properties_for_event_done', + fallback: 'Event properties', + }, }, // Insights - list_insights: { active: 'Loading insights', done: 'Insights' }, - explain_insight: { active: 'Loading insight', done: 'Insight' }, + list_insights: { + active: { + key: 'chat.tool_list_insights_active', + fallback: 'Loading insights', + }, + done: { + key: 'chat.tool_list_insights_done', + fallback: 'Insights', + }, + }, + explain_insight: { + active: { + key: 'chat.tool_explain_insight_active', + fallback: 'Loading insight', + }, + done: { + key: 'chat.tool_explain_insight_done', + fallback: 'Insight', + }, + }, find_related_insights: { - active: 'Finding related insights', - done: 'Related insights', + active: { + key: 'chat.tool_find_related_insights_active', + fallback: 'Finding related insights', + }, + done: { + key: 'chat.tool_find_related_insights_done', + fallback: 'Related insights', + }, }, // Group - get_group_full: { active: 'Loading group', done: 'Group' }, - get_group_members: { active: 'Loading group members', done: 'Group members' }, - get_group_events: { active: 'Loading group events', done: 'Group events' }, + get_group_full: { + active: { + key: 'chat.tool_get_group_full_active', + fallback: 'Loading group', + }, + done: { + key: 'chat.tool_get_group_full_done', + fallback: 'Group', + }, + }, + get_group_members: { + active: { + key: 'chat.tool_get_group_members_active', + fallback: 'Loading group members', + }, + done: { + key: 'chat.tool_get_group_members_done', + fallback: 'Group members', + }, + }, + get_group_events: { + active: { + key: 'chat.tool_get_group_events_active', + fallback: 'Loading group events', + }, + done: { + key: 'chat.tool_get_group_events_done', + fallback: 'Group events', + }, + }, get_group_metrics: { - active: 'Loading group metrics', - done: 'Group metrics', + active: { + key: 'chat.tool_get_group_metrics_active', + fallback: 'Loading group metrics', + }, + done: { + key: 'chat.tool_get_group_metrics_done', + fallback: 'Group metrics', + }, + }, + compare_groups: { + active: { + key: 'chat.tool_compare_groups_active', + fallback: 'Comparing groups', + }, + done: { + key: 'chat.tool_compare_groups_done', + fallback: 'Group comparison', + }, }, - compare_groups: { active: 'Comparing groups', done: 'Group comparison' }, // Report editor preview_report_with_changes: { - active: 'Previewing changes', - done: 'Report preview', + active: { + key: 'chat.tool_preview_report_with_changes_active', + fallback: 'Previewing changes', + }, + done: { + key: 'chat.tool_preview_report_with_changes_done', + fallback: 'Report preview', + }, }, suggest_breakdowns: { - active: 'Suggesting breakdowns', - done: 'Breakdown suggestions', + active: { + key: 'chat.tool_suggest_breakdowns_active', + fallback: 'Suggesting breakdowns', + }, + done: { + key: 'chat.tool_suggest_breakdowns_done', + fallback: 'Breakdown suggestions', + }, }, compare_to_previous_period: { - active: 'Comparing to previous period', - done: 'Period comparison', + active: { + key: 'chat.tool_compare_to_previous_period_active', + fallback: 'Comparing to previous period', + }, + done: { + key: 'chat.tool_compare_to_previous_period_done', + fallback: 'Period comparison', + }, }, find_anomalies_in_current_report: { - active: 'Finding anomalies', - done: 'Anomalies', + active: { + key: 'chat.tool_find_anomalies_in_current_report_active', + fallback: 'Finding anomalies', + }, + done: { + key: 'chat.tool_find_anomalies_in_current_report_done', + fallback: 'Anomalies', + }, }, explain_filter_impact: { - active: 'Analyzing filter impact', - done: 'Filter impact', + active: { + key: 'chat.tool_explain_filter_impact_active', + fallback: 'Analyzing filter impact', + }, + done: { + key: 'chat.tool_explain_filter_impact_done', + fallback: 'Filter impact', + }, }, // UI mutators (client-side) apply_filters: { - active: 'Applying date range', - done: 'Date range updated', + active: { + key: 'chat.tool_apply_filters_active', + fallback: 'Applying date range', + }, + done: { + key: 'chat.tool_apply_filters_done', + fallback: 'Date range updated', + }, }, set_property_filters: { - active: 'Applying filters', - done: 'Filters updated', + active: { + key: 'chat.tool_set_property_filters_active', + fallback: 'Applying filters', + }, + done: { + key: 'chat.tool_set_property_filters_done', + fallback: 'Filters updated', + }, }, set_event_names_filter: { - active: 'Applying event filter', - done: 'Event filter updated', + active: { + key: 'chat.tool_set_event_names_filter_active', + fallback: 'Applying event filter', + }, + done: { + key: 'chat.tool_set_event_names_filter_done', + fallback: 'Event filter updated', + }, }, // References list_references: { - active: 'Loading references', - done: 'References', + active: { + key: 'chat.tool_list_references_active', + fallback: 'Loading references', + }, + done: { + key: 'chat.tool_list_references_done', + fallback: 'References', + }, }, get_references_around: { - active: 'Checking references around this date', - done: 'Nearby references', - }, -}; + active: { + key: 'chat.tool_get_references_around_active', + fallback: 'Checking references around this date', + }, + done: { + key: 'chat.tool_get_references_around_done', + fallback: 'Nearby references', + }, + }, +} as const; export type ToolPhrasePhase = 'active' | 'done'; -export function getToolPhrase(toolName: string, phase: ToolPhrasePhase): string { - const explicit = PHRASES[toolName]; - if (explicit) return explicit[phase]; +export type ToolPhraseLabel = + | { + key: string; + fallback: string; + values?: Record; + } + | { + text: string; + }; + +export function getToolPhrase( + toolName: string, + phase: ToolPhrasePhase, +): ToolPhraseLabel { + const explicit = PHRASES[toolName as keyof typeof PHRASES]; + if (explicit) { + const phrase = explicit[phase]; + return { + key: phrase.key, + fallback: phrase.fallback, + }; + } const matchedPrefix = VERB_PREFIXES.find(([p]) => toolName.startsWith(p)); const verb = matchedPrefix?.[0] ?? ''; - const verbLabel = matchedPrefix?.[1] ?? 'Running'; - + const verbKey = matchedPrefix?.[1] ?? 'chat.tool_fallback_verb_running'; + const verbFallback = matchedPrefix?.[2] ?? 'Running'; const noun = humanizeNoun(toolName.slice(verb.length) || toolName); - return phase === 'active' ? `${verbLabel} ${noun.toLowerCase()}` : noun; + return phase === 'active' + ? { + key: 'chat.tool_fallback_active', + fallback: `${verbFallback} ${noun.toLowerCase()}`, + values: { + verb: { key: verbKey, fallback: verbFallback }, + tool: noun.toLowerCase(), + }, + } + : { text: noun }; } function humanizeNoun(s: string): string { diff --git a/apps/start/src/components/click-to-copy.tsx b/apps/start/src/components/click-to-copy.tsx index 0d912918f..29760e07e 100644 --- a/apps/start/src/components/click-to-copy.tsx +++ b/apps/start/src/components/click-to-copy.tsx @@ -1,4 +1,5 @@ import { clipboard } from '@/utils/clipboard'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Tooltiper } from './ui/tooltip'; @@ -10,14 +11,16 @@ type Props = { }; const ClickToCopy = ({ children, value }: Props) => { + const { t } = useTranslation(); + return ( { clipboard(value); - toast('Copied to clipboard'); + toast(t('ui.copied_to_clipboard')); }} > {children} diff --git a/apps/start/src/components/clients/create-client-success.tsx b/apps/start/src/components/clients/create-client-success.tsx index 09e0c7fe5..ed246e02f 100644 --- a/apps/start/src/components/clients/create-client-success.tsx +++ b/apps/start/src/components/clients/create-client-success.tsx @@ -1,12 +1,14 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { DownloadIcon, RocketIcon } from 'lucide-react'; +import { Trans, useTranslation } from 'react-i18next'; import CopyInput from '../forms/copy-input'; type Props = { id: string; secret: string; type?: 'read' | 'write' | 'root' }; export function CreateClientSuccess({ id, secret, type }: Props) { + const { t } = useTranslation(); const mcpToken = btoa(`${id}:${secret}`); const showMcpToken = type === 'root' || type === 'read'; @@ -24,41 +26,43 @@ export function CreateClientSuccess({ id, secret, type }: Props) { return (
- + {secret && (
- +

- You will only need the secret if you want to send server events. + {t('clients.secret_help')}

)} {secret && showMcpToken && (
- +

- Use this token to authenticate with the MCP server (base64 encoded - client ID and secret). + {t('clients.mcp_token_help')}

)} - Get started! + {t('clients.get_started_title')} - Read our{' '} - - documentation - {' '} - to get started. Easy peasy! + + ), + }} + i18nKey="clients.get_started_description" + />
diff --git a/apps/start/src/components/clients/table/columns.tsx b/apps/start/src/components/clients/table/columns.tsx index 5a7de0ba8..d788fc344 100644 --- a/apps/start/src/components/clients/table/columns.tsx +++ b/apps/start/src/components/clients/table/columns.tsx @@ -10,24 +10,26 @@ import { handleError, useTRPC } from '@/integrations/trpc/react'; import { pushModal, showConfirm } from '@/modals'; import type { RouterOutputs } from '@/trpc/client'; import { clipboard } from '@/utils/clipboard'; +import { useTranslation } from 'react-i18next'; export function useColumns() { + const { t } = useTranslation(); const columns: ColumnDef[] = [ { accessorKey: 'name', - header: 'Name', + header: t('clients.column_name'), cell: ({ row }) => { return
{row.original.name}
; }, }, { accessorKey: 'id', - header: 'Client ID', + header: t('clients.field_client_id'), cell: ({ row }) => , }, { accessorKey: 'createdAt', - header: 'Created at', + header: t('clients.column_created_at'), size: ColumnCreatedAt.size, cell: ({ row }) => { const item = row.original; @@ -41,9 +43,8 @@ export function useColumns() { const deletion = useMutation( trpc.client.remove.mutationOptions({ onSuccess() { - toast('Success', { - description: - 'Client revoked, incoming requests will be rejected.', + toast(t('clients.revoke_success_title'), { + description: t('clients.revoke_success_description'), }); queryClient.invalidateQueries(trpc.client.list.pathFilter()); }, @@ -53,21 +54,21 @@ export function useColumns() { return ( <> clipboard(client.id)}> - Copy client ID + {t('clients.action_copy_client_id')} { pushModal('EditClient', client); }} > - Edit + {t('clients.action_edit')} { showConfirm({ - title: 'Revoke client', - text: 'Are you sure you want to revoke this client? This action cannot be undone.', + title: t('clients.revoke_confirm_title'), + text: t('clients.revoke_confirm_description'), onConfirm() { deletion.mutate({ id: client.id, @@ -77,7 +78,7 @@ export function useColumns() { }} variant="destructive" > - Revoke + {t('clients.action_revoke')} ); diff --git a/apps/start/src/components/cohort/cohort-criteria-builder.tsx b/apps/start/src/components/cohort/cohort-criteria-builder.tsx index ed9c199fc..8038faa00 100644 --- a/apps/start/src/components/cohort/cohort-criteria-builder.tsx +++ b/apps/start/src/components/cohort/cohort-criteria-builder.tsx @@ -13,6 +13,7 @@ import type { PropertyBasedCohortDefinition, } from '@openpanel/validation'; import { PlusIcon, TrashIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { PureFilterItem } from '../report/sidebar/filters/FilterItem'; import { PropertiesCombobox } from '../report/sidebar/PropertiesCombobox'; @@ -25,6 +26,7 @@ export function CohortCriteriaBuilder({ definition, onChange, }: CohortCriteriaBuilderProps) { + const { t } = useTranslation(); const { projectId } = useAppParams(); const eventNames = useEventNames({ projectId }); @@ -51,7 +53,7 @@ export function CohortCriteriaBuilder({ onClick={() => handleTypeChange('event')} className="flex-1" > - Event-based + {t('cohorts.event_based')}
@@ -89,6 +91,7 @@ function EventBasedBuilder({ onChange, eventNames: eventNamesArray, }: EventBasedBuilderProps) { + const { t } = useTranslation(); const eventNames = eventNamesArray.map((event) => ({ value: event.name, label: event.name, @@ -145,17 +148,17 @@ function EventBasedBuilder({ return (
- Match + {t('cohorts.match')}
@@ -176,7 +179,7 @@ function EventBasedBuilder({ onClick={addEventCriteria} icon={PlusIcon} > - Add event criteria + {t('cohorts.add_event_criteria')}
); @@ -195,6 +198,7 @@ function EventCriteriaItem({ onRemove, eventNames, }: EventCriteriaItemProps) { + const { t } = useTranslation(); const addFilter = (propertyName: string) => { onChange({ ...criteria, @@ -247,14 +251,14 @@ function EventCriteriaItem({
- + onChange({ ...criteria, name: String(values[0] ?? '') }) } - placeholder="Select event..." + placeholder={t('cohorts.select_event_placeholder')} className="w-full" />
@@ -270,7 +274,7 @@ function EventCriteriaItem({
- +
@@ -283,16 +287,16 @@ function EventCriteriaItem({ }) } items={[ - { value: 'gte', label: 'At least' }, - { value: 'eq', label: 'Exactly' }, - { value: 'lte', label: 'At most' }, + { value: 'gte', label: t('cohorts.at_least') }, + { value: 'eq', label: t('cohorts.exactly') }, + { value: 'lte', label: t('cohorts.at_most') }, ]} - label="Operator" + label={t('cohorts.operator')} > - times + {t('cohorts.times')}
- +
{criteria.timeframe.type === 'relative' ? ( @@ -377,20 +381,25 @@ function EventCriteriaItem({ }) } items={[ - { value: '7d', label: '7 days' }, - { value: '30d', label: '30 days' }, - { value: '90d', label: '90 days' }, - { value: '180d', label: '180 days' }, - { value: '365d', label: '365 days' }, + { value: '7d', label: t('cohorts.period_days', { count: 7 }) }, + { value: '30d', label: t('cohorts.period_days', { count: 30 }) }, + { value: '90d', label: t('cohorts.period_days', { count: 90 }) }, + { value: '180d', label: t('cohorts.period_days', { count: 180 }) }, + { value: '365d', label: t('cohorts.period_days', { count: 365 }) }, ]} - label="Period" + label={t('cohorts.period')} > ) : !criteria.timeframe.end ? ( @@ -427,7 +436,7 @@ function EventCriteriaItem({ } className="flex-1 rounded border px-2 py-1 text-sm" /> - to + {t('cohorts.to')} 0 && (
{criteria.filters.map((filter) => ( @@ -488,7 +497,7 @@ function EventCriteriaItem({ icon={PlusIcon} disabled={!criteria.name} > - Add filter + {t('filters.add_filter')} )} @@ -505,6 +514,7 @@ function PropertyBasedBuilder({ definition, onChange, }: PropertyBasedBuilderProps) { + const { t } = useTranslation(); const addPropertyFilter = (propertyName: string) => { onChange({ ...definition, @@ -577,17 +587,17 @@ function PropertyBasedBuilder({ return (
- Match + {t('cohorts.match')}
@@ -621,7 +631,7 @@ function PropertyBasedBuilder({ onClick={() => setOpen(true)} icon={PlusIcon} > - Add property filter + {t('cohorts.add_property_filter')} )} diff --git a/apps/start/src/components/cohort/cohort-events-chart.tsx b/apps/start/src/components/cohort/cohort-events-chart.tsx index 77c89326f..366ce25ea 100644 --- a/apps/start/src/components/cohort/cohort-events-chart.tsx +++ b/apps/start/src/components/cohort/cohort-events-chart.tsx @@ -17,12 +17,14 @@ import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useNumber } from '@/hooks/use-numer-formatter'; import { getChartColor } from '@/utils/theme'; import { WidgetHead, WidgetTitle } from '../overview/overview-widget'; +import { useTranslation } from 'react-i18next'; type Props = { data: { date: string; count: number }[]; }; function Tooltip(props: any) { + const { t } = useTranslation(); const number = useNumber(); const formatDate = useFormatDateInterval({ interval: 'day', short: false }); const payload = props.payload?.[0]?.payload; @@ -40,7 +42,9 @@ function Tooltip(props: any) { style={{ background: getChartColor(0) }} />
-
Events
+
+ {t('cohorts.events')} +
- Events last 30 days ({total.toLocaleString()}) + {t('cohorts.events_last_30_days', { + count: total.toLocaleString(), + })} {total === 0 ? (

- No events yet + {t('cohorts.no_events_yet')}

) : (
diff --git a/apps/start/src/components/events/event-list-item.tsx b/apps/start/src/components/events/event-list-item.tsx index 5908d70b2..9610cdfb4 100644 --- a/apps/start/src/components/events/event-list-item.tsx +++ b/apps/start/src/components/events/event-list-item.tsx @@ -7,10 +7,12 @@ import { useAppParams } from '@/hooks/use-app-params'; import { pushModal } from '@/modals'; import { cn } from '@/utils/cn'; import { getProfileName } from '@/utils/getters'; +import { useTranslation } from 'react-i18next'; type EventListItemProps = IServiceEventMinimal | IServiceEvent; export function EventListItem(props: EventListItemProps) { + const { t } = useTranslation(); const { organizationId, projectId } = useAppParams(); const { createdAt, name, path, meta } = props; const profile = 'profile' in props ? props.profile : null; @@ -21,7 +23,7 @@ export function EventListItem(props: EventListItemProps) { return path; } - return `Route: ${path}`; + return t('events.route_name', { path }); } return name.replace(/_/g, ' '); diff --git a/apps/start/src/components/events/event-listener.tsx b/apps/start/src/components/events/event-listener.tsx index 08df9b61d..59fa27cd0 100644 --- a/apps/start/src/components/events/event-listener.tsx +++ b/apps/start/src/components/events/event-listener.tsx @@ -8,12 +8,14 @@ import { useAppParams } from '@/hooks/use-app-params'; import { useDebounceState } from '@/hooks/use-debounce-state'; import useWS from '@/hooks/use-ws'; import { cn } from '@/utils/cn'; +import { useTranslation } from 'react-i18next'; export default function EventListener({ onRefresh, }: { onRefresh: () => void; }) { + const { t } = useTranslation(); const { projectId } = useAppParams(); const counter = useDebounceState(0, 1000); useWS<{ count: number }>( @@ -53,16 +55,19 @@ export default function EventListener({ />
{counter.debounced === 0 ? ( - 'Listening' + t('events.listening') ) : ( - + )} {counter.debounced === 0 - ? 'Listening to new events' - : 'Click to refresh'} + ? t('events.listening_to_new_events') + : t('events.click_to_refresh')} ); diff --git a/apps/start/src/components/events/table/columns.tsx b/apps/start/src/components/events/table/columns.tsx index 27478b332..0bb2461b8 100644 --- a/apps/start/src/components/events/table/columns.tsx +++ b/apps/start/src/components/events/table/columns.tsx @@ -9,13 +9,15 @@ import { KeyValueGrid } from '@/components/ui/key-value-grid'; import { useNumber } from '@/hooks/use-numer-formatter'; import { pushModal } from '@/modals'; import { getProfileName } from '@/utils/getters'; +import { useTranslation } from 'react-i18next'; export function useColumns() { + const { t } = useTranslation(); const number = useNumber(); const columns: ColumnDef[] = [ { accessorKey: 'createdAt', - header: 'Created at', + header: t('events.column_created_at'), size: ColumnCreatedAt.size, cell: ({ row }) => { const session = row.original; @@ -25,7 +27,7 @@ export function useColumns() { { size: 300, accessorKey: 'name', - header: 'Name', + header: t('events.column_name'), cell({ row }) { const { name, path, revenue } = row.original; const fullTitle = @@ -43,7 +45,9 @@ export function useColumns() { return ( <> - Screen: + + {t('events.screen_label')}{' '} + {path} ); @@ -95,7 +99,7 @@ export function useColumns() { }, { accessorKey: 'profileId', - header: 'Profile', + header: t('events.column_profile'), cell({ row }) { const { profile, profileId, deviceId } = row.original; if (profile) { @@ -116,7 +120,7 @@ export function useColumns() { className="whitespace-nowrap font-medium hover:underline" href={`/profiles/${encodeURIComponent(profileId)}`} > - Unknown + {t('events.unknown_profile')} ); } @@ -127,7 +131,7 @@ export function useColumns() { className="whitespace-nowrap font-medium hover:underline" href={`/profiles/${encodeURIComponent(deviceId)}`} > - Anonymous + {t('events.anonymous_profile')} ); } @@ -137,7 +141,7 @@ export function useColumns() { }, { accessorKey: 'sessionId', - header: 'Session ID', + header: t('events.column_session_id'), size: 100, meta: { hidden: true, @@ -156,7 +160,7 @@ export function useColumns() { }, { accessorKey: 'deviceId', - header: 'Device ID', + header: t('events.column_device_id'), size: 320, meta: { hidden: true, @@ -164,7 +168,7 @@ export function useColumns() { }, { accessorKey: 'country', - header: 'Country', + header: t('events.column_country'), size: 150, cell({ row }) { const { country, city } = row.original; @@ -178,7 +182,7 @@ export function useColumns() { }, { accessorKey: 'os', - header: 'OS', + header: t('events.column_os'), size: 130, cell({ row }) { const { os } = row.original; @@ -192,7 +196,7 @@ export function useColumns() { }, { accessorKey: 'browser', - header: 'Browser', + header: t('events.column_browser'), size: 110, cell({ row }) { const { browser } = row.original; @@ -206,7 +210,7 @@ export function useColumns() { }, { accessorKey: 'groups', - header: 'Groups', + header: t('events.column_groups'), size: 200, meta: { hidden: true, @@ -232,7 +236,7 @@ export function useColumns() { }, { accessorKey: 'properties', - header: 'Properties', + header: t('events.column_properties'), size: 400, meta: { hidden: true, @@ -253,7 +257,9 @@ export function useColumns() { if (items.length > limit) { data.push({ name: '', - value: `${items.length - limit} more item${items.length - limit === 1 ? '' : 's'}`, + value: t('events.more_properties_count', { + count: items.length - limit, + }), }); } diff --git a/apps/start/src/components/events/table/index.tsx b/apps/start/src/components/events/table/index.tsx index c545ee802..6114f509a 100644 --- a/apps/start/src/components/events/table/index.tsx +++ b/apps/start/src/components/events/table/index.tsx @@ -26,6 +26,7 @@ import { useAppParams } from '@/hooks/use-app-params'; import { pushModal } from '@/modals'; import type { RouterInputs, RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; +import { useTranslation } from 'react-i18next'; type Props = { query: UseInfiniteQueryResult< @@ -141,6 +142,7 @@ const VirtualizedEventsTable = ({ isLoading, onRowClick, }: VirtualizedEventsTableProps & { onRowClick?: (row: any) => void }) => { + const { t } = useTranslation(); const parentRef = useRef(null); const headerColumns = table.getAllLeafColumns().filter((col) => { @@ -195,8 +197,8 @@ const VirtualizedEventsTable = ({ {!isLoading && data.length === 0 && ( )} @@ -335,6 +337,7 @@ function EventsTableToolbar({ table: Table; showEventListener: boolean; }) { + const { t } = useTranslation(); const { projectId } = useAppParams(); const [startDate, setStartDate] = useQueryState( 'startDate', @@ -365,7 +368,7 @@ function EventsTableToolbar({ > {startDate && endDate ? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}` - : 'Date range'} + : t('events.date_range')} diff --git a/apps/start/src/components/events/table/item.tsx b/apps/start/src/components/events/table/item.tsx index f50ee3247..f43992ac6 100644 --- a/apps/start/src/components/events/table/item.tsx +++ b/apps/start/src/components/events/table/item.tsx @@ -7,6 +7,7 @@ import { formatTimeAgoOrDateTime } from '@/utils/date'; import { getProfileName } from '@/utils/getters'; import type { IServiceEvent } from '@openpanel/db'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { Skeleton } from '../../skeleton'; import { EventIcon } from '../event-icon'; @@ -18,6 +19,7 @@ interface EventItemProps { export const EventItem = memo( ({ event, viewOptions, className }) => { + const { t } = useTranslation(); let url: string | null = ''; if (event.path && event.origin) { if (viewOptions.origin !== false && event.origin) { @@ -81,7 +83,9 @@ export const EventItem = memo( {event.name === 'screen_view' ? ( <> - Visit: + + {t('events.visit_label')} + {url ? url : event.path} diff --git a/apps/start/src/components/feedback-button.tsx b/apps/start/src/components/feedback-button.tsx index 234325126..35f7176c4 100644 --- a/apps/start/src/components/feedback-button.tsx +++ b/apps/start/src/components/feedback-button.tsx @@ -1,8 +1,10 @@ import { useRouteContext } from '@tanstack/react-router'; +import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { op } from '@/utils/op'; export function FeedbackButton({ className }: { className?: string }) { + const { t } = useTranslation(); const context = useRouteContext({ strict: false }); return ( ); } diff --git a/apps/start/src/components/filters/FiltersBuilder.tsx b/apps/start/src/components/filters/FiltersBuilder.tsx index 5019946cb..c288592fb 100644 --- a/apps/start/src/components/filters/FiltersBuilder.tsx +++ b/apps/start/src/components/filters/FiltersBuilder.tsx @@ -11,6 +11,7 @@ import type { IChartEventFilterOperator, IChartEventFilterValue, } from '@openpanel/validation'; +import { useTranslation } from 'react-i18next'; interface FiltersBuilderProps { value: IChartEventFilter[]; @@ -34,9 +35,10 @@ export function FiltersBuilder({ onChange, categories = ['event', 'profile', 'group', 'cohort'], eventName = '', - addLabel = 'Add filter', + addLabel, className, }: FiltersBuilderProps) { + const { t } = useTranslation(); const setFilter = (updated: IChartEventFilter) => { onChange(value.map((f) => (f.id === updated.id ? updated : f))); }; @@ -140,7 +142,7 @@ export function FiltersBuilder({ icon={FilterIcon} onClick={() => setOpen((p) => !p)} > - {addLabel} + {addLabel ?? t('filters.add_filter')} )} diff --git a/apps/start/src/components/filters/TableFilterPills.tsx b/apps/start/src/components/filters/TableFilterPills.tsx index 001e777dd..bd932c2c9 100644 --- a/apps/start/src/components/filters/TableFilterPills.tsx +++ b/apps/start/src/components/filters/TableFilterPills.tsx @@ -14,6 +14,7 @@ import { getCohortIds, type IChartEventFilter, } from '@openpanel/validation'; +import { useTranslation } from 'react-i18next'; interface TableFilterPillsProps { /** URL key the filters live under. Sessions tables use `f`. */ @@ -27,37 +28,48 @@ interface TableFilterPillsProps { nuqsOptions?: NuqsOptions; } -/** Friendly labels for the hard-coded session.* filter family. */ -const SESSION_LABELS: Record = { - 'session.is_bounce': 'Bounced', - 'session.screen_view_count': 'Screen views', - 'session.event_count': 'Events', - 'session.duration': 'Duration', - 'session.revenue': 'Revenue', - 'session.performed_event': 'Performed event', -}; +type FilterNameLabel = + | { key: string; values?: Record } + | { text: string }; -function humanizeFilterName(name: string): string { - if (name === 'cohort' || name.startsWith('cohort:')) return 'Cohort'; - if (SESSION_LABELS[name]) return SESSION_LABELS[name]!; +function humanizeFilterName(name: string): FilterNameLabel { + if (name === 'cohort' || name.startsWith('cohort:')) { + return { key: 'filters.cohort' }; + } + if (name === 'session.is_bounce') return { key: 'filters.session_bounced' }; + if (name === 'session.screen_view_count') { + return { key: 'filters.session_screen_views' }; + } + if (name === 'session.event_count') return { key: 'filters.session_events' }; + if (name === 'session.duration') return { key: 'filters.session_duration' }; + if (name === 'session.revenue') return { key: 'filters.session_revenue' }; + if (name === 'session.performed_event') { + return { key: 'filters.session_performed_event' }; + } if (name.startsWith('profile.')) { const rest = name.replace(/^profile\./, ''); - return `Profile · ${rest.replace(/^properties\./, '')}`; + return { + key: 'filters.profile_property', + values: { property: rest.replace(/^properties\./, '') }, + }; } if (name.startsWith('group.')) { const rest = name.replace(/^group\./, ''); - return `Group · ${rest.replace(/^properties\./, '')}`; + return { + key: 'filters.group_property', + values: { property: rest.replace(/^properties\./, '') }, + }; } - return getPropertyLabel(name); + return { text: getPropertyLabel(name) }; } -function formatFilterValue(filter: IChartEventFilter): string { +function formatFilterValue(filter: IChartEventFilter): string | { key: string } { if (filter.name === 'session.is_bounce') { const v = filter.value[0]; if (v === undefined) return ''; const truthy = typeof v === 'boolean' ? v : String(v).toLowerCase() === 'true'; - return truthy ? 'Yes' : 'No'; + return { key: truthy ? 'filters.yes' : 'filters.no' }; } return filter.value .filter((v) => v !== null && v !== undefined && v !== '') @@ -72,6 +84,7 @@ export function TableFilterPills({ className, nuqsOptions, }: TableFilterPillsProps) { + const { t } = useTranslation(); const { projectId } = useAppParams(); const [filters, setFilters] = useTableFilters(urlKey, nuqsOptions); const cohorts = useCohorts( @@ -100,7 +113,7 @@ export function TableFilterPills({ icon={FilterIcon} onClick={openSheet} > - Filters + {t('filters.filters')} {filters.length > 0 && ( {filters.length} @@ -111,14 +124,17 @@ export function TableFilterPills({ const isCohort = filter.operator === 'inCohort' || filter.operator === 'notInCohort'; const cohortIds = isCohort ? getCohortIds(filter) : []; + const nameLabel = humanizeFilterName(filter.name); const valueText = isCohort ? cohortIds .map( (id) => cohortNames.get(id) ?? cohortNames.get(id.replace(/^cohort:/, '')), ) .filter(Boolean) - .join(', ') || 'pick cohort' + .join(', ') || t('filters.pick_cohort') : formatFilterValue(filter); + const renderedValueText = + typeof valueText === 'string' ? valueText : t(valueText.key); return (
- {humanizeFilterName(filter.name)} + {'text' in nameLabel + ? nameLabel.text + : t(nameLabel.key, nameLabel.values)} - {valueText && ( + {renderedValueText && ( )} diff --git a/apps/start/src/components/full-page-error-state.tsx b/apps/start/src/components/full-page-error-state.tsx index c04c8ba55..204c3fa81 100644 --- a/apps/start/src/components/full-page-error-state.tsx +++ b/apps/start/src/components/full-page-error-state.tsx @@ -1,19 +1,22 @@ import { ServerCrashIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { FullPageEmptyState } from './full-page-empty-state'; export const FullPageErrorState = ({ - title = 'Error...', - description = 'Something went wrong...', + title, + description, children, }: { title?: string; description?: string; children?: React.ReactNode }) => { + const { t } = useTranslation(); + return ( - {description} + {description ?? t('ui.error_description')} {children &&
{children}
}
); diff --git a/apps/start/src/components/full-page-loading-state.tsx b/apps/start/src/components/full-page-loading-state.tsx index aeefc2bf4..f3189f5ba 100644 --- a/apps/start/src/components/full-page-loading-state.tsx +++ b/apps/start/src/components/full-page-loading-state.tsx @@ -1,17 +1,20 @@ import type { LucideIcon } from 'lucide-react'; import { Loader2Icon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { FullPageEmptyState } from './full-page-empty-state'; const FullPageLoadingState = ({ - title = 'Fetching...', - description = 'Please wait while we fetch your data...', + title, + description, }: { title?: string; description?: string }) => { + const { t } = useTranslation(); + return ( ( diff --git a/apps/start/src/components/fullscreen-toggle.tsx b/apps/start/src/components/fullscreen-toggle.tsx index b5674645c..44cc22f00 100644 --- a/apps/start/src/components/fullscreen-toggle.tsx +++ b/apps/start/src/components/fullscreen-toggle.tsx @@ -3,6 +3,7 @@ import { bind } from 'bind-event-listener'; import { ChevronLeftIcon, FullscreenIcon } from 'lucide-react'; import { parseAsBoolean, useQueryState } from 'nuqs'; import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useDebounce } from 'usehooks-ts'; import { Button } from './ui/button'; @@ -37,12 +38,13 @@ export const Fullscreen = (props: Props) => { }; export const FullscreenOpen = () => { + const { t } = useTranslation(); const [fullscreen, setIsFullscreen] = useFullscreen(); if (fullscreen) { return null; } return ( - +
diff --git a/apps/start/src/components/integrations/all-integrations.tsx b/apps/start/src/components/integrations/all-integrations.tsx index b0db0cab0..0c58fecd6 100644 --- a/apps/start/src/components/integrations/all-integrations.tsx +++ b/apps/start/src/components/integrations/all-integrations.tsx @@ -1,13 +1,17 @@ import { Button } from '@/components/ui/button'; import { pushModal } from '@/modals'; import { PlugIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { IntegrationCard, IntegrationCardFooter } from './integration-card'; -import { INTEGRATIONS } from './integrations'; +import { useIntegrations } from './integrations'; export function AllIntegrations() { + const { t } = useTranslation(); + const integrations = useIntegrations(); + return (
- {INTEGRATIONS.map((integration) => ( + {integrations.map((integration) => ( - Connect + {t('integrations.action_connect')} diff --git a/apps/start/src/components/integrations/forms/discord-integration.tsx b/apps/start/src/components/integrations/forms/discord-integration.tsx index 4dd722c3c..038b7cde1 100644 --- a/apps/start/src/components/integrations/forms/discord-integration.tsx +++ b/apps/start/src/components/integrations/forms/discord-integration.tsx @@ -9,6 +9,7 @@ import { zCreateDiscordIntegration } from '@openpanel/validation'; import { useMutation } from '@tanstack/react-query'; import { path, mergeDeepRight } from 'ramda'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import type { z } from 'zod'; @@ -21,6 +22,7 @@ export function DiscordIntegrationForm({ defaultValues?: RouterOutputs['integration']['get']; onSuccess: () => void; }) { + const { t } = useTranslation(); const { organizationId } = useAppParams(); const form = useForm({ defaultValues: mergeDeepRight( @@ -42,7 +44,7 @@ export function DiscordIntegrationForm({ trpc.integration.createOrUpdate.mutationOptions({ onSuccess, onError() { - toast.error('Failed to create integration'); + toast.error(t('integrations.error_create_failed')); }, }), ); @@ -52,19 +54,19 @@ export function DiscordIntegrationForm({ }; const handleError = () => { - toast.error('Validation error'); + toast.error(t('integrations.error_validation')); }; const handleTest = async () => { const webhookUrl = form.getValues('config.url'); if (!webhookUrl) { - return toast.error('Webhook URL is required'); + return toast.error(t('integrations.error_webhook_url_required')); } const res = await sendTestDiscordNotification(webhookUrl); if (res.ok) { - toast.success('Test notification sent'); + toast.success(t('integrations.success_test_notification_sent')); } else { - toast.error('Failed to send test notification'); + toast.error(t('integrations.error_test_notification_failed')); } }; @@ -74,22 +76,22 @@ export function DiscordIntegrationForm({ className="col gap-4" >
diff --git a/apps/start/src/components/integrations/forms/slack-integration.tsx b/apps/start/src/components/integrations/forms/slack-integration.tsx index f7a8c3bb8..a293f1263 100644 --- a/apps/start/src/components/integrations/forms/slack-integration.tsx +++ b/apps/start/src/components/integrations/forms/slack-integration.tsx @@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { zCreateSlackIntegration } from '@openpanel/validation'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import type { z } from 'zod'; @@ -19,6 +20,7 @@ export function SlackIntegrationForm({ defaultValues?: RouterOutputs['integration']['get']; onSuccess: () => void; }) { + const { t } = useTranslation(); const { organizationId } = useAppParams(); const form = useForm({ @@ -37,7 +39,7 @@ export function SlackIntegrationForm({ onSuccess(); }, onError() { - toast.error('Failed to create integration'); + toast.error(t('integrations.error_create_failed')); }, }), ); @@ -47,7 +49,7 @@ export function SlackIntegrationForm({ }; const handleError = () => { - toast.error('Validation error'); + toast.error(t('integrations.error_validation')); }; return ( @@ -56,12 +58,12 @@ export function SlackIntegrationForm({ className="col gap-4" > - + ); } diff --git a/apps/start/src/components/integrations/forms/webhook-integration.tsx b/apps/start/src/components/integrations/forms/webhook-integration.tsx index ca39b686b..79061b16d 100644 --- a/apps/start/src/components/integrations/forms/webhook-integration.tsx +++ b/apps/start/src/components/integrations/forms/webhook-integration.tsx @@ -14,6 +14,7 @@ import { path, mergeDeepRight } from 'ramda'; import { useEffect } from 'react'; import { Controller, useFieldArray, useWatch } from 'react-hook-form'; import { useForm } from 'react-hook-form'; +import { Trans, useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import type { z } from 'zod'; @@ -55,6 +56,7 @@ export function WebhookIntegrationForm({ defaultValues?: RouterOutputs['integration']['get']; onSuccess: () => void; }) { + const { t } = useTranslation(); const { organizationId } = useAppParams(); // Convert headers from Record to array format for form UI @@ -122,7 +124,8 @@ export function WebhookIntegrationForm({ onError(error) { // Handle validation errors from tRPC if (error.data?.code === 'BAD_REQUEST') { - const errorMessage = error.message || 'Invalid JavaScript template'; + const errorMessage = + error.message || t('integrations.error_invalid_javascript_template'); toast.error(errorMessage); // Set form error if it's a JavaScript template error if (errorMessage.includes('JavaScript template')) { @@ -132,7 +135,7 @@ export function WebhookIntegrationForm({ }); } } else { - toast.error('Failed to create integration'); + toast.error(t('integrations.error_create_failed')); } }, }), @@ -143,7 +146,7 @@ export function WebhookIntegrationForm({ }; const handleError = () => { - toast.error('Validation error'); + toast.error(t('integrations.error_validation')); }; return ( @@ -152,31 +155,31 @@ export function WebhookIntegrationForm({ className="col gap-4" >
{headersArray.fields.map((field, index) => (
@@ -198,7 +201,7 @@ export function WebhookIntegrationForm({ className="self-start" icon={PlusIcon} > - Add Header + {t('integrations.action_add_header')}
@@ -208,20 +211,20 @@ export function WebhookIntegrationForm({ name="config.mode" render={({ field }) => ( (

- Write a JavaScript function that transforms the event - payload. The function receives payload as a - parameter and should return an object. + }} + />

- Available in payload: + {t('integrations.available_payload_title')}

  • - payload.name - Event name + payload.name -{' '} + {t('integrations.payload_name_description')}
  • - payload.profileId - User profile ID + payload.profileId -{' '} + {t('integrations.payload_profile_id_description')}
  • - payload.properties - Full properties object + payload.properties -{' '} + {t('integrations.payload_properties_description')}
  • - payload.properties.your.property - Nested - property value + payload.properties.your.property -{' '} + {t('integrations.payload_nested_property_description')}
  • - payload.profile.firstName - Profile property + payload.profile.firstName -{' '} + {t('integrations.payload_profile_property_description')}
  • @@ -275,12 +283,12 @@ export function WebhookIntegrationForm({ browser path createdAt - and more... + {t('integrations.payload_more_fields')}

- Available helpers: + {t('integrations.available_helpers_title')}

  • @@ -290,7 +298,7 @@ export function WebhookIntegrationForm({

- Example: + {t('integrations.example_title')}

                     {`(payload) => ({
@@ -302,8 +310,8 @@ export function WebhookIntegrationForm({
 })`}
                   

- Security: Network calls, file system - access, and other dangerous operations are blocked. + {t('integrations.security_title')}{' '} + {t('integrations.security_description')}

} @@ -331,7 +339,11 @@ export function WebhookIntegrationForm({ /> )} - + ); } diff --git a/apps/start/src/components/integrations/integration-card.tsx b/apps/start/src/components/integrations/integration-card.tsx index c0a35edde..d3ab99974 100644 --- a/apps/start/src/components/integrations/integration-card.tsx +++ b/apps/start/src/components/integrations/integration-card.tsx @@ -50,10 +50,12 @@ export function IntegrationCardHeaderButtons({ export function IntegrationCardLogoImage({ src, backgroundColor, + alt, className, }: { src: string; backgroundColor: string; + alt: string; className?: string; }) { return ( @@ -63,7 +65,7 @@ export function IntegrationCardLogoImage({ backgroundColor, }} > - Integration Logo + {alt} ); } diff --git a/apps/start/src/components/integrations/integrations.tsx b/apps/start/src/components/integrations/integrations.tsx index 2edd94215..be1214905 100644 --- a/apps/start/src/components/integrations/integrations.tsx +++ b/apps/start/src/components/integrations/integrations.tsx @@ -1,49 +1,59 @@ import type { IIntegrationConfig } from '@openpanel/validation'; import { WebhookIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { IntegrationCardLogo, IntegrationCardLogoImage, } from './integration-card'; -export const INTEGRATIONS: { +export type IntegrationDefinition = { type: IIntegrationConfig['type']; name: string; description: string; icon: React.ReactNode; -}[] = [ - { - type: 'slack', - name: 'Slack', - description: - 'Connect your Slack workspace to get notified when new issues are created.', - icon: ( - - ), - }, - { - type: 'discord', - name: 'Discord', - description: - 'Connect your Discord server to get notified when new issues are created.', - icon: ( - - ), - }, - { - type: 'webhook', - name: 'Webhook', - description: - 'Create a webhook to take actions in your own systems when new events are created.', - icon: ( - - - - ), - }, -]; +}; + +export function useIntegrations(): IntegrationDefinition[] { + const { t } = useTranslation(); + + return useMemo( + () => [ + { + type: 'slack', + name: t('integrations.slack_name'), + description: t('integrations.slack_description'), + icon: ( + + ), + }, + { + type: 'discord', + name: t('integrations.discord_name'), + description: t('integrations.discord_description'), + icon: ( + + ), + }, + { + type: 'webhook', + name: t('integrations.webhook_name'), + description: t('integrations.webhook_description'), + icon: ( + + + + ), + }, + ], + [t], + ); +} diff --git a/apps/start/src/components/json-editor.tsx b/apps/start/src/components/json-editor.tsx index 7cb711db4..f856de30f 100644 --- a/apps/start/src/components/json-editor.tsx +++ b/apps/start/src/components/json-editor.tsx @@ -11,6 +11,7 @@ import { } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useTheme } from './theme-provider'; interface JsonEditorProps { @@ -32,6 +33,7 @@ export function JsonEditor({ language = 'json', onValidate, }: JsonEditorProps) { + const { t } = useTranslation(); const editorRef = useRef(null); const viewRef = useRef(null); const themeCompartmentRef = useRef(null); @@ -58,7 +60,7 @@ export function JsonEditor({ } catch (e) { setIsValid(false); const errorMsg = - e instanceof Error ? e.message : 'Invalid JSON syntax'; + e instanceof Error ? e.message : t('ui.json_editor_invalid_json_syntax'); setError(errorMsg); onValidate?.(false, errorMsg); } @@ -210,7 +212,10 @@ export function JsonEditor({ /> {!isValid && (

- {error || `Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`} + {error || + t('ui.json_editor_invalid_syntax', { + language: language === 'javascript' ? 'JavaScript' : 'JSON', + })}

)}
diff --git a/apps/start/src/components/language-toggle.tsx b/apps/start/src/components/language-toggle.tsx new file mode 100644 index 000000000..170b7aeb5 --- /dev/null +++ b/apps/start/src/components/language-toggle.tsx @@ -0,0 +1,52 @@ +import { CheckIcon, LanguagesIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + languageLabels, + normalizeLanguage, + supportedLanguages, +} from '@/i18n/locales'; +import { cn } from '@/lib/utils'; + +export function LanguageToggle({ className }: { className?: string }) { + const { i18n, t } = useTranslation(); + const currentLanguage = normalizeLanguage( + i18n.resolvedLanguage ?? i18n.language + ); + + return ( + + + + + + {supportedLanguages.map((language) => ( + { + window.localStorage.setItem('openpanel-language', language); + document.documentElement.lang = language; + void i18n.changeLanguage(language); + }} + > + {languageLabels[language]} + {currentLanguage === language && } + + ))} + + + ); +} diff --git a/apps/start/src/components/login-left-panel.tsx b/apps/start/src/components/login-left-panel.tsx index ccc4288d7..4fe7c1c5d 100644 --- a/apps/start/src/components/login-left-panel.tsx +++ b/apps/start/src/components/login-left-panel.tsx @@ -5,62 +5,40 @@ import { CarouselNext, CarouselPrevious, } from '@/components/ui/carousel'; +import { useTranslation } from 'react-i18next'; import { SellingPoint } from './selling-points'; const sellingPoints = [ { - key: 'welcome', - render: () => ( - - ), + titleKey: 'auth.login_panel_alternative_title', + descriptionKey: 'auth.login_panel_alternative_description', + bgImage: '/img-1.webp', }, { - key: 'selling-point-2', - render: () => ( - - ), + titleKey: 'auth.login_panel_reliable_title', + descriptionKey: 'auth.login_panel_reliable_description', + bgImage: '/img-2.webp', }, { - key: 'selling-point-3', - render: () => ( - - ), + titleKey: 'auth.login_panel_simple_title', + descriptionKey: 'auth.login_panel_simple_description', + bgImage: '/img-3.webp', }, { - key: 'selling-point-4', - render: () => ( - - ), + titleKey: 'auth.login_panel_privacy_title', + descriptionKey: 'auth.login_panel_privacy_description', + bgImage: '/img-4.webp', }, { - key: 'selling-point-5', - render: () => ( - - ), + titleKey: 'auth.login_panel_open_source_title', + descriptionKey: 'auth.login_panel_open_source_description', + bgImage: '/img-5.webp', }, -]; +] as const; export function LoginLeftPanel() { + const { t } = useTranslation(); + return (
{/* Carousel */} @@ -75,11 +53,15 @@ export function LoginLeftPanel() { {sellingPoints.map((point, index) => (
- {point.render()} +
))} diff --git a/apps/start/src/components/login-navbar.tsx b/apps/start/src/components/login-navbar.tsx index 67d70418c..bfaa9dd7a 100644 --- a/apps/start/src/components/login-navbar.tsx +++ b/apps/start/src/components/login-navbar.tsx @@ -1,11 +1,14 @@ import { MenuIcon, XIcon } from 'lucide-react'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LanguageToggle } from './language-toggle'; import { LogoSquare } from './logo'; import { Button } from './ui/button'; import { useAppContext } from '@/hooks/use-app-context'; import { cn } from '@/utils/cn'; export function LoginNavbar({ className }: { className?: string }) { + const { t } = useTranslation(); const { isSelfHosted } = useAppContext(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -19,90 +22,93 @@ export function LoginNavbar({ className }: { className?: string }) { - {isSelfHosted ? 'Self-hosted analytics' : 'OpenPanel.dev'} + {isSelfHosted ? t('auth.self_hosted_analytics') : 'OpenPanel.dev'} {isSelfHosted && ( )} -
- - {mobileMenuOpen && ( - <> - + {mobileMenuOpen && ( + <> +
); diff --git a/apps/start/src/components/notifications/notification-rules.tsx b/apps/start/src/components/notifications/notification-rules.tsx index d8802d21c..f6fceae6a 100644 --- a/apps/start/src/components/notifications/notification-rules.tsx +++ b/apps/start/src/components/notifications/notification-rules.tsx @@ -5,12 +5,14 @@ import { useQuery } from '@tanstack/react-query'; import { AnimatePresence, motion } from 'framer-motion'; import { PencilRulerIcon, PlusIcon } from 'lucide-react'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { FullPageEmptyState } from '../full-page-empty-state'; import { IntegrationCardSkeleton } from '../integrations/integration-card'; import { Button } from '../ui/button'; import { RuleCard } from './rule-card'; export function NotificationRules() { + const { t } = useTranslation(); const { projectId } = useAppParams(); const trpc = useTRPC(); const query = useQuery( @@ -26,11 +28,11 @@ export function NotificationRules() { if (!isLoading && data.length === 0) { return ( - -

- You have not created any rules yet. Create a rule to start getting - notifications. -

+ +

{t('notifications.rules_empty_description')}

); @@ -58,7 +60,7 @@ export function NotificationRules() { }) } > - Add Rule + {t('notifications.action_add_rule')}
diff --git a/apps/start/src/components/notifications/rule-card.tsx b/apps/start/src/components/notifications/rule-card.tsx index 9c3da3c51..ff2492736 100644 --- a/apps/start/src/components/notifications/rule-card.tsx +++ b/apps/start/src/components/notifications/rule-card.tsx @@ -5,6 +5,7 @@ import type { NotificationRule } from '@openpanel/db'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { FilterIcon } from 'lucide-react'; +import { Trans, useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { ColorSquare } from '../color-square'; import { @@ -19,6 +20,8 @@ import { Tooltiper } from '../ui/tooltip'; function EventBadge({ event, }: { event: NotificationRule['config']['events'][number] }) { + const { t } = useTranslation(); + return ( - {event.name === '*' ? 'Any event' : event.name} + {event.name === '*' ? t('notifications.rule_any_event') : event.name} {Boolean(event.filters.length) && ( )} @@ -45,12 +48,13 @@ function EventBadge({ export function RuleCard({ rule, }: { rule: RouterOutputs['notification']['rules'][number] }) { + const { t } = useTranslation(); const trpc = useTRPC(); const client = useQueryClient(); const deletion = useMutation( trpc.notification.deleteRule.mutationOptions({ onSuccess() { - toast.success('Rule deleted'); + toast.success(t('notifications.rule_deleted_success')); client.refetchQueries( trpc.notification.rules.queryOptions({ projectId: rule.projectId, @@ -64,17 +68,19 @@ export function RuleCard({ case 'events': return (
-
Get notified when
- {rule.config.events.map((event) => ( - - ))} -
occurs
+ + + {rule.config.events.map((event) => ( + + ))} + +
); case 'funnel': return (
-
Get notified when a session has completed this funnel
+
{t('notifications.rule_funnel_description')}
{rule.config.events.map((event, index) => (
{ showConfirm({ - title: `Delete ${rule.name}?`, - text: 'This action cannot be undone.', + title: t('notifications.delete_rule_confirm_title', { + name: rule.name, + }), + text: t('notifications.delete_rule_confirm_description'), onConfirm: () => { deletion.mutate({ id: rule.id, @@ -118,7 +126,7 @@ export function RuleCard({ }); }} > - Delete + {t('notifications.action_delete')}
diff --git a/apps/start/src/components/notifications/table/columns.tsx b/apps/start/src/components/notifications/table/columns.tsx index ae1f3e21d..87ae85baa 100644 --- a/apps/start/src/components/notifications/table/columns.tsx +++ b/apps/start/src/components/notifications/table/columns.tsx @@ -8,6 +8,7 @@ import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers'; import type { RouterOutputs } from '@/trpc/client'; import type { INotificationPayload } from '@openpanel/db'; +import { useTranslation } from 'react-i18next'; function getEventFromPayload(payload: INotificationPayload | null) { if (payload?.type === 'event') { @@ -20,10 +21,12 @@ function getEventFromPayload(payload: INotificationPayload | null) { } export function useColumns() { + const { t } = useTranslation(); + const columns: ColumnDef[] = [ { accessorKey: 'title', - header: 'Title', + header: t('notifications.column_title'), cell({ row }) { const { title } = row.original; return ( @@ -35,13 +38,13 @@ export function useColumns() { }, meta: { variant: 'text', - placeholder: 'Search', - label: 'Title', + placeholder: t('notifications.search_placeholder'), + label: t('notifications.column_title'), }, }, { accessorKey: 'message', - header: 'Message', + header: t('notifications.column_message'), cell({ row }) { const { message } = row.original; return ( @@ -51,36 +54,36 @@ export function useColumns() { ); }, meta: { - label: 'Message', + label: t('notifications.column_message'), hidden: true, }, }, { accessorKey: 'integration', - header: 'Integration', + header: t('notifications.column_integration'), cell({ row }) { const integration = row.original.integration; return
{integration?.name}
; }, meta: { - label: 'Integration', + label: t('notifications.column_integration'), }, }, { accessorKey: 'notificationRule', - header: 'Rule', + header: t('notifications.column_rule'), cell({ row }) { const rule = row.original.notificationRule; return
{rule?.name}
; }, meta: { - label: 'Rule', + label: t('notifications.column_rule'), hidden: true, }, }, { accessorKey: 'country', - header: 'Country', + header: t('notifications.column_country'), cell({ row }) { const { payload } = row.original; const event = getEventFromPayload(payload); @@ -95,7 +98,7 @@ export function useColumns() { ); }, meta: { - label: 'Country', + label: t('notifications.column_country'), }, }, { @@ -115,12 +118,12 @@ export function useColumns() { ); }, meta: { - label: 'OS', + label: t('notifications.column_os'), }, }, { accessorKey: 'browser', - header: 'Browser', + header: t('notifications.column_browser'), cell({ row }) { const { payload } = row.original; const event = getEventFromPayload(payload); @@ -135,12 +138,12 @@ export function useColumns() { ); }, meta: { - label: 'Browser', + label: t('notifications.column_browser'), }, }, { accessorKey: 'profile', - header: createHeaderColumn('Profile'), + header: createHeaderColumn(t('notifications.column_profile')), cell({ row }) { const { payload } = row.original; const event = getEventFromPayload(payload); @@ -157,12 +160,12 @@ export function useColumns() { ); }, meta: { - label: 'Profile', + label: t('notifications.column_profile'), }, }, { accessorKey: 'createdAt', - header: 'Created at', + header: t('notifications.column_created_at'), size: ColumnCreatedAt.size, cell: ({ row }) => { const item = row.original; @@ -171,8 +174,8 @@ export function useColumns() { filterFn: 'isWithinRange', meta: { variant: 'dateRange', - placeholder: 'Created at', - label: 'Created at', + placeholder: t('notifications.column_created_at'), + label: t('notifications.column_created_at'), }, }, ]; diff --git a/apps/start/src/components/onboarding-left-panel.tsx b/apps/start/src/components/onboarding-left-panel.tsx index 99d668cfa..7fc19860a 100644 --- a/apps/start/src/components/onboarding-left-panel.tsx +++ b/apps/start/src/components/onboarding-left-panel.tsx @@ -5,53 +5,63 @@ import { } from '@/components/ui/carousel'; import Autoplay from 'embla-carousel-autoplay'; import { QuoteIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; -const testimonials = [ +type Testimonial = { + key: string; + bgImage: string; + quoteKey: string; + author?: string; + authorKey?: string; + site?: string; +}; + +const testimonials: Testimonial[] = [ { key: 'thomas', bgImage: '/img-1.webp', - quote: - "OpenPanel is BY FAR the best open-source analytics I've ever seen. Better UX/UI, many more features, and incredible support from the founder.", + quoteKey: 'onboarding.testimonial_thomas_quote', author: 'Thomas Sanlis', site: 'uneed.best', }, { key: 'julien', bgImage: '/img-2.webp', - quote: - 'After testing several product analytics tools, we chose OpenPanel and we are very satisfied. Profiles and Conversion Events are our favorite features.', + quoteKey: 'onboarding.testimonial_julien_quote', author: 'Julien Hany', site: 'strackr.com', }, { key: 'piotr', bgImage: '/img-3.webp', - quote: - 'The Overview tab is great — it has everything I need. The UI is beautiful, clean, modern, very pleasing to the eye.', + quoteKey: 'onboarding.testimonial_piotr_quote', author: 'Piotr Kulpinski', site: 'producthunt.com', }, { key: 'selfhost', bgImage: '/img-4.webp', - quote: - "After paying a lot to PostHog for years, OpenPanel gives us the same — in many ways better — analytics while keeping full ownership of our data. We don't want to run any business without OpenPanel anymore.", - author: 'Self-hosting user', + quoteKey: 'onboarding.testimonial_selfhost_quote', + authorKey: 'onboarding.testimonial_selfhost_author', site: undefined, }, ]; function TestimonialSlide({ bgImage, - quote, + quoteKey, author, + authorKey, site, }: { bgImage: string; - quote: string; - author: string; + quoteKey: string; + author?: string; + authorKey?: string; site?: string; }) { + const { t } = useTranslation(); + return (
- {quote} + {t(quoteKey)}
- — {author} + — {authorKey ? t(authorKey) : author} {site && · {site}}
@@ -84,14 +94,15 @@ export function OnboardingLeftPanel() { plugins={[Autoplay({ delay: 6000, stopOnInteraction: false })]} > - {testimonials.map((t) => ( - + {testimonials.map((testimonial) => ( +
diff --git a/apps/start/src/components/onboarding/connect-web.tsx b/apps/start/src/components/onboarding/connect-web.tsx index a1b4a9dee..500a5c4b9 100644 --- a/apps/start/src/components/onboarding/connect-web.tsx +++ b/apps/start/src/components/onboarding/connect-web.tsx @@ -6,12 +6,14 @@ import Syntax from '@/components/syntax'; import { useAppContext } from '@/hooks/use-app-context'; import { pushModal } from '@/modals'; import { clipboard } from '@/utils/clipboard'; +import { useTranslation } from 'react-i18next'; interface Props { client: IServiceClient | null; } const ConnectWeb = ({ client }: Props) => { + const { t } = useTranslation(); const context = useAppContext(); const code = `