feat: create onboarding page with multi-step form and validation
This commit is contained in:
parent
c307593a1b
commit
0418bec226
@ -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;
|
||||
}
|
||||
364
app/components/OnboardingForm.vue
Normal file
364
app/components/OnboardingForm.vue
Normal 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>
|
||||
10
app/pages/onboarding/index.vue
Normal file
10
app/pages/onboarding/index.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OnboardingForm from '@/components/OnboardingForm.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OnboardingForm />
|
||||
</template>
|
||||
69
docs/history/2026-07-23-onboarding-page.md
Normal file
69
docs/history/2026-07-23-onboarding-page.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user