From 6c5ebe8d20fb72334eefc290aa54ea7e14b410de Mon Sep 17 00:00:00 2001 From: Dao Ho <84757503+Dao-Ho@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:23:30 -0400 Subject: [PATCH 1/2] first pass onboarding --- clients/mobile/app/_layout.tsx | 1 + clients/mobile/app/onboarding.tsx | 5 + .../onboarding/employee-role-step.tsx | 50 +++++ .../onboarding/invite-team-step.tsx | 145 +++++++++++++ .../components/onboarding/onboarding-mocks.ts | 43 ++++ .../components/onboarding/onboarding-page.tsx | 116 +++++++++++ .../onboarding/property-details-step.tsx | 196 ++++++++++++++++++ .../components/onboarding/role-card.tsx | 47 +++++ .../onboarding/role-selection-step.tsx | 50 +++++ .../components/onboarding/step-layout.tsx | 68 ++++++ clients/mobile/components/onboarding/types.ts | 15 ++ .../components/onboarding/welcome-step.tsx | 38 ++++ clients/mobile/lib/utils.ts | 6 + 13 files changed, 780 insertions(+) create mode 100644 clients/mobile/app/onboarding.tsx create mode 100644 clients/mobile/components/onboarding/employee-role-step.tsx create mode 100644 clients/mobile/components/onboarding/invite-team-step.tsx create mode 100644 clients/mobile/components/onboarding/onboarding-mocks.ts create mode 100644 clients/mobile/components/onboarding/onboarding-page.tsx create mode 100644 clients/mobile/components/onboarding/property-details-step.tsx create mode 100644 clients/mobile/components/onboarding/role-card.tsx create mode 100644 clients/mobile/components/onboarding/role-selection-step.tsx create mode 100644 clients/mobile/components/onboarding/step-layout.tsx create mode 100644 clients/mobile/components/onboarding/types.ts create mode 100644 clients/mobile/components/onboarding/welcome-step.tsx create mode 100644 clients/mobile/lib/utils.ts diff --git a/clients/mobile/app/_layout.tsx b/clients/mobile/app/_layout.tsx index 23f9a506..48e1274f 100644 --- a/clients/mobile/app/_layout.tsx +++ b/clients/mobile/app/_layout.tsx @@ -48,6 +48,7 @@ function AppLayout() { options={{ headerShown: false }} /> + ; +} diff --git a/clients/mobile/components/onboarding/employee-role-step.tsx b/clients/mobile/components/onboarding/employee-role-step.tsx new file mode 100644 index 00000000..726ce543 --- /dev/null +++ b/clients/mobile/components/onboarding/employee-role-step.tsx @@ -0,0 +1,50 @@ +import { View } from "react-native"; +import type { OnboardingFormData } from "./types"; +import { EMPLOYEE_ROLES } from "./onboarding-mocks"; +import { RoleCard } from "./role-card"; +import { StepLayout } from "./step-layout"; + +type EmployeeRoleStepProps = { + formData: OnboardingFormData; + updateForm: (updates: Partial) => void; + onNext: () => void; + onBack: () => void; + stepCurrent: number; + stepTotal: number; +}; + +export function EmployeeRoleStep({ + formData, + updateForm, + onNext, + onBack, + stepCurrent, + stepTotal, +}: EmployeeRoleStepProps) { + function handleSelect(roleId: string) { + updateForm({ employeeRole: roleId }); + onNext(); + } + + return ( + + + {EMPLOYEE_ROLES.map((role) => ( + handleSelect(role.id)} + /> + ))} + + + ); +} diff --git a/clients/mobile/components/onboarding/invite-team-step.tsx b/clients/mobile/components/onboarding/invite-team-step.tsx new file mode 100644 index 00000000..00ee3b2e --- /dev/null +++ b/clients/mobile/components/onboarding/invite-team-step.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { + View, + Text, + TextInput, + Pressable, + KeyboardAvoidingView, + Platform, + ScrollView, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Check } from "lucide-react-native"; +import { cn } from "@/lib/utils"; +import type { OnboardingFormData } from "./types"; + +type InviteTeamStepProps = { + formData: OnboardingFormData; + updateForm: (updates: Partial) => void; + onComplete: () => void; +}; + +export function InviteTeamStep({ + formData, + updateForm, + onComplete, +}: InviteTeamStepProps) { + const [invited, setInvited] = useState(false); + + function handleInvite() { + if (formData.inviteEmail.trim() !== "") { + setInvited(true); + } + } + + return ( + + + + + {/* Top branding */} + + + S + + + + {/* Main content */} + + + Invite your team + + + SelfServe is better when the whole staff is connected. + + + {/* Email row */} + + + + + + { + updateForm({ inviteEmail: v }); + setInvited(false); + }} + keyboardType="email-address" + autoCapitalize="none" + returnKeyType="send" + onSubmitEditing={handleInvite} + /> + + + Invite + + + + + {/* Success feedback */} + {invited && ( + + + + Invite sent! + + + )} + + + You can also do this later from your settings. + + + + {/* Action buttons */} + + + + Go to Dashboard + + + + + Skip for now + + + + + + + + ); +} diff --git a/clients/mobile/components/onboarding/onboarding-mocks.ts b/clients/mobile/components/onboarding/onboarding-mocks.ts new file mode 100644 index 00000000..f294ec41 --- /dev/null +++ b/clients/mobile/components/onboarding/onboarding-mocks.ts @@ -0,0 +1,43 @@ +export const TOP_LEVEL_ROLES = [ + { + id: "manager", + label: "Manager", + description: "Lorem ipsum dolor sit amet consectetur.", + }, + { + id: "employee", + label: "Employee", + description: "Lorem ipsum dolor sit amet consectetur.", + }, +]; + +export const EMPLOYEE_ROLES = [ + { + id: "manager", + label: "Manager", + description: "Oversees operations and staff.", + }, + { + id: "front_desk", + label: "Front Desk", + description: "Handles guest check-ins and requests.", + }, + { + id: "housekeeping", + label: "Housekeeping", + description: "Manages room cleaning and maintenance.", + }, + { + id: "maintenance", + label: "Maintenance", + description: "Handles repairs and facilities.", + }, +]; + +export const PROPERTY_TYPES = [ + "Hotel", + "Motel", + "Resort", + "Bed & Breakfast", + "Hostel", +]; diff --git a/clients/mobile/components/onboarding/onboarding-page.tsx b/clients/mobile/components/onboarding/onboarding-page.tsx new file mode 100644 index 00000000..3058f045 --- /dev/null +++ b/clients/mobile/components/onboarding/onboarding-page.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { router } from "expo-router"; +import type { OnboardingFormData, OnboardingStep } from "./types"; +import { WelcomeStep } from "./welcome-step"; +import { RoleSelectionStep } from "./role-selection-step"; +import { EmployeeRoleStep } from "./employee-role-step"; +import { PropertyDetailsStep } from "./property-details-step"; +import { InviteTeamStep } from "./invite-team-step"; + +const INITIAL_FORM_DATA: OnboardingFormData = { + role: null, + employeeRole: null, + hotelName: "", + numberOfRooms: "", + propertyType: "", + inviteEmail: "", +}; + +function getStepProgress( + step: OnboardingStep, + role: string | null +): { current: number; total: number } | null { + if (step === "welcome") return null; + + if (role === "employee") { + const steps: OnboardingStep[] = [ + "role", + "employeeRole", + "propertyDetails", + "inviteTeam", + ]; + return { current: steps.indexOf(step) + 1, total: 4 }; + } + + const steps: OnboardingStep[] = ["role", "propertyDetails", "inviteTeam"]; + const index = steps.indexOf(step); + // Before role is confirmed, show indeterminate progress + return { current: index >= 0 ? index + 1 : 1, total: 3 }; +} + +export function OnboardingPage() { + const [currentStep, setCurrentStep] = useState("welcome"); + const [formData, setFormData] = useState(INITIAL_FORM_DATA); + + function updateForm(updates: Partial) { + setFormData((prev) => ({ ...prev, ...updates })); + } + + function handleRoleSelected(role: string) { + if (role === "employee") { + setCurrentStep("employeeRole"); + } else { + setCurrentStep("propertyDetails"); + } + } + + function handleComplete() { + router.replace("/(tabs)"); + } + + const progress = getStepProgress(currentStep, formData.role); + + switch (currentStep) { + case "welcome": + return setCurrentStep("role")} />; + + case "role": + return ( + setCurrentStep("welcome")} + stepCurrent={progress!.current} + stepTotal={progress!.total} + /> + ); + + case "employeeRole": + return ( + setCurrentStep("propertyDetails")} + onBack={() => setCurrentStep("role")} + stepCurrent={progress!.current} + stepTotal={progress!.total} + /> + ); + + case "propertyDetails": + return ( + setCurrentStep("inviteTeam")} + onBack={() => + setCurrentStep( + formData.role === "employee" ? "employeeRole" : "role" + ) + } + stepCurrent={progress!.current} + stepTotal={progress!.total} + /> + ); + + case "inviteTeam": + return ( + + ); + } +} diff --git a/clients/mobile/components/onboarding/property-details-step.tsx b/clients/mobile/components/onboarding/property-details-step.tsx new file mode 100644 index 00000000..6eade0de --- /dev/null +++ b/clients/mobile/components/onboarding/property-details-step.tsx @@ -0,0 +1,196 @@ +import { useState } from "react"; +import { + View, + Text, + TextInput, + Pressable, + Modal, + ScrollView, + KeyboardAvoidingView, + Platform, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { ChevronLeft, ChevronDown, Check } from "lucide-react-native"; +import { cn } from "@/lib/utils"; +import { PROPERTY_TYPES } from "./onboarding-mocks"; +import type { OnboardingFormData } from "./types"; + +type PropertyDetailsStepProps = { + formData: OnboardingFormData; + updateForm: (updates: Partial) => void; + onNext: () => void; + onBack: () => void; + stepCurrent: number; + stepTotal: number; +}; + +export function PropertyDetailsStep({ + formData, + updateForm, + onNext, + onBack, + stepCurrent, + stepTotal, +}: PropertyDetailsStepProps) { + const [pickerVisible, setPickerVisible] = useState(false); + + const isValid = + formData.hotelName.trim() !== "" && + formData.numberOfRooms.trim() !== "" && + formData.propertyType !== ""; + + return ( + + + {/* Header */} + + + + + + + + {Array.from({ length: stepTotal }, (_, i) => ( + + ))} + + + Step {stepCurrent} of {stepTotal} + + + + + Tell us about your property + + + We'll use this to set up your workspace. + + + + {/* Form */} + + {/* Hotel Name */} + + + Hotel Name + + updateForm({ hotelName: v })} + returnKeyType="next" + /> + + + {/* Number of Rooms */} + + + Number of Rooms + + updateForm({ numberOfRooms: v.replace(/[^0-9]/g, "") })} + keyboardType="number-pad" + returnKeyType="done" + /> + + + {/* Property Type */} + + + Property Type + + setPickerVisible(true)} + className="bg-bg-input rounded-xl px-4 py-3.5 flex-row items-center justify-between" + > + + {formData.propertyType || "Select property type"} + + + + + + + {/* Footer */} + + + + Continue + + + + + + {/* Property Type Picker Modal */} + setPickerVisible(false)} + > + + setPickerVisible(false)} + /> + + + + Property Type + + {PROPERTY_TYPES.map((type) => ( + { + updateForm({ propertyType: type }); + setPickerVisible(false); + }} + className="flex-row items-center justify-between py-4 border-b border-stroke-subtle" + > + {type} + {formData.propertyType === type && ( + + )} + + ))} + + + + + ); +} diff --git a/clients/mobile/components/onboarding/role-card.tsx b/clients/mobile/components/onboarding/role-card.tsx new file mode 100644 index 00000000..a7b1f3ef --- /dev/null +++ b/clients/mobile/components/onboarding/role-card.tsx @@ -0,0 +1,47 @@ +import { Pressable, View, Text } from "react-native"; +import { cn } from "@/lib/utils"; + +type RoleCardProps = { + label: string; + description: string; + selected: boolean; + onSelect: () => void; +}; + +export function RoleCard({ label, description, selected, onSelect }: RoleCardProps) { + return ( + + + {selected && ( + + )} + + + + {label} + + + {description} + + + + ); +} diff --git a/clients/mobile/components/onboarding/role-selection-step.tsx b/clients/mobile/components/onboarding/role-selection-step.tsx new file mode 100644 index 00000000..4818c28a --- /dev/null +++ b/clients/mobile/components/onboarding/role-selection-step.tsx @@ -0,0 +1,50 @@ +import { View } from "react-native"; +import type { OnboardingFormData } from "./types"; +import { TOP_LEVEL_ROLES } from "./onboarding-mocks"; +import { RoleCard } from "./role-card"; +import { StepLayout } from "./step-layout"; + +type RoleSelectionStepProps = { + formData: OnboardingFormData; + updateForm: (updates: Partial) => void; + onNext: (role: string) => void; + onBack: () => void; + stepCurrent: number; + stepTotal: number; +}; + +export function RoleSelectionStep({ + formData, + updateForm, + onNext, + onBack, + stepCurrent, + stepTotal, +}: RoleSelectionStepProps) { + function handleSelect(roleId: string) { + updateForm({ role: roleId }); + onNext(roleId); + } + + return ( + + + {TOP_LEVEL_ROLES.map((role) => ( + handleSelect(role.id)} + /> + ))} + + + ); +} diff --git a/clients/mobile/components/onboarding/step-layout.tsx b/clients/mobile/components/onboarding/step-layout.tsx new file mode 100644 index 00000000..f307b60c --- /dev/null +++ b/clients/mobile/components/onboarding/step-layout.tsx @@ -0,0 +1,68 @@ +import { View, Text, Pressable } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { ChevronLeft } from "lucide-react-native"; +import type { ReactNode } from "react"; + +type StepLayoutProps = { + title: string; + subtitle?: string; + stepCurrent?: number; + stepTotal?: number; + onBack?: () => void; + children: ReactNode; + footer?: ReactNode; +}; + +export function StepLayout({ + title, + subtitle, + stepCurrent, + stepTotal, + onBack, + children, + footer, +}: StepLayoutProps) { + return ( + + + {onBack && ( + + + + )} + + {stepCurrent !== undefined && stepTotal !== undefined && ( + + + {Array.from({ length: stepTotal }, (_, i) => ( + + ))} + + + Step {stepCurrent} of {stepTotal} + + + )} + + {title} + {subtitle ? ( + + {subtitle} + + ) : null} + + + {children} + + {footer ? {footer} : null} + + ); +} diff --git a/clients/mobile/components/onboarding/types.ts b/clients/mobile/components/onboarding/types.ts new file mode 100644 index 00000000..120b9466 --- /dev/null +++ b/clients/mobile/components/onboarding/types.ts @@ -0,0 +1,15 @@ +export type OnboardingFormData = { + role: string | null; + employeeRole: string | null; + hotelName: string; + numberOfRooms: string; + propertyType: string; + inviteEmail: string; +}; + +export type OnboardingStep = + | "welcome" + | "role" + | "employeeRole" + | "propertyDetails" + | "inviteTeam"; diff --git a/clients/mobile/components/onboarding/welcome-step.tsx b/clients/mobile/components/onboarding/welcome-step.tsx new file mode 100644 index 00000000..868b5b2e --- /dev/null +++ b/clients/mobile/components/onboarding/welcome-step.tsx @@ -0,0 +1,38 @@ +import { View, Text, Pressable } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +type WelcomeStepProps = { + onNext: () => void; +}; + +export function WelcomeStep({ onNext }: WelcomeStepProps) { + return ( + + + + + S + + SelfServe + + + + + Welcome to{"\n"}SelfServe + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec risus + massa, venenatis in sapien sit amet, aliquam facilisis nunc. + + + + + Get Started + + + + ); +} diff --git a/clients/mobile/lib/utils.ts b/clients/mobile/lib/utils.ts new file mode 100644 index 00000000..a5ef1935 --- /dev/null +++ b/clients/mobile/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} From 1e0585acbda4cdf3facecc69750c4e46ca4f4fea Mon Sep 17 00:00:00 2001 From: Dao Ho <84757503+Dao-Ho@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:27:26 -0400 Subject: [PATCH 2/2] redirect at root --- clients/mobile/app/index.tsx | 5 +++++ clients/mobile/context/startup.tsx | 2 ++ 2 files changed, 7 insertions(+) diff --git a/clients/mobile/app/index.tsx b/clients/mobile/app/index.tsx index 2ac21ffd..b7985b2d 100644 --- a/clients/mobile/app/index.tsx +++ b/clients/mobile/app/index.tsx @@ -13,6 +13,11 @@ export default function Index() { return; } + if (status === StartupStatus.Onboarding) { + router.replace("/onboarding"); + return; + } + router.replace("/(tabs)"); }, [status, router]); diff --git a/clients/mobile/context/startup.tsx b/clients/mobile/context/startup.tsx index 44843e5a..93a80e04 100644 --- a/clients/mobile/context/startup.tsx +++ b/clients/mobile/context/startup.tsx @@ -7,6 +7,7 @@ export enum StartupStatus { Loading, Unauthenticated, NoUserInfo, + Onboarding, Ready, } @@ -48,6 +49,7 @@ export function StartupProvider({ children }: { children: React.ReactNode }) { if (!isSignedIn) return StartupStatus.Unauthenticated; if (status === "pending") return StartupStatus.Loading; if (status === "error") return StartupStatus.NoUserInfo; + if (data?.is_onboarded === false) return StartupStatus.Onboarding; return StartupStatus.Ready; }, [isLoaded, isSignedIn, status]);