Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions clients/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function AppLayout() {
options={{ headerShown: false }}
/>
<Stack.Screen name="(auth)/sign-in" options={{ headerShown: false }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{ presentation: "modal", title: "Modal" }}
Expand Down
5 changes: 5 additions & 0 deletions clients/mobile/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export default function Index() {
return;
}

if (status === StartupStatus.Onboarding) {
router.replace("/onboarding");
return;
}

router.replace("/(tabs)");
}, [status, router]);

Expand Down
5 changes: 5 additions & 0 deletions clients/mobile/app/onboarding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { OnboardingPage } from "@/components/onboarding/onboarding-page";

export default function OnboardingScreen() {
return <OnboardingPage />;
}
50 changes: 50 additions & 0 deletions clients/mobile/components/onboarding/employee-role-step.tsx
Original file line number Diff line number Diff line change
@@ -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<OnboardingFormData>) => 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 (
<StepLayout
title="What's your specific role?"
subtitle="Select the position that matches your day-to-day work."
stepCurrent={stepCurrent}
stepTotal={stepTotal}
onBack={onBack}
>
<View className="flex-1 px-6 pt-6">
{EMPLOYEE_ROLES.map((role) => (
<RoleCard
key={role.id}
label={role.label}
description={role.description}
selected={formData.employeeRole === role.id}
onSelect={() => handleSelect(role.id)}
/>
))}
</View>
</StepLayout>
);
}
145 changes: 145 additions & 0 deletions clients/mobile/components/onboarding/invite-team-step.tsx
Original file line number Diff line number Diff line change
@@ -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<OnboardingFormData>) => void;
onComplete: () => void;
};

export function InviteTeamStep({
formData,
updateForm,
onComplete,
}: InviteTeamStepProps) {
const [invited, setInvited] = useState(false);

function handleInvite() {
if (formData.inviteEmail.trim() !== "") {
setInvited(true);
}
}

return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View className="flex-1 px-6 py-8 justify-between">
{/* Top branding */}
<View className="items-start">
<View className="w-10 h-10 rounded-xl bg-primary items-center justify-center">
<Text className="text-white text-base font-bold">S</Text>
</View>
</View>

{/* Main content */}
<View>
<Text className="text-3xl font-bold text-text-default mb-2">
Invite your team
</Text>
<Text className="text-base text-text-subtle leading-6 mb-8">
SelfServe is better when the whole staff is connected.
</Text>

{/* Email row */}
<View className="flex-row items-center gap-3 mb-3">
<View className="w-10 h-10 rounded-full bg-bg-input items-center justify-center flex-shrink-0">
<Text className="text-base text-text-subtle font-medium">+</Text>
</View>
<TextInput
className="flex-1 bg-bg-input rounded-xl px-4 py-3 text-base text-text-default"
placeholder="colleague@hotel.com"
placeholderTextColor="#bababa"
value={formData.inviteEmail}
onChangeText={(v) => {
updateForm({ inviteEmail: v });
setInvited(false);
}}
keyboardType="email-address"
autoCapitalize="none"
returnKeyType="send"
onSubmitEditing={handleInvite}
/>
<Pressable
onPress={handleInvite}
disabled={formData.inviteEmail.trim() === ""}
className={cn(
"rounded-xl px-4 py-3",
formData.inviteEmail.trim() !== ""
? "bg-primary"
: "bg-stroke-subtle"
)}
>
<Text
className={cn(
"text-sm font-semibold",
formData.inviteEmail.trim() !== ""
? "text-white"
: "text-text-disabled"
)}
>
Invite
</Text>
</Pressable>
</View>

{/* Success feedback */}
{invited && (
<View className="flex-row items-center gap-1.5 ml-14">
<Check size={14} color="#006c4c" />
<Text className="text-sm text-success font-medium">
Invite sent!
</Text>
</View>
)}

<Text className="text-xs text-text-subtle mt-4 ml-14">
You can also do this later from your settings.
</Text>
</View>

{/* Action buttons */}
<View style={{ gap: 12 }}>
<Pressable
onPress={onComplete}
className="bg-primary rounded-2xl py-4 items-center"
>
<Text className="text-white text-base font-semibold">
Go to Dashboard
</Text>
</Pressable>
<Pressable
onPress={onComplete}
className="py-4 items-center"
>
<Text className="text-base text-text-subtle font-medium">
Skip for now
</Text>
</Pressable>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
43 changes: 43 additions & 0 deletions clients/mobile/components/onboarding/onboarding-mocks.ts
Original file line number Diff line number Diff line change
@@ -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",
];
116 changes: 116 additions & 0 deletions clients/mobile/components/onboarding/onboarding-page.tsx
Original file line number Diff line number Diff line change
@@ -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<OnboardingStep>("welcome");
const [formData, setFormData] = useState<OnboardingFormData>(INITIAL_FORM_DATA);

function updateForm(updates: Partial<OnboardingFormData>) {
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 <WelcomeStep onNext={() => setCurrentStep("role")} />;

case "role":
return (
<RoleSelectionStep
formData={formData}
updateForm={updateForm}
onNext={handleRoleSelected}
onBack={() => setCurrentStep("welcome")}
stepCurrent={progress!.current}
stepTotal={progress!.total}
/>
);

case "employeeRole":
return (
<EmployeeRoleStep
formData={formData}
updateForm={updateForm}
onNext={() => setCurrentStep("propertyDetails")}
onBack={() => setCurrentStep("role")}
stepCurrent={progress!.current}
stepTotal={progress!.total}
/>
);

case "propertyDetails":
return (
<PropertyDetailsStep
formData={formData}
updateForm={updateForm}
onNext={() => setCurrentStep("inviteTeam")}
onBack={() =>
setCurrentStep(
formData.role === "employee" ? "employeeRole" : "role"
)
}
stepCurrent={progress!.current}
stepTotal={progress!.total}
/>
);

case "inviteTeam":
return (
<InviteTeamStep
formData={formData}
updateForm={updateForm}
onComplete={handleComplete}
/>
);
}
}
Loading
Loading