feat: create onboarding page with multi-step form and validation

This commit is contained in:
Yoga Pangestu 2026-07-23 22:28:30 +07:00
parent c307593a1b
commit 0418bec226
4 changed files with 471 additions and 1 deletions

View File

@ -1,4 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@ -118,7 +117,35 @@
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
* {
scrollbar-width: thin;
scrollbar-color: #c4c4c4 transparent;
}
.dark * {
scrollbar-color: #444 transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #c4c4c4;
border-radius: 9999px;
}
.dark *::-webkit-scrollbar-thumb {
background-color: #444;
}

View File

@ -0,0 +1,364 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import * as z from 'zod'
useSeoMeta({
title: 'Onboarding',
ogTitle: 'Onboarding',
description: 'Siapkan bisnis Anda dalam beberapa langkah mudah.',
ogDescription: 'Siapkan bisnis Anda dalam beberapa langkah mudah.',
})
const config = useRuntimeConfig()
const router = useRouter()
const currentStep = ref(0)
const loadingSubmit = ref(false)
// --- Types ---
interface BusinessType {
id: string
code: string
name: string
description: string | null
is_active: boolean
}
interface Plan {
id: string
code: string
name: string
description: string | null
price: number
limits: Record<string, number> | null
is_active: boolean
}
// --- Step 1: Business Type ---
const selectedBusinessType = ref<string | null>(null)
const { data: businessTypes } = useFetch<BusinessType[]>(`${config.public.apiBase}/business-types?is_active=true`)
function selectBusinessType(id: string) {
selectedBusinessType.value = selectedBusinessType.value === id ? null : id
}
// --- Step 2: Business Info ---
const businessName = ref('')
const businessPhone = ref('')
const businessEmail = ref('')
const businessAddress = ref('')
const errors = ref<Record<string, string>>({})
const businessInfoSchema = z.object({
name: z.string().min(1, 'Nama bisnis wajib diisi').max(100, 'Nama bisnis maksimal 100 karakter'),
phone: z.string().max(20, 'Nomor telepon maksimal 20 karakter').optional().or(z.literal('')),
email: z.string().email('Format email tidak valid').max(100, 'Email maksimal 100 karakter').optional().or(z.literal('')),
address: z.string().max(255, 'Alamat maksimal 255 karakter').optional().or(z.literal('')),
})
function generateSlug(name: string) {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
// --- Step 3: Plan ---
const selectedPlan = ref<string | null>(null)
const { data: plans } = useFetch<Plan[]>(`${config.public.apiBase}/plans`)
const planButtons = computed(() => {
if (!plans.value) return []
return plans.value.map((plan, i) => ({
...plan,
price: `Rp${plan.price.toLocaleString('id-ID')}`,
billingCycle: '/bulan',
badge: i === 1 ? 'Paling Populer' : null,
highlight: i === 1,
features: plan.limits
? Object.entries(plan.limits).map(([key, val]) => `${key}: ${val}`)
: [],
}))
})
function selectPlan(id: string) {
selectedPlan.value = id
}
// --- Validation per step ---
function validateStep(): boolean {
if (currentStep.value === 0) {
if (!selectedBusinessType.value) {
toast.warning('Pilih jenis bisnis', {
description: 'Silakan pilih salah satu jenis bisnis terlebih dahulu.',
})
return false
}
return true
}
if (currentStep.value === 1) {
errors.value = {}
const result = businessInfoSchema.safeParse({
name: businessName.value,
phone: businessPhone.value,
email: businessEmail.value,
address: businessAddress.value,
})
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as string
errors.value[field] = issue.message
}
toast.warning('Data tidak lengkap', {
description: 'Silakan lengkapi data bisnis dengan benar.',
})
return false
}
return true
}
if (currentStep.value === 2) {
if (!selectedPlan.value) {
toast.warning('Pilih paket', {
description: 'Silakan pilih salah satu paket terlebih dahulu.',
})
return false
}
return true
}
return true
}
// --- Stepper ---
const stepLabels = ['Bisnis', 'Info', 'Paket']
function handleNext() {
const valid = validateStep()
if (valid && currentStep.value < stepLabels.length - 1) {
currentStep.value++
}
}
function handlePrev() {
if (currentStep.value > 0) {
currentStep.value--
}
}
async function handleSubmit() {
const valid = validateStep()
if (!valid) return
loadingSubmit.value = true
try {
await $fetch(`${config.public.apiBase}/tenants`, {
method: 'POST',
body: {
name: businessName.value,
slug: generateSlug(businessName.value),
business_type_id: selectedBusinessType.value,
plan_id: selectedPlan.value,
phone: businessPhone.value || undefined,
email: businessEmail.value || undefined,
address: businessAddress.value || undefined,
},
})
toast.success('Berhasil!', {
description: 'Bisnis Anda telah dibuat. Selamat datang di Profitra!',
})
router.push('/')
} catch (error: any) {
const detail = error?.data?.detail
const message = Array.isArray(detail)
? detail.map((e: any) => e.msg).join(', ')
: error?.data?.message || 'Terjadi kesalahan. Silakan coba lagi.'
toast.error('Gagal membuat bisnis', {
description: message,
})
} finally {
loadingSubmit.value = false
}
}
</script>
<template>
<div class="flex justify-between h-screen overflow-hidden">
<div class="w-[55%] h-screen flex flex-col justify-between items-start p-20 overflow-y-auto">
<div class="flex gap-2 items-center mb-8">
<img src="https://img.icons8.com/color/1200/google-logo.jpg" alt="Profitra" class="w-8 h-8" />
<p class="text-lg font-bold">Profitra</p>
</div>
<div class="flex-1 flex flex-col min-h-0 gap-4 w-full">
<div class="flex gap-2 items-center">
<template v-for="(label, i) in stepLabels" :key="i">
<span
class="h-2 rounded-full transition-all duration-300"
:class="i === currentStep ? 'w-10 bg-primary' : 'w-2 bg-muted-foreground/30'"
/>
<span v-if="i === currentStep" class="text-sm font-medium">{{ label }}</span>
</template>
</div>
<!-- Step 0: Jenis Bisnis -->
<template v-if="currentStep === 0">
<div>
<h4 class="text-2xl font-semibold">Pilih Jenis Bisnis Anda</h4>
<p class="text-muted-foreground">Profitra akan menyesuaikan fitur dan pengaturan awal berdasarkan jenis usaha yang Anda jalankan.</p>
</div>
<div class="min-h-0 overflow-y-auto grid grid-cols-4 gap-4 p-1">
<div
v-for="bt in businessTypes"
:key="bt.id"
class="relative bg-cover bg-center h-50 rounded-2xl cursor-pointer transition-all duration-200"
:class="selectedBusinessType === bt.id ? 'ring-2 ring-primary ring-offset-2' : ''"
style="background-image: url('https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT0my2vkhvpEw8z09QunZKuGQKwWgVsEhK_HUIPuzNfdLbLuz9bg7mUmCo&s=10')"
@click="selectBusinessType(bt.id)"
>
<h5 class="absolute bottom-0 p-4 text-white font-semibold rounded-b-2xl w-full bg-gradient-to-t from-black/60 to-transparent">
{{ bt.name }}
</h5>
</div>
</div>
</template>
<!-- Step 1: Info Bisnis -->
<template v-if="currentStep === 1">
<div>
<h4 class="text-2xl font-semibold">Informasi Bisnis</h4>
<p class="text-muted-foreground">Lengkapi data bisnis Anda untuk melanjutkan.</p>
</div>
<div class="grid grid-cols-2 gap-4">
<Field :class="{ 'text-destructive': errors.name }">
<FieldLabel for="business-name">
Nama Bisnis <span class="text-destructive">*</span>
</FieldLabel>
<Input
id="business-name"
v-model="businessName"
placeholder="Masukkan nama bisnis"
:class="{ 'border-destructive': errors.name }"
/>
<p v-if="errors.name" class="text-sm text-destructive mt-1">
{{ errors.name }}
</p>
</Field>
<Field :class="{ 'text-destructive': errors.phone }">
<FieldLabel for="business-phone">No. Telepon</FieldLabel>
<Input
id="business-phone"
v-model="businessPhone"
placeholder="08xxxxxxxxxx"
:class="{ 'border-destructive': errors.phone }"
/>
<p v-if="errors.phone" class="text-sm text-destructive mt-1">
{{ errors.phone }}
</p>
</Field>
<Field :class="{ 'text-destructive': errors.email }">
<FieldLabel for="business-email">Email</FieldLabel>
<Input
id="business-email"
v-model="businessEmail"
placeholder="email@bisnis.com"
:class="{ 'border-destructive': errors.email }"
/>
<p v-if="errors.email" class="text-sm text-destructive mt-1">
{{ errors.email }}
</p>
</Field>
<Field :class="{ 'text-destructive': errors.address }">
<FieldLabel for="business-address">Alamat</FieldLabel>
<Input
id="business-address"
v-model="businessAddress"
placeholder="Alamat bisnis"
:class="{ 'border-destructive': errors.address }"
/>
<p v-if="errors.address" class="text-sm text-destructive mt-1">
{{ errors.address }}
</p>
</Field>
</div>
</template>
<!-- Step 2: Pilih Paket -->
<template v-if="currentStep === 2">
<div>
<h4 class="text-2xl font-semibold">Pilih Paket Langganan</h4>
<p class="text-muted-foreground">Tentukan paket yang sesuai dengan kebutuhan bisnis Anda.</p>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div
v-for="plan in planButtons"
:key="plan.id"
class="rounded-xl border p-4 flex flex-col gap-3"
:class="plan.highlight ? 'border-primary ring-1 ring-primary' : 'border-border'"
>
<div class="flex items-center justify-between">
<h5 class="font-semibold text-base">{{ plan.name }}</h5>
<span
v-if="plan.badge"
class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
{{ plan.badge }}
</span>
</div>
<div>
<span class="text-xl font-bold">{{ plan.price }}</span>
<span class="text-muted-foreground">{{ plan.billingCycle }}</span>
</div>
<ul v-if="plan.features?.length" class="space-y-1 text-sm text-muted-foreground">
<li v-for="(feat, fi) in plan.features" :key="fi" class="flex items-start gap-1.5">
<span class="i-lucide-check h-4 w-4 mt-0.5 shrink-0 text-primary" />
<span>{{ feat }}</span>
</li>
</ul>
<Button
:variant="selectedPlan === plan.id ? 'default' : (plan.highlight ? 'default' : 'outline')"
class="mt-auto"
@click="selectPlan(plan.id)"
>
{{ selectedPlan === plan.id ? 'Dipilih' : 'Pilih' }}
</Button>
</div>
</div>
</template>
<div class="space-y-2">
<div class="flex gap-2">
<Button variant="outline" :disabled="currentStep === 0" @click="handlePrev">
Kembali
</Button>
<Button v-if="currentStep < stepLabels.length - 1" @click="handleNext">
Lanjut
</Button>
<Button v-else :disabled="loadingSubmit" @click="handleSubmit">
<span v-if="loadingSubmit" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
Selesai
</Button>
</div>
</div>
</div>
</div>
<div class="w-[45%] py-4 pe-4">
<div
class="relative bg-cover h-full rounded-2xl"
style="background-image: url('https://images.pexels.com/photos/37594406/pexels-photo-37594406.jpeg')"
>
<div class="absolute bottom-0 pl-4 pb-4">
<h5 class="text-xl font-bold text-white">Kelola bisnis lebih mudah bersama Profitra</h5>
<span class="text-base text-white/80">Pilih jenis usaha Anda dan mulai kelola bisnis dengan cara yang lebih modern.</span>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,10 @@
<script lang="ts">
</script>
<script setup lang="ts">
import OnboardingForm from '@/components/OnboardingForm.vue'
</script>
<template>
<OnboardingForm />
</template>

