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": "复制",