Rebrand to Waseel, swap Stripe for Areeba, add phone-ready auth and DB seed

- Rename app: Waseel (name, slug, scheme waseel://, com.waseel.app ids, splash)
- Payments: replace Stripe with Areeba hosted checkout (create/verify API routes,
  lib/areeba.ts, WebBrowser-based payment flow)
- Maps: migrate address autocomplete to Places API (New), drop legacy library
- Web support: map stub for web (native maps are iOS/Android only)
- Auth: keep email + Google OAuth; fix OAuth redirect for Expo Go
- Add scripts/seed-db.mjs (schema + Lebanese driver seed)
- Pin Expo SDK 51 compatible package versions
This commit is contained in:
Krikorios
2026-08-22 15:53:41 +03:00
parent a2f46e6c03
commit 62dc5e9d53
22 changed files with 20837 additions and 369 deletions
+5 -3
View File
@@ -15,6 +15,8 @@ EXPO_PUBLIC_GEOAPIFY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# google api key # google api key
EXPO_PUBLIC_GOOGLE_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX EXPO_PUBLIC_GOOGLE_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# stripe api key # areeba payment gateway (credentials issued after merchant onboarding)
EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY_HERE AREEBA_API_BASE_URL="https://your-gateway-host.areeba.com"
STRIPE_SECRET_KEY=sk_test_YOUR_KEY_HERE AREEBA_MERCHANT_ID=XXXXXXXXXXXX
AREEBA_API_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
AREEBA_API_VERSION=100
+6 -9
View File
@@ -1,13 +1,13 @@
{ {
"expo": { "expo": {
"name": "Ryde", "name": "Waseel",
"description": "Find your perfect ride with Ryde.", "description": "Find your perfect ride with Waseel.",
"githubUrl": "https://github.com/sanidhyy/uber-clone", "githubUrl": "https://github.com/sanidhyy/uber-clone",
"slug": "Ryde", "slug": "waseel",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "ryde", "scheme": "waseel",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"splash": { "splash": {
"image": "./assets/images/splash.png", "image": "./assets/images/splash.png",
@@ -16,14 +16,14 @@
}, },
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.sanidhyy.Ryde" "bundleIdentifier": "com.waseel.app"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png", "foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"package": "com.sanidhyy.Ryde" "package": "com.waseel.app"
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",
@@ -44,9 +44,6 @@
"extra": { "extra": {
"router": { "router": {
"origin": "https://example.com/" "origin": "https://example.com/"
},
"eas": {
"projectId": "fe645595-95a5-45b2-825d-aa6a0ddb2b9c"
} }
} }
} }
+38
View File
@@ -0,0 +1,38 @@
import { createCheckoutSession } from "@/lib/areeba";
export async function POST(req: Request) {
const body = await req.json();
const { name, email, amount, returnUrl } = body;
if (!name || !email || !amount)
return new Response(
JSON.stringify({ error: "Missing required payment information." }),
{ status: 400 },
);
try {
const orderId = `waseel-${Date.now()}`;
const session = await createCheckoutSession({
orderId,
amount: parseFloat(amount),
currency: "USD",
description: `Waseel ride payment for ${name}`,
returnUrl: returnUrl || "waseel://book-ride",
});
return new Response(
JSON.stringify({
orderId,
checkoutUrl: session.checkoutUrl,
successIndicator: session.successIndicator,
}),
);
} catch (err) {
console.log("[AREEBA_PAYMENT_CREATE]: ", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
+35
View File
@@ -0,0 +1,35 @@
import { retrieveOrder } from "@/lib/areeba";
export async function POST(req: Request) {
const body = await req.json();
const { orderId, resultIndicator, successIndicator } = body;
if (!orderId)
return new Response(JSON.stringify({ error: "Missing order id." }), {
status: 400,
});
try {
const order = await retrieveOrder(orderId);
// The gateway appends resultIndicator to the return URL after payment;
// it must match the successIndicator issued when the session was created.
const indicatorMatches =
!successIndicator || resultIndicator === successIndicator;
return new Response(
JSON.stringify({
success: order.paid && indicatorMatches,
status: order.status,
amount: order.amount,
currency: order.currency,
}),
);
} catch (err) {
console.log("[AREEBA_PAYMENT_VERIFY]: ", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
-60
View File
@@ -1,60 +0,0 @@
import { Stripe } from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.json();
const { name, email, amount } = body;
if (!name || !email || !amount)
return new Response(
JSON.stringify({ error: "Please enter a valid email address" }),
{ status: 404 },
);
try {
let customer;
const existingCustomer = await stripe.customers.list({ email });
if (existingCustomer.data.length > 0) customer = existingCustomer.data[0];
else {
customer = await stripe.customers.create({
name,
email,
});
}
const ephemeralKey = await stripe.ephemeralKeys.create(
{ customer: customer.id },
{ apiVersion: "2024-06-20" },
);
const paymentIntent = await stripe.paymentIntents.create({
amount: parseInt(amount) * 100,
currency: "USD",
customer: customer.id,
automatic_payment_methods: {
enabled: true,
allow_redirects: "never",
},
});
return new Response(
JSON.stringify({
paymentIntent: paymentIntent,
ephemeralKey: ephemeralKey,
customer: customer.id,
}),
);
} catch (err) {
console.log("[PAYMENT_CREATE]: ", err);
return new Response(
JSON.stringify({
error: "Internal Server Error",
}),
{ status: 500 },
);
}
}
-44
View File
@@ -1,44 +0,0 @@
import { Stripe } from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.json();
const { payment_method_id, payment_intent_id, customer_id } = body;
if (!payment_method_id || !payment_intent_id || !customer_id)
return new Response(
JSON.stringify({ error: "Missing required payment information." }),
{ status: 404 },
);
try {
const paymentMethod = await stripe.paymentMethods.attach(
payment_method_id,
{
customer: customer_id,
},
);
const result = await stripe.paymentIntents.confirm(payment_intent_id, {
payment_method: paymentMethod.id,
});
return new Response(
JSON.stringify({
success: true,
message: "Payment completed successfully.",
result,
}),
);
} catch (err) {
console.log("[PAYMENT_PAY]: ", err);
return new Response(
JSON.stringify({
error: "Internal Server Error",
}),
{ status: 500 },
);
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ const SignIn = () => {
<View className="p-5"> <View className="p-5">
<InputField <InputField
label="Email" label="Email"
placeholder="john.doe@email.com" placeholder="karim@email.com"
icon={icons.email} icon={icons.email}
value={form.email} value={form.email}
onChangeText={(value) => onChangeText={(value) =>
+2 -2
View File
@@ -114,7 +114,7 @@ const SignUp = () => {
<View className="p-5"> <View className="p-5">
<InputField <InputField
label="Name" label="Name"
placeholder="John Doe" placeholder="Karim Haddad"
icon={icons.person} icon={icons.person}
value={form.name} value={form.name}
onChangeText={(value) => onChangeText={(value) =>
@@ -128,7 +128,7 @@ const SignUp = () => {
<InputField <InputField
label="Email" label="Email"
placeholder="john.doe@email.com" placeholder="karim@email.com"
icon={icons.email} icon={icons.email}
value={form.email} value={form.email}
onChangeText={(value) => onChangeText={(value) =>
-7
View File
@@ -1,5 +1,4 @@
import { useUser } from "@clerk/clerk-expo"; import { useUser } from "@clerk/clerk-expo";
import { StripeProvider } from "@stripe/stripe-react-native";
import { Image, Text, View } from "react-native"; import { Image, Text, View } from "react-native";
import { Payment } from "@/components/payment"; import { Payment } from "@/components/payment";
@@ -18,11 +17,6 @@ const BookRide = () => {
)[0]; )[0];
return ( return (
<StripeProvider
publishableKey={process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY!}
merchantIdentifier="merchant.ryde.com"
urlScheme="ryde" // match it with app.json scheme
>
<RideLayout title="Book Ride"> <RideLayout title="Book Ride">
<> <>
<Text className="text-xl font-JakartaSemiBold mb-3"> <Text className="text-xl font-JakartaSemiBold mb-3">
@@ -109,7 +103,6 @@ const BookRide = () => {
/> />
</> </>
</RideLayout> </RideLayout>
</StripeProvider>
); );
}; };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+137 -53
View File
@@ -1,9 +1,63 @@
import { View, Image } from "react-native"; import { useEffect, useRef, useState } from "react";
import { GooglePlacesAutocomplete } from "react-native-google-places-autocomplete"; import {
FlatList,
Image,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { icons } from "@/constants"; import { icons } from "@/constants";
import type { GoogleInputProps } from "@/types/type"; import type { GoogleInputProps } from "@/types/type";
const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!;
interface Suggestion {
placeId: string;
text: string;
}
// Places API (New) — the legacy Places web service is unavailable to
// newer Google Cloud projects.
const fetchSuggestions = async (input: string): Promise<Suggestion[]> => {
const res = await fetch(
"https://places.googleapis.com/v1/places:autocomplete",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": googleApiKey,
},
body: JSON.stringify({ input, languageCode: "en" }),
},
);
const data = await res.json();
return (data.suggestions ?? [])
.map((s: any) => ({
placeId: s.placePrediction?.placeId as string,
text: s.placePrediction?.text?.text as string,
}))
.filter((s: Suggestion) => s.placeId && s.text);
};
const fetchPlaceDetails = async (placeId: string) => {
const res = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
headers: {
"X-Goog-Api-Key": googleApiKey,
"X-Goog-FieldMask": "location,formattedAddress",
},
});
const data = await res.json();
return {
latitude: data.location?.latitude as number,
longitude: data.location?.longitude as number,
address: data.formattedAddress as string,
};
};
export const GoogleTextInput = ({ export const GoogleTextInput = ({
icon, icon,
initialLocation, initialLocation,
@@ -11,59 +65,59 @@ export const GoogleTextInput = ({
textInputBackgroundColor, textInputBackgroundColor,
handlePress, handlePress,
}: GoogleInputProps) => { }: GoogleInputProps) => {
const googlePlacesApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!; const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (query.trim().length < 3) {
setSuggestions([]);
return;
}
debounceRef.current = setTimeout(async () => {
try {
setSuggestions(await fetchSuggestions(query));
} catch (err) {
console.log("[PLACES_AUTOCOMPLETE]: ", err);
}
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
const onSelect = async (suggestion: Suggestion) => {
setQuery(suggestion.text);
setSuggestions([]);
try {
const details = await fetchPlaceDetails(suggestion.placeId);
handlePress({
latitude: details.latitude,
longitude: details.longitude,
address: suggestion.text,
});
} catch (err) {
console.log("[PLACE_DETAILS]: ", err);
}
};
return ( return (
<View <View
className={`flex flex-row items-center justify-center relative z-50 rounded-xl ${containerStyles}`} className={`flex flex-row items-center justify-center relative z-50 rounded-xl ${containerStyles}`}
> >
<GooglePlacesAutocomplete <View className="flex-1 mx-5">
fetchDetails={true} <View
placeholder="Search" className="flex flex-row items-center rounded-full px-4 mt-1"
debounce={200} style={{
styles={{ backgroundColor: textInputBackgroundColor || "white",
textInputContainer: {
alignItems: "center",
justifyContent: "center",
borderRadius: 20,
marginHorizontal: 20,
position: "relative",
shadowColor: "#d4d4d4", shadowColor: "#d4d4d4",
},
textInput: {
backgroundColor: textInputBackgroundColor
? textInputBackgroundColor
: "white",
fontSize: 16,
fontWeight: "600",
marginTop: 5,
width: "100%",
borderRadius: 200,
},
listView: {
backgroundColor: textInputBackgroundColor
? textInputBackgroundColor
: "white",
position: "relative",
top: 0,
width: "100%",
borderRadius: 10,
shadowColor: "#d4d4d4",
zIndex: 99,
},
}} }}
onPress={(data, details = null) => { >
handlePress({
latitude: details?.geometry.location.lat!,
longitude: details?.geometry.location.lng!,
address: data.description,
});
}}
query={{
key: googlePlacesApiKey,
language: "en",
}}
renderLeftButton={() => (
<View className="justify-center items-center w-6 h-6"> <View className="justify-center items-center w-6 h-6">
<Image <Image
source={icon ? icon : icons.search} source={icon ? icon : icons.search}
@@ -72,12 +126,42 @@ export const GoogleTextInput = ({
resizeMode="contain" resizeMode="contain"
/> />
</View> </View>
)}
textInputProps={{ <TextInput
placeholderTextColor: "gray", value={query}
placeholder: initialLocation ?? "Where do you want to go?", onChangeText={setQuery}
}} placeholder={initialLocation ?? "Where do you want to go?"}
placeholderTextColor="gray"
className="flex-1 p-3 text-base font-JakartaSemiBold"
/> />
</View> </View>
{suggestions.length > 0 && (
<View
className="rounded-xl mt-1"
style={{
backgroundColor: textInputBackgroundColor || "white",
shadowColor: "#d4d4d4",
}}
>
<FlatList
data={suggestions}
keyExtractor={(item) => item.placeId}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => onSelect(item)}
className="p-3 border-b border-general-700"
>
<Text className="text-base font-JakartaRegular">
{item.text}
</Text>
</TouchableOpacity>
)}
/>
</View>
)}
</View>
</View>
); );
}; };
+14
View File
@@ -0,0 +1,14 @@
import { Text, View } from "react-native";
// react-native-maps does not support web. This stub keeps the web bundle
// working for local testing; use a native build for real map functionality.
export const Map = () => {
return (
<View className="w-full h-full rounded-2xl bg-general-100 flex items-center justify-center">
<Text className="text-general-200 text-center font-JakartaMedium">
Map is not available on web.{"\n"}Run on Android/iOS for the full
experience.
</Text>
</View>
);
};
+6 -2
View File
@@ -1,7 +1,7 @@
import { useOAuth } from "@clerk/clerk-expo"; import { useOAuth } from "@clerk/clerk-expo";
import { router } from "expo-router"; import { router } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { Image, Text, View } from "react-native"; import { Image, Text, View, Alert } from "react-native";
import { icons } from "@/constants"; import { icons } from "@/constants";
import { googleOAuth } from "@/lib/auth"; import { googleOAuth } from "@/lib/auth";
@@ -22,8 +22,12 @@ export const OAuth = ({ title }: OAuthProps) => {
if (result?.code === "session_exists" || result?.code === "success") { if (result?.code === "session_exists" || result?.code === "success") {
router.replace("/(root)/(tabs)/home"); router.replace("/(root)/(tabs)/home");
} }
} catch (err) { } catch (err: any) {
console.error("OAuth error", err); console.error("OAuth error", err);
Alert.alert(
"Google sign-in failed",
err?.errors?.[0]?.longMessage || err?.message || "Please try again.",
);
} }
}, [startOAuthFlow]); }, [startOAuthFlow]);
+61 -72
View File
@@ -1,8 +1,6 @@
import { useAuth } from "@clerk/clerk-expo"; import { useAuth } from "@clerk/clerk-expo";
import { PaymentSheetError, useStripe } from "@stripe/stripe-react-native";
import type { Result } from "@stripe/stripe-react-native/lib/typescript/src/types/PaymentMethod";
import type { IntentCreationCallbackParams } from "@stripe/stripe-react-native/lib/typescript/src/types/PaymentSheet";
import { router } from "expo-router"; import { router } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useState } from "react"; import { useState } from "react";
import { Alert, Image, Text, View } from "react-native"; import { Alert, Image, Text, View } from "react-native";
import ReactNativeModal from "react-native-modal"; import ReactNativeModal from "react-native-modal";
@@ -30,44 +28,10 @@ export const Payment = ({
destinationLongitude, destinationLongitude,
} = useLocationStore(); } = useLocationStore();
const { userId } = useAuth(); const { userId } = useAuth();
const { initPaymentSheet, presentPaymentSheet } = useStripe();
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [processing, setProcessing] = useState(false);
const confirmHandler = async ( const recordRide = async () => {
paymentMethod: Result,
_shouldSavePaymentMethod: boolean,
intentCreationCallback: (result: IntentCreationCallbackParams) => void,
) => {
const { paymentIntent, customer } = await fetchAPI(
"/(api)/(stripe)/create",
{
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify({
name: fullName || email,
email,
amount,
paymentMethodId: paymentMethod.id,
}),
},
);
if (paymentIntent.client_secret) {
const { result } = await fetchAPI("/(api)/(stripe)/pay", {
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify({
payment_method_id: paymentMethod.id,
payment_intent_id: paymentIntent.id,
customer_id: customer,
}),
});
if (result.client_secret) {
await fetchAPI("/(api)/ride/create", { await fetchAPI("/(api)/ride/create", {
method: "POST", method: "POST",
headers: { headers: {
@@ -87,55 +51,80 @@ export const Payment = ({
user_id: userId, user_id: userId,
}), }),
}); });
intentCreationCallback({
clientSecret: result.client_secret,
});
}
}
}; };
const initializePaymentSheet = async () => { const payWithAreeba = async () => {
const { error } = await initPaymentSheet({ setProcessing(true);
merchantDisplayName: "Ryde, Inc.",
intentConfiguration: { try {
mode: { // 1. Create an Areeba checkout session on our server.
amount: parseInt(amount) * 100, const { orderId, checkoutUrl, successIndicator, error } = await fetchAPI(
currencyCode: "USD", "/(api)/(areeba)/create",
{
method: "POST",
headers: {
"Content-type": "application/json",
}, },
confirmHandler, body: JSON.stringify({
name: fullName || email,
email,
amount,
}),
}, },
style: "alwaysLight", );
returnURL: "ryde://book-ride", // make sure protocol matches scheme in app.json
if (error || !checkoutUrl) throw new Error(error || "No checkout URL");
// 2. Open Areeba's hosted payment page. After payment the gateway
// redirects back to the app (waseel://book-ride).
const browserResult = await WebBrowser.openAuthSessionAsync(
checkoutUrl,
"waseel://book-ride",
);
let resultIndicator: string | undefined;
if (browserResult.type === "success" && browserResult.url) {
resultIndicator = new URL(browserResult.url).searchParams.get(
"resultIndicator",
) as string;
}
// 3. Verify the payment server-side before recording the ride.
const verification = await fetchAPI("/(api)/(areeba)/verify", {
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify({ orderId, resultIndicator, successIndicator }),
}); });
if (error) { if (verification.success) {
await recordRide();
setSuccess(true);
} else {
Alert.alert( Alert.alert(
"Error", "Payment not completed",
"Something went wrong while initializing your payment. Please try again.", "Your payment was cancelled or could not be verified. Please try again.",
); );
} }
}; } catch (err) {
console.log("[PAYMENT]: ", err);
const openPaymentSheet = async () => { Alert.alert(
await initializePaymentSheet(); "Error",
"Something went wrong while processing your payment. Please try again.",
const { error } = await presentPaymentSheet(); );
} finally {
if (error) { setProcessing(false);
if (error.code !== PaymentSheetError.Canceled)
Alert.alert(`Error code: ${error.code}`, error.message);
} else {
setSuccess(true);
} }
}; };
return ( return (
<> <>
<CustomButton <CustomButton
title="Confirm ride" title={processing ? "Processing..." : "Confirm ride"}
className="my-2" className="my-2"
onPress={openPaymentSheet} onPress={payWithAreeba}
disabled={processing}
/> />
<ReactNativeModal <ReactNativeModal
+3 -3
View File
@@ -78,14 +78,14 @@ export const onboarding = [
id: 1, id: 1,
title: "The perfect ride is just a tap away!", title: "The perfect ride is just a tap away!",
description: description:
"Your journey begins with Ryde. Find your ideal ride effortlessly.", "Your journey begins with Waseel. Find your ideal ride effortlessly.",
image: images.onboarding1, image: images.onboarding1,
}, },
{ {
id: 2, id: 2,
title: "Best car in your hands with Ryde", title: "Best car in your hands with Waseel",
description: description:
"Discover the convenience of finding your perfect ride with Ryde", "Discover the convenience of finding your perfect ride with Waseel",
image: images.onboarding2, image: images.onboarding2,
}, },
{ {
+5 -3
View File
@@ -16,9 +16,11 @@ declare global {
// google api key // google api key
EXPO_PUBLIC_GOOGLE_API_KEY: string; EXPO_PUBLIC_GOOGLE_API_KEY: string;
// stripe api key // areeba payment gateway
EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY: string; AREEBA_API_BASE_URL: string;
STRIPE_SECRET_KEY: string; AREEBA_MERCHANT_ID: string;
AREEBA_API_PASSWORD: string;
AREEBA_API_VERSION: string;
} }
} }
} }
+115
View File
@@ -0,0 +1,115 @@
// Server-side helpers for the Areeba payment gateway (MPGS-style API).
//
// Areeba issues merchant credentials after onboarding:
// AREEBA_API_BASE_URL e.g. https://<your-gateway-host>.areeba.com
// AREEBA_MERCHANT_ID your merchant id
// AREEBA_API_PASSWORD API password for the merchant
// AREEBA_API_VERSION gateway REST API version (default: 100)
//
// NOTE: verify the exact field names against the integration docs Areeba
// sends you — the gateway is Mastercard Payment Gateway (MPGS) based, and
// the payloads below follow that convention.
export const areebaConfig = () => {
const baseUrl = process.env.AREEBA_API_BASE_URL?.replace(/\/$/, "");
const merchantId = process.env.AREEBA_MERCHANT_ID;
const apiPassword = process.env.AREEBA_API_PASSWORD;
const apiVersion = process.env.AREEBA_API_VERSION || "100";
if (!baseUrl || !merchantId || !apiPassword) {
throw new Error(
"Missing Areeba configuration. Set AREEBA_API_BASE_URL, AREEBA_MERCHANT_ID and AREEBA_API_PASSWORD in .env",
);
}
return { baseUrl, merchantId, apiPassword, apiVersion };
};
const authHeader = (merchantId: string, apiPassword: string) =>
`Basic ${Buffer.from(`merchant.${merchantId}:${apiPassword}`).toString("base64")}`;
// Creates a checkout session and returns the hosted payment page URL.
export const createCheckoutSession = async ({
orderId,
amount,
currency,
description,
returnUrl,
}: {
orderId: string;
amount: number; // major units, e.g. 25.5 USD
currency: string;
description: string;
returnUrl: string;
}) => {
const { baseUrl, merchantId, apiPassword, apiVersion } = areebaConfig();
const res = await fetch(
`${baseUrl}/api/rest/version/${apiVersion}/merchant/${merchantId}/session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeader(merchantId, apiPassword),
},
body: JSON.stringify({
apiOperation: "INITIATE_CHECKOUT",
order: {
id: orderId,
amount: amount.toFixed(2),
currency,
description,
},
interaction: {
operation: "PURCHASE",
merchant: { name: "Waseel" },
returnUrl,
},
}),
},
);
const data = await res.json();
if (!res.ok || !data?.session?.id) {
console.log("[AREEBA_CREATE_SESSION]: ", data);
throw new Error(
data?.error?.explanation || "Failed to create Areeba checkout session",
);
}
return {
sessionId: data.session.id as string,
// Used to verify the redirect result (compare with resultIndicator).
successIndicator: data.successIndicator as string | undefined,
checkoutUrl: `${baseUrl}/checkout/pay/${data.session.id}?checkoutVersion=1.0.0`,
};
};
// Retrieves an order and reports whether it was paid.
export const retrieveOrder = async (orderId: string) => {
const { baseUrl, merchantId, apiPassword, apiVersion } = areebaConfig();
const res = await fetch(
`${baseUrl}/api/rest/version/${apiVersion}/merchant/${merchantId}/order/${orderId}`,
{ headers: { Authorization: authHeader(merchantId, apiPassword) } },
);
const data = await res.json();
if (!res.ok) {
console.log("[AREEBA_RETRIEVE_ORDER]: ", data);
throw new Error(
data?.error?.explanation || "Failed to retrieve Areeba order",
);
}
return {
status: data.status as string | undefined,
result: data.result as string | undefined,
amount: data.amount as string | undefined,
currency: data.currency as string | undefined,
// PURCHASE auto-captures; CAPTURED means the money was taken.
paid: data.result === "SUCCESS" && data.status === "CAPTURED",
};
};
+4 -3
View File
@@ -44,10 +44,11 @@ type StartOAuthFlowType = (
export const googleOAuth = async (startOAuthFlow: StartOAuthFlowType) => { export const googleOAuth = async (startOAuthFlow: StartOAuthFlowType) => {
try { try {
// No explicit scheme: in Expo Go this resolves to exp://... so the
// browser can redirect back into the app; in a standalone build it
// automatically uses the app.json scheme ("waseel").
const { createdSessionId, signUp, setActive } = await startOAuthFlow({ const { createdSessionId, signUp, setActive } = await startOAuthFlow({
redirectUrl: Linking.createURL("/(root)/(tabs)/home", { redirectUrl: Linking.createURL("/(root)/(tabs)/home"),
scheme: "ryde", // match with scheme in app.json
}),
}); });
if (createdSessionId) { if (createdSessionId) {
+9
View File
@@ -44,3 +44,12 @@ export function formatDate(dateString: string): string {
return `${day < 10 ? "0" + day : day} ${month} ${year}`; return `${day < 10 ? "0" + day : day} ${month} ${year}`;
} }
// Normalizes a Lebanese phone number to E.164 (+961...).
export function normalizePhone(raw: string): string {
const cleaned = raw.replace(/[^\d+]/g, "");
if (cleaned.startsWith("+")) return cleaned;
return `+961${cleaned.replace(/^0+/, "")}`;
}
+20219
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -1,5 +1,5 @@
{ {
"name": "uber-clone", "name": "waseel",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.1", "version": "1.0.1",
"scripts": { "scripts": {
@@ -19,7 +19,7 @@
"email": "sanidhyyy@gmail.com", "email": "sanidhyyy@gmail.com",
"url": "https://github.com/sanidhyy" "url": "https://github.com/sanidhyy"
}, },
"description": "Find your perfect ride with Ryde.", "description": "Find your perfect ride with Waseel.",
"keywords": [ "keywords": [
"react", "react",
"reactjs", "reactjs",
@@ -78,10 +78,10 @@
"@gorhom/bottom-sheet": "^4.6.4", "@gorhom/bottom-sheet": "^4.6.4",
"@neondatabase/serverless": "^0.9.4", "@neondatabase/serverless": "^0.9.4",
"@react-navigation/native": "^6.0.2", "@react-navigation/native": "^6.0.2",
"@stripe/stripe-react-native": "^0.38.4",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"expo": "~51.0.28", "expo": "~51.0.28",
"expo-auth-session": "~5.5.2",
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-font": "~12.0.9", "expo-font": "~12.0.9",
"expo-linking": "^6.3.1", "expo-linking": "^6.3.1",
@@ -96,10 +96,9 @@
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.74.5", "react-native": "^0.74.5",
"react-native-gesture-handler": "^2.19.0", "react-native-gesture-handler": "~2.16.1",
"react-native-google-places-autocomplete": "^2.5.6", "react-native-maps": "^1.14.0",
"react-native-maps": "^1.18.0",
"react-native-maps-directions": "^1.9.0", "react-native-maps-directions": "^1.9.0",
"react-native-modal": "^13.0.1", "react-native-modal": "^13.0.1",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
@@ -107,7 +106,6 @@
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-swiper": "^1.6.0", "react-native-swiper": "^1.6.0",
"react-native-web": "~0.19.10", "react-native-web": "~0.19.10",
"stripe": "^16.9.0",
"zustand": "^4.5.5" "zustand": "^4.5.5"
}, },
"devDependencies": { "devDependencies": {
+72
View File
@@ -0,0 +1,72 @@
// Creates and seeds the Waseel database tables on Neon.
// Usage: node scripts/seed-db.mjs (reads DATABASE_URL from .env)
import { neon } from "@neondatabase/serverless";
import { readFileSync } from "fs";
const env = readFileSync(new URL("../.env", import.meta.url), "utf8");
const databaseUrl = env
.split("\n")
.find((l) => l.startsWith("DATABASE_URL="))
?.split("=")
.slice(1)
.join("=")
.trim()
.replace(/^"|"$/g, "");
if (!databaseUrl) {
console.error("DATABASE_URL not found in .env");
process.exit(1);
}
const sql = neon(databaseUrl);
await sql`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
clerk_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`;
await sql`CREATE TABLE IF NOT EXISTS drivers (
id SERIAL PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
profile_image_url TEXT,
car_image_url TEXT,
car_seats INTEGER NOT NULL,
rating NUMERIC(2,1) NOT NULL
)`;
await sql`CREATE TABLE IF NOT EXISTS rides (
ride_id SERIAL PRIMARY KEY,
origin_address TEXT NOT NULL,
destination_address TEXT NOT NULL,
origin_latitude DOUBLE PRECISION NOT NULL,
origin_longitude DOUBLE PRECISION NOT NULL,
destination_latitude DOUBLE PRECISION NOT NULL,
destination_longitude DOUBLE PRECISION NOT NULL,
ride_time INTEGER NOT NULL,
fare_price INTEGER NOT NULL,
payment_status VARCHAR(50) NOT NULL,
driver_id INTEGER NOT NULL REFERENCES drivers(id),
user_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`;
const count = await sql`SELECT COUNT(*)::int AS n FROM drivers`;
if (count[0].n === 0) {
await sql`INSERT INTO drivers
(first_name, last_name, profile_image_url, car_image_url, car_seats, rating)
VALUES
('Karim', 'Haddad', 'https://randomuser.me/api/portraits/men/32.jpg', 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=600', 4, 4.8),
('Rana', 'Khalil', 'https://randomuser.me/api/portraits/women/44.jpg', 'https://images.unsplash.com/photo-1552519507-da3b142c6e3d?w=600', 4, 4.9),
('Omar', 'Chehab', 'https://randomuser.me/api/portraits/men/75.jpg', 'https://images.unsplash.com/photo-1580273916550-e323be2ae537?w=600', 4, 4.6),
('Layal', 'Abou-Jaoude', 'https://randomuser.me/api/portraits/women/68.jpg', 'https://images.unsplash.com/photo-1590362891991-f776e747a588?w=600', 2, 4.7)`;
console.log("Seeded 4 drivers.");
} else {
console.log(`Drivers table already has ${count[0].n} rows, skipping seed.`);
}
console.log("Database ready.");