View File

@ -0,0 +1,69 @@
# Onboarding Page - Session 3
**Tanggal:** 2026-07-23
**Status:** Selesai
## Tujuan
Membuat halaman onboarding pada `web2/app/components/OnboardingForm.vue` dengan referensi dari `web/app/pages/onboarding.vue`, mengadaptasi komponen Nuxt UI ke shadcn-vue.
## Yang Dikerjakan
### 1. Buat `app/pages/onboarding/index.vue`
- Page wrapper yang merender `<OnboardingForm />`.
### 2. Buat `app/components/OnboardingForm.vue`
- Multi-step form (3 langkah) dengan stepper UI.
- **Step 0 - Bisnis:** Pilih jenis bisnis (grid kartu dengan gambar).
- **Step 1 - Info:** Form nama bisnis, telepon, email, alamat.
- **Step 2 - Paket:** Pilih paket langganan (card pricing).
- Validasi per step menggunakan Zod.
- Submit handler: `POST /tenants` via `$fetch`.
- Toast notification (success/error/warning) via `vue-sonner`.
- Loading state pada tombol submit.
- Layout split-screen (55% form + 45% gambar dekoratif).
- Navigasi: tombol Kembali, Lanjut, Selesai.
## File yang Dibuat
| File | Aksi |
|------|------|
| `app/pages/onboarding/index.vue` | Dibuat |
| `app/components/OnboardingForm.vue` | Dibuat |
## API yang Digunakan
- `GET /v1/business-types?is_active=true` - Daftar jenis bisnis
- `GET /v1/plans` - Daftar paket langganan
- `POST /v1/tenants` - Buat bisnis baru
## Adaptasi Nuxt UI → shadcn-vue
| Nuxt UI (web) | shadcn-vue (web2) |
|---|---|
| `UButton` | `Button` |
| `UInput` | `Input` |
| `UFormField` | `Field` + `FieldLabel` |
| `UBadge` | `<span>` styled |
| `UIcon` | `i-lucide-*` class |
| `useToast()` | `toast` dari `vue-sonner` |
## Fields Form (Step 1)
| Field | Type | Validasi |
|-------|------|----------|
| `name` | text | wajib, max 100 karakter |
| `phone` | text | optional, max 20 karakter |
| `email` | email | optional, format email, max 100 karakter |
| `address` | text | optional, max 255 karakter |
## Error Handling
- Field-level errors dari Zod validation
- Toast warning untuk validasi step
- Toast error untuk API errors
- Toast success untuk submit berhasil
## Catatan
- Route `/onboarding` memerlukan hard refresh atau restart dev server setelah pembuatan file baru.