365 lines
12 KiB
Vue
365 lines
12 KiB
Vue
<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>
|