From e725819c01e3144b8a12e8ab8c349cf2588c01b9 Mon Sep 17 00:00:00 2001 From: Tran Anh Tuan Date: Sat, 15 Nov 2025 16:58:07 +0700 Subject: [PATCH] add en/vi language --- LOCALIZATION.md | 224 +++++++++++++ app.json | 9 + app/(tabs)/_layout.tsx | 16 +- app/(tabs)/index.tsx | 2 +- app/(tabs)/setting.tsx | 70 ++-- app/_layout.tsx | 62 ++-- app/auth/login.tsx | 107 +++++- assets/icons/en_icon.png | Bin 0 -> 19575 bytes assets/icons/vi_icon.png | Bin 0 -> 17434 bytes components/ButtonCancelTrip.tsx | 7 +- components/ButtonCreateNewHaulOrTrip.tsx | 64 ++-- components/ButtonEndTrip.tsx | 10 +- components/map/GPSInfoPanel.tsx | 14 +- components/map/SosButton.tsx | 25 +- components/rotate-switch.tsx | 307 ++++++++++++++++++ components/tripInfo/CrewListTable.tsx | 24 +- components/tripInfo/FishingToolsList.tsx | 20 +- components/tripInfo/NetListTable.tsx | 36 +- components/tripInfo/TripCostTable.tsx | 28 +- .../modal/CreateOrUpdateHaulModal.tsx | 90 +++-- components/tripInfo/modal/CrewDetailModal.tsx | 43 ++- .../NetDetailModal/components/InfoSection.tsx | 20 +- .../tripInfo/modal/TripCostDetailModal.tsx | 45 ++- components/ui/slice-switch.tsx | 246 ++++++++++++++ config/localization.ts | 2 + config/localization/i18n.ts | 27 ++ hooks/use-i18n.ts | 119 +++++++ locales/en.json | 197 +++++++++++ locales/vi.json | 198 +++++++++++ package-lock.json | 60 ++++ package.json | 3 + 31 files changed, 1843 insertions(+), 232 deletions(-) create mode 100644 LOCALIZATION.md create mode 100644 assets/icons/en_icon.png create mode 100644 assets/icons/vi_icon.png create mode 100644 components/rotate-switch.tsx create mode 100644 components/ui/slice-switch.tsx create mode 100644 config/localization.ts create mode 100644 config/localization/i18n.ts create mode 100644 hooks/use-i18n.ts create mode 100644 locales/en.json create mode 100644 locales/vi.json diff --git a/LOCALIZATION.md b/LOCALIZATION.md new file mode 100644 index 0000000..e4c7bcb --- /dev/null +++ b/LOCALIZATION.md @@ -0,0 +1,224 @@ +# Localization Setup Guide + +## Overview + +Ứng dụng đã được cấu hình hỗ trợ 2 ngôn ngữ: **English (en)** và **Tiếng Việt (vi)** sử dụng `expo-localization` và `i18n-js`. + +## Cấu trúc thư mục + +``` +/config + /localization + - i18n.ts # Cấu hình i18n (khởi tạo locale, enable fallback, etc.) + - localization.ts # Export main exports + +/locales + - en.json # Các string tiếng Anh + - vi.json # Các string tiếng Việt + +/hooks + - use-i18n.ts # Hook để sử dụng i18n trong components + +/state + - use-locale-store.ts # Zustand store cho global locale state management +``` + +## Cách sử dụng + +### 1. Import i18n hook trong component + +```tsx +import { useI18n } from "@/hooks/use-i18n"; + +export default function MyComponent() { + const { t, locale, setLocale } = useI18n(); + + return ( + + {t("common.ok")} + {t("navigation.home")} + Current locale: {locale} + + ); +} +``` + +### 2. Thêm translation keys + +#### Mở file `/locales/en.json` và thêm: + +```json +{ + "myFeature": { + "title": "My Feature Title", + "description": "My Feature Description" + } +} +``` + +#### Mở file `/locales/vi.json` và thêm translation tương ứng: + +```json +{ + "myFeature": { + "title": "Tiêu đề Tính năng Của Tôi", + "description": "Mô tả Tính năng Của Tôi" + } +} +``` + +### 3. Sử dụng trong component + +```tsx +const { t } = useI18n(); + +return {t("myFeature.title")}; +``` + +## Cách thay đổi ngôn ngữ + +```tsx +const { setLocale } = useI18n(); + +// Thay đổi sang Tiếng Việt + diff --git a/components/rotate-switch.tsx b/components/rotate-switch.tsx new file mode 100644 index 0000000..63b65a2 --- /dev/null +++ b/components/rotate-switch.tsx @@ -0,0 +1,307 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Animated, + Image, + ImageSourcePropType, + Pressable, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from "react-native"; + +const AnimatedImage = Animated.createAnimatedComponent(Image); + +const SIZE_PRESETS = { + sm: { width: 64, height: 32 }, + md: { width: 80, height: 40 }, + lg: { width: 96, height: 48 }, +} as const; + +type SwitchSize = keyof typeof SIZE_PRESETS; + +const DEFAULT_TOGGLE_DURATION = 400; +const DEFAULT_OFF_IMAGE = + "https://images.vexels.com/media/users/3/163966/isolated/preview/6ecbb5ec8c121c0699c9b9179d6b24aa-england-flag-language-icon-circle.png"; +const DEFAULT_ON_IMAGE = + "https://cdn-icons-png.flaticon.com/512/197/197473.png"; +const DEFAULT_INACTIVE_BG = "#D3DAD9"; +const DEFAULT_ACTIVE_BG = "#C2E2FA"; +const PRESSED_SCALE = 0.96; +const PRESS_FEEDBACK_DURATION = 120; + +type RotateSwitchProps = { + size?: SwitchSize; + onImage?: ImageSourcePropType | string; + offImage?: ImageSourcePropType | string; + initialValue?: boolean; + duration?: number; + activeBackgroundColor?: string; + inactiveBackgroundColor?: string; + style?: StyleProp; + onChange?: (value: boolean) => void; +}; + +const toImageSource = ( + input: ImageSourcePropType | string | undefined, + fallbackUri: string +): ImageSourcePropType => { + if (typeof input === "string") { + return { uri: input }; + } + + if (input) { + return input; + } + + return { uri: fallbackUri }; +}; + +const RotateSwitch = ({ + size = "md", + onImage, + offImage, + duration, + activeBackgroundColor = DEFAULT_ACTIVE_BG, + inactiveBackgroundColor = DEFAULT_INACTIVE_BG, + initialValue = false, + style, + onChange, +}: RotateSwitchProps) => { + const { width: containerWidth, height: containerHeight } = + SIZE_PRESETS[size] ?? SIZE_PRESETS.md; + const knobSize = containerHeight; + const knobTravel = containerWidth - knobSize; + const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0); + + const resolvedOffImage = useMemo( + () => toImageSource(offImage, DEFAULT_OFF_IMAGE), + [offImage] + ); + const resolvedOnImage = useMemo( + () => toImageSource(onImage, DEFAULT_ON_IMAGE), + [onImage] + ); + + const [isOn, setIsOn] = useState(initialValue); + const [bgOn, setBgOn] = useState(initialValue); + const [displaySource, setDisplaySource] = useState( + initialValue ? resolvedOnImage : resolvedOffImage + ); + + const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current; + const pressScale = useRef(new Animated.Value(1)).current; + const listenerIdRef = useRef(null); + + useEffect(() => { + setDisplaySource(bgOn ? resolvedOnImage : resolvedOffImage); + }, [bgOn, resolvedOffImage, resolvedOnImage]); + + const removeProgressListener = () => { + if (listenerIdRef.current != null) { + progress.removeListener(listenerIdRef.current as string); + listenerIdRef.current = null; + } + }; + + const attachHalfwaySwapListener = (next: boolean) => { + removeProgressListener(); + let swapped = false; + listenerIdRef.current = progress.addListener(({ value }) => { + if (swapped) return; + const crossedHalfway = next ? value >= 0.5 : value <= 0.5; + if (!crossedHalfway) return; + swapped = true; + setBgOn(next); + setDisplaySource(next ? resolvedOnImage : resolvedOffImage); + removeProgressListener(); + }); + }; + + // Clean up listener on unmount + useEffect(() => { + return () => { + removeProgressListener(); + }; + }, []); + + // Keep internal state in sync when `initialValue` prop changes. + // Users may pass a changing `initialValue` (like from parent state) and + // expect the switch to reflect that. Animate `progress` toward the + // corresponding value and update images/background when done. + useEffect(() => { + // If no change, do nothing + if (initialValue === isOn) return; + + const next = initialValue; + const targetValue = next ? 1 : 0; + + progress.stopAnimation(); + removeProgressListener(); + + if (animationDuration <= 0) { + progress.setValue(targetValue); + setIsOn(next); + setBgOn(next); + setDisplaySource(next ? resolvedOnImage : resolvedOffImage); + return; + } + + // Update isOn immediately so accessibilityState etc. reflect change. + setIsOn(next); + + attachHalfwaySwapListener(next); + + Animated.timing(progress, { + toValue: targetValue, + duration: animationDuration, + useNativeDriver: true, + }).start(() => { + // Ensure final state reflects the target in case animation skips halfway listener. + setBgOn(next); + setDisplaySource(next ? resolvedOnImage : resolvedOffImage); + }); + }, [ + initialValue, + isOn, + animationDuration, + progress, + resolvedOffImage, + resolvedOnImage, + ]); + + const knobTranslateX = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, knobTravel], + }); + + const knobRotation = progress.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "180deg"], + }); + + const animatePress = (toValue: number) => { + Animated.timing(pressScale, { + toValue, + duration: PRESS_FEEDBACK_DURATION, + useNativeDriver: true, + }).start(); + }; + + const handlePressIn = () => { + animatePress(PRESSED_SCALE); + }; + + const handlePressOut = () => { + animatePress(1); + }; + + const handleToggle = () => { + const next = !isOn; + const targetValue = next ? 1 : 0; + + progress.stopAnimation(); + removeProgressListener(); + + if (animationDuration <= 0) { + progress.setValue(targetValue); + setIsOn(next); + setBgOn(next); + onChange?.(next); + return; + } + + setIsOn(next); + + attachHalfwaySwapListener(next); + + Animated.timing(progress, { + toValue: targetValue, + duration: animationDuration, + useNativeDriver: true, + }).start(() => { + setBgOn(next); + onChange?.(next); + }); + }; + + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + pressable: { + alignSelf: "flex-start", + }, + shadowWrapper: { + justifyContent: "center", + position: "relative", + shadowColor: "#000", + shadowOpacity: 0.15, + shadowOffset: { width: 0, height: 4 }, + shadowRadius: 6, + elevation: 6, + backgroundColor: "transparent", + }, + container: { + flex: 1, + justifyContent: "center", + position: "relative", + overflow: "hidden", + }, + knob: { + position: "absolute", + top: 0, + left: 0, + }, +}); + +export default RotateSwitch; diff --git a/components/tripInfo/CrewListTable.tsx b/components/tripInfo/CrewListTable.tsx index 3a33202..a8f6375 100644 --- a/components/tripInfo/CrewListTable.tsx +++ b/components/tripInfo/CrewListTable.tsx @@ -1,4 +1,5 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; +import { useI18n } from "@/hooks/use-i18n"; import { useTrip } from "@/state/use-trip"; import React, { useRef, useState } from "react"; import { Animated, Text, TouchableOpacity, View } from "react-native"; @@ -13,6 +14,7 @@ const CrewListTable: React.FC = () => { const [selectedCrew, setSelectedCrew] = useState( null ); + const { t } = useI18n(); const { trip } = useTrip(); @@ -51,7 +53,7 @@ const CrewListTable: React.FC = () => { onPress={handleToggle} style={styles.headerRow} > - Danh sách thuyền viên + {t("trip.crewList.title")} {collapsed && ( {tongThanhVien} )} @@ -75,10 +77,12 @@ const CrewListTable: React.FC = () => { {/* Header */} - Tên + + {t("trip.crewList.nameHeader")} + - Chức vụ + {t("trip.crewList.roleHeader")} @@ -99,7 +103,9 @@ const CrewListTable: React.FC = () => { {/* Footer */} - Tổng cộng + + {t("trip.crewList.totalLabel")} + {tongThanhVien} @@ -109,10 +115,12 @@ const CrewListTable: React.FC = () => { {/* Header */} - Tên + + {t("trip.crewList.nameHeader")} + - Chức vụ + {t("trip.crewList.roleHeader")} @@ -133,7 +141,9 @@ const CrewListTable: React.FC = () => { {/* Footer */} - Tổng cộng + + {t("trip.crewList.totalLabel")} + {tongThanhVien} diff --git a/components/tripInfo/FishingToolsList.tsx b/components/tripInfo/FishingToolsList.tsx index afc4d21..ed283dd 100644 --- a/components/tripInfo/FishingToolsList.tsx +++ b/components/tripInfo/FishingToolsList.tsx @@ -1,4 +1,5 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; +import { useI18n } from "@/hooks/use-i18n"; import { useTrip } from "@/state/use-trip"; import React, { useRef, useState } from "react"; import { Animated, Text, TouchableOpacity, View } from "react-native"; @@ -8,6 +9,7 @@ const FishingToolsTable: React.FC = () => { const [collapsed, setCollapsed] = useState(true); const [contentHeight, setContentHeight] = useState(0); const animatedHeight = useRef(new Animated.Value(0)).current; + const { t } = useI18n(); const { trip } = useTrip(); const data: Model.FishingGear[] = trip?.fishing_gears ?? []; @@ -31,7 +33,7 @@ const FishingToolsTable: React.FC = () => { onPress={handleToggle} style={styles.headerRow} > - Danh sách ngư cụ + {t("trip.fishingTools.title")} {collapsed && {tongSoLuong}} { > {/* Table Header */} - Tên + + {t("trip.fishingTools.nameHeader")} + - Số lượng + {t("trip.fishingTools.quantityHeader")} @@ -69,7 +73,7 @@ const FishingToolsTable: React.FC = () => { {/* Footer */} - Tổng cộng + {t("trip.fishingTools.totalLabel")} {tongSoLuong} @@ -81,9 +85,11 @@ const FishingToolsTable: React.FC = () => { {/* Table Header */} - Tên + + {t("trip.fishingTools.nameHeader")} + - Số lượng + {t("trip.fishingTools.quantityHeader")} @@ -98,7 +104,7 @@ const FishingToolsTable: React.FC = () => { {/* Footer */} - Tổng cộng + {t("trip.fishingTools.totalLabel")} {tongSoLuong} diff --git a/components/tripInfo/NetListTable.tsx b/components/tripInfo/NetListTable.tsx index 952c3a6..9cd6b70 100644 --- a/components/tripInfo/NetListTable.tsx +++ b/components/tripInfo/NetListTable.tsx @@ -1,4 +1,5 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; +import { useI18n } from "@/hooks/use-i18n"; import { useFishes } from "@/state/use-fish"; import { useTrip } from "@/state/use-trip"; import React, { useEffect, useRef, useState } from "react"; @@ -12,6 +13,7 @@ const NetListTable: React.FC = () => { const animatedHeight = useRef(new Animated.Value(0)).current; const [modalVisible, setModalVisible] = useState(false); const [selectedNet, setSelectedNet] = useState(null); + const { t } = useI18n(); const { trip } = useTrip(); const { fishSpecies, getFishSpecies } = useFishes(); useEffect(() => { @@ -49,7 +51,7 @@ const NetListTable: React.FC = () => { onPress={handleToggle} style={styles.headerRow} > - Danh sách mẻ lưới + {t("trip.netList.title")} {collapsed && ( {trip?.fishing_logs?.length} @@ -84,15 +86,21 @@ const NetListTable: React.FC = () => { > {/* Header */} - STT - Trạng thái + + {t("trip.netList.sttHeader")} + + + {t("trip.netList.statusHeader")} + {/* Body */} {trip?.fishing_logs?.map((item, index) => ( {/* Cột STT */} - Mẻ {index + 1} + + {t("trip.netList.haulPrefix")} {index + 1} + {/* Cột Trạng thái */} @@ -106,7 +114,9 @@ const NetListTable: React.FC = () => { onPress={() => handleStatusPress(item.fishing_log_id)} > - {item.status ? "Đã hoàn thành" : "Chưa hoàn thành"} + {item.status + ? t("trip.netList.completed") + : t("trip.netList.pending")} @@ -118,15 +128,21 @@ const NetListTable: React.FC = () => { {/* Header */} - STT - Trạng thái + + {t("trip.netList.sttHeader")} + + + {t("trip.netList.statusHeader")} + {/* Body */} {trip?.fishing_logs?.map((item, index) => ( {/* Cột STT */} - Mẻ {index + 1} + + {t("trip.netList.haulPrefix")} {index + 1} + {/* Cột Trạng thái */} @@ -140,7 +156,9 @@ const NetListTable: React.FC = () => { onPress={() => handleStatusPress(item.fishing_log_id)} > - {item.status ? "Đã hoàn thành" : "Chưa hoàn thành"} + {item.status + ? t("trip.netList.completed") + : t("trip.netList.pending")} diff --git a/components/tripInfo/TripCostTable.tsx b/components/tripInfo/TripCostTable.tsx index c6c6c71..41b2eb4 100644 --- a/components/tripInfo/TripCostTable.tsx +++ b/components/tripInfo/TripCostTable.tsx @@ -1,4 +1,5 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; +import { useI18n } from "@/hooks/use-i18n"; import { useTrip } from "@/state/use-trip"; import React, { useRef, useState } from "react"; import { Animated, Text, TouchableOpacity, View } from "react-native"; @@ -14,6 +15,7 @@ const TripCostTable: React.FC = () => { const [contentHeight, setContentHeight] = useState(0); const [modalVisible, setModalVisible] = useState(false); const animatedHeight = useRef(new Animated.Value(0)).current; + const { t } = useI18n(); const { trip } = useTrip(); @@ -50,7 +52,7 @@ const TripCostTable: React.FC = () => { // marginBottom: 12, }} > - Chi phí chuyến đi + {t("trip.costTable.title")} {collapsed && ( { {/* Header */} - Loại + {t("trip.costTable.typeHeader")} + + + {t("trip.costTable.totalCostHeader")} - Tổng chi phí {/* Body */} @@ -99,7 +103,7 @@ const TripCostTable: React.FC = () => { {/* Footer */} - Tổng cộng + {t("trip.costTable.totalLabel")} {tongCong.toLocaleString()} @@ -112,7 +116,9 @@ const TripCostTable: React.FC = () => { style={styles.viewDetailButton} onPress={handleViewDetail} > - Xem chi tiết + + {t("trip.costTable.viewDetail")} + )} @@ -121,9 +127,11 @@ const TripCostTable: React.FC = () => { {/* Header */} - Loại + {t("trip.costTable.typeHeader")} + + + {t("trip.costTable.totalCostHeader")} - Tổng chi phí {/* Body */} @@ -139,7 +147,7 @@ const TripCostTable: React.FC = () => { {/* Footer */} - Tổng cộng + {t("trip.costTable.totalLabel")} {tongCong.toLocaleString()} @@ -152,7 +160,9 @@ const TripCostTable: React.FC = () => { style={styles.viewDetailButton} onPress={handleViewDetail} > - Xem chi tiết + + {t("trip.costTable.viewDetail")} + )} diff --git a/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx b/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx index 77c063d..bab20f3 100644 --- a/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx +++ b/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx @@ -2,6 +2,7 @@ import Select from "@/components/Select"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { queryGpsData } from "@/controller/DeviceController"; import { queryUpdateFishingLogs } from "@/controller/TripController"; +import { useI18n } from "@/hooks/use-i18n"; import { showErrorToast, showSuccessToast } from "@/services/toast_service"; import { useFishes } from "@/state/use-fish"; import { useTrip } from "@/state/use-trip"; @@ -46,21 +47,16 @@ const SIZE_UNITS_OPTIONS = SIZE_UNITS.map((unit) => ({ // Zod schema cho 1 dòng cá const fishItemSchema = z.object({ - id: z.number().min(1, "Chọn loài cá"), - quantity: z - .number({ invalid_type_error: "Số lượng phải là số" }) - .positive("Số lượng > 0"), - unit: z.enum(UNITS, { required_error: "Chọn đơn vị" }), - size: z - .number({ invalid_type_error: "Kích thước phải là số" }) - .positive("Kích thước > 0") - .optional(), + id: z.number().min(1, ""), + quantity: z.number({ invalid_type_error: "" }).positive(""), + unit: z.enum(UNITS, { required_error: "" }), + size: z.number({ invalid_type_error: "" }).positive("").optional(), sizeUnit: z.enum(SIZE_UNITS), }); // Schema tổng: mảng các item const formSchema = z.object({ - fish: z.array(fishItemSchema).min(1, "Thêm ít nhất 1 loài cá"), + fish: z.array(fishItemSchema).min(1, ""), }); type FormValues = z.infer; @@ -78,6 +74,7 @@ const CreateOrUpdateHaulModal: React.FC = ({ fishingLog, fishingLogIndex, }) => { + const { t } = useI18n(); const [isCreateMode, setIsCreateMode] = React.useState(!fishingLog?.info); const [isEditing, setIsEditing] = React.useState(false); const [expandedFishIndices, setExpandedFishIndices] = React.useState< @@ -112,7 +109,7 @@ const CreateOrUpdateHaulModal: React.FC = ({ const onSubmit = async (values: FormValues) => { // Ensure species list is available so we can populate name/rarity if (!fishSpecies || fishSpecies.length === 0) { - showErrorToast("Danh sách loài cá chưa sẵn sàng"); + showErrorToast(t("trip.createHaulModal.fishListNotReady")); return; } // Helper to map form rows -> API info entries (single place) @@ -134,7 +131,7 @@ const CreateOrUpdateHaulModal: React.FC = ({ try { const gpsResp = await queryGpsData(); if (!gpsResp.data) { - showErrorToast("Không thể lấy dữ liệu GPS hiện tại"); + showErrorToast(t("trip.createHaulModal.gpsError")); return; } const gpsData = gpsResp.data; @@ -177,21 +174,21 @@ const CreateOrUpdateHaulModal: React.FC = ({ if (resp?.status === 200) { showSuccessToast( fishingLog?.fishing_log_id == null - ? "Thêm mẻ cá thành công" - : "Cập nhật mẻ cá thành công" + ? t("trip.createHaulModal.addSuccess") + : t("trip.createHaulModal.updateSuccess") ); getTrip(); onClose(); } else { showErrorToast( fishingLog?.fishing_log_id == null - ? "Thêm mẻ cá thất bại" - : "Cập nhật mẻ cá thất bại" + ? t("trip.createHaulModal.addError") + : t("trip.createHaulModal.updateError") ); } } catch (err) { console.error("onSubmit error:", err); - showErrorToast("Có lỗi xảy ra khi lưu mẻ cá"); + showErrorToast(t("trip.createHaulModal.validationError")); } }; @@ -315,7 +312,7 @@ const CreateOrUpdateHaulModal: React.FC = ({ return ( - {fishName || "Chọn loài cá"}: + {fishName || t("trip.createHaulModal.selectFish")}: {fishName ? `${quantity} ${unit}` : "---"} @@ -335,7 +332,9 @@ const CreateOrUpdateHaulModal: React.FC = ({ name={`fish.${index}.id`} render={({ field: { value, onChange } }) => ( - Tên cá + + {t("trip.createHaulModal.fishName")} + ({ label: unit.label, @@ -400,13 +403,13 @@ const CreateOrUpdateHaulModal: React.FC = ({ }))} value={value} onChange={onChange} - placeholder="Chọn đơn vị" + placeholder={t("trip.createHaulModal.unit")} disabled={!isEditing} listStyle={{ maxHeight: 100 }} /> {errors.fish?.[index]?.unit && ( - {errors.fish[index]?.unit?.message as string} + {t("trip.createHaulModal.unit")} )} @@ -423,7 +426,10 @@ const CreateOrUpdateHaulModal: React.FC = ({ name={`fish.${index}.size`} render={({ field: { value, onChange, onBlur } }) => ( - Kích thước + + {t("trip.createHaulModal.size")} ( + {t("trip.createHaulModal.optional")}) + = ({ /> {errors.fish?.[index]?.size && ( - {errors.fish[index]?.size?.message as string} + {t("trip.createHaulModal.size")} )} @@ -452,12 +458,14 @@ const CreateOrUpdateHaulModal: React.FC = ({ name={`fish.${index}.sizeUnit`} render={({ field: { value, onChange } }) => ( - Đơn vị + + {t("trip.createHaulModal.unit")} +