Skip to content

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 🎉

Internal documentation for iNET Portal