Skip to main content

Onboarding Feature

Fitur onboarding memungkinkan user untuk setup data awal bisnis mereka melalui multi-step wizard dengan spreadsheet-based data entry.

Overview

Onboarding wizard terdiri dari beberapa langkah untuk mengumpulkan data bisnis:

Feature Structure

features/onboarding/
├── api/
│   ├── validateStep.ts       # Validate step data
│   ├── submitStep.ts         # Submit step data
│   └── completeOnboarding.ts # Complete onboarding
├── components/
│   ├── OnboardingWizard.vue  # Main wizard component
│   ├── StepIndicator.vue     # Progress indicator
│   ├── StepBusinessInfo.vue  # Step 1: Business info
│   ├── StepProducts.vue      # Step 2: Products spreadsheet
│   ├── StepSuppliers.vue     # Step 3: Suppliers spreadsheet
│   ├── StepEmployees.vue     # Step 4: Employees spreadsheet
│   └── StepReview.vue        # Step 5: Review all data
├── composables/
│   ├── useOnboarding.ts      # Main onboarding logic
│   └── useStepValidation.ts  # Step validation
├── store/
│   └── onboarding.store.ts   # Onboarding state
├── types/
│   └── onboarding.types.ts   # Type definitions
└── index.ts                  # Barrel export

Onboarding Store

// features/onboarding/store/onboarding.store.ts
import { defineStore } from 'pinia'
import type { OnboardingStep, OnboardingData } from '../types'

export const useOnboardingStore = defineStore('onboarding', () => {
  // State
  const currentStep = ref<number>(1)
  const totalSteps = ref<number>(5)
  const isLoading = ref(false)
  const error = ref<string | null>(null)

  // Data per step
  const businessInfo = ref<BusinessInfo | null>(null)
  const products = ref<Product[]>([])
  const suppliers = ref<Supplier[]>([])
  const employees = ref<Employee[]>([])

  // Getters
  const progress = computed(() =>
    Math.round((currentStep.value / totalSteps.value) * 100)
  )

  const canGoNext = computed(() => {
    switch (currentStep.value) {
      case 1: return !!businessInfo.value?.name
      case 2: return products.value.length > 0
      case 3: return suppliers.value.length > 0
      case 4: return employees.value.length > 0
      case 5: return true
      default: return false
    }
  })

  const canGoPrevious = computed(() => currentStep.value > 1)

  // Actions
  const nextStep = () => {
    if (currentStep.value < totalSteps.value) {
      currentStep.value++
    }
  }

  const previousStep = () => {
    if (currentStep.value > 1) {
      currentStep.value--
    }
  }

  const goToStep = (step: number) => {
    if (step >= 1 && step <= totalSteps.value) {
      currentStep.value = step
    }
  }

  const setBusinessInfo = (data: BusinessInfo) => {
    businessInfo.value = data
  }

  const setProducts = (data: Product[]) => {
    products.value = data
  }

  const setSuppliers = (data: Supplier[]) => {
    suppliers.value = data
  }

  const setEmployees = (data: Employee[]) => {
    employees.value = data
  }

  const reset = () => {
    currentStep.value = 1
    businessInfo.value = null
    products.value = []
    suppliers.value = []
    employees.value = []
    error.value = null
  }

  return {
    // State
    currentStep,
    totalSteps,
    isLoading,
    error,
    businessInfo,
    products,
    suppliers,
    employees,
    // Getters
    progress,
    canGoNext,
    canGoPrevious,
    // Actions
    nextStep,
    previousStep,
    goToStep,
    setBusinessInfo,
    setProducts,
    setSuppliers,
    setEmployees,
    reset,
  }
}, {
  persist: true, // Persist progress across page reloads
})

Onboarding Composable

// features/onboarding/composables/useOnboarding.ts
import { useOnboardingStore } from '../store/onboarding.store'
import { validateStep, submitStep, completeOnboarding } from '../api'

export const useOnboarding = () => {
  const store = useOnboardingStore()
  const toast = useToast()
  const router = useRouter()

  const validateCurrentStep = async () => {
    store.isLoading = true
    store.error = null

    try {
      let data: unknown

      switch (store.currentStep) {
        case 1:
          data = store.businessInfo
          break
        case 2:
          data = store.products
          break
        case 3:
          data = store.suppliers
          break
        case 4:
          data = store.employees
          break
        default:
          return true
      }

      const result = await validateStep(store.currentStep, data)
      return result.valid
    } catch (e) {
      store.error = e instanceof Error ? e.message : 'Validation failed'
      return false
    } finally {
      store.isLoading = false
    }
  }

  const goNext = async () => {
    const isValid = await validateCurrentStep()

    if (isValid) {
      store.nextStep()
    } else {
      toast.add({
        title: 'Validation Error',
        description: 'Please fix the errors before continuing.',
        color: 'red',
      })
    }
  }

  const goPrevious = () => {
    store.previousStep()
  }

  const complete = async () => {
    store.isLoading = true

    try {
      await completeOnboarding({
        businessInfo: store.businessInfo!,
        products: store.products,
        suppliers: store.suppliers,
        employees: store.employees,
      })

      toast.add({
        title: 'Success',
        description: 'Onboarding completed successfully!',
        color: 'green',
      })

      store.reset()
      router.push('/')
    } catch (e) {
      store.error = e instanceof Error ? e.message : 'Failed to complete onboarding'
      toast.add({
        title: 'Error',
        description: store.error,
        color: 'red',
      })
    } finally {
      store.isLoading = false
    }
  }

  return {
    // State
    currentStep: computed(() => store.currentStep),
    totalSteps: computed(() => store.totalSteps),
    progress: computed(() => store.progress),
    isLoading: computed(() => store.isLoading),
    error: computed(() => store.error),
    canGoNext: computed(() => store.canGoNext),
    canGoPrevious: computed(() => store.canGoPrevious),
    // Data
    businessInfo: computed(() => store.businessInfo),
    products: computed(() => store.products),
    suppliers: computed(() => store.suppliers),
    employees: computed(() => store.employees),
    // Actions
    goNext,
    goPrevious,
    goToStep: store.goToStep,
    setBusinessInfo: store.setBusinessInfo,
    setProducts: store.setProducts,
    setSuppliers: store.setSuppliers,
    setEmployees: store.setEmployees,
    complete,
    reset: store.reset,
  }
}

Wizard Component

<!-- features/onboarding/components/OnboardingWizard.vue -->
<script setup lang="ts">
import { useOnboarding } from '../composables/useOnboarding'

const {
  currentStep,
  totalSteps,
  progress,
  isLoading,
  canGoNext,
  canGoPrevious,
  goNext,
  goPrevious,
  complete,
} = useOnboarding()

const steps = [
  { title: 'Business Info', description: 'Basic business information' },
  { title: 'Products', description: 'Add your products' },
  { title: 'Suppliers', description: 'Add your suppliers' },
  { title: 'Employees', description: 'Add your employees' },
  { title: 'Review', description: 'Review and confirm' },
]
</script>

<template>
  <div class="max-w-4xl mx-auto p-6">
    <!-- Progress Bar -->
    <div class="mb-8">
      <UProgress :value="progress" color="primary" />
      <p class="text-sm text-gray-500 mt-2">
        Step {{ currentStep }} of {{ totalSteps }}
      </p>
    </div>

    <!-- Step Indicator -->
    <div class="flex justify-between mb-8">
      <div
        v-for="(step, index) in steps"
        :key="index"
        class="flex flex-col items-center"
        :class="{
          'text-primary-500': currentStep === index + 1,
          'text-green-500': currentStep > index + 1,
          'text-gray-400': currentStep < index + 1,
        }"
      >
        <div
          class="w-8 h-8 rounded-full flex items-center justify-center"
          :class="{
            'bg-primary-500 text-white': currentStep === index + 1,
            'bg-green-500 text-white': currentStep > index + 1,
            'bg-gray-200': currentStep < index + 1,
          }"
        >
          <UIcon v-if="currentStep > index + 1" name="i-heroicons-check" />
          <span v-else>{{ index + 1 }}</span>
        </div>
        <span class="text-xs mt-1 hidden md:block">{{ step.title }}</span>
      </div>
    </div>

    <!-- Step Content -->
    <UCard class="mb-8">
      <template #header>
        <h2 class="text-xl font-semibold">{{ steps[currentStep - 1].title }}</h2>
        <p class="text-gray-500">{{ steps[currentStep - 1].description }}</p>
      </template>

      <KeepAlive>
        <component :is="getStepComponent(currentStep)" />
      </KeepAlive>
    </UCard>

    <!-- Navigation -->
    <div class="flex justify-between">
      <UButton
        v-if="canGoPrevious"
        variant="outline"
        @click="goPrevious"
      >
        Previous
      </UButton>
      <div v-else />

      <UButton
        v-if="currentStep < totalSteps"
        color="primary"
        :disabled="!canGoNext"
        :loading="isLoading"
        @click="goNext"
      >
        Next
      </UButton>

      <UButton
        v-else
        color="primary"
        :loading="isLoading"
        @click="complete"
      >
        Complete Setup
      </UButton>
    </div>
  </div>
</template>

<script lang="ts">
// Helper to get step component
const getStepComponent = (step: number) => {
  switch (step) {
    case 1: return resolveComponent('StepBusinessInfo')
    case 2: return resolveComponent('StepProducts')
    case 3: return resolveComponent('StepSuppliers')
    case 4: return resolveComponent('StepEmployees')
    case 5: return resolveComponent('StepReview')
    default: return null
  }
}
</script>

Products Step with Spreadsheet

<!-- features/onboarding/components/StepProducts.vue -->
<script setup lang="ts">
import { useOnboarding } from '../composables/useOnboarding'
import { HotTable } from '@handsontable/vue3'
import { registerAllModules } from 'handsontable/registry'

registerAllModules()

const { products, setProducts } = useOnboarding()

// Column definitions
const columns = [
  { data: 'name', type: 'text' },
  { data: 'sku', type: 'text' },
  { data: 'price', type: 'numeric', numericFormat: { pattern: '0,0' } },
  { data: 'stock', type: 'numeric' },
  { data: 'category', type: 'dropdown', source: ['Electronics', 'Food', 'Clothing'] },
]

const colHeaders = ['Product Name', 'SKU', 'Price', 'Stock', 'Category']

// Initialize with sample data or empty rows
const tableData = ref(
  products.value.length > 0
    ? products.value
    : Array(10).fill(null).map(() => ({
        name: '',
        sku: '',
        price: 0,
        stock: 0,
        category: '',
      }))
)

// Handle data changes
const onAfterChange = (changes: any) => {
  if (changes) {
    // Filter out empty rows and update store
    const validProducts = tableData.value.filter(
      row => row.name && row.sku && row.price > 0
    )
    setProducts(validProducts)
  }
}
</script>

<template>
  <div>
    <p class="mb-4 text-gray-600">
      Enter your products below. You can copy-paste from Excel.
    </p>

    <HotTable
      :data="tableData"
      :columns="columns"
      :colHeaders="colHeaders"
      :rowHeaders="true"
      :minRows="10"
      :minSpareRows="3"
      :contextMenu="true"
      :copyPaste="true"
      :stretchH="'all'"
      :height="400"
      :licenseKey="'non-commercial-and-evaluation'"
      @afterChange="onAfterChange"
    />

    <p class="mt-4 text-sm text-gray-500">
      {{ products.length }} products added
    </p>
  </div>
</template>

IndexedDB Storage

Onboarding menggunakan Dexie untuk menyimpan data geografis (countries, cities) secara lokal:
// features/onboarding/utils/geoStore.ts
import Dexie, { Table } from 'dexie'

interface Country {
  id: number
  name: string
  code: string
}

interface City {
  id: number
  name: string
  countryCode: string
}

class GeoDatabase extends Dexie {
  countries!: Table<Country>
  cities!: Table<City>

  constructor() {
    super('GeoStore')
    this.version(1).stores({
      countries: '++id, name, code',
      cities: '++id, name, countryCode',
    })
  }
}

export const geoDb = new GeoDatabase()

// Load countries into IndexedDB
export const loadCountries = async () => {
  const count = await geoDb.countries.count()
  if (count === 0) {
    // Fetch from API and store
    const countries = await $fetch<Country[]>('/api/v1/geo/countries')
    await geoDb.countries.bulkAdd(countries)
  }
  return geoDb.countries.toArray()
}

API Integration

// features/onboarding/api/validateStep.ts
export const validateStep = async (step: number, data: unknown) => {
  return await $fetch<{ valid: boolean; errors?: string[] }>(
    `/api/v1/onboarding/step/${step}/validate`,
    {
      method: 'POST',
      body: data,
    }
  )
}

// features/onboarding/api/completeOnboarding.ts
export const completeOnboarding = async (data: OnboardingData) => {
  return await $fetch('/api/v1/onboarding/complete', {
    method: 'POST',
    body: data,
  })
}

Best Practices

  • Simpan progress di Pinia dengan persistence
  • User bisa lanjutkan onboarding setelah refresh
  • Clear state setelah complete
  • Validate setiap step sebelum lanjut
  • Server-side validation untuk data integrity
  • Show clear error messages
  • Support copy-paste dari Excel
  • Auto-add spare rows
  • Context menu untuk operasi umum
  • Clear visual feedback untuk errors
  • Use IndexedDB untuk data besar (geo data)
  • Lazy load data yang tidak segera dibutuhkan
  • Clear temporary data setelah complete

Next Steps