Skip to content

Commit

Permalink
Implement an AI Advisor (#125)
Browse files Browse the repository at this point in the history
This pull request introduces several significant changes, including the
addition of a new AI advisor feature, updates to the rewards page, and
enhancements to the messaging system. The most important changes are
summarized below:

### AI Advisor Feature:
* Added a new `AdvisorChatbot` component that allows users to interact
with an AI advisor. This includes setting up the chat interface,
handling message sending, and displaying messages.
(`app/(tabs)/ai-advisor.tsx`)
* Updated `TabLayout` to include the new AI advisor tab with an
appropriate icon. (`app/(tabs)/_layout.tsx`)
[[1]](diffhunk://#diff-ea7f70dea9b63c4fcf80ad308f46fc316bfd05e41531a2a44dd56e1ea215a637R7)
[[2]](diffhunk://#diff-ea7f70dea9b63c4fcf80ad308f46fc316bfd05e41531a2a44dd56e1ea215a637R87-R98)

### Rewards Page Enhancements:
* Refactored the rewards page to fetch and display rewards dynamically,
including a search functionality to filter rewards.
(`app/(tabs)/rewards.tsx`)
[[1]](diffhunk://#diff-c35e1e058777e8b7e00c54546afb9a15451de31415bb33b3e65497322ecc17ebL1-R42)
[[2]](diffhunk://#diff-c35e1e058777e8b7e00c54546afb9a15451de31415bb33b3e65497322ecc17ebL33-L149)
* Updated the single reward page to fetch reward details dynamically and
display additional information such as images and legal disclosures.
(`app/rewards/[id].tsx`)
([app/rewards/[id].tsxL1-R98](diffhunk://#diff-e9cb913d6859fe44b83b3a2f5065f76164b17d5beda8c9a8e010218dfba95a4fL1-R98))

### Messaging System Improvements:
* Added new queries and mutations for handling messages, including
fetching messages by thread ID and sending messages.
(`convex/messages.ts`)
* Updated the schema to include new tables for messages and threads,
with appropriate indexing. (`convex/schema.ts`)

### UI and Styling Updates:
* Introduced a new CSS file for styling the reward descriptions and
links. (`app/rewards/dom-content.css`)
* Enhanced the `Input` component to use a consistent font family across
the application. (`components/ui/input.tsx`)
[[1]](diffhunk://#diff-c2f62fb0cb5e2955363d1c27d9cb0b33d03fed2193d16c3877af492d82770d2cR3)
[[2]](diffhunk://#diff-c2f62fb0cb5e2955363d1c27d9cb0b33d03fed2193d16c3877af492d82770d2cR12-R14)

These changes collectively improve the functionality and user experience
of the application, particularly with the introduction of the AI advisor
and the dynamic handling of rewards.- **add Bot icon**
  • Loading branch information
michaeleii authored Dec 10, 2024
2 parents 359ace5 + 8f1bc7c commit 0062bcd
Show file tree
Hide file tree
Showing 19 changed files with 5,044 additions and 6,403 deletions.
13 changes: 13 additions & 0 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { User } from "~/lib/icons/User";
import { Goal, Mountain, TrendingUp } from "lucide-react-native";
import { fontFamily } from "~/lib/font";
import { Star } from "~/lib/icons/Star";
import { Bot } from "~/lib/icons/Bot";

export default function TabLayout() {
return (
Expand Down Expand Up @@ -83,6 +84,18 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name="ai-advisor"
options={{
title: "Advisor",
headerShown: false,
tabBarIcon: ({ color }) => (
<View style={{ alignItems: "center" }}>
<Bot color={color} />
</View>
),
}}
/>
</Tabs>
</>
);
Expand Down
100 changes: 100 additions & 0 deletions app/(tabs)/ai-advisor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { SafeAreaView } from "react-native-safe-area-context";
// import AsyncStorage from "@react-native-async-storage/async-storage";

import { useState, useRef, useMemo } from "react";
import { View, KeyboardAvoidingView, Platform, FlatList } from "react-native";
import { Text } from "~/components/ui/text";
import { Button } from "~/components/ui/button";
import { useMutation, useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { cn } from "~/lib/utils";
import { Send } from "~/lib/icons/Send";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";

export default function AdvisorChatbot() {
const thread = useQuery(api.threads.getThread);
const remoteMessages = useQuery(api.messages.getMessages, {
threadId: thread?.threadId,
});
const sendMessage = useMutation(api.messages.sendMessage);

const messages = useMemo(
() =>
[
{
role: "assistant",
content:
"Hey there, I'm your personal AI Advisor. What can I help you with?",
threadId: "",
},
].concat(remoteMessages ?? []),
[remoteMessages]
);

const [inputText, setInputText] = useState("");
const flatListRef = useRef<FlatList>(null);

const handleSend = async () => {
if (!inputText) {
return;
}
await sendMessage({ threadId: thread?.threadId, message: inputText });
setInputText("");
};

return (
<SafeAreaView
style={{ flex: 1, backgroundColor: "#082139" }}
edges={["top", "left", "right"]}
>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1"
>
<FlatList
ref={flatListRef}
data={messages}
onContentSizeChange={() => {
if (flatListRef.current) {
flatListRef.current.scrollToEnd({ animated: true });
}
}}
renderItem={({ item }) => (
<MessageComp isViewer={item.role === "user"} text={item.content} />
)}
ItemSeparatorComponent={() => <Separator />}
/>

<View className="relative flex-row items-center justify-between bg-card">
<Input
className="native:h-20 flex-1 rounded-none border-0 bg-card py-6 placeholder:text-muted-foreground"
value={inputText}
onChangeText={setInputText}
placeholder="Send a message..."
multiline
/>
<Button variant="ghost" hitSlop={20} onPress={handleSend}>
<Send className="text-white" />
</Button>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

function MessageComp({ text, isViewer }: { text: string; isViewer: boolean }) {
return (
<View className={cn("gap-3 p-4", isViewer && "bg-card")}>
<Text className={cn(!isViewer && "font-semibold")}>{text}</Text>
<Text
className={cn(
"text-sm font-light text-muted-foreground",
isViewer && "text-right"
)}
>
{isViewer ? "You" : "LT AI Advisor"}
</Text>
</View>
);
}
197 changes: 80 additions & 117 deletions app/(tabs)/rewards.tsx
Original file line number Diff line number Diff line change
@@ -1,151 +1,114 @@
import { ActivityIndicator, FlatList, Pressable, View } from "react-native";
import type { LucideIcon } from "lucide-react-native";
import {
FlatList,
Pressable,
View,
Image,
ActivityIndicator,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

import { Link } from "expo-router";
import { Link, useLocalSearchParams } from "expo-router";
import { Text } from "~/components/ui/text";
import SearchInput from "~/components/search-input";
import { Coins } from "~/lib/icons/Coins";
import { HandHeart } from "~/lib/icons/HandHeart";
import { Gift } from "~/lib/icons/Gift";
import { TentTree } from "~/lib/icons/TentTree";
import { api } from "~/convex/_generated/api";
import { useQuery } from "convex/react";
import { useAction, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import type { ListProductsResponseProductsInner } from "tremendous";
import { MaterialIcons } from "@expo/vector-icons";

export default function RewardsPage() {
const [rewards, setRewards] = useState<
ListProductsResponseProductsInner[] | null
>(null);
const { query } = useLocalSearchParams<{ query?: string }>();
const user = useQuery(api.users.currentUser);
const fetchRewards = useAction(api.tremendous.listRewardsAction);

useEffect(() => {
fetchRewards().then((products) => setRewards(products));
}, [fetchRewards]);

const filteredRewards = rewards
? query
? rewards.filter((reward) =>
reward.name.toLowerCase().includes(query.toLowerCase())
)
: rewards
: null;

return (
<SafeAreaView style={{ flex: 1, backgroundColor: "#082139" }}>
{user ? (
<View>
<View className="gap-10 p-4">
<View className="flex flex-row items-center gap-4">
<Text>
<Coins className="text-primary" size={40} />
</Text>
<View className="gap-2">
<Text className="text-2xl font-bold">{user?.points ?? 0}</Text>
<Text className="text-md">LT Token Balance</Text>
</View>
</View>
<View className="gap-3">
<Text className="text-2xl font-semibold">Available Rewards</Text>
<SearchInput />
</View>
<View className="gap-10 bg-background p-4">
<View className="flex flex-row items-center gap-4">
<Text>
<Coins className="text-primary" size={40} />
</Text>
<View className="gap-2">
<Text className="text-2xl font-bold">{user?.points ?? 0}</Text>
<Text className="text-md">LT Token Balance</Text>
</View>
<FlatList
contentContainerStyle={{
paddingBottom: 208,
}}
data={rewardData}
ItemSeparatorComponent={() => <View className="py-2" />}
ListFooterComponent={() => (
<Text className="my-5 text-center text-sm">End of Rewards</Text>
)}
renderItem={({ item }) => (
<RewardItem
id={item.id}
Icon={item.icon}
type={item.type}
token={item.token}
name={item.name}
description={item.description}
/>
)}
/>
</View>
<View className="gap-3">
<Text className="text-2xl font-semibold">Available Rewards</Text>
<SearchInput query={query} />
</View>
</View>
{filteredRewards ? (
<FlatList
data={filteredRewards}
ItemSeparatorComponent={() => <View className="py-2" />}
renderItem={({ item }) => <RewardItem product={item} />}
/>
) : (
<View className="flex-1 bg-background">
<View className="h-full flex-1 items-center justify-center bg-background">
<ActivityIndicator />
</View>
)}
</SafeAreaView>
);
}

export const rewardData = [
{
id: "0",
icon: HandHeart,
type: "Charitable donation",
token: 30,
name: "Reward Name",
description: "This is a short description for this reward",
},
{
id: "1",
icon: Gift,
type: "Gift card",
token: 20,
name: "Reward Name",
description: "This is a short description for this reward",
},
{
id: "2",
icon: TentTree,
type: "Experience",
token: 50,
name: "Reward Name",
description: "This is a short description for this reward",
},
{
id: "3",
icon: TentTree,
type: "Experience",
token: 40,
name: "Reward Name",
description: "This is a short description for this reward",
},
{
id: "4",
icon: Gift,
type: "Gift card",
token: 60,
name: "Reward Name",
description: "This is a short description for this reward",
},
];
interface RewardItemProps {
product: ListProductsResponseProductsInner;
}

function RewardItem({
id,
Icon,
type,
token,
name,
description,
}: {
id: string;
Icon: LucideIcon;
type: string;
token: number;
name: string;
description: string;
}) {
function RewardItem({ product }: RewardItemProps) {
return (
<Link
href={{
pathname: "/rewards/[id]",
params: { id },
params: { id: product.id },
}}
asChild
>
<Pressable>
<View className="justify-between bg-[#0e2942] px-6 py-5">
<View className="flex flex-row justify-between">
<View className="flex flex-row items-center gap-2">
<View className="rounded-lg bg-white/20 p-1 backdrop-blur-sm">
<Icon className="text-foreground" />
</View>
<Text>{type}</Text>
</View>
<Text className="font-medium">{token} tokens</Text>
</View>
<View className="gap-1">
<Text className="text-xl font-semibold">{name}</Text>
<Text className="line-clamp-2 overflow-ellipsis">
{description}
</Text>
<View className="relative">
<View className="absolute h-full w-full bg-[#0e2942]/50"></View>
{!!product.images.length && (
<Image
src={product.images.at(0)?.src}
className="-z-10 h-[200px] w-full"
/>
)}

<View className="absolute flex flex-row items-center gap-2 pl-4 pt-4">
{product.category === "merchant_card" && (
<>
<View className="rounded-lg bg-slate-500 p-1">
<MaterialIcons
name="card-giftcard"
size={20}
color={"white"}
/>
</View>
<Text>Gift Card</Text>
</>
)}
</View>
<Text className="absolute bottom-0 pb-4 pl-4 text-xl font-semibold">
{product.name}
</Text>
</View>
</Pressable>
</Link>
Expand Down
Loading

0 comments on commit 0062bcd

Please sign in to comment.