diff --git a/package.json b/package.json index f4baf33af..0d75a2b74 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@expo/metro-runtime": "^3.2.3", "@gorhom/bottom-sheet": "4.6.3", "@hookform/resolvers": "^3.9.0", + "@lukemorales/query-key-factory": "^1.3.4", "@shopify/flash-list": "1.6.4", "@tanstack/react-query": "^5.52.1", "app-icon-badge": "^0.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ba613f4c..f567fe6bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@hookform/resolvers': specifier: ^3.9.0 version: 3.9.0(react-hook-form@7.53.0(react@18.2.0)) + '@lukemorales/query-key-factory': + specifier: ^1.3.4 + version: 1.3.4(@tanstack/query-core@5.52.2)(@tanstack/react-query@5.52.2(react@18.2.0)) '@shopify/flash-list': specifier: 1.6.4 version: 1.6.4(@babel/runtime@7.25.4)(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0) @@ -1580,6 +1583,13 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lukemorales/query-key-factory@1.3.4': + resolution: {integrity: sha512-A3frRDdkmaNNQi6mxIshsDk4chRXWoXa05US8fBo4kci/H+lVmujS6QrwQLLGIkNIRFGjMqp2uKjC4XsLdydRw==} + engines: {node: '>=14'} + peerDependencies: + '@tanstack/query-core': '>= 4.0.0' + '@tanstack/react-query': '>= 4.0.0' + '@motionone/animation@10.18.0': resolution: {integrity: sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw==} @@ -9639,6 +9649,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.0 optional: true + '@lukemorales/query-key-factory@1.3.4(@tanstack/query-core@5.52.2)(@tanstack/react-query@5.52.2(react@18.2.0))': + dependencies: + '@tanstack/query-core': 5.52.2 + '@tanstack/react-query': 5.52.2(react@18.2.0) + '@motionone/animation@10.18.0': dependencies: '@motionone/easing': 10.18.0 diff --git a/src/api/auth/use-login.ts b/src/api/auth/use-login.ts new file mode 100644 index 000000000..599c917fe --- /dev/null +++ b/src/api/auth/use-login.ts @@ -0,0 +1,37 @@ +import { createMutation } from 'react-query-kit'; + +import { client } from '../common'; + +type Variables = { + email?: string; // optional because API doesn't require email + username: string; + password: string; + expiresInMins?: number; +}; + +type Response = { + id: number; + username: string; + email: string; + accessToken: string; + refreshToken: string; +}; + +const login = async (variables: Variables) => { + const { data } = await client({ + url: 'auth/login', + method: 'POST', + data: { + username: variables.username, + password: variables.password, + }, + headers: { + 'Content-Type': 'application/json', + }, + }); + return data; +}; + +export const useLogin = createMutation({ + mutationFn: (variables) => login(variables), +}); diff --git a/src/api/carts/query-keys.ts b/src/api/carts/query-keys.ts new file mode 100644 index 000000000..8b5e74025 --- /dev/null +++ b/src/api/carts/query-keys.ts @@ -0,0 +1,6 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +export const cartKeys = createQueryKeys('carts', { + list: (filters) => [filters], + detail: (id) => [id], +}); diff --git a/src/api/carts/types.ts b/src/api/carts/types.ts new file mode 100644 index 000000000..0d9066b36 --- /dev/null +++ b/src/api/carts/types.ts @@ -0,0 +1,19 @@ +export type Product = { + id: number; + title: string; + price: number; + quantity: number; + total: number; + discountPercentage: number; + discountedTotal: number; + thumbnail: string; +}; +export type Cart = { + id: number; + products: Product[]; + total: number; + discountedTotal: number; + userId: number; + totalProducts: number; + totalQuantity: number; +}; diff --git a/src/api/carts/use-carts.ts b/src/api/carts/use-carts.ts new file mode 100644 index 000000000..21d01892b --- /dev/null +++ b/src/api/carts/use-carts.ts @@ -0,0 +1,36 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { client } from '../common'; +import { cartKeys } from './query-keys'; + +type Variables = { + limit?: number | string; + offset?: number | string; +}; + +const DEFAULT_LIMIT = 10; +const DEFAULT_OFFSET = 0; + +const getCarts = async ({ + limit = DEFAULT_LIMIT, + offset = DEFAULT_OFFSET, +}: Variables) => { + const { data } = await client.get('carts', { + params: { + limit, + offset, + }, + }); + return { data, offset: Number(offset), limit: Number(limit) }; +}; + +export const useCarts = (variables: Variables) => + useInfiniteQuery({ + ...cartKeys.list(variables), + queryFn: () => getCarts(variables), + initialPageParam: { offset: DEFAULT_OFFSET, limit: DEFAULT_LIMIT }, + getNextPageParam: (lastPage) => { + const { offset, limit } = lastPage; + return { offset: offset + limit, limit }; + }, + }); diff --git a/src/api/common/interceptors.ts b/src/api/common/interceptors.ts index 05d15fe44..82fc601e0 100644 --- a/src/api/common/interceptors.ts +++ b/src/api/common/interceptors.ts @@ -1,13 +1,19 @@ import type { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { useAuth } from '@/core'; + import { client } from './client'; import { toCamelCase, toSnakeCase } from './utils'; export default function interceptors() { + const token = useAuth.getState().token; client.interceptors.request.use((config: InternalAxiosRequestConfig) => { if (config.data) { config.data = toSnakeCase(config.data); } + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } return config; }); @@ -16,6 +22,6 @@ export default function interceptors() { response.data = toCamelCase(response.data); return response; }, - (error: AxiosError) => Promise.reject(error) + (error: AxiosError) => Promise.reject(error), ); } diff --git a/src/api/posts/index.ts b/src/api/posts/index.ts index fad8551ea..9c2e9287c 100644 --- a/src/api/posts/index.ts +++ b/src/api/posts/index.ts @@ -1,4 +1,5 @@ export * from './types'; export * from './use-add-post'; export * from './use-post'; +export * from './use-post-comments'; export * from './use-posts'; diff --git a/src/api/posts/types.ts b/src/api/posts/types.ts index 460be1c35..10d0eafa1 100644 --- a/src/api/posts/types.ts +++ b/src/api/posts/types.ts @@ -4,3 +4,15 @@ export type Post = { title: string; body: string; }; + +export type Comment = { + id: number; + body: string; + postId: number; + likes: number; + user: { + id: number; + username: string; + fullName: string; + }; +}; diff --git a/src/api/posts/use-add-post.ts b/src/api/posts/use-add-post.ts index cb4c3bd9c..32bb338b6 100644 --- a/src/api/posts/use-add-post.ts +++ b/src/api/posts/use-add-post.ts @@ -13,5 +13,8 @@ export const useAddPost = createMutation({ url: 'posts/add', method: 'POST', data: variables, + headers: { + 'Content-Type': 'application/json', + }, }).then((response) => response.data), }); diff --git a/src/api/posts/use-post-comments.ts b/src/api/posts/use-post-comments.ts new file mode 100644 index 000000000..03a36c86a --- /dev/null +++ b/src/api/posts/use-post-comments.ts @@ -0,0 +1,24 @@ +import { createQuery } from 'react-query-kit'; + +import { queryFactory } from '@/api/query-factory'; + +import { client } from '../common'; +import { type Comment } from './types'; + +type Variables = { + id?: number; +}; + +type Response = { + comments: Comment[]; +}; + +const getPostComments = async (id?: number) => { + const { data } = await client.get(`posts/${id}/comments`); + return data; +}; + +export const usePostComments = createQuery({ + ...queryFactory.posts.detail(1)._ctx.comments, + fetcher: (variables) => getPostComments(variables?.id), +}); diff --git a/src/api/posts/use-post.ts b/src/api/posts/use-post.ts index 7638f765f..6e28b9bb1 100644 --- a/src/api/posts/use-post.ts +++ b/src/api/posts/use-post.ts @@ -1,15 +1,20 @@ import type { AxiosError } from 'axios'; import { createQuery } from 'react-query-kit'; +import { queryFactory } from '@/api/query-factory'; + import { client } from '../common'; import type { Post } from './types'; type Variables = { id: string }; type Response = Post; +const getPosts = async (variables: Variables) => { + const { data } = await client.get(`posts/${variables.id}`); + return data; +}; + export const usePost = createQuery({ - queryKey: ['posts'], - fetcher: (variables) => client - .get(`posts/${variables.id}`) - .then((response) => response.data), + ...queryFactory.posts.detail(1), + fetcher: getPosts, }); diff --git a/src/api/posts/use-posts.ts b/src/api/posts/use-posts.ts index a4dd79e07..9358abef5 100644 --- a/src/api/posts/use-posts.ts +++ b/src/api/posts/use-posts.ts @@ -1,6 +1,8 @@ import type { AxiosError } from 'axios'; import { createQuery } from 'react-query-kit'; +import { queryFactory } from '@/api/query-factory'; + import { client } from '../common'; import type { Post } from './types'; @@ -8,6 +10,7 @@ type Response = Post[]; type Variables = void; // as react-query-kit is strongly typed, we need to specify the type of the variables as void in case we don't need them export const usePosts = createQuery({ - queryKey: ['posts'], + // old queryKey: ['posts'], + ...queryFactory.posts.list({}), // this translates to ['posts', filters] fetcher: () => client.get(`posts`).then((response) => response.data.posts), }); diff --git a/src/api/query-factory.ts b/src/api/query-factory.ts new file mode 100644 index 000000000..969059c90 --- /dev/null +++ b/src/api/query-factory.ts @@ -0,0 +1,28 @@ +import { + createQueryKeys, + mergeQueryKeys, +} from '@lukemorales/query-key-factory'; + +import { cartKeys } from './carts/query-keys'; +type Filters = { + limit?: number; + offset?: number; +}; +export const postKeys = createQueryKeys('posts', { + list: (filters: Filters) => [filters], + detail: (id) => ({ + queryKey: [id], + contextQueries: { + comments: { + queryKey: null, + }, + }, + }), +}); + +const productsKeys = createQueryKeys('products', { + list: (filters) => [filters], + detail: (id) => [id], +}); + +export const queryFactory = mergeQueryKeys(postKeys, cartKeys, productsKeys); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 84e666aed..8f68a1999 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -8,6 +8,7 @@ import { Feed as FeedIcon, Settings as SettingsIcon, Style as StyleIcon, + Support as SupportIcon, } from '@/ui/icons'; export default function TabLayout() { @@ -17,7 +18,7 @@ export default function TabLayout() { await SplashScreen.hideAsync(); }, []); useEffect(() => { - const TIMEOUT = 1000 + const TIMEOUT = 1000; if (status !== 'idle') { setTimeout(() => { hideSplash(); @@ -42,7 +43,15 @@ export default function TabLayout() { tabBarTestID: 'feed-tab', }} /> - + , + tabBarTestID: 'carts-tab', + }} + /> ( - - - Create - - - ); + + + Create + + +); diff --git a/src/app/(app)/carts.tsx b/src/app/(app)/carts.tsx new file mode 100644 index 000000000..936dc1562 --- /dev/null +++ b/src/app/(app)/carts.tsx @@ -0,0 +1,28 @@ +import { FlashList } from '@shopify/flash-list'; +import React from 'react'; + +import { type Cart } from '@/api/carts/types'; +import { useCarts } from '@/api/carts/use-carts'; +import CartItem from '@/components/cart-item/cart-item'; +import { ActivityIndicator, FocusAwareStatusBar, Text, View } from '@/ui'; + +export default function Carts() { + const { data, isLoading } = useCarts({ limit: 10, offset: 0 }); + + return ( + + + {isLoading ? ( + + ) : ( + + data={data?.pages.flatMap((page) => page.data.carts)} + renderItem={({ item }) => } + ListEmptyComponent={No carts} + estimatedItemSize={30} + keyExtractor={(_, index) => `item-${index}`} + /> + )} + + ); +} diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 25c0591a8..fcd068292 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -8,9 +8,10 @@ import { EmptyList, FocusAwareStatusBar, Text, View } from '@/ui'; export default function Feed() { const { data, isPending, isError } = usePosts(); + const renderItem = useCallback( ({ item }: { item: Post }) => , - [] + [], ); if (isError) { diff --git a/src/app/feed/[id].tsx b/src/app/feed/[id].tsx index eaddb283c..4b634fcfb 100644 --- a/src/app/feed/[id].tsx +++ b/src/app/feed/[id].tsx @@ -1,16 +1,23 @@ import { Stack, useLocalSearchParams } from 'expo-router'; +import { FlatList } from 'react-native'; -import { usePost } from '@/api'; +import { type Comment, usePost, usePostComments } from '@/api'; import { ActivityIndicator, FocusAwareStatusBar, Text, View } from '@/ui'; export default function Post() { const local = useLocalSearchParams<{ id: string }>(); const { data, isPending, isError } = usePost({ - //@ts-ignore variables: { id: local.id }, }); + const { + data: { comments } = { comments: [] }, + isLoading: isLoadingComments, + } = usePostComments({ + variables: { id: data?.id }, + }); + if (isPending) { return ( @@ -36,6 +43,27 @@ export default function Post() { {data.title} {data.body} + {isLoadingComments ? ( + + Loading comments... + + + ) : ( + + data={comments} + renderItem={({ item: comment }) => ( + + + {comment.body} + + )} + keyExtractor={(item) => item.id.toString()} + ListEmptyComponent={No comments yet} + ListHeaderComponent={ + Comments: + } + /> + )} ); } diff --git a/src/app/feed/add-post.tsx b/src/app/feed/add-post.tsx index 6d8b2d814..3e18ec47e 100644 --- a/src/app/feed/add-post.tsx +++ b/src/app/feed/add-post.tsx @@ -1,14 +1,16 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { Stack } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; +import { Stack, useRouter } from 'expo-router'; import { useForm } from 'react-hook-form'; import { showMessage } from 'react-native-flash-message'; import { z } from 'zod'; import { useAddPost } from '@/api'; +import { queryFactory } from '@/api/query-factory'; import { Button, ControlledInput, showErrorMessage, View } from '@/ui'; -const TITLE_MIN_CHARS = 10 -const BODY_MIN_CHARS = 120 +const TITLE_MIN_CHARS = 10; +const BODY_MIN_CHARS = 120; const schema = z.object({ title: z.string().min(TITLE_MIN_CHARS), body: z.string().min(BODY_MIN_CHARS), @@ -21,6 +23,8 @@ export default function AddPost() { resolver: zodResolver(schema), }); const { mutate: addPost, isPending } = useAddPost(); + const queryClient = useQueryClient(); + const router = useRouter(); const onSubmit = (data: FormType) => { addPost( @@ -31,13 +35,13 @@ export default function AddPost() { message: 'Post added successfully', type: 'success', }); - // here you can navigate to the post list and refresh the list data - //queryClient.invalidateQueries(usePosts.getKey()); - }, - onError: () => { - showErrorMessage('Error adding post'); + queryClient.invalidateQueries({ + queryKey: queryFactory.posts.list({}).queryKey, + }); + router.back(); }, - } + onError: () => showErrorMessage('Error adding post'), + }, ); }; return ( diff --git a/src/app/login.tsx b/src/app/login.tsx index 28a51d0eb..0cfcad314 100644 --- a/src/app/login.tsx +++ b/src/app/login.tsx @@ -1,5 +1,7 @@ import { useRouter } from 'expo-router'; +import { showMessage } from 'react-native-flash-message'; +import { useLogin } from '@/api/auth/use-login'; import type { LoginFormProps } from '@/components/login-form'; import { LoginForm } from '@/components/login-form'; import { useAuth } from '@/core'; @@ -8,12 +10,16 @@ import { FocusAwareStatusBar } from '@/ui'; export default function Login() { const router = useRouter(); const signIn = useAuth.use.signIn(); + const { mutate: login } = useLogin({ + onSuccess: (data) => { + signIn({ access: data.accessToken, refresh: data.refreshToken }); + router.push('/'); + }, + onError: (error) => showMessage({ message: error.message, type: 'danger' }), + }); const onSubmit: LoginFormProps['onSubmit'] = (data) => { - // eslint-disable-next-line no-console - console.log(data); - signIn({ access: 'access-token', refresh: 'refresh-token' }); - router.push('/'); + login(data); }; return ( <> diff --git a/src/components/cart-item/cart-item.tsx b/src/components/cart-item/cart-item.tsx new file mode 100644 index 000000000..42f4496ae --- /dev/null +++ b/src/components/cart-item/cart-item.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import { type Cart, type Product } from '@/api/carts/types'; +import { Image, Text, TouchableOpacity, View } from '@/ui'; + +const DEFAULT_TO_FIXED = 2; + +const CartItem = ({ cart }: { cart: Cart }) => { + const [expanded, setExpanded] = React.useState(false); + + return ( + + setExpanded(!expanded)} + className="flex-row items-center justify-between border-b border-gray-200 p-4" + > + + Cart #{cart.id} + User ID: {cart.userId} + + + + ${cart.discountedTotal.toFixed(DEFAULT_TO_FIXED)} + + + ${cart.total.toFixed(DEFAULT_TO_FIXED)} + + + + + {/* Products List (Expandable) */} + {expanded && ( + + + {cart.totalProducts} Products + + Total Qty: {cart.totalQuantity} + + + + {cart.products.map((product) => ( + + ))} + + )} + + ); +}; + +const ProductRow = ({ product }: { product: Product }) => ( + + + + {product.title} + Qty: {product.quantity} + + + + ${product.discountedTotal.toFixed(DEFAULT_TO_FIXED)} + + + ${product.total.toFixed(DEFAULT_TO_FIXED)} + + + + -{product.discountPercentage}% + + + + +); + +export default CartItem; diff --git a/src/components/login-form.test.tsx b/src/components/login-form.test.tsx index 112c0cec7..e86cf490c 100644 --- a/src/components/login-form.test.tsx +++ b/src/components/login-form.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, screen, setup, waitFor } from '@/core/test-utils'; +import { cleanup, fireEvent, render, screen, waitFor } from '@/core/test-utils'; import type { LoginFormProps } from './login-form'; import { LoginForm } from './login-form'; @@ -10,55 +10,58 @@ const onSubmitMock: jest.Mock = jest.fn(); describe('LoginForm Form ', () => { const LOGIN_BUTTON = 'login-button'; it('renders correctly', async () => { - setup(); + render(); expect(await screen.findByText(/Sign in/i)).toBeOnTheScreen(); }); it('should display required error when values are empty', async () => { - const { user } = setup(); + render(); const button = screen.getByTestId(LOGIN_BUTTON); - expect(screen.queryByText(/Email is required/i)).not.toBeOnTheScreen(); - await user.press(button); - expect(await screen.findByText(/Email is required/i)).toBeOnTheScreen(); + expect(screen.queryByText(/Username is required/i)).not.toBeOnTheScreen(); + fireEvent.press(button); + expect(await screen.findByText(/Username is required/i)).toBeOnTheScreen(); expect(screen.getByText(/Password is required/i)).toBeOnTheScreen(); }); it('should display matching error when email is invalid', async () => { - const { user } = setup(); + render(); const button = screen.getByTestId(LOGIN_BUTTON); const emailInput = screen.getByTestId('email-input'); + const usernameInput = screen.getByTestId('username-input'); const passwordInput = screen.getByTestId('password-input'); - await user.type(emailInput, 'yyyyy'); - await user.type(passwordInput, 'test'); - await user.press(button); + fireEvent.changeText(emailInput, 'yyyy'); + fireEvent.changeText(usernameInput, ' '); + fireEvent.changeText(passwordInput, 'test'); + fireEvent.press(button); + expect(screen.queryByText(/Username is required/i)).not.toBeOnTheScreen(); expect(await screen.findByText(/Invalid Email Format/i)).toBeOnTheScreen(); - expect(screen.queryByText(/Email is required/i)).not.toBeOnTheScreen(); }); it('Should call LoginForm with correct values when values are valid', async () => { - const { user } = setup(); + render(); const button = screen.getByTestId(LOGIN_BUTTON); - const emailInput = screen.getByTestId('email-input'); + const emailInput = screen.getByTestId('username-input'); const passwordInput = screen.getByTestId('password-input'); - await user.type(emailInput, 'youssef@gmail.com'); - await user.type(passwordInput, 'password'); - await user.press(button); + fireEvent.changeText(emailInput, 'youssef'); + fireEvent.changeText(passwordInput, 'password'); + fireEvent.press(button); await waitFor(() => { expect(onSubmitMock).toHaveBeenCalledTimes(1); }); - // expect.objectContaining({}) because we don't want to test the target event we are receiving from the onSubmit function + // undefined because we don't use second argument of the SubmitHandler expect(onSubmitMock).toHaveBeenCalledWith( { - email: 'youssef@gmail.com', + email: undefined, + username: 'youssef', password: 'password', }, - expect.objectContaining({}), + undefined, ); }); }); diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index 22d151d77..66d721dd4 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -6,14 +6,12 @@ import z from 'zod'; import { Button, ControlledInput, Text, View } from '@/ui'; -const MIN_CHARS = 6 +const MIN_CHARS = 6; const schema = z.object({ - name: z.string().optional(), - email: z - .string({ - required_error: 'Email is required', - }) - .email('Invalid email format'), + username: z.string({ + required_error: 'Username is required', + }), + email: z.string().email('Invalid email format').optional(), password: z .string({ required_error: 'Password is required', @@ -43,17 +41,16 @@ export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => { -