I18n và UX Patterns
Mục tiêu bài học
- Hiểu i18n implementation trong eKYC
- Nắm UX best practices
- Biết cách handle edge cases UX
- Học accessibility patterns
Internationalization (i18n)
Setup
typescript
// main.ts
import { createI18n } from "vue-i18n";
import en from "./locales/en.json";
import vi from "./locales/vi.json";
const i18n = createI18n({
legacy: false,
locale: "vi", // Default locale
fallbackLocale: "en",
messages: {
en,
vi,
},
});
app.use(i18n);Locale Files Structure
json
// vi.json
{
"locale": {
// eKYC General
"ekyc_title": "Xác thực danh tính điện tử",
"ekyc_description": "Vui lòng chuẩn bị CCCD/CMND và khuôn mặt của bạn",
// Steps
"preparation": "Chuẩn bị",
"front_card": "Mặt trước",
"back_card": "Mặt sau",
"face_verification": "Xác thực khuôn mặt",
"review": "Xem lại",
// Preparation Step
"security_title": "Thông tin bảo mật",
"security_message": "Hình ảnh CMT/CCCD của Quý khách được mã hóa và bảo mật tuyệt đối.",
"preparation_instructions": "Vui lòng chuẩn bị CCCD/CMND và đảm bảo ánh sáng đủ",
// Camera
"camera_permission_denied": "Vui lòng cấp quyền truy cập camera",
"camera_not_found": "Không tìm thấy camera trên thiết bị",
"camera_in_use": "Camera đang được sử dụng bởi ứng dụng khác",
// Face Detection
"loading_model": "Đang tải mô hình phát hiện khuôn mặt...",
"no_face_detected": "Không phát hiện khuôn mặt",
"position_face_in_frame": "Vui lòng đưa khuôn mặt vào khung hình",
"hold_still": "Giữ yên...",
"ready_to_capture": "Sẵn sàng chụp",
// OCR
"ocr_processing": "Đang đọc thông tin...",
"ocr_success": "Đọc thông tin thành công",
"ocr_failed": "Không thể đọc thông tin từ ảnh",
"ocr_low_confidence": "Độ chính xác thấp, vui lòng chụp lại",
// Face Verification
"face_verification_success": "Xác thực khuôn mặt thành công",
"face_not_match": "Khuôn mặt không khớp với ảnh trên CCCD",
"face_spoof_detected": "Phát hiện không phải khuôn mặt thật",
// Address & Ward
"address_not_updated_since_merge": "Quý khách chưa thực hiện thay đổi địa chỉ theo đơn vị hành chính mới sát nhập từ 01/07/2025",
"select_new_ward": "Chọn phường/xã mới",
"old_ward": "Phường/xã cũ",
"new_ward": "Phường/xã mới",
"no_ward_found_for_address": "Không tìm thấy xã/phường phù hợp với địa chỉ đã nhập",
// Manual Ward Search
"ekyc_manual_search_link": "Nếu thông tin tự động chưa đúng, vui lòng nhấn vào đây để thực hiện tìm kiếm thủ công",
"ekyc_manual_search_title": "Tìm kiếm phường/xã thủ công",
"ekyc_manual_search_input_label": "Nhập tên phường/xã cũ",
"ekyc_manual_search_input_placeholder": "Ví dụ: Phường Yên Giang",
"ekyc_manual_search_button": "Tìm kiếm",
"ekyc_manual_search_loading": "Đang tìm kiếm...",
"ekyc_manual_search_empty": "Nhập tên phường/xã và nhấn tìm kiếm",
"ekyc_manual_search_instruction": "Nhập tên phường/xã cũ để tìm thông tin mới sau sáp nhập",
"ekyc_manual_search_new_ward": "Phường/xã mới",
"ekyc_manual_search_new_province": "Tỉnh/Thành phố mới",
"ekyc_manual_search_old_ward": "Phường/xã cũ",
"ekyc_manual_search_select_button": "Chọn",
"ekyc_manual_search_selected": "Đã chọn phường/xã thành công",
"ekyc_manual_search_query_required": "Vui lòng nhập tên phường/xã để tìm kiếm",
"ekyc_manual_search_no_results": "Không tìm thấy kết quả",
"ekyc_manual_search_error": "Có lỗi xảy ra khi tìm kiếm",
// Non-Vietnam Warning
"ekyc_non_vietnam_title": "Thông tin địa chỉ",
"ekyc_non_vietnam_desc": "Vui lòng nhập thông tin địa chỉ thủ công",
// Gender
"ekyc_other": "Khác",
"ekyc_gender_placeholder": "Chọn giới tính",
"ekyc_manual_search_input_placeholder": "Ví dụ: Phường Cầu Giấy",
"ekyc_manual_search_button": "Tìm kiếm",
"ekyc_manual_search_loading": "Đang tìm kiếm...",
"ekyc_manual_search_empty": "Nhập tên phường/xã để tìm kiếm",
"ekyc_manual_search_instruction": "Nhập tên phường/xã cũ để tìm thông tin mới sau sáp nhập",
"ekyc_manual_search_new_ward": "Phường/xã mới",
"ekyc_manual_search_new_province": "Tỉnh/Thành phố",
"ekyc_manual_search_old_ward": "Phường/xã cũ",
"ekyc_manual_search_select_button": "Chọn",
"ekyc_manual_search_selected": "Đã chọn phường/xã",
"ekyc_manual_search_query_required": "Vui lòng nhập tên phường/xã để tìm kiếm",
"ekyc_manual_search_no_results": "Không tìm thấy kết quả",
"ekyc_manual_search_error": "Có lỗi xảy ra khi tìm kiếm",
// Non-Vietnam Warning
"ekyc_non_vietnam_title": "Thông tin địa chỉ",
"ekyc_non_vietnam_desc": "Vui lòng nhập thông tin địa chỉ thủ công",
// Gender
"ekyc_other": "Khác",
"ekyc_gender_placeholder": "Chọn giới tính",
// Actions
"capture": "Chụp ảnh",
"retake": "Chụp lại",
"continue": "Tiếp tục",
"go_back": "Quay lại",
"submit_ekyc": "Hoàn tất xác thực",
"retry": "Thử lại",
// Errors
"network_error": "Lỗi kết nối mạng",
"timeout_error": "Hết thời gian chờ",
"unknown_error": "Đã xảy ra lỗi"
}
}json
// en.json
{
"locale": {
// eKYC General
"ekyc_title": "Electronic Identity Verification",
"ekyc_description": "Please prepare your ID card and your face",
// Steps
"preparation": "Preparation",
"front_card": "Front Card",
"back_card": "Back Card",
"face_verification": "Face Verification",
"review": "Review"
// ... (English translations)
}
}Sử dụng trong Components
vue
<template>
<div class="ekyc-step">
<h1>{{ t("locale.ekyc_title") }}</h1>
<p>{{ t("locale.ekyc_description") }}</p>
<!-- Với tham số -->
<p>
{{ t("locale.similarity_score", { score: similarity }) }}
</p>
<!-- Số nhiều -->
<p>
{{ t("locale.retries_left", retryCount) }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
// Thay đổi ngôn ngữ
function switchLanguage(lang: "vi" | "en") {
locale.value = lang;
}
</script>Thông điệp động
typescript
// Với tham số
t('locale.similarity_score', { score: 85 })
// Vi: "Độ tương đồng: 85%"
// En: "Similarity: 85%"
// Số nhiều
// vi.json
{
"retries_left": "Còn {count} lần thử | Còn {count} lần thử"
}
// en.json
{
"retries_left": "{count} retry left | {count} retries left"
}
// Sử dụng
t('locale.retries_left', 1) // "Còn 1 lần thử" / "1 retry left"
t('locale.retries_left', 3) // "Còn 3 lần thử" / "3 retries left"Các mẫu UX
1. Tiết lộ dần dần
Hiển thị thông tin từng bước, không làm người dùng quá tải:
vue
<template>
<div class="ekyc-flow">
<!-- Stepper cho context -->
<div class="stepper">
<div
v-for="(step, index) in steps"
:key="step"
:class="getStepClass(step)"
>
<div class="step-number">{{ index + 1 }}</div>
<div class="step-label">{{ t(`locale.${step}`) }}</div>
</div>
</div>
<!-- Current step only -->
<div class="step-content">
<component :is="currentStepComponent" />
</div>
<!-- Navigation -->
<div class="navigation">
<BtnBase v-if="canGoBack" @click="goBack">
{{ t("locale.go_back") }}
</BtnBase>
<BtnBase v-if="canContinue" class="c-btn-primary" @click="goNext">
{{ t("locale.continue") }}
</BtnBase>
</div>
</div>
</template>
<script setup lang="ts">
const steps = ["preparation", "front", "back", "face", "review"];
const currentStepIndex = ref(0);
const currentStepComponent = computed(() => {
return stepComponents[steps[currentStepIndex.value]];
});
function getStepClass(step: string): string {
const index = steps.indexOf(step);
if (index < currentStepIndex.value) return "step-completed";
if (index === currentStepIndex.value) return "step-active";
return "step-pending";
}
</script>2. Trạng thái tải
Luôn hiển thị phản hồi khi đang xử lý:
vue
<template>
<div class="action-button">
<BtnBase
:loading="isProcessing"
:disabled="!canProceed || isProcessing"
@click="handleAction"
>
<template v-if="isProcessing">
<Lucide icon="Loader" class="animate-spin" />
<span>{{ loadingMessage }}</span>
</template>
<template v-else>
<Lucide icon="Camera" />
<span>{{ t("locale.capture") }}</span>
</template>
</BtnBase>
<!-- Progress bar (for long operations) -->
<div v-if="isProcessing && showProgress" class="progress-bar">
<div class="progress-fill" :style="{ width: `${progress}%` }" />
<span class="progress-text">{{ progress }}%</span>
</div>
</div>
</template>
<script setup lang="ts">
const isProcessing = ref(false);
const progress = ref(0);
const loadingMessage = ref("");
async function handleAction() {
isProcessing.value = true;
try {
// Step 1
loadingMessage.value = t("locale.uploading");
progress.value = 30;
await upload();
// Step 2
loadingMessage.value = t("locale.processing");
progress.value = 70;
await process();
// Done
progress.value = 100;
loadingMessage.value = t("locale.done");
} finally {
isProcessing.value = false;
progress.value = 0;
}
}
</script>3. Error Handling UX
Show errors rõ ràng với actions:
vue
<template>
<div v-if="error" class="error-container">
<!-- Icon based on error type -->
<Lucide :icon="errorIcon" :class="`text-${errorLevel}-500`" />
<!-- Error message -->
<h3>{{ error.title }}</h3>
<p>{{ error.message }}</p>
<!-- Details (expandable) -->
<button
v-if="error.details"
@click="showDetails = !showDetails"
class="details-toggle"
>
{{ showDetails ? t("locale.hide_details") : t("locale.show_details") }}
</button>
<div v-if="showDetails" class="error-details">
{{ error.details }}
</div>
<!-- Actions -->
<div class="error-actions">
<!-- Primary action -->
<BtnBase class="c-btn-primary" @click="handlePrimaryAction">
{{ error.primaryAction || t("locale.retry") }}
</BtnBase>
<!-- Secondary action -->
<BtnBase
v-if="error.secondaryAction"
class="c-btn-gray"
@click="handleSecondaryAction"
>
{{ error.secondaryAction }}
</BtnBase>
<!-- Help -->
<button class="help-link" @click="showHelp">
{{ t("locale.need_help") }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface ErrorInfo {
title: string;
message: string;
details?: string;
level: "error" | "warning" | "info";
primaryAction?: string;
secondaryAction?: string;
}
const error = ref<ErrorInfo | null>(null);
const showDetails = ref(false);
const errorIcon = computed(() => {
switch (error.value?.level) {
case "error":
return "XCircle";
case "warning":
return "AlertTriangle";
case "info":
return "Info";
default:
return "AlertCircle";
}
});
function showError(err: Error) {
error.value = {
title: t("locale.error_occurred"),
message: err.message,
details: err.stack,
level: "error",
primaryAction: t("locale.retry"),
};
}
</script>4. Empty States
Handle empty data gracefully:
vue
<template>
<div class="data-container">
<div v-if="loading" class="loading-state">
<Lucide icon="Loader" class="animate-spin" />
<p>{{ t("locale.loading") }}</p>
</div>
<div v-else-if="error" class="error-state">
<!-- Error UI -->
</div>
<div v-else-if="!data || data.length === 0" class="empty-state">
<Lucide icon="FileQuestion" class="empty-icon" />
<h3>{{ t("locale.no_data") }}</h3>
<p>{{ t("locale.no_data_description") }}</p>
<BtnBase @click="handleEmptyAction">
{{ t("locale.empty_action") }}
</BtnBase>
</div>
<div v-else class="data-content">
<!-- Actual data -->
</div>
</div>
</template>5. Confirmation Dialogs
Confirm trước khi actions quan trọng:
vue
<template>
<ModalAntDesign
v-model:visible="showConfirm"
:title="t('locale.confirm_action')"
>
<div class="confirm-content">
<Lucide icon="AlertTriangle" class="text-orange-500" />
<p>{{ confirmMessage }}</p>
<!-- Show what will happen -->
<div class="impact-info">
<h4>{{ t("locale.this_will") }}:</h4>
<ul>
<li v-for="impact in impacts" :key="impact">
{{ t(`locale.${impact}`) }}
</li>
</ul>
</div>
</div>
<template #footer>
<BtnBase class="c-btn-gray" @click="handleCancel">
{{ t("locale.cancel") }}
</BtnBase>
<BtnBase
class="c-btn-primary"
:loading="confirming"
@click="handleConfirm"
>
{{ t("locale.confirm") }}
</BtnBase>
</template>
</ModalAntDesign>
</template>6. Manual Search Modal Pattern
Modal tìm kiếm thủ công với search input và results list:
vue
<template>
<a-modal
v-model:visible="showManualWardSearchModal"
:title="t('locale.ekyc_manual_search_title')"
width="600px"
:footer="null"
>
<div class="space-y-4">
<!-- Search Input with Button -->
<div>
<label class="block text-sm font-medium mb-2">
{{ t('locale.ekyc_manual_search_input_label') }}
</label>
<div class="flex gap-2">
<a-input
v-model:value="manualWardSearchQuery"
:placeholder="t('locale.ekyc_manual_search_input_placeholder')"
@press-enter="performManualWardSearch"
class="flex-1"
/>
<BtnBase
lucide-icon="Search"
:loading="loadingManualWardSearch"
@click="performManualWardSearch"
class="c-btn-primary"
>
{{ t('locale.ekyc_manual_search_button') }}
</BtnBase>
</div>
</div>
<!-- Loading State -->
<div v-if="loadingManualWardSearch" class="text-center py-8">
<Lucide icon="Loader" class="animate-spin mx-auto mb-2" />
<p>{{ t('locale.ekyc_manual_search_loading') }}</p>
</div>
<!-- Empty State (no query) -->
<div v-else-if="!manualWardSearchQuery" class="text-center py-8">
<p class="text-gray-500">{{ t('locale.ekyc_manual_search_empty') }}</p>
<p class="text-sm text-gray-400 mt-2">
{{ t('locale.ekyc_manual_search_instruction') }}
</p>
</div>
<!-- No Results -->
<div v-else-if="manualWardSearchResults.length === 0" class="text-center py-8">
<p class="text-gray-500">{{ t('locale.ekyc_manual_search_no_results') }}</p>
</div>
<!-- Results List -->
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
<div
v-for="(ward, index) in manualWardSearchResults"
:key="index"
class="p-4 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors"
@click="selectWardFromManualSearch(ward)"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">
{{ ward.newWard }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ t('locale.ekyc_manual_search_new_province') }}: {{ ward.newProvince }}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
{{ t('locale.ekyc_manual_search_old_ward') }}: {{ ward.oldWard }}
</p>
</div>
<BtnBase class="c-btn-gray text-xs ml-4">
{{ t('locale.ekyc_manual_search_select_button') }}
</BtnBase>
</div>
</div>
</div>
</div>
</a-modal>
</template>Key UX patterns:
- ✅ Search input với Enter key support
- ✅ Loading state với spinner
- ✅ Empty states (no query, no results)
- ✅ Clickable results với hover effect
- ✅ Clear visual hierarchy (ward name → province → old ward)
7. Gender Dropdown với "Khác" Option
Dropdown giới tính với 3 options: Nam, Nữ, Khác:
vue
<template>
<a-select
v-model:value="missingFieldsInput.gender"
:placeholder="t('locale.ekyc_gender_placeholder')"
class="w-full"
>
<a-select-option value="Nam">Nam</a-select-option>
<a-select-option value="Nữ">Nữ</a-select-option>
<a-select-option value="Khác">{{ t('locale.ekyc_other') }}</a-select-option>
</a-select>
</template>Mapping to API format:
typescript
const genderMap: { [key: string]: string } = {
Nam: 'male',
Nữ: 'female',
Khác: 'other',
male: 'male',
female: 'female',
other: 'other',
};
// When submitting to API
updateData.gender = genderMap[mergedFrontOcrData.sex] || mergedFrontOcrData.sex.toLowerCase();8. Mobile Optimization
vue
<template>
<div :class="`ekyc-container ${isMobile ? 'mobile' : 'desktop'}`">
<!-- Camera view - full screen on mobile -->
<div :class="`camera-view ${isMobile ? 'fullscreen' : ''}`">
<video ref="videoRef" />
</div>
<!-- Controls - bottom sheet on mobile -->
<div :class="`controls ${isMobile ? 'bottom-sheet' : 'sidebar'}`">
<BtnBase
:class="isMobile ? 'w-full h-12' : 'w-auto h-10'"
@click="capture"
>
{{ t("locale.capture") }}
</BtnBase>
</div>
</div>
</template>
<script setup lang="ts">
import { useBreakpoints } from "@vueuse/core";
const breakpoints = useBreakpoints({
mobile: 640,
tablet: 768,
desktop: 1024,
});
const isMobile = breakpoints.smaller("tablet");
</script>
<style scoped>
/* Mobile-first approach */
.camera-view {
width: 100%;
height: 60vh;
}
.camera-view.fullscreen {
height: 100vh;
position: fixed;
inset: 0;
z-index: 50;
}
.controls.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: white;
border-top: 1px solid #e5e7eb;
}
@media (min-width: 768px) {
.camera-view {
height: 500px;
}
.controls.sidebar {
position: relative;
padding: 2rem;
}
}
</style>Accessibility
1. Keyboard Navigation
vue
<template>
<div
class="focusable-element"
tabindex="0"
@keydown.enter="handleAction"
@keydown.space.prevent="handleAction"
@keydown.esc="handleCancel"
>
<!-- Content -->
</div>
</template>2. ARIA Labels
vue
<template>
<button
:aria-label="t('locale.capture_face_image')"
:aria-busy="isProcessing"
:aria-disabled="!canCapture"
@click="capture"
>
<Lucide icon="Camera" aria-hidden="true" />
<span>{{ t("locale.capture") }}</span>
</button>
<div role="alert" aria-live="polite" v-if="statusMessage">
{{ statusMessage }}
</div>
</template>3. Focus Management
typescript
// Auto-focus sau khi step change
watch(currentStep, async () => {
await nextTick();
// Focus first interactive element
const firstInput = document.querySelector<HTMLElement>(
".step-content input, .step-content button"
);
firstInput?.focus();
});
// Trap focus trong modal
function trapFocus(modalElement: HTMLElement) {
const focusableElements = modalElement.querySelectorAll(
'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
modalElement.addEventListener("keydown", (e) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
});
}Performance UX
1. Optimistic Updates
typescript
async function handleAction() {
// Update UI immediately
isSuccess.value = true;
showSuccessMessage();
try {
// Actual API call in background
await apiCall();
} catch (error) {
// Revert if failed
isSuccess.value = false;
showErrorMessage();
}
}2. Skeleton Loading
vue
<template>
<div v-if="loading" class="skeleton">
<div class="skeleton-line w-3/4 h-6" />
<div class="skeleton-line w-1/2 h-4 mt-2" />
<div class="skeleton-line w-full h-20 mt-4" />
</div>
<div v-else class="actual-content">
<!-- Real content -->
</div>
</template>
<style scoped>
.skeleton-line {
@apply bg-gray-200 rounded animate-pulse;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>Tổng kết
I18n best practices:
- ✅ Tất cả text phải qua
t() - ✅ Organize keys theo features
- ✅ Support pluralization và parameters
- ✅ Fallback locale
UX principles:
- ✅ Progressive disclosure
- ✅ Clear loading states
- ✅ Helpful error messages
- ✅ Empty state handling
- ✅ Confirmation for important actions
- ✅ Mobile-first responsive
- ✅ Accessibility (keyboard, ARIA, focus)
Performance UX:
- ✅ Optimistic updates
- ✅ Skeleton loading
- ✅ Background processing
→ END of eKYC Documentation 🎉