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
Copy
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
Copy
// 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
Copy
// 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
Copy
<!-- 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
Copy
<!-- 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:Copy
// 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
Copy
// 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
Progress Persistence
Progress Persistence
- Simpan progress di Pinia dengan persistence
- User bisa lanjutkan onboarding setelah refresh
- Clear state setelah complete
Validation
Validation
- Validate setiap step sebelum lanjut
- Server-side validation untuk data integrity
- Show clear error messages
Spreadsheet UX
Spreadsheet UX
- Support copy-paste dari Excel
- Auto-add spare rows
- Context menu untuk operasi umum
- Clear visual feedback untuk errors
Data Storage
Data Storage
- Use IndexedDB untuk data besar (geo data)
- Lazy load data yang tidak segera dibutuhkan
- Clear temporary data setelah complete