Skip to content

Commit cdf8f39

Browse files
Merge remote-tracking branch 'origin/main' into feat/129-link-geusts-frontend-to-backend
2 parents 1072903 + 4f23fe9 commit cdf8f39

File tree

10 files changed

+240
-36
lines changed

10 files changed

+240
-36
lines changed

clients/mobile/app/(tabs)/_layout.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const TabBarIcon = ({ name, focused, activeColor, label }: TabBarIconProps) => (
2828
);
2929

3030
const PlusButton = () => (
31-
<View className="bg-primary rounded-full w-14 h-14 items-center justify-center -mb-2">
31+
<View className="bg-primary rounded-full size-16 items-center justify-center mb-6">
3232
<IconSymbol size={22} name="plus" color={Colors["light"].background} />
3333
</View>
3434
);
@@ -48,17 +48,15 @@ export default function TabLayout() {
4848
tabBarActiveTintColor: c.tabBarActive,
4949
tabBarInactiveTintColor: c.tabBarActive,
5050
tabBarItemStyle: {
51-
paddingVertical: 10,
51+
paddingVertical: 0,
5252
},
5353
tabBarStyle: {
54-
height: 70,
54+
paddingTop: 8,
55+
paddingHorizontal: 20,
56+
height: 68,
5557
},
5658
}}
5759
>
58-
<Tabs.Screen
59-
name="create-task-ai"
60-
options={{ href: null, headerShown: false }}
61-
/>
6260
<Tabs.Screen
6361
name="tasks"
6462
options={{
Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,55 @@
1-
import LogoutButton from "@/components/Logout";
1+
import { View, Text, ScrollView, ActivityIndicator } from "react-native";
2+
import { SafeAreaView } from "react-native-safe-area-context";
3+
import { useAuth } from "@clerk/clerk-expo";
24
import { router } from "expo-router";
3-
import { View } from "react-native";
5+
import { useGetUser } from "@shared";
6+
import LogoutButton from "@/components/Logout";
7+
import { ProfileHero } from "@/components/profile/ProfileHero";
8+
import { ProfileInfoCard } from "@/components/profile/ProfileInfoCard";
49

510
export default function Profile() {
11+
const { userId } = useAuth();
12+
const { data: user, isLoading } = useGetUser(userId ?? undefined);
13+
614
const onSignOut = () => {
715
router.replace("/sign-in");
816
};
917

18+
const firstName = user?.first_name ?? "";
19+
const lastName = user?.last_name ?? "";
20+
const displayName = [firstName, lastName].filter(Boolean).join(" ") || "User";
21+
1022
return (
11-
<View className="flex-1 justify-center px-6">
12-
<LogoutButton onSignOut={onSignOut} />
13-
</View>
23+
<SafeAreaView className="flex-1 bg-bg-primary" edges={["top"]}>
24+
<View className="px-4 py-4 border-b border-stroke-subtle">
25+
<Text className="text-2xl font-semibold text-text-default">
26+
Profile
27+
</Text>
28+
<Text className="text-sm text-text-subtle">Overview of profile</Text>
29+
</View>
30+
31+
{isLoading ? (
32+
<View className="flex-1 items-center justify-center">
33+
<ActivityIndicator size="large" />
34+
</View>
35+
) : (
36+
<ScrollView className="flex-1" contentContainerClassName="pb-12">
37+
<ProfileHero
38+
firstName={firstName}
39+
lastName={lastName}
40+
avatarUrl={user?.profile_picture ?? undefined}
41+
/>
42+
<ProfileInfoCard
43+
governmentName={displayName}
44+
email={user?.primary_email ?? "—"}
45+
phoneNumber={user?.phone_number ?? "—"}
46+
department={user?.department ?? "—"}
47+
/>
48+
<View className="px-4 mt-8">
49+
<LogoutButton onSignOut={onSignOut} />
50+
</View>
51+
</ScrollView>
52+
)}
53+
</SafeAreaView>
1454
);
1555
}

clients/mobile/app/_layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function AppLayout() {
4040
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
4141
<Stack>
4242
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
43+
<Stack.Screen name="create-task-ai" options={{ headerShown: false }} />
4344
<Stack.Screen name="(auth)/sign-in" options={{ headerShown: false }} />
4445
<Stack.Screen
4546
name="modal"

clients/mobile/app/(tabs)/create-task-ai.tsx renamed to clients/mobile/app/create-task-ai.tsx

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { useAPIClient } from "@shared/api/client";
2929
import { getConfig } from "@shared/api/config";
3030
import type { GenerateRequestResponse, MakeRequest } from "@shared";
31+
import { SkeletonCard } from "@/components/ui/SkeletonCard";
3132

3233
// Token values for JSX props that can't use className (icon colors, placeholder, etc.)
3334
const colors = {
@@ -51,30 +52,6 @@ type GeneratedTask = {
5152
description?: string;
5253
};
5354

54-
function SkeletonLine({ width }: { width: `${number}%` | number }) {
55-
return (
56-
<View
57-
className="bg-stroke-subtle rounded-full h-[10px]"
58-
style={{ width }}
59-
/>
60-
);
61-
}
62-
63-
function SkeletonCard() {
64-
return (
65-
<View className="bg-stroke-disabled rounded-lg px-4 py-4 gap-3">
66-
<SkeletonLine width="100%" />
67-
<SkeletonLine width="66%" />
68-
<SkeletonLine width="84%" />
69-
<View className="flex-row justify-between">
70-
<SkeletonLine width="34%" />
71-
<SkeletonLine width="34%" />
72-
</View>
73-
<SkeletonLine width="100%" />
74-
</View>
75-
);
76-
}
77-
7855
type TaskFieldRowProps = {
7956
icon: React.ReactNode;
8057
label: string;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { View, Text, Image } from "react-native";
2+
3+
type ProfileHeroProps = {
4+
firstName: string;
5+
lastName: string;
6+
avatarUrl: string | undefined;
7+
};
8+
9+
export function ProfileHero({
10+
firstName,
11+
lastName,
12+
avatarUrl,
13+
}: ProfileHeroProps) {
14+
const displayName = [firstName, lastName].filter(Boolean).join(" ") || "User";
15+
const initial = displayName.charAt(0).toUpperCase();
16+
17+
return (
18+
<View className="items-center py-8 gap-4">
19+
{avatarUrl ? (
20+
<Image source={{ uri: avatarUrl }} className="w-24 h-24 rounded-full" />
21+
) : (
22+
<View className="w-24 h-24 rounded-full bg-primary items-center justify-center">
23+
<Text className="text-4xl font-semibold text-white">{initial}</Text>
24+
</View>
25+
)}
26+
<View className="items-center gap-1">
27+
<Text className="text-2xl font-bold text-text-default">
28+
{displayName}
29+
</Text>
30+
<Text className="text-sm font-semibold text-primary">Hotel Chain</Text>
31+
</View>
32+
</View>
33+
);
34+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { View, Text } from "react-native";
2+
import { formatPhoneNumber } from "@/utils";
3+
4+
function DetailRow({ label, value }: { label: string; value: string }) {
5+
return (
6+
<View className="flex-row items-center py-4 border-b border-stroke-subtle">
7+
<Text className="w-2/5 text-sm font-medium text-text-default">
8+
{label}
9+
</Text>
10+
<Text className="flex-1 text-sm text-text-subtle">{value}</Text>
11+
</View>
12+
);
13+
}
14+
15+
type ProfileInfoCardProps = {
16+
governmentName: string;
17+
email: string;
18+
phoneNumber: string;
19+
department: string;
20+
};
21+
22+
export function ProfileInfoCard({
23+
governmentName,
24+
email,
25+
phoneNumber,
26+
department,
27+
}: ProfileInfoCardProps) {
28+
return (
29+
<View className="rounded-xl border border-stroke-subtle bg-white mx-4 px-4">
30+
<DetailRow label="Government Name" value={governmentName} />
31+
<DetailRow label="Email" value={email} />
32+
<DetailRow label="Phone Number" value={formatPhoneNumber(phoneNumber)} />
33+
<View className="flex-row items-center py-4">
34+
<Text className="w-2/5 text-sm font-medium text-text-default">
35+
Department
36+
</Text>
37+
<Text className="flex-1 text-sm text-text-subtle">{department}</Text>
38+
</View>
39+
</View>
40+
);
41+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useEffect, useRef } from "react";
2+
import { Animated, Easing, View } from "react-native";
3+
4+
type SkeletonLineProps = {
5+
width: `${number}%` | number;
6+
opacity: Animated.Value;
7+
};
8+
9+
function SkeletonLine({ width, opacity }: SkeletonLineProps) {
10+
return (
11+
<Animated.View
12+
className="bg-stroke-subtle rounded-full h-[10px]"
13+
style={[{ width }, { opacity }]}
14+
/>
15+
);
16+
}
17+
18+
export function SkeletonCard() {
19+
const opacity = useRef(new Animated.Value(1)).current;
20+
21+
useEffect(() => {
22+
Animated.loop(
23+
Animated.sequence([
24+
Animated.timing(opacity, {
25+
toValue: 0.3,
26+
duration: 700,
27+
easing: Easing.inOut(Easing.ease),
28+
useNativeDriver: true,
29+
}),
30+
Animated.timing(opacity, {
31+
toValue: 1,
32+
duration: 700,
33+
easing: Easing.inOut(Easing.ease),
34+
useNativeDriver: true,
35+
}),
36+
]),
37+
).start();
38+
39+
return () => opacity.stopAnimation();
40+
}, [opacity]);
41+
42+
return (
43+
<View className="bg-stroke-disabled rounded-lg px-4 py-4 gap-3">
44+
<SkeletonLine width="100%" opacity={opacity} />
45+
<SkeletonLine width="66%" opacity={opacity} />
46+
<SkeletonLine width="84%" opacity={opacity} />
47+
<View className="flex-row justify-between">
48+
<SkeletonLine width="34%" opacity={opacity} />
49+
<SkeletonLine width="34%" opacity={opacity} />
50+
</View>
51+
<SkeletonLine width="100%" opacity={opacity} />
52+
</View>
53+
);
54+
}

clients/mobile/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
import { Filter } from "@/components/ui/filters";
22

3+
/** Strip everything except digits, capped at 10. */
4+
export function parsePhoneDigits(value: string): string {
5+
return value.replace(/\D/g, "").slice(0, 10);
6+
}
7+
8+
/**
9+
* Format a raw digit string (or already-formatted string) as (XXX) XXX-XXXX.
10+
* Non-phone values like "—" are returned unchanged.
11+
*/
12+
export function formatPhoneNumber(value: string): string {
13+
const d = parsePhoneDigits(value);
14+
if (d.length === 0) return value; // preserve placeholder like "—"
15+
if (d.length <= 3) return `(${d}`;
16+
if (d.length <= 6) return `(${d.slice(0, 3)}) ${d.slice(3)}`;
17+
return `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}`;
18+
}
19+
320
const toFilterConfig = <T extends number | string>(
421
options: T[],
522
selected: T[],

clients/shared/src/api/users.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
import type { User } from "./generated/models";
3+
import { useAPIClient } from "./client";
4+
5+
export const getUserQueryKey = (userId: string | undefined) =>
6+
["user", userId] as const;
7+
8+
export const useGetUser = (userId: string | undefined) => {
9+
const api = useAPIClient();
10+
return useQuery({
11+
queryKey: getUserQueryKey(userId),
12+
queryFn: () => api.get<User>(`/users/${userId}`),
13+
enabled: !!userId,
14+
});
15+
};
16+
17+
export const useUpdateUser = (userId: string | undefined) => {
18+
const api = useAPIClient();
19+
const queryClient = useQueryClient();
20+
return useMutation({
21+
mutationFn: (updates: { phone_number?: string }) =>
22+
api.put<User>(`/users/${userId}`, updates),
23+
onMutate: async (updates) => {
24+
await queryClient.cancelQueries({ queryKey: getUserQueryKey(userId) });
25+
const previous = queryClient.getQueryData<User>(getUserQueryKey(userId));
26+
queryClient.setQueryData<User>(getUserQueryKey(userId), (old) => ({
27+
...old!,
28+
...updates,
29+
}));
30+
return { previous };
31+
},
32+
onError: (_err, _vars, context) => {
33+
queryClient.setQueryData(getUserQueryKey(userId), context?.previous);
34+
},
35+
onSettled: () => {
36+
queryClient.invalidateQueries({ queryKey: getUserQueryKey(userId) });
37+
},
38+
});
39+
};

clients/shared/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export type {
7272
RoomRequestsResponse,
7373
} from "./api/generated/models";
7474

75+
// User hooks
76+
export { getUserQueryKey, useGetUser, useUpdateUser } from "./api/users";
77+
7578
// Department types and hooks
7679
export type { Department } from "./types/departments";
7780

0 commit comments

Comments
 (0)