Add rider/driver role selection after sign-up
- users.role column (+ migration in seed script) - /(api)/user: GET role by clerkId, PATCH to set role - Role selection screen; drivers get placeholder driver-home - Root index routes by role; hardened location handling on home
This commit is contained in:
@@ -1,5 +1,26 @@
|
|||||||
import { neon } from "@neondatabase/serverless";
|
import { neon } from "@neondatabase/serverless";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const sql = neon(process.env.DATABASE_URL!);
|
||||||
|
const clerkId = new URL(req.url).searchParams.get("clerkId");
|
||||||
|
|
||||||
|
if (!clerkId) {
|
||||||
|
return Response.json({ error: "Missing clerkId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sql`
|
||||||
|
SELECT id, name, email, role FROM users WHERE clerk_id = ${clerkId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return Response.json({ data: response[0] ?? null });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[GET_USER]: ", error);
|
||||||
|
|
||||||
|
return Response.json({ error }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const sql = neon(process.env.DATABASE_URL!);
|
const sql = neon(process.env.DATABASE_URL!);
|
||||||
const { name, email, clerkId } = await req.json();
|
const { name, email, clerkId } = await req.json();
|
||||||
@@ -36,3 +57,31 @@ export async function POST(req: Request) {
|
|||||||
return Response.json({ error }, { status: 500 });
|
return Response.json({ error }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request) {
|
||||||
|
const sql = neon(process.env.DATABASE_URL!);
|
||||||
|
const { clerkId, role } = await req.json();
|
||||||
|
|
||||||
|
if (!clerkId || !["rider", "driver"].includes(role)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Missing clerkId or invalid role." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sql`
|
||||||
|
UPDATE users SET role = ${role} WHERE clerk_id = ${clerkId} RETURNING id, role
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (response.length === 0) {
|
||||||
|
return Response.json({ error: "User not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ data: response[0] });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[PATCH_USER]: ", error);
|
||||||
|
|
||||||
|
return Response.json({ error }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ const SignUp = () => {
|
|||||||
|
|
||||||
<CustomButton
|
<CustomButton
|
||||||
title="Browse Home"
|
title="Browse Home"
|
||||||
onPress={() => router.push("/(root)/(tabs)/home")}
|
onPress={() => router.push("/")}
|
||||||
className="mt-5"
|
className="mt-5"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const Home = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requestLocation = async () => {
|
const requestLocation = async () => {
|
||||||
|
try {
|
||||||
let { status } = await Location.requestForegroundPermissionsAsync();
|
let { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
|
||||||
if (status !== "granted") return setHasPermissions(false);
|
if (status !== "granted") return setHasPermissions(false);
|
||||||
@@ -56,16 +57,28 @@ const Home = () => {
|
|||||||
|
|
||||||
let location = await Location.getCurrentPositionAsync();
|
let location = await Location.getCurrentPositionAsync();
|
||||||
|
|
||||||
|
let addressText = "Unknown location";
|
||||||
|
try {
|
||||||
const address = await Location.reverseGeocodeAsync({
|
const address = await Location.reverseGeocodeAsync({
|
||||||
longitude: location.coords?.longitude,
|
longitude: location.coords?.longitude,
|
||||||
latitude: location.coords?.latitude,
|
latitude: location.coords?.latitude,
|
||||||
});
|
});
|
||||||
|
if (address[0]) {
|
||||||
|
addressText = `${address[0].name}, ${address[0].region}`;
|
||||||
|
}
|
||||||
|
} catch (geocodeErr) {
|
||||||
|
console.log("[REVERSE_GEOCODE]: ", geocodeErr);
|
||||||
|
}
|
||||||
|
|
||||||
setUserLocation({
|
setUserLocation({
|
||||||
latitude: location.coords.latitude,
|
latitude: location.coords.latitude,
|
||||||
longitude: location.coords.longitude,
|
longitude: location.coords.longitude,
|
||||||
address: `${address[0].name}, ${address[0].region}`,
|
address: addressText,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log("[LOCATION]: ", err);
|
||||||
|
setHasPermissions(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
requestLocation();
|
requestLocation();
|
||||||
@@ -143,7 +156,16 @@ const Home = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View className="flex flex-row items-center bg-transparent h-[300px]">
|
<View className="flex flex-row items-center bg-transparent h-[300px]">
|
||||||
{hasPermissions && <Map />}
|
{hasPermissions ? (
|
||||||
|
<Map />
|
||||||
|
) : (
|
||||||
|
<View className="flex-1 items-center justify-center bg-white rounded-2xl h-full">
|
||||||
|
<Text className="text-general-200 text-center font-JakartaMedium px-5">
|
||||||
|
Location access is off.{"\n"}Enable it in your device
|
||||||
|
settings to see nearby drivers.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="text-xl font-JakartaBold mt-5 mb-3">
|
<Text className="text-xl font-JakartaBold mt-5 mb-3">
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const RootLayout = () => {
|
|||||||
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
|
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="confirm-ride" options={{ headerShown: false }} />
|
<Stack.Screen name="confirm-ride" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
|
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="role" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="driver-home"
|
||||||
|
options={{ headerShown: false, gestureEnabled: false }}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useClerk, useUser } from "@clerk/clerk-expo";
|
||||||
|
import { Image, Text, View } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { CustomButton } from "@/components/custom-button";
|
||||||
|
import { images } from "@/constants";
|
||||||
|
|
||||||
|
// Placeholder driver home. The driver experience (going online, accepting
|
||||||
|
// rides) is not built yet — drivers are registered here and managed in the
|
||||||
|
// database for now.
|
||||||
|
const DriverHome = () => {
|
||||||
|
const { user } = useUser();
|
||||||
|
const { signOut } = useClerk();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-white justify-center items-center px-7">
|
||||||
|
<Image
|
||||||
|
source={images.check}
|
||||||
|
alt="Registered"
|
||||||
|
className="w-[110px] h-[110px] mb-5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text className="text-2xl font-JakartaBold text-center">
|
||||||
|
You're registered as a driver, {user?.firstName || "there"}!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-base text-general-200 font-Jakarta text-center mt-3">
|
||||||
|
Driver mode is coming soon. We'll contact you at{" "}
|
||||||
|
{user?.emailAddresses[0]?.emailAddress} once your account is activated.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
title="Sign Out"
|
||||||
|
onPress={() => signOut()}
|
||||||
|
className="mt-10"
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DriverHome;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useUser } from "@clerk/clerk-expo";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { fetchAPI } from "@/lib/fetch";
|
||||||
|
|
||||||
|
const RoleSelection = () => {
|
||||||
|
const { user } = useUser();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const chooseRole = async (role: "rider" | "driver") => {
|
||||||
|
if (!user?.id || saving) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await fetchAPI("/(api)/user", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clerkId: user.id, role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw new Error(error);
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
role === "driver" ? "/(root)/driver-home" : "/(root)/(tabs)/home",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("[ROLE_SELECT]: ", err);
|
||||||
|
Alert.alert("Error", "Could not save your choice. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-white justify-center px-7">
|
||||||
|
<Text className="text-3xl font-JakartaExtraBold text-center">
|
||||||
|
How will you use Waseel?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-base text-general-200 font-Jakarta text-center mt-3 mb-10">
|
||||||
|
You can change this later by contacting support.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => chooseRole("rider")}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-primary-500 rounded-2xl p-7 mb-5 items-center"
|
||||||
|
>
|
||||||
|
<Text className="text-5xl mb-3">🧍</Text>
|
||||||
|
<Text className="text-2xl font-JakartaBold text-white">
|
||||||
|
I'm a Rider
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm font-Jakarta text-white/80 text-center mt-2">
|
||||||
|
Book rides and get around Lebanon
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => chooseRole("driver")}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-general-600 rounded-2xl p-7 items-center"
|
||||||
|
>
|
||||||
|
<Text className="text-5xl mb-3">🚗</Text>
|
||||||
|
<Text className="text-2xl font-JakartaBold">I'm a Driver</Text>
|
||||||
|
<Text className="text-sm font-Jakarta text-general-200 text-center mt-2">
|
||||||
|
Give rides and earn money
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleSelection;
|
||||||
+29
-3
@@ -1,12 +1,38 @@
|
|||||||
import { useAuth } from "@clerk/clerk-expo";
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, View } from "react-native";
|
||||||
|
|
||||||
|
import { fetchAPI } from "@/lib/fetch";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn, userId } = useAuth();
|
||||||
|
const [role, setRole] = useState<string | null | undefined>(undefined);
|
||||||
|
|
||||||
if (isSignedIn) return <Redirect href="/(root)/(tabs)/home" />;
|
useEffect(() => {
|
||||||
|
if (!isSignedIn || !userId) return;
|
||||||
|
|
||||||
return <Redirect href="/(auth)/welcome" />;
|
fetchAPI(`/(api)/user?clerkId=${userId}`)
|
||||||
|
.then((res) => setRole(res?.data?.role ?? null))
|
||||||
|
.catch(() => setRole(null));
|
||||||
|
}, [isSignedIn, userId]);
|
||||||
|
|
||||||
|
if (!isSignedIn) return <Redirect href="/(auth)/welcome" />;
|
||||||
|
|
||||||
|
// Still loading the user's role from the database.
|
||||||
|
if (role === undefined) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-white">
|
||||||
|
<ActivityIndicator size="large" color="#0286FF" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "driver") return <Redirect href="/(root)/driver-home" />;
|
||||||
|
if (role === "rider") return <Redirect href="/(root)/(tabs)/home" />;
|
||||||
|
|
||||||
|
// Signed in but no role chosen yet.
|
||||||
|
return <Redirect href="/(root)/role" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const OAuth = ({ title }: OAuthProps) => {
|
|||||||
const result = await googleOAuth(startOAuthFlow);
|
const result = await googleOAuth(startOAuthFlow);
|
||||||
|
|
||||||
if (result?.code === "session_exists" || result?.code === "success") {
|
if (result?.code === "session_exists" || result?.code === "success") {
|
||||||
router.replace("/(root)/(tabs)/home");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("OAuth error", err);
|
console.error("OAuth error", err);
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ await sql`CREATE TABLE IF NOT EXISTS users (
|
|||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
email VARCHAR(255) NOT NULL,
|
email VARCHAR(255) NOT NULL,
|
||||||
clerk_id VARCHAR(255) NOT NULL,
|
clerk_id VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
|
// For databases created before roles existed.
|
||||||
|
await sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20)`;
|
||||||
|
|
||||||
await sql`CREATE TABLE IF NOT EXISTS drivers (
|
await sql`CREATE TABLE IF NOT EXISTS drivers (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
first_name VARCHAR(100) NOT NULL,
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user