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]);