feat: Initialize monorepo structure and comprehensive documentation
This commit establishes the new monorepo architecture for the KROW Workforce platform. Key changes include: - Reorganized project into `frontend-web`, `mobile-apps`, `firebase`, `scripts`, and `secrets` directories. - Updated `Makefile` to support the new monorepo layout and automate Base44 export integration. - Fixed `scripts/prepare-export.js` for ES module compatibility and global component import resolution. - Created and updated `CONTRIBUTING.md` for developer onboarding. - Restructured, renamed, and translated all `docs/` files for clarity and consistency. - Implemented an interactive internal launchpad with diagram viewing capabilities. - Configured base Firebase project files (`firebase.json`, security rules). - Updated `README.md` to reflect the new project structure and documentation overview.
This commit is contained in:
513
frontend-web/src/pages/Onboarding.jsx
Normal file
513
frontend-web/src/pages/Onboarding.jsx
Normal file
@@ -0,0 +1,513 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle2, UserPlus, User, Lock, Briefcase } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export default function Onboarding() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const inviteCode = urlParams.get('invite');
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
title: "",
|
||||
department: "",
|
||||
hub: "",
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
});
|
||||
|
||||
// Fetch invite details if invite code exists
|
||||
const { data: invite } = useQuery({
|
||||
queryKey: ['team-invite', inviteCode],
|
||||
queryFn: async () => {
|
||||
const allInvites = await base44.entities.TeamMemberInvite.list();
|
||||
const foundInvite = allInvites.find(inv => inv.invite_code === inviteCode && inv.invite_status === 'pending');
|
||||
|
||||
if (foundInvite) {
|
||||
// Pre-fill form with invite data
|
||||
const nameParts = (foundInvite.full_name || "").split(' ');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
email: foundInvite.email,
|
||||
first_name: nameParts[0] || "",
|
||||
last_name: nameParts.slice(1).join(' ') || ""
|
||||
}));
|
||||
}
|
||||
|
||||
return foundInvite;
|
||||
},
|
||||
enabled: !!inviteCode,
|
||||
});
|
||||
|
||||
// Fetch available hubs for the team
|
||||
const { data: hubs = [] } = useQuery({
|
||||
queryKey: ['team-hubs-onboarding', invite?.team_id],
|
||||
queryFn: async () => {
|
||||
if (!invite?.team_id) return [];
|
||||
const allHubs = await base44.entities.TeamHub.list();
|
||||
return allHubs.filter(h => h.team_id === invite.team_id && h.is_active);
|
||||
},
|
||||
enabled: !!invite?.team_id,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
if (!invite) {
|
||||
throw new Error("Invalid invitation. Please contact your team administrator.");
|
||||
}
|
||||
|
||||
// Check if invite was already accepted
|
||||
if (invite.invite_status !== 'pending') {
|
||||
throw new Error("This invitation has already been used. Please contact your team administrator for a new invitation.");
|
||||
}
|
||||
|
||||
// Check for duplicate email in TeamMember
|
||||
const allMembers = await base44.entities.TeamMember.list();
|
||||
const existingMemberByEmail = allMembers.find(m =>
|
||||
m.email?.toLowerCase() === data.email.toLowerCase() && m.team_id === invite.team_id
|
||||
);
|
||||
|
||||
if (existingMemberByEmail) {
|
||||
throw new Error(`A team member with email ${data.email} already exists in this team. Please contact your team administrator.`);
|
||||
}
|
||||
|
||||
// Check for duplicate phone in TeamMember
|
||||
if (data.phone) {
|
||||
const existingMemberByPhone = allMembers.find(m =>
|
||||
m.phone === data.phone && m.team_id === invite.team_id
|
||||
);
|
||||
|
||||
if (existingMemberByPhone) {
|
||||
throw new Error(`A team member with phone number ${data.phone} already exists in this team. Please contact your team administrator.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create team member record
|
||||
const member = await base44.entities.TeamMember.create({
|
||||
team_id: invite.team_id,
|
||||
member_name: `${data.first_name} ${data.last_name}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
title: data.title,
|
||||
department: data.department,
|
||||
hub: data.hub,
|
||||
role: invite.role || "member",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
// Update invite status to accepted
|
||||
await base44.entities.TeamMemberInvite.update(invite.id, {
|
||||
invite_status: "accepted",
|
||||
accepted_date: new Date().toISOString()
|
||||
});
|
||||
|
||||
return { member, invite };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setStep(4);
|
||||
toast({
|
||||
title: "✅ Registration Successful!",
|
||||
description: "You've been added to the team successfully.",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "❌ Registration Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1) {
|
||||
// Validate basic info
|
||||
if (!formData.first_name || !formData.last_name || !formData.email || !formData.phone) {
|
||||
toast({
|
||||
title: "Missing Information",
|
||||
description: "Please fill in your name, email, and phone number",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
} else if (step === 2) {
|
||||
// Validate additional info
|
||||
if (!formData.title || !formData.department) {
|
||||
toast({
|
||||
title: "Missing Information",
|
||||
description: "Please fill in your title and department",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStep(3);
|
||||
} else if (step === 3) {
|
||||
// Validate password
|
||||
if (!formData.password || formData.password !== formData.confirmPassword) {
|
||||
toast({
|
||||
title: "Password Mismatch",
|
||||
description: "Passwords do not match",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
toast({
|
||||
title: "Password Too Short",
|
||||
description: "Password must be at least 6 characters",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
registerMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
if (!inviteCode || !invite) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full border-2 border-red-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<span className="text-4xl">❌</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[#1C323E] mb-4">Invalid Invitation</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
This invitation link is invalid or has expired. Please contact your team administrator for a new invitation.
|
||||
</p>
|
||||
<Button onClick={() => navigate(createPageUrl("Home"))} variant="outline">
|
||||
Go to Home
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if invite was already accepted
|
||||
if (invite.invite_status === 'accepted') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full border-2 border-yellow-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<span className="text-4xl">⚠️</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[#1C323E] mb-4">Invitation Already Used</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
This invitation has already been accepted. If you need access, please contact your team administrator.
|
||||
</p>
|
||||
<Button onClick={() => navigate(createPageUrl("Home"))} variant="outline">
|
||||
Go to Home
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-2xl w-full">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full mb-4">
|
||||
<UserPlus className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-[#1C323E] to-[#0A39DF] bg-clip-text text-transparent mb-2">
|
||||
Join {invite.team_name}
|
||||
</h1>
|
||||
<p className="text-slate-600">
|
||||
You've been invited by {invite.invited_by} as a <strong>{invite.role}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
{step < 4 && (
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 1 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}>
|
||||
{step > 1 ? <CheckCircle2 className="w-5 h-5" /> : '1'}
|
||||
</div>
|
||||
<div className={`w-20 h-1 ${step >= 2 ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 2 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}>
|
||||
{step > 2 ? <CheckCircle2 className="w-5 h-5" /> : '2'}
|
||||
</div>
|
||||
<div className={`w-20 h-1 ${step >= 3 ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 3 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}>
|
||||
{step > 3 ? <CheckCircle2 className="w-5 h-5" /> : '3'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Basic Information */}
|
||||
{step === 1 && (
|
||||
<Card className="border-2 border-slate-200 shadow-xl">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-[#0A39DF]" />
|
||||
Basic Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="first_name">First Name *</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
placeholder="John"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="last_name">Last Name *</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
placeholder="Doe"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="john@example.com"
|
||||
className="mt-2"
|
||||
disabled={!!invite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="w-full bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Work Information */}
|
||||
{step === 2 && (
|
||||
<Card className="border-2 border-slate-200 shadow-xl">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Briefcase className="w-5 h-5 text-[#0A39DF]" />
|
||||
Work Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Job Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="e.g., Manager, Coordinator, Supervisor"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="department">Department *</Label>
|
||||
<Select value={formData.department} onValueChange={(value) => setFormData({ ...formData, department: value })}>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue placeholder="Select department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Operations">Operations</SelectItem>
|
||||
<SelectItem value="Sales">Sales</SelectItem>
|
||||
<SelectItem value="HR">HR</SelectItem>
|
||||
<SelectItem value="Finance">Finance</SelectItem>
|
||||
<SelectItem value="IT">IT</SelectItem>
|
||||
<SelectItem value="Marketing">Marketing</SelectItem>
|
||||
<SelectItem value="Customer Service">Customer Service</SelectItem>
|
||||
<SelectItem value="Logistics">Logistics</SelectItem>
|
||||
<SelectItem value="Management">Management</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{hubs.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="hub">Hub Location (Optional)</Label>
|
||||
<Select value={formData.hub} onValueChange={(value) => setFormData({ ...formData, hub: value })}>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue placeholder="Select hub location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>No Hub</SelectItem>
|
||||
{hubs.map((hub) => (
|
||||
<SelectItem key={hub.id} value={hub.hub_name}>
|
||||
{hub.hub_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep(1)}
|
||||
className="flex-1"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Create Password */}
|
||||
{step === 3 && (
|
||||
<Card className="border-2 border-slate-200 shadow-xl">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-[#0A39DF]" />
|
||||
Create Your Password
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="password">Password *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirmPassword">Confirm Password *</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<h4 className="font-semibold text-[#1C323E] mb-2">Review Your Information:</h4>
|
||||
<div className="space-y-1 text-sm text-slate-600">
|
||||
<p><strong>Name:</strong> {formData.first_name} {formData.last_name}</p>
|
||||
<p><strong>Email:</strong> {formData.email}</p>
|
||||
<p><strong>Phone:</strong> {formData.phone}</p>
|
||||
<p><strong>Title:</strong> {formData.title}</p>
|
||||
<p><strong>Department:</strong> {formData.department}</p>
|
||||
{formData.hub && <p><strong>Hub:</strong> {formData.hub}</p>}
|
||||
<p><strong>Role:</strong> {invite.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep(2)}
|
||||
className="flex-1"
|
||||
disabled={registerMutation.isPending}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={registerMutation.isPending}
|
||||
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
|
||||
>
|
||||
{registerMutation.isPending ? 'Creating Account...' : 'Complete Registration'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: Success */}
|
||||
{step === 4 && (
|
||||
<Card className="border-2 border-green-200 shadow-xl">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-6">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-[#1C323E] mb-4">
|
||||
Welcome to the Team! 🎉
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-2">
|
||||
Your account has been created successfully!
|
||||
</p>
|
||||
<div className="bg-slate-50 p-4 rounded-lg mb-8 text-left">
|
||||
<h3 className="font-semibold text-[#1C323E] mb-2">Your Profile:</h3>
|
||||
<div className="space-y-1 text-sm text-slate-600">
|
||||
<p><strong>Name:</strong> {formData.first_name} {formData.last_name}</p>
|
||||
<p><strong>Email:</strong> {formData.email}</p>
|
||||
<p><strong>Title:</strong> {formData.title}</p>
|
||||
<p><strong>Department:</strong> {formData.department}</p>
|
||||
{formData.hub && <p><strong>Hub:</strong> {formData.hub}</p>}
|
||||
<p><strong>Team:</strong> {invite.team_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(createPageUrl("Dashboard"))}
|
||||
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user