diff --git a/components/batch/BatchActionBar.tsx b/components/batch/BatchActionBar.tsx
index a536dae..0fa9211 100644
--- a/components/batch/BatchActionBar.tsx
+++ b/components/batch/BatchActionBar.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { View, Text, Pressable } from "react-native";
import { Trash2, Edit3, X } from "lucide-react-native";
+import { useTranslation } from "react-i18next";
import { cn } from "~/lib/utils";
import { useThemeColors } from "~/lib/theme-colors";
@@ -26,6 +27,7 @@ export function BatchActionBar({
onClearSelection,
className,
}: BatchActionBarProps) {
+ const { t } = useTranslation();
const { accent } = useThemeColors();
if (selectedCount === 0) return null;
@@ -52,7 +54,7 @@ export function BatchActionBar({
className="flex-row items-center rounded-lg bg-primary/10 px-3 py-2"
>
- Edit
+ {t("common.edit")}
)}
{onBatchDelete && (
@@ -61,7 +63,7 @@ export function BatchActionBar({
className="flex-row items-center rounded-lg bg-destructive/10 px-3 py-2"
>
- Delete
+ {t("common.delete")}
)}
diff --git a/components/common/UndoSnackbar.tsx b/components/common/UndoSnackbar.tsx
index 0d2f982..7300dd4 100644
--- a/components/common/UndoSnackbar.tsx
+++ b/components/common/UndoSnackbar.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
+import { useTranslation } from "react-i18next";
export interface UndoSnackbarProps {
message: string;
@@ -16,6 +17,7 @@ export function UndoSnackbar({
visible,
testID = "undo-snackbar",
}: UndoSnackbarProps) {
+ const { t } = useTranslation();
const timerRef = React.useRef | null>(null);
const [show, setShow] = React.useState(visible);
@@ -51,10 +53,10 @@ export function UndoSnackbar({
- Undo
+ {t("common.undo")}
);
diff --git a/components/query/QueryBuilder.tsx b/components/query/QueryBuilder.tsx
index c8783c1..a010e8f 100644
--- a/components/query/QueryBuilder.tsx
+++ b/components/query/QueryBuilder.tsx
@@ -1,6 +1,7 @@
import React, { useCallback } from "react";
import { View, Text, Pressable, ScrollView } from "react-native";
import { Plus, Trash2, ToggleLeft } from "lucide-react-native";
+import { useTranslation } from "react-i18next";
import { cn } from "~/lib/utils";
import { useThemeColors } from "~/lib/theme-colors";
import type { FieldDefinition } from "~/components/renderers/types";
@@ -44,6 +45,7 @@ export function QueryBuilder({
onClear,
className,
}: QueryBuilderProps) {
+ const { t } = useTranslation();
const { accent } = useThemeColors();
const handleAdd = useCallback(() => {
const firstField = fields[0]?.name ?? "";
@@ -67,7 +69,7 @@ export function QueryBuilder({
{root.filters.length > 0 && (
- Clear
+ {t("common.clear")}
)}
@@ -98,7 +100,7 @@ export function QueryBuilder({
className="mt-3 flex-row items-center self-start rounded-lg border border-dashed border-border px-3 py-2"
>
- Add filter
+ {t("filter.addFilter")}
);
diff --git a/components/renderers/SwipeableRow.tsx b/components/renderers/SwipeableRow.tsx
index af787e4..7a047d3 100644
--- a/components/renderers/SwipeableRow.tsx
+++ b/components/renderers/SwipeableRow.tsx
@@ -1,6 +1,7 @@
import React, { useRef } from "react";
import { View, Text, Pressable, Animated } from "react-native";
import { Swipeable } from "react-native-gesture-handler";
+import { useTranslation } from "react-i18next";
import { Edit, Trash2 } from "lucide-react-native";
/* ------------------------------------------------------------------ */
@@ -44,6 +45,7 @@ export function SwipeableRow({
onEdit,
onDelete,
}: SwipeableRowProps) {
+ const { t } = useTranslation();
const swipeableRef = useRef(null);
const hasActions = !!(onEdit || onDelete);
@@ -81,7 +83,7 @@ export function SwipeableRow({
style={{ width: ACTION_WIDTH }}
>
- Edit
+ {t("common.edit")}
)}
@@ -97,7 +99,7 @@ export function SwipeableRow({
style={{ width: ACTION_WIDTH }}
>
- Delete
+ {t("common.delete")}
)}
diff --git a/components/renderers/fields/FileField.tsx b/components/renderers/fields/FileField.tsx
index 39a01c4..3469bd7 100644
--- a/components/renderers/fields/FileField.tsx
+++ b/components/renderers/fields/FileField.tsx
@@ -11,6 +11,7 @@ import {
} from "lucide-react-native";
import type { FieldDefinition } from "../types";
import type { FileUploadResult } from "~/hooks/useFileUpload";
+import { useTranslation } from "react-i18next";
import { useToast } from "~/components/ui/Toast";
import { useThemeColors } from "~/lib/theme-colors";
@@ -81,6 +82,7 @@ export function FileField({
onShare,
error,
}: FileFieldProps) {
+ const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false);
const { toastError } = useToast();
const { accent } = useThemeColors();
@@ -96,12 +98,12 @@ export function FileField({
onChange?.(result);
}
} catch {
- toastError("Could not upload the file. Please try again.");
+ toastError(t("fields.uploadFailed"));
} finally {
setIsUploading(false);
}
},
- [onChange, toastError],
+ [onChange, toastError, t],
);
/* ---- Read-only ---- */
@@ -116,7 +118,7 @@ export function FileField({
- {fileInfo.name ?? fileInfo.id ?? "File"}
+ {fileInfo.name ?? fileInfo.id ?? t("fields.file")}
{onDownload && fileInfo.id && (
onDownload(fileInfo.id!, fileInfo.name ?? "file")}>
@@ -140,7 +142,7 @@ export function FileField({
- {fileInfo.name ?? "Attached file"}
+ {fileInfo.name ?? t("fields.attachedFile")}
onChange?.(null)}>
@@ -152,7 +154,7 @@ export function FileField({
{isUploading ? (
- Uploading…
+ {t("fields.uploading")}
) : (
@@ -162,7 +164,7 @@ export function FileField({
onPress={() => handleUpload(onPickImage)}
>
- Gallery
+ {t("fields.gallery")}
)}
{(field.type === "image" || field.type === "file") && onCapturePhoto && (
@@ -171,7 +173,7 @@ export function FileField({
onPress={() => handleUpload(onCapturePhoto)}
>
- Camera
+ {t("fields.camera")}
)}
{onPickDocument && (
@@ -180,7 +182,7 @@ export function FileField({
onPress={() => handleUpload(onPickDocument)}
>
- File
+ {t("fields.file")}
)}
diff --git a/components/ui/DatePicker.tsx b/components/ui/DatePicker.tsx
index e949ba8..e6ed989 100644
--- a/components/ui/DatePicker.tsx
+++ b/components/ui/DatePicker.tsx
@@ -9,6 +9,7 @@ import {
ChevronDown,
} from "lucide-react-native";
import * as Haptics from "expo-haptics";
+import { useTranslation } from "react-i18next";
import { cn } from "~/lib/utils";
import { formatDate, formatDateTime } from "~/lib/formatting";
@@ -96,6 +97,7 @@ export function DatePicker({
className,
error,
}: DatePickerProps) {
+ const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const selected = React.useMemo(() => parseDateValue(value), [value]);
@@ -295,7 +297,7 @@ export function DatePicker({
}}
className="rounded-lg px-3 py-2 active:bg-accent"
>
- Clear
+ {t("common.clear")}
- Today
+ {t("common.today")}
{mode !== "date" && (
- Done
+ {t("common.done")}
)}
diff --git a/components/ui/MultiSelect.tsx b/components/ui/MultiSelect.tsx
index 054c5dc..2185afc 100644
--- a/components/ui/MultiSelect.tsx
+++ b/components/ui/MultiSelect.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { Modal, Pressable, ScrollView, Text, View } from "react-native";
import { ChevronDown, Check, X } from "lucide-react-native";
import * as Haptics from "expo-haptics";
+import { useTranslation } from "react-i18next";
import { cn } from "~/lib/utils";
import type { SelectOption } from "~/components/renderers/types";
@@ -31,6 +32,7 @@ export function MultiSelect({
className,
error,
}: MultiSelectProps) {
+ const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const selectedSet = React.useMemo(() => new Set(value), [value]);
const selectedOptions = options.filter((o) => selectedSet.has(String(o.value)));
@@ -109,7 +111,7 @@ export function MultiSelect({
})}
setOpen(false)} className="mt-1 items-center rounded-lg bg-primary py-2.5">
- Done
+ {t("common.done")}
diff --git a/components/ui/Reasoning.tsx b/components/ui/Reasoning.tsx
index 8dd7fb9..2ef3f6c 100644
--- a/components/ui/Reasoning.tsx
+++ b/components/ui/Reasoning.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { ChevronRight, ChevronDown, Brain } from "lucide-react-native";
+import { useTranslation } from "react-i18next";
import { cn } from "~/lib/utils";
/**
@@ -19,6 +20,7 @@ export function Reasoning({
reasoning: string;
className?: string;
}) {
+ const { t } = useTranslation();
const [open, setOpen] = useState(false);
if (!reasoning || reasoning.trim() === "") return null;
return (
@@ -31,7 +33,7 @@ export function Reasoning({
accessibilityState={{ expanded: open }}
>
- Reasoning
+ {t("ai.reasoning")}
{open ? (
) : (
diff --git a/components/views/SaveViewDialog.tsx b/components/views/SaveViewDialog.tsx
index 5da7fb1..0fe2fb7 100644
--- a/components/views/SaveViewDialog.tsx
+++ b/components/views/SaveViewDialog.tsx
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import { View, Text, TextInput, Pressable, Modal } from "react-native";
import { X, Save } from "lucide-react-native";
+import { useTranslation } from "react-i18next";
import { cn } from "~/lib/utils";
import type { SaveViewInput } from "~/hooks/useViewStorage";
@@ -27,6 +28,7 @@ export function SaveViewDialog({
onSave,
initialValues,
}: SaveViewDialogProps) {
+ const { t } = useTranslation();
const [name, setName] = useState(initialValues?.name ?? "");
const [visibility, setVisibility] = useState<"private" | "shared">(
initialValues?.visibility ?? "private",
@@ -56,25 +58,25 @@ export function SaveViewDialog({
{/* Header */}
- Save View
+ {t("views.saveView")}
{/* Name input */}
- View Name
+ {t("views.viewName")}
{/* Visibility toggle */}
- Visibility
+ {t("views.visibility")}
setVisibility("private")}
@@ -91,7 +93,7 @@ export function SaveViewDialog({
visibility === "private" ? "text-primary" : "text-muted-foreground",
)}
>
- Private
+ {t("views.private")}
- Shared
+ {t("views.shared")}
@@ -125,7 +127,7 @@ export function SaveViewDialog({
>
- {isSaving ? "Saving…" : "Save View"}
+ {isSaving ? t("common.saving") : t("views.saveView")}
diff --git a/jest.setup.ts b/jest.setup.ts
index e3fa0f1..039558f 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -203,3 +203,10 @@ const mockAppState = {
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
};
jest.mock("react-native/Libraries/AppState/AppState", () => mockAppState);
+
+/* ---- i18next ---- */
+// Initialize the shared i18n instance so components' `t()` calls resolve to
+// real (English) strings in tests instead of returning raw keys. Mirrors the
+// app, where importing a screen pulls in `~/lib/i18n`; tests render components
+// in isolation, so without this the translation layer is never set up.
+require("~/lib/i18n");
diff --git a/locales/ar.json b/locales/ar.json
index 4f4fe01..5377e9c 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -26,7 +26,10 @@
"more": "المزيد",
"settings": "الإعدادات",
"language": "اللغة",
- "packages": "الحزم"
+ "packages": "الحزم",
+ "clear": "مسح",
+ "today": "اليوم",
+ "undo": "تراجع"
},
"auth": {
"signIn": "تسجيل الدخول",
@@ -209,7 +212,21 @@
"required": "هذا الحقل مطلوب.",
"invalidEmail": "يرجى إدخال عنوان بريد إلكتروني صالح.",
"invalidUrl": "يرجى إدخال عنوان URL صالح.",
- "invalidNumber": "يرجى إدخال رقم صالح."
+ "invalidNumber": "يرجى إدخال رقم صالح.",
+ "gallery": "المعرض",
+ "camera": "الكاميرا",
+ "file": "ملف",
+ "uploading": "جارٍ الرفع…",
+ "uploadFailed": "تعذّر رفع الملف. يرجى المحاولة مرة أخرى.",
+ "attachedFile": "ملف مرفق"
+ },
+ "views": {
+ "saveView": "حفظ العرض",
+ "viewName": "اسم العرض",
+ "namePlaceholder": "مثال: عملائي المحتملون النشطون",
+ "visibility": "الظهور",
+ "private": "خاص",
+ "shared": "مُشترك"
},
"nav": {
"home": "الرئيسية",
@@ -272,6 +289,7 @@
"profileFallback": "مستخدم"
},
"ai": {
+ "reasoning": "الاستدلال",
"title": "المساعد الذكي",
"thinking": "يفكّر…",
"copy": "نسخ",
diff --git a/locales/en.json b/locales/en.json
index 431c970..d66d7de 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -26,7 +26,10 @@
"more": "More",
"settings": "Settings",
"language": "Language",
- "packages": "Packages"
+ "packages": "Packages",
+ "clear": "Clear",
+ "today": "Today",
+ "undo": "Undo"
},
"auth": {
"signIn": "Sign In",
@@ -205,7 +208,21 @@
"required": "This field is required.",
"invalidEmail": "Please enter a valid email address.",
"invalidUrl": "Please enter a valid URL.",
- "invalidNumber": "Please enter a valid number."
+ "invalidNumber": "Please enter a valid number.",
+ "gallery": "Gallery",
+ "camera": "Camera",
+ "file": "File",
+ "uploading": "Uploading…",
+ "uploadFailed": "Could not upload the file. Please try again.",
+ "attachedFile": "Attached file"
+ },
+ "views": {
+ "saveView": "Save View",
+ "viewName": "View Name",
+ "namePlaceholder": "e.g. My Active Leads",
+ "visibility": "Visibility",
+ "private": "Private",
+ "shared": "Shared"
},
"nav": {
"home": "Home",
@@ -269,6 +286,7 @@
"profileFallback": "User"
},
"ai": {
+ "reasoning": "Reasoning",
"title": "AI Assistant",
"thinking": "Thinking…",
"copy": "Copy",
diff --git a/locales/zh.json b/locales/zh.json
index fd927df..fa9308d 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -26,7 +26,10 @@
"more": "更多",
"settings": "设置",
"language": "语言",
- "packages": "应用包"
+ "packages": "应用包",
+ "clear": "清除",
+ "today": "今天",
+ "undo": "撤销"
},
"auth": {
"signIn": "登录",
@@ -204,7 +207,21 @@
"required": "此字段为必填项。",
"invalidEmail": "请输入有效的邮箱地址。",
"invalidUrl": "请输入有效的 URL。",
- "invalidNumber": "请输入有效的数字。"
+ "invalidNumber": "请输入有效的数字。",
+ "gallery": "相册",
+ "camera": "相机",
+ "file": "文件",
+ "uploading": "上传中…",
+ "uploadFailed": "文件上传失败,请重试。",
+ "attachedFile": "已附加文件"
+ },
+ "views": {
+ "saveView": "保存视图",
+ "viewName": "视图名称",
+ "namePlaceholder": "例如:我的活跃线索",
+ "visibility": "可见性",
+ "private": "私有",
+ "shared": "共享"
},
"nav": {
"home": "首页",
@@ -267,6 +284,7 @@
"profileFallback": "用户"
},
"ai": {
+ "reasoning": "推理",
"title": "AI 助手",
"thinking": "正在思考…",
"copy": "复制",