(url, { cache: "force-cache" });
+};
diff --git a/src/app/breeds/@modal/[id]/page.tsx b/src/app/breeds/@modal/[id]/page.tsx
new file mode 100644
index 00000000..dac0c2fb
--- /dev/null
+++ b/src/app/breeds/@modal/[id]/page.tsx
@@ -0,0 +1,41 @@
+import { getImagesByBreedId } from "@/app/_api";
+import { Error, ErrorAction } from "@/components/server.error";
+import { redirect } from "next/navigation";
+import {
+ BreedDetailsModal,
+ BreedDetailsModalSkeleton,
+} from "@/app/breeds/_components/breedDetails";
+import { Suspense } from "react";
+
+async function retryAction() {
+ "use server";
+ redirect("/breeds");
+}
+
+export default async function BreedsPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return (
+ }>
+
+
+ );
+}
+
+async function BreedImagesLoader({ id }: { id: string }) {
+ const response = await getImagesByBreedId({ breed_id: id, limit: 10 });
+
+ if (response.error) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/app/breeds/@modal/default.tsx b/src/app/breeds/@modal/default.tsx
new file mode 100644
index 00000000..6ddf1b76
--- /dev/null
+++ b/src/app/breeds/@modal/default.tsx
@@ -0,0 +1,3 @@
+export default function Default() {
+ return null;
+}
diff --git a/src/app/breeds/@modal/page.tsx b/src/app/breeds/@modal/page.tsx
new file mode 100644
index 00000000..67e08591
--- /dev/null
+++ b/src/app/breeds/@modal/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return null;
+}
diff --git a/src/app/breeds/_components/breedDetails.tsx b/src/app/breeds/_components/breedDetails.tsx
new file mode 100644
index 00000000..c924acb9
--- /dev/null
+++ b/src/app/breeds/_components/breedDetails.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { Modal } from "@/components/modal";
+import { CatImage } from "@/types";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+
+export const BreedDetailsModalSkeleton = () => {
+ const router = useRouter();
+
+ const onClose = () => {
+ router.push("/breeds", { scroll: false });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
+
+export const BreedDetailsModal = ({ images }: { images: CatImage[] }) => {
+ const router = useRouter();
+
+ const onClose = () => {
+ router.push("/breeds", { scroll: false });
+ };
+
+ const breedName = images[0]?.breeds?.[0]?.name || "Breed Images";
+
+ return (
+
+
+
+
+ {breedName}
+
+
+
+
+
+
+ {images.map((image) => (
+
+
+
+ ))}
+
+
+
+
+ Showing {images.length} image{images.length !== 1 ? "s" : ""}{" "}
+ for this breed
+
+
+
+
+
+
+ );
+};
diff --git a/src/app/breeds/_components/breedsList.tsx b/src/app/breeds/_components/breedsList.tsx
new file mode 100644
index 00000000..7431e218
--- /dev/null
+++ b/src/app/breeds/_components/breedsList.tsx
@@ -0,0 +1,59 @@
+import { Card } from "@/components/card";
+import { Breed } from "@/types";
+
+const BreedCardSkeleton = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const BreedsListSkeleton = () => {
+ return (
+
+ {Array.from({ length: 30 }).map((_, index) => (
+
+ ))}
+
+ );
+};
+
+export const BreedsList = ({ breeds }: { breeds: Breed[] }) => {
+ return (
+
+ {breeds.map((breed) => {
+ return (
+
+
+
+ {breed.name}
+
+ {breed.origin && (
+
+
+ {breed.origin}
+
+
+ )}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/src/app/breeds/_constants.tsx b/src/app/breeds/_constants.tsx
new file mode 100644
index 00000000..98c00c67
--- /dev/null
+++ b/src/app/breeds/_constants.tsx
@@ -0,0 +1 @@
+export const GET_BREEDS_LIMIT = 100;
diff --git a/src/app/breeds/_page.tsx b/src/app/breeds/_page.tsx
new file mode 100644
index 00000000..a5c4a023
--- /dev/null
+++ b/src/app/breeds/_page.tsx
@@ -0,0 +1,52 @@
+import { getBreeds } from "@/app/_api";
+import { Error, ErrorAction } from "@/components/server.error";
+import { redirect } from "next/navigation";
+import { PageContainer } from "@/components/pageContainer";
+import { Suspense } from "react";
+import {
+ BreedsList,
+ BreedsListSkeleton,
+} from "@/app/breeds/_components/breedsList";
+import { GET_BREEDS_LIMIT } from "@/app/breeds/_constants";
+
+async function retryAction() {
+ "use server";
+ redirect("/breeds");
+}
+
+export default async function BreedsPage() {
+ return (
+
+ }>
+
+
+
+ );
+}
+
+async function BreedLoader() {
+ const breeds = await getBreeds({ limit: GET_BREEDS_LIMIT });
+
+ if (breeds.error) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ Found {breeds.data.length} amazing cat breeds
+
+
+ >
+ );
+}
diff --git a/src/app/breeds/default.tsx b/src/app/breeds/default.tsx
new file mode 100644
index 00000000..277a7c1e
--- /dev/null
+++ b/src/app/breeds/default.tsx
@@ -0,0 +1,3 @@
+import BreedsPage from "./_page";
+
+export default BreedsPage;
diff --git a/src/app/breeds/layout.tsx b/src/app/breeds/layout.tsx
new file mode 100644
index 00000000..3fe3c849
--- /dev/null
+++ b/src/app/breeds/layout.tsx
@@ -0,0 +1,16 @@
+const Layout = ({
+ children,
+ modal,
+}: {
+ children: React.ReactNode;
+ modal: React.ReactNode;
+}) => {
+ return (
+ <>
+ {modal}
+ {children}
+ >
+ );
+};
+
+export default Layout;
diff --git a/src/app/breeds/page.tsx b/src/app/breeds/page.tsx
new file mode 100644
index 00000000..277a7c1e
--- /dev/null
+++ b/src/app/breeds/page.tsx
@@ -0,0 +1,3 @@
+import BreedsPage from "./_page";
+
+export default BreedsPage;
diff --git a/src/app/cats-gallery/@modal/[id]/page.tsx b/src/app/cats-gallery/@modal/[id]/page.tsx
new file mode 100644
index 00000000..4084e534
--- /dev/null
+++ b/src/app/cats-gallery/@modal/[id]/page.tsx
@@ -0,0 +1,66 @@
+import { getCatById, getFavouriteImages } from "@/app/_api";
+import {
+ ImageDetailsModal,
+ ImageDetailsModalSkeleton,
+} from "@/app/cats-gallery/_components/imageDetails";
+import { Error, ErrorAction } from "@/components/server.error";
+import { redirect } from "next/navigation";
+import { Suspense } from "react";
+import { getUserId } from "@/app/user/_server.utils";
+
+async function retryAction() {
+ "use server";
+ redirect("/cats-gallery");
+}
+
+export default async function ModalPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return (
+ }>
+
+
+ );
+}
+
+async function ImageDetailsLoader({ id }: { id: string }) {
+ const userId = await getUserId();
+ if (!userId) {
+ return (
+
+
+
+ );
+ }
+
+ const imageDetailsResponse = await getCatById(id);
+ const favouritedResponse = await getFavouriteImages({
+ image_id: id,
+ sub_id: userId,
+ limit: 1,
+ });
+ if (imageDetailsResponse.error || favouritedResponse.error) {
+ return (
+
+
+
+ );
+ }
+
+ const favourite =
+ favouritedResponse.data.length > 0 ? favouritedResponse.data[0] : null;
+
+ return (
+
+ );
+}
diff --git a/src/app/cats-gallery/@modal/default.tsx b/src/app/cats-gallery/@modal/default.tsx
new file mode 100644
index 00000000..6ddf1b76
--- /dev/null
+++ b/src/app/cats-gallery/@modal/default.tsx
@@ -0,0 +1,3 @@
+export default function Default() {
+ return null;
+}
diff --git a/src/app/cats-gallery/@modal/page.tsx b/src/app/cats-gallery/@modal/page.tsx
new file mode 100644
index 00000000..67e08591
--- /dev/null
+++ b/src/app/cats-gallery/@modal/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return null;
+}
diff --git a/src/app/cats-gallery/_components/imageDetails.tsx b/src/app/cats-gallery/_components/imageDetails.tsx
new file mode 100644
index 00000000..d2a29a38
--- /dev/null
+++ b/src/app/cats-gallery/_components/imageDetails.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { Modal } from "@/components/modal";
+import { CatImage, Breed, CatFavourites } from "@/types";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { StarRating } from "@/components/starsRating";
+import Link from "next/link";
+import { Card } from "@/components/card";
+import { Favourite } from "@/app/favourites/_components/favouriteList";
+
+const BreedCard = ({ breed }: { breed: Breed }) => {
+ const physicalTraits = [
+ { key: "weight", label: "Weight", value: `${breed.weight.metric}kg` },
+ { key: "life_span", label: "Life Span", value: `${breed.life_span}y` },
+ { key: "origin", label: "Origin", value: breed.origin },
+ ];
+
+ const personalityHealth = [
+ {
+ key: "child_friendly",
+ label: "Child Friendly",
+ value: breed.child_friendly,
+ isRating: true,
+ },
+ {
+ key: "health_issues",
+ label: "Health Issues",
+ value: breed.health_issues,
+ isRating: true,
+ },
+ {
+ key: "energy_level",
+ label: "Energy Level",
+ value: breed.energy_level,
+ isRating: true,
+ },
+ {
+ key: "intelligence",
+ label: "Intelligence",
+ value: breed.intelligence,
+ isRating: true,
+ },
+ ];
+
+ return (
+
+
+
+
+
{breed.name}
+
+
+
+
+
+
+
+ Physical Traits
+
+
+ {physicalTraits.map((item) => (
+
+
+ {item.label}:
+
+
+ {item.value}
+
+
+ ))}
+
+
+
+
+
+ Personality & Health
+
+
+ {personalityHealth.map((item) => (
+
+
+ {item.label}:
+
+
+ {item.isRating ? (
+
+ ) : (
+ item.value
+ )}
+
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+export const ImageDetailsModalSkeleton = () => {
+ const router = useRouter();
+
+ const onClose = () => {
+ router.push("/cats-gallery", { scroll: false });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const ImageDetailsModal = ({
+ image,
+ favourite,
+}: {
+ image: CatImage;
+ favourite: CatFavourites | null;
+}) => {
+ const router = useRouter();
+
+ const onClose = () => {
+ router.push("/cats-gallery", { scroll: false });
+ };
+
+ return (
+
+
+
+
+
+ Cat #{image.id}
+
+
+
+
+
+
+
+
+
+ {image.breeds && image.breeds.length > 0 ? (
+
+
+ Breed Information
+
+ {image.breeds.map((breed) => (
+
+ ))}
+
+ ) : (
+
+
+ No breed information available for this cat
+
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/app/cats-gallery/_components/imagesList.tsx b/src/app/cats-gallery/_components/imagesList.tsx
new file mode 100644
index 00000000..f004bde0
--- /dev/null
+++ b/src/app/cats-gallery/_components/imagesList.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { CatImage } from "@/types";
+import { getCatsImagesList } from "@/app/_api";
+import { Button } from "@/components/button";
+import { useFetch } from "@/hooks/useFetch";
+import { useAlert } from "@/components/alert";
+import {
+ GET_CATS_IMAGES_LIST_LIMIT,
+ GET_CATS_IMAGES_MIME_TYPES,
+} from "@/app/cats-gallery/_constants";
+import { Card } from "@/components/card";
+import { useLoadMore } from "@/hooks/useLoadMore";
+
+export const ImagesList = ({ cats }: { cats: CatImage[] }) => {
+ const { items: cards, loadMore } = useLoadMore({
+ initialData: cats,
+ pageSize: GET_CATS_IMAGES_LIST_LIMIT,
+ });
+ const { showError } = useAlert();
+ const { loading, execute } = useFetch(getCatsImagesList);
+
+ const handleLoadMore = async () => {
+ const { error } = await loadMore((page) =>
+ execute({
+ page,
+ limit: GET_CATS_IMAGES_LIST_LIMIT,
+ mime_types: GET_CATS_IMAGES_MIME_TYPES,
+ })
+ );
+
+ if (error) {
+ return showError(error.message);
+ }
+ };
+
+ return (
+
+
+ {cards.map((cat, index) => (
+ /**
+ * Because the cat api return randomly cats there is a change that the same cat will be returned again
+ * for that reason we are adding the index to the key as duplicate cats will have the same id. Since this list is "static" (just adding new items,
+ * no reordering, filtering, or searching) using index in the key is fine.
+ *
+ */
+
+
+
+
+ Cat #{cat.id}
+
+
+
+ ))}
+ {loading &&
+ Array.from({ length: GET_CATS_IMAGES_LIST_LIMIT }).map((_, index) => (
+
+ ))}
+
+
+
+
+ {loading ? "Loading..." : "Load More Cats"}
+
+
+
+ );
+};
+
+const ImageItemSkeleton = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export const ImageListSkeleton = () => {
+ return (
+
+ {Array.from({ length: GET_CATS_IMAGES_LIST_LIMIT }).map((_, index) => (
+
+ ))}
+
+ );
+};
diff --git a/src/app/cats-gallery/_constants.ts b/src/app/cats-gallery/_constants.ts
new file mode 100644
index 00000000..ae12f14c
--- /dev/null
+++ b/src/app/cats-gallery/_constants.ts
@@ -0,0 +1,2 @@
+export const GET_CATS_IMAGES_LIST_LIMIT = 10;
+export const GET_CATS_IMAGES_MIME_TYPES = "jpg,png";
diff --git a/src/app/cats-gallery/_page.tsx b/src/app/cats-gallery/_page.tsx
new file mode 100644
index 00000000..e787d146
--- /dev/null
+++ b/src/app/cats-gallery/_page.tsx
@@ -0,0 +1,54 @@
+/**
+ * Reusable page component for default.tsx and page.tsx
+ * Ensures both routes render identical content with a single source of truth.
+ */
+
+import { getCatsImagesList } from "@/app/_api";
+import {
+ ImagesList,
+ ImageListSkeleton,
+} from "@/app/cats-gallery/_components/imagesList";
+import { redirect } from "next/navigation";
+
+import { GET_CATS_IMAGES_LIST_LIMIT } from "@/app/cats-gallery/_constants";
+import { Error, ErrorAction } from "@/components/server.error";
+import { PageContainer } from "@/components/pageContainer";
+import { Suspense } from "react";
+
+async function retryAction() {
+ "use server";
+ redirect("/cats-gallery");
+}
+
+export default async function CatsGalleryPage() {
+ return (
+
+ }>
+
+
+
+ );
+}
+
+async function CatsLoader() {
+ const response = await getCatsImagesList({
+ limit: GET_CATS_IMAGES_LIST_LIMIT,
+ mime_types: "jpg,png",
+ });
+
+ if (response.error) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/app/cats-gallery/default.tsx b/src/app/cats-gallery/default.tsx
new file mode 100644
index 00000000..8ef9dcdc
--- /dev/null
+++ b/src/app/cats-gallery/default.tsx
@@ -0,0 +1,3 @@
+import CatsGalleryPage from "./_page";
+
+export default CatsGalleryPage;
diff --git a/src/app/cats-gallery/layout.tsx b/src/app/cats-gallery/layout.tsx
new file mode 100644
index 00000000..3fe3c849
--- /dev/null
+++ b/src/app/cats-gallery/layout.tsx
@@ -0,0 +1,16 @@
+const Layout = ({
+ children,
+ modal,
+}: {
+ children: React.ReactNode;
+ modal: React.ReactNode;
+}) => {
+ return (
+ <>
+ {modal}
+ {children}
+ >
+ );
+};
+
+export default Layout;
diff --git a/src/app/cats-gallery/page.tsx b/src/app/cats-gallery/page.tsx
new file mode 100644
index 00000000..8ef9dcdc
--- /dev/null
+++ b/src/app/cats-gallery/page.tsx
@@ -0,0 +1,3 @@
+import CatsGalleryPage from "./_page";
+
+export default CatsGalleryPage;
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 00000000..32d8f6a0
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { Button } from "@/components/button";
+
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return (
+
+
Something went wrong!
+
+
+
+
πΏ
+
+ Error while trying to load the page
+
+
+
+
+
+ {error.message || "Unknown error!"}
+
+
+
+
+ reset()}>
+ Try again
+
+
+
+
+
+ );
+}
diff --git a/src/app/favourites/_actions.tsx b/src/app/favourites/_actions.tsx
new file mode 100644
index 00000000..ce112cca
--- /dev/null
+++ b/src/app/favourites/_actions.tsx
@@ -0,0 +1,43 @@
+"use server";
+
+import { deleteCatFromFavourites, addCatToFavourites } from "@/app/_api";
+
+export const toggleCatFavourite = async ({
+ isFavourite,
+ favouriteId,
+ imageId,
+ userId,
+}: {
+ imageId: string;
+ userId: string;
+ isFavourite: boolean;
+ favouriteId: string | undefined;
+}) => {
+ if (isFavourite && favouriteId) {
+ const res = await deleteCatFromFavourites({
+ image_id: imageId,
+ sub_id: userId,
+ favourite_id: favouriteId,
+ });
+ if (res.error) {
+ return { isFavourite, error: res.error.message, favouriteId };
+ }
+
+ return { isFavourite: false, error: null, favouriteId: undefined };
+ }
+
+ const res = await addCatToFavourites({
+ image_id: imageId,
+ sub_id: userId,
+ });
+
+ if (res.error) {
+ return {
+ isFavourite,
+ error: res.error.message,
+ favouriteId: undefined,
+ };
+ }
+
+ return { isFavourite: true, error: null, favouriteId: res.data?.id };
+};
diff --git a/src/app/favourites/_components/favouriteList.tsx b/src/app/favourites/_components/favouriteList.tsx
new file mode 100644
index 00000000..2b2e500c
--- /dev/null
+++ b/src/app/favourites/_components/favouriteList.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import { IconButton } from "@/components/iconButton";
+import { Card } from "@/components/card";
+import { CatFavourites } from "@/types";
+import { getFavouriteImages } from "@/app/_api";
+import {
+ GET_FAVOURITE_IMAGES_LIMIT,
+ GET_FAVOURITE_IMAGES_ORDER,
+} from "@/app/favourites/_constants";
+import { useFetch } from "@/hooks/useFetch";
+import { Button } from "@/components/button";
+import { NotFound } from "@/components/notFound";
+import { getOrCreateUserId } from "@/app/user/_client.utils";
+import { useAlert } from "@/components/alert";
+import { motion, AnimatePresence } from "framer-motion";
+
+import { revalidateCache } from "@/app/_api";
+import { cacheTags } from "@/app/_api/constants";
+import { useLoadMore } from "@/hooks/useLoadMore";
+import { useActionState } from "react";
+import { toggleCatFavourite } from "../_actions";
+
+export const Favourite = ({
+ imageId,
+ favourite,
+ onFavouriteRemoved,
+}: {
+ imageId: string;
+ favourite: CatFavourites | null;
+ onFavouriteRemoved?: () => void;
+}) => {
+ const { showError } = useAlert();
+ const favouriteId = favourite?.id;
+ const isFavourite = favouriteId !== undefined;
+ const userId = getOrCreateUserId();
+
+ const [state, formAction, isPending] = useActionState(
+ async (state: {
+ isFavourite: boolean;
+ favouriteId: string | undefined;
+ }) => {
+ const res = await toggleCatFavourite({
+ isFavourite: state.isFavourite,
+ favouriteId: state.favouriteId,
+ imageId,
+ userId,
+ });
+
+ /**
+ * We want always to revalidate the cache even if there is an error in the response,
+ * the reason is the data might be stale, for example we might have remove the favourite from another
+ * browser and our UI hasn't been updated yet.
+ */
+ revalidateCache(`${cacheTags.favourites}-${userId}`);
+ revalidateCache(`${cacheTags.favourites}-${userId}-${imageId}`);
+
+ if (res.error) {
+ showError(res.error);
+ return { ...state };
+ }
+
+ if (!res.isFavourite) {
+ onFavouriteRemoved?.();
+ }
+
+ return { isFavourite: res.isFavourite, favouriteId: res.favouriteId };
+ },
+
+ { isFavourite, favouriteId }
+ );
+
+ return (
+
+ );
+};
+
+export const FavouritesList = ({
+ favourites,
+}: {
+ favourites: CatFavourites[];
+}) => {
+ const { loading, execute } = useFetch(getFavouriteImages);
+ const { showError } = useAlert();
+ const userId = getOrCreateUserId();
+
+ const {
+ items: cards,
+ hasMore,
+ loadMore,
+ setItems,
+ } = useLoadMore({
+ initialData: favourites,
+ pageSize: GET_FAVOURITE_IMAGES_LIMIT,
+ });
+
+ const handleLoadMore = async () => {
+ const { error } = await loadMore((page) =>
+ execute({
+ page,
+ limit: GET_FAVOURITE_IMAGES_LIMIT,
+ sub_id: userId,
+ order: GET_FAVOURITE_IMAGES_ORDER,
+ })
+ );
+
+ if (error) {
+ return showError(error.message);
+ }
+ };
+
+ if (cards.length === 0 && !hasMore) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {cards.map((favourite) => (
+
+
+
+
+
+
+ Cat #{favourite.image_id}
+
+
+
+
+ setItems((prev) =>
+ prev.filter(
+ (item) => item.image.id !== favourite.image.id
+ )
+ )
+ }
+ imageId={favourite.image.id}
+ favourite={favourite}
+ />
+
+
+
+ ))}
+
+ {loading &&
+ Array.from({ length: GET_FAVOURITE_IMAGES_LIMIT }).map((_, index) => (
+
+ ))}
+
+
+ {hasMore && (
+
+
+ {loading ? "Loading..." : "Load More Cats"}
+
+
+ )}
+
+ );
+};
+
+const FavouriteCardSkeleton = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export const FavouritesListSkeleton = () => {
+ return (
+
+ {Array.from({ length: GET_FAVOURITE_IMAGES_LIMIT }).map((_, index) => (
+
+ ))}
+
+ );
+};
diff --git a/src/app/favourites/_constants.tsx b/src/app/favourites/_constants.tsx
new file mode 100644
index 00000000..0f690f1a
--- /dev/null
+++ b/src/app/favourites/_constants.tsx
@@ -0,0 +1,2 @@
+export const GET_FAVOURITE_IMAGES_LIMIT = 20;
+export const GET_FAVOURITE_IMAGES_ORDER = "DESC";
diff --git a/src/app/favourites/page.tsx b/src/app/favourites/page.tsx
new file mode 100644
index 00000000..301b2bd1
--- /dev/null
+++ b/src/app/favourites/page.tsx
@@ -0,0 +1,64 @@
+import { getFavouriteImages } from "@/app/_api";
+import { redirect } from "next/navigation";
+import { Error, ErrorAction } from "@/components/server.error";
+import { PageContainer } from "@/components/pageContainer";
+import {
+ FavouritesList,
+ FavouritesListSkeleton,
+} from "@/app/favourites/_components/favouriteList";
+import {
+ GET_FAVOURITE_IMAGES_LIMIT,
+ GET_FAVOURITE_IMAGES_ORDER,
+} from "@/app/favourites/_constants";
+import { getUserId } from "@/app/user/_server.utils";
+import { Suspense } from "react";
+
+async function retryAction() {
+ "use server";
+ redirect("/favourites");
+}
+
+export default async function FavouritesPage() {
+ return (
+
+ }>
+
+
+
+ );
+}
+
+async function FavouritesLoader() {
+ const userId = await getUserId();
+ if (!userId) {
+ return (
+
+
+
+ );
+ }
+
+ const response = await getFavouriteImages({
+ limit: GET_FAVOURITE_IMAGES_LIMIT,
+ sub_id: userId,
+ order: GET_FAVOURITE_IMAGES_ORDER,
+ });
+
+ if (response.error) {
+ return (
+
+
+
+ );
+ }
+ return ;
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 00000000..f0a495f7
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,130 @@
+@import "tailwindcss";
+
+@layer utilities {
+ .scrollbar-hide {
+ -webkit-scrollbar: none;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+}
+
+@theme {
+ --color-neo-black: rgb(0, 0, 0);
+ --color-neo-white: rgb(255, 255, 255);
+ --color-neo-red: rgb(255, 82, 82);
+ --color-neo-blue: rgb(82, 82, 255);
+ --color-neo-yellow: rgb(255, 255, 82);
+ --color-neo-green: rgb(82, 255, 82);
+ --color-neo-pink: rgb(255, 166, 246);
+ --color-neo-cyan: rgb(166, 255, 246);
+ --color-neo-orange: rgb(255, 166, 82);
+ --color-neo-purple: rgb(165, 180, 251);
+
+ --color-neo-gray-100: rgb(245, 245, 245);
+ --color-neo-gray-200: rgb(229, 229, 229);
+ --color-neo-gray-300: rgb(212, 212, 212);
+ --color-neo-gray-400: rgb(163, 163, 163);
+ --color-neo-gray-500: rgb(115, 115, 115);
+ --color-neo-gray-600: rgb(82, 82, 82);
+ --color-neo-gray-700: rgb(64, 64, 64);
+ --color-neo-gray-800: rgb(38, 38, 38);
+ --color-neo-gray-900: rgb(23, 23, 23);
+
+ --font-family-neo: "Courier New", monospace;
+ --font-family-neo-bold: "Impact", "Arial Black", sans-serif;
+
+ --shadow-neo: 4px 4px 0px 0px rgb(0, 0, 0);
+ --shadow-neo-lg: 8px 8px 0px 0px rgb(0, 0, 0);
+ --shadow-neo-xl: 12px 12px 0px 0px rgb(0, 0, 0);
+ --shadow-neo-inner: inset 2px 2px 0px 0px rgb(0, 0, 0);
+ --shadow-neo-red: 4px 4px 0px 0px var(--color-neo-red);
+ --shadow-neo-green: 4px 4px 0px 0px var(--color-neo-green);
+
+ --border-width-neo: 3px;
+ --border-radius-neo: 0px;
+
+ --spacing-neo: 4px;
+ --spacing-neo-lg: 8px;
+ --spacing-neo-xl: 12px;
+}
+
+* {
+ font-family: var(--font-family-neo);
+}
+
+.bg-neo-yellow,
+.bg-neo-green,
+.bg-neo-pink {
+ color: rgb(0, 0, 0);
+}
+
+.bg-neo-red,
+.bg-neo-blue,
+.bg-neo-purple,
+.bg-neo-cyan,
+.bg-neo-orange {
+ color: rgb(255, 255, 255);
+}
+
+.btn-neo {
+ @apply border-neo border-neo-black shadow-neo;
+ @apply px-neo-lg py-neo font-bold uppercase;
+ @apply transition-all duration-200 ease-out;
+ @apply hover:translate-x-1 hover:translate-y-1 hover:shadow-none cursor-pointer;
+ @apply active:translate-x-0 active:translate-y-0 active:shadow-neo-inner;
+ @apply disabled:translate-x-0 disabled:translate-y-0 disabled:shadow-neo disabled:cursor-not-allowed;
+ @apply disabled:hover:translate-x-0 disabled:hover:translate-y-0 disabled:hover:shadow-neo;
+ @apply disabled:active:translate-x-0 disabled:active:translate-y-0 disabled:active:shadow-neo;
+}
+
+.btn-neo-secondary {
+ @apply bg-neo-purple;
+}
+
+.btn-neo-primary {
+ @apply bg-neo-pink;
+}
+
+.btn-neo-danger {
+ @apply bg-neo-red;
+}
+
+.btn-neo-success {
+ @apply bg-neo-green;
+}
+
+.card-neo {
+ @apply bg-neo-white border-neo border-neo-black shadow-neo;
+ @apply p-neo-lg;
+}
+
+.text-neo-title {
+ @apply text-4xl uppercase tracking-wider font-bold;
+ font-family: var(--font-family-neo-bold);
+}
+
+.text-neo-heading {
+ @apply text-2xl uppercase tracking-wide font-bold;
+ font-family: var(--font-family-neo-bold);
+}
+
+.container-neo {
+ @apply max-w-6xl mx-auto px-neo-lg;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.animate-spin {
+ animation: spin 1s linear infinite;
+}
+.animate-spin path {
+ animation: spin 1s linear infinite;
+ transform-origin: center;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 00000000..9f34e690
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,69 @@
+import "./globals.css";
+import { Tabs } from "@/components/tabs";
+import { AlertProvider } from "@/components/alert";
+import { Metadata } from "next";
+
+const tabItems = [
+ {
+ label: "Cats Gallery",
+ href: "/cats-gallery",
+ },
+ {
+ label: "Cat Breeds",
+ href: "/breeds",
+ },
+ {
+ label: "Favourites",
+ href: "/favourites",
+ },
+];
+
+export const metadata: Metadata = {
+ title: "React Cat Challenge",
+ description:
+ "Neo Brutal design Cat App - Discover amazing cats from all over the internet",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+
+
+ React Cat Challenge
+
+
+ Neo Brutal design Cat App
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Created by Alexandros Ntitoras
+
+
+
+
+
+ );
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 00000000..d9e1ae68
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,23 @@
+import { Button } from "@/components/button";
+import Link from "next/link";
+
+export default function NotFound() {
+ return (
+
+
+
+
πΏ
+
+ Page not found.
+
+
+
+
+
+ Return to cats gallery
+
+
+
+
+ );
+}
diff --git a/src/app/user/_client.utils.ts b/src/app/user/_client.utils.ts
new file mode 100644
index 00000000..8f17d85b
--- /dev/null
+++ b/src/app/user/_client.utils.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { COOKIE_MAX_AGE, COOKIE_USER_ID_KEY } from "@/app/user/_constants";
+import { v4 as uuidv4 } from "uuid";
+
+const getCookie = (name: string): string | null => {
+ if (typeof document === "undefined") return null;
+
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(";").shift() || null;
+ return null;
+};
+
+const createCookie = (name: string, value: string): string => {
+ if (typeof document === "undefined") return value;
+
+ document.cookie = `${name}=${value}; max-age=${COOKIE_MAX_AGE}; path=/; SameSite=lax`;
+ return value;
+};
+
+export const getOrCreateUserId = (): string => {
+ const userId = getCookie(COOKIE_USER_ID_KEY);
+
+ if (userId) {
+ return userId;
+ }
+
+ return createCookie(COOKIE_USER_ID_KEY, uuidv4());
+};
diff --git a/src/app/user/_constants.ts b/src/app/user/_constants.ts
new file mode 100644
index 00000000..da3db34d
--- /dev/null
+++ b/src/app/user/_constants.ts
@@ -0,0 +1,2 @@
+export const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
+export const COOKIE_USER_ID_KEY = "gwi-cats-__user";
diff --git a/src/app/user/_server.utils.ts b/src/app/user/_server.utils.ts
new file mode 100644
index 00000000..6e63c64c
--- /dev/null
+++ b/src/app/user/_server.utils.ts
@@ -0,0 +1,13 @@
+"server-only";
+
+import { COOKIE_USER_ID_KEY } from "@/app/user/_constants";
+import { cookies } from "next/headers";
+
+const getCookie = async (name: string): Promise => {
+ const cookieStore = await cookies();
+ return cookieStore.get(name)?.value || null;
+};
+
+export const getUserId = async () => {
+ return await getCookie(COOKIE_USER_ID_KEY);
+};
diff --git a/src/components/alert.tsx b/src/components/alert.tsx
new file mode 100644
index 00000000..c1be19ee
--- /dev/null
+++ b/src/components/alert.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import React, { useEffect, useState, createContext, useContext } from "react";
+import { createPortal } from "react-dom";
+import { Button } from "./button";
+import { v4 as uuidv4 } from "uuid";
+
+type AlertProps = {
+ type: "error" | "success";
+ title?: string;
+ message: string;
+ autoClose?: boolean;
+ autoCloseDelay?: number;
+};
+
+export const Alert: React.FC = ({
+ type,
+ title,
+ message,
+ autoClose = true,
+ autoCloseDelay = 5000,
+}) => {
+ const [isVisible, setIsVisible] = useState(true);
+
+ useEffect(() => {
+ if (!autoClose) return;
+
+ const timeoutId = setTimeout(() => {
+ setIsVisible(false);
+ }, autoCloseDelay);
+
+ return () => clearTimeout(timeoutId);
+ }, [autoClose, autoCloseDelay]);
+
+ const alertStyles = {
+ error:
+ "bg-neo-white border-neo border-neo-red shadow-neo-red p-neo-lg text-neo-black",
+ success:
+ "bg-neo-white border-neo border-neo-green shadow-neo-green p-neo-lg text-neo-black",
+ };
+
+ if (!isVisible) return null;
+
+ const alertContent = (
+
+
+
+
{
+ setIsVisible(false);
+ }}
+ aria-label="Close alert"
+ >
+ β
+
+
+ {title && (
+
+ {title}
+
+ )}
+
{message}
+
+
+
+
+ );
+
+ return createPortal(alertContent, document.body);
+};
+
+type AlertContextType = {
+ showSuccess: (message: string) => void;
+ showError: (message: string) => void;
+ alerts: AlertProps[];
+};
+
+type AlertItem = {
+ id: string;
+} & Pick;
+
+const AlertContext = createContext(undefined);
+
+export const useAlert = () => {
+ const context = useContext(AlertContext);
+ if (!context) {
+ throw new Error("useAlert must be used within an AlertProvider");
+ }
+ return context;
+};
+
+export const AlertProvider = ({ children }: { children: React.ReactNode }) => {
+ const [alerts, setAlerts] = useState([]);
+
+ const showSuccess = (message: string) => {
+ const id = uuidv4();
+ setAlerts((prev) => [...prev, { id, type: "success", message }]);
+ setTimeout(() => {
+ setAlerts((prev) => prev.filter((alert) => alert.id !== id));
+ }, 5000);
+ };
+
+ const showError = (message: string) => {
+ const id = uuidv4();
+ setAlerts((prev) => [...prev, { id, type: "error", message }]);
+ setTimeout(() => {
+ setAlerts((prev) => prev.filter((alert) => alert.id !== id));
+ }, 5000);
+ };
+
+ return (
+
+ {children}
+ {alerts.map((alert) => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/button.tsx b/src/components/button.tsx
new file mode 100644
index 00000000..45f20525
--- /dev/null
+++ b/src/components/button.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+
+export type ButtonProps = React.ButtonHTMLAttributes & {
+ variant?: "primary" | "secondary" | "danger" | "success" | "ghost";
+ children: React.ReactNode;
+};
+
+export const Button: React.FC = ({
+ variant = "primary",
+ children,
+ className = "",
+ ...props
+}) => {
+ const variantClasses = {
+ primary: "btn-neo btn-neo-primary",
+ secondary: "btn-neo btn-neo-secondary",
+ danger: "btn-neo btn-neo-danger",
+ success: "btn-neo btn-neo-success",
+ ghost: "btn-neo",
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/card.tsx b/src/components/card.tsx
new file mode 100644
index 00000000..140c75fa
--- /dev/null
+++ b/src/components/card.tsx
@@ -0,0 +1,120 @@
+import React from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { UrlObject } from "url";
+
+type CardRootProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+const CardRoot: React.FC = ({ children, className = "" }) => {
+ const baseClasses = "card-neo transition-all duration-300 ease-out";
+
+ const combinedClasses = `${baseClasses} ${className}`.trim();
+
+ return {children}
;
+};
+
+type CardLinkProps = {
+ children: React.ReactNode;
+ href: UrlObject;
+ className?: string;
+ scroll?: boolean;
+};
+
+const CardLink: React.FC = ({
+ children,
+ href,
+ className = "",
+ scroll = false,
+ ...props
+}) => {
+ const baseClasses =
+ "card-neo hover:shadow-neo-lg hover:scale-105 transition-all duration-300 ease-out cursor-pointer";
+ const combinedClasses = `${baseClasses} ${className}`.trim();
+
+ return (
+
+ {children}
+
+ );
+};
+
+type CardContentProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+const CardContent = ({ children, className = "" }: CardContentProps) => {
+ return {children}
;
+};
+
+type CardTitleProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+const CardTitle = ({ children, className = "" }: CardTitleProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+type CardBodyProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+const CardBody = ({ children, className = "" }: CardBodyProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+type CardImageProps = {
+ src: string;
+ alt: string;
+ className?: string;
+ fill?: boolean;
+ sizes?: string;
+ quality?: number;
+};
+
+const CardImage = ({
+ src,
+ alt,
+ className = "",
+ fill = false,
+ sizes,
+ quality = 75,
+}: CardImageProps) => {
+ return (
+
+
+
+ );
+};
+
+export const Card = {
+ Root: CardRoot,
+ RootLink: CardLink,
+ Content: CardContent,
+ Title: CardTitle,
+ Body: CardBody,
+ Image: CardImage,
+};
diff --git a/src/components/icon.tsx b/src/components/icon.tsx
new file mode 100644
index 00000000..d8d38d06
--- /dev/null
+++ b/src/components/icon.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+
+export type IconProps = {
+ name: string;
+ className?: string;
+ size?: "small" | "medium";
+};
+
+const viewBox = {
+ small: "0 0 16 16",
+ medium: "0 0 20 20",
+};
+
+const _size = {
+ small: 16,
+ medium: 20,
+};
+
+export const Icon: React.FC = ({
+ name,
+ className = "",
+ size = "medium",
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/components/iconButton.tsx b/src/components/iconButton.tsx
new file mode 100644
index 00000000..f684ce03
--- /dev/null
+++ b/src/components/iconButton.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+import { Icon, IconProps } from "./icon";
+import { Button, ButtonProps } from "./button";
+
+type IconButtonProps = Omit & {
+ icon: string;
+ size?: IconProps["size"];
+ color?: "neo-pink" | "neo-red" | "neo-gray-400";
+ loading?: boolean;
+};
+
+export const IconButton: React.FC = ({
+ icon,
+ onClick,
+ className = "",
+ size = "medium",
+ disabled = false,
+ "aria-label": ariaLabel,
+ variant = "ghost",
+ loading = false,
+ color,
+ ...props
+}) => {
+ const colorClass = color ? `text-${color}` : "";
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/modal.tsx b/src/components/modal.tsx
new file mode 100644
index 00000000..049b7235
--- /dev/null
+++ b/src/components/modal.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { IconButton } from "./iconButton";
+
+type ModalRootProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ children: React.ReactNode;
+};
+
+const ModalRoot: React.FC = ({ isOpen, onClose, children }) => {
+ /**
+ * we need to track if the modal is mounted to make sure that we are at the cient side,
+ * other wise we are getting an error that document is not defined
+ */
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+
+ if (!isOpen) return;
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("keydown", handleEscape);
+ document.body.style.overflow = "hidden";
+
+ return () => {
+ document.removeEventListener("keydown", handleEscape);
+ document.body.style.overflow = "unset";
+ };
+ }, [isOpen, onClose]);
+
+ if (!isOpen || !mounted) return null;
+
+ const modalContent = (
+
+ );
+
+ return createPortal(modalContent, document.body);
+};
+
+type ModalContentProps = {
+ children: React.ReactNode;
+};
+
+const ModalContent = ({ children }: ModalContentProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+type ModalHeaderProps = {
+ children: React.ReactNode;
+};
+
+const ModalHeader = ({ children }: ModalHeaderProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+type ModalTitleProps = {
+ children: React.ReactNode;
+};
+
+const ModalTitle = ({ children }: ModalTitleProps) => {
+ return {children} ;
+};
+
+type ModalBodyProps = {
+ children: React.ReactNode;
+};
+
+const ModalBody = ({ children }: ModalBodyProps) => {
+ return {children}
;
+};
+
+type ModalCloseProps = {
+ onClick: () => void;
+};
+
+const ModalClose = ({ onClick }: ModalCloseProps) => {
+ return (
+
+ );
+};
+
+export const Modal = {
+ Root: ModalRoot,
+ Content: ModalContent,
+ Header: ModalHeader,
+ Title: ModalTitle,
+ Body: ModalBody,
+ Close: ModalClose,
+};
diff --git a/src/components/notFound.tsx b/src/components/notFound.tsx
new file mode 100644
index 00000000..c1fb7165
--- /dev/null
+++ b/src/components/notFound.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link";
+import { Button } from "@/components/button";
+
+export const NotFound = ({
+ message,
+ title,
+}: {
+ message: string;
+ title: string;
+}) => {
+ return (
+
+
+
π±
+
{title}
+
{message}
+
+
+
Browse Cats
+
+
+ );
+};
diff --git a/src/components/pageContainer.tsx b/src/components/pageContainer.tsx
new file mode 100644
index 00000000..15fc56aa
--- /dev/null
+++ b/src/components/pageContainer.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+
+type PageContainerProps = {
+ title: string;
+ description?: string;
+ children: React.ReactNode;
+};
+
+const PageContainer: React.FC = ({
+ title,
+ description,
+ children,
+}) => {
+ return (
+
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ {children}
+
+ );
+};
+
+export { PageContainer };
diff --git a/src/components/server.error.tsx b/src/components/server.error.tsx
new file mode 100644
index 00000000..90064bf7
--- /dev/null
+++ b/src/components/server.error.tsx
@@ -0,0 +1,50 @@
+"server-only";
+
+import { Button } from "@/components/button";
+
+type ErrorProps = {
+ title?: string;
+ message?: string;
+ children?: React.ReactNode;
+};
+
+export const ErrorAction = ({
+ action,
+ label,
+ variant = "primary",
+}: {
+ action: () => void;
+ label: string;
+ variant?: "primary" | "secondary";
+}) => {
+ return (
+
+ );
+};
+
+export const Error = ({ title, message, children }: ErrorProps) => {
+ return (
+
+
+
+
+
+
+ {children &&
{children}
}
+
+
+ );
+};
diff --git a/src/components/starsRating.tsx b/src/components/starsRating.tsx
new file mode 100644
index 00000000..90fac48d
--- /dev/null
+++ b/src/components/starsRating.tsx
@@ -0,0 +1,25 @@
+export const StarRating = ({
+ value,
+ max = 5,
+}: {
+ value: number;
+ max?: number;
+}) => {
+ const stars = Array.from({ length: max }, (_, i) => (
+
+ β
+
+ ));
+
+ return (
+
+
+ {value}/{max}
+
+
{stars}
+
+ );
+};
diff --git a/src/components/tabs.tsx b/src/components/tabs.tsx
new file mode 100644
index 00000000..c52036e5
--- /dev/null
+++ b/src/components/tabs.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+type TabItem = {
+ label: string;
+ href: string;
+ icon?: string;
+};
+
+type TabsProps = {
+ items: TabItem[];
+ className?: string;
+};
+
+const Tabs: React.FC = ({ items, className = "" }) => {
+ const pathname = usePathname();
+
+ return (
+
+ {items.map((item) => {
+ const isActive =
+ pathname === item.href || pathname.startsWith(item.href + "/");
+
+ return (
+
+ {item.label}
+
+ );
+ })}
+
+ );
+};
+
+export { Tabs };
diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts
new file mode 100644
index 00000000..2fce074e
--- /dev/null
+++ b/src/hooks/useFetch.ts
@@ -0,0 +1,59 @@
+import { useEffect, useRef, useState, useCallback } from "react";
+import { ApiSuccessOrError } from "@/types";
+
+type UseFetchOptions = {
+ fetchOnMount?: boolean;
+};
+
+export function useFetch(
+ fetchFn: (args: Args) => Promise>,
+ options: UseFetchOptions = {}
+) {
+ const { fetchOnMount } = options;
+
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ /**
+ * We need to check if the component is mounted to avoid setting state on an unmounted component
+ */
+ const isMounted = useRef(true);
+
+ const execute = useCallback(
+ async (args?: Args) => {
+ setLoading(true);
+ setError(null);
+
+ const result = await fetchFn(args as Args);
+
+ if (!isMounted.current) return result;
+
+ setLoading(false);
+
+ if (result.error) {
+ setError(result.error.message);
+ setData(null);
+ return result;
+ }
+
+ setData(result.data);
+
+ return result;
+ },
+ [fetchFn]
+ );
+
+ useEffect(() => {
+ isMounted.current = true;
+
+ if (fetchOnMount) {
+ execute();
+ }
+
+ return () => {
+ isMounted.current = false;
+ };
+ }, [execute, fetchOnMount]);
+
+ return { data, loading, error, execute };
+}
diff --git a/src/hooks/useLoadMore.ts b/src/hooks/useLoadMore.ts
new file mode 100644
index 00000000..14ae4c57
--- /dev/null
+++ b/src/hooks/useLoadMore.ts
@@ -0,0 +1,69 @@
+import { ApiSuccessOrError } from "@/types";
+import { useState } from "react";
+
+export function useLoadMore({
+ initialData,
+ initialPage = 0,
+ pageSize,
+}: {
+ initialData: T[];
+ initialPage?: number;
+ pageSize: number;
+}) {
+ const [items, setItems] = useState(initialData);
+
+ const [currentPage, setCurrentPage] = useState(initialPage);
+ const [hasMore, setHasMore] = useState(initialData.length >= pageSize);
+
+ const loadMore = async (
+ fetchPage: (page: number) => Promise>
+ ) => {
+ const nextPage = currentPage + 1;
+
+ const { data, error } = await fetchPage(nextPage);
+
+ if (error) {
+ return { data: null, error, newData: null, page: nextPage };
+ }
+
+ const existingIds = new Set(items.map((item) => item.id));
+ const uniqueData = data.filter((item) => !existingIds.has(item.id));
+
+ /**
+ * If uniqueData is empty it means that we have reach the end of the available data.
+ * Since the pagination api does not provide total number of items, this is the way to know
+ * that we have reached the end of the pagination.
+ */
+ if (uniqueData.length === 0) {
+ setHasMore(false);
+ return {
+ data: items,
+ error: null,
+ newData: [],
+ page: nextPage,
+ };
+ }
+
+ setCurrentPage(nextPage);
+ if (uniqueData.length < pageSize) {
+ setHasMore(false);
+ }
+
+ const updatedItems = [...items, ...uniqueData];
+ setItems(updatedItems);
+
+ return {
+ data: updatedItems,
+ error: null,
+ newData: uniqueData,
+ page: nextPage,
+ };
+ };
+
+ return {
+ items,
+ hasMore,
+ loadMore,
+ setItems,
+ };
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 00000000..9539cb1d
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { v4 as uuidv4 } from "uuid";
+import { COOKIE_USER_ID_KEY, COOKIE_MAX_AGE } from "@/app/user/_constants";
+
+export function middleware(request: NextRequest) {
+ const response = NextResponse.next();
+
+ if (!request.cookies.get(COOKIE_USER_ID_KEY)) {
+ response.cookies.set(COOKIE_USER_ID_KEY, uuidv4(), {
+ sameSite: "lax",
+ path: "/",
+ httpOnly: false,
+ maxAge: COOKIE_MAX_AGE,
+ });
+ }
+
+ return response;
+}
+
+export const config = {
+ matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
+};
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 00000000..e5a6292e
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,54 @@
+export type Breed = {
+ id: number;
+ name: string;
+ life_span: string;
+ energy_level: number;
+ intelligence: number;
+ origin: string;
+ health_issues: number;
+ child_friendly: number;
+ weight: {
+ imperial: string;
+ metric: string;
+ };
+};
+
+export type Category = {
+ id: number;
+ name: string;
+};
+
+export type CatImage = {
+ id: string;
+ url: string;
+ width?: number;
+ height?: number;
+ mime_type?: string;
+ breeds?: Breed[];
+ categories?: Category[];
+};
+
+export type CatFavourites = {
+ id: string;
+ image_id: string;
+ sub_id: string;
+ created_at: string;
+ image: CatImage;
+ user_id: string;
+};
+
+export type ApiResponse = {
+ data: T;
+ status: number;
+ ok: boolean;
+ error: null;
+};
+
+export type ApiErrorResponse = {
+ data: null;
+ status: number;
+ ok: boolean;
+ error: { message: string; stack?: string };
+};
+
+export type ApiSuccessOrError = ApiResponse | ApiErrorResponse;
diff --git a/src/utils/http.ts b/src/utils/http.ts
new file mode 100644
index 00000000..4909ccee
--- /dev/null
+++ b/src/utils/http.ts
@@ -0,0 +1,118 @@
+import { ApiSuccessOrError } from "@/types";
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
+const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION;
+const API_KEY = process.env.NEXT_PUBLIC_API_KEY;
+
+type CacheOptions = "force-cache" | "no-store";
+
+type NextOptions = { tags?: string[]; revalidate?: number };
+
+type RequestConfig = {
+ method?: string;
+ headers?: Record;
+ body?: string;
+ cache?: CacheOptions;
+ next?: NextOptions;
+};
+
+export function buildUrl(
+ endpoint: string,
+ params?: Record
+): string {
+ const fullUrl = `${API_BASE_URL}/${API_VERSION}${endpoint}`;
+ const url = new URL(fullUrl);
+
+ if (params) {
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ url.searchParams.append(key, String(value));
+ }
+ });
+ }
+
+ return url.toString();
+}
+
+async function apiRequest(
+ url: string,
+ requestConfig?: RequestConfig
+): Promise> {
+ const { cache, ...fetchConfig } = requestConfig || {};
+
+ const config = {
+ headers: {
+ "Content-Type": "application/json",
+ ...(API_KEY && {
+ "x-api-key": API_KEY,
+ }),
+ },
+ ...fetchConfig,
+
+ ...(typeof cache === "string" && { cache }),
+ } as const;
+
+ try {
+ const response = await fetch(url, config);
+ const data = await response.json();
+
+ if (!response.ok) {
+ return {
+ data: null,
+ status: response.status,
+ ok: response.ok,
+ error: {
+ message: data.message || "Request failed",
+ },
+ };
+ }
+
+ return {
+ data,
+ status: response.status,
+ ok: response.ok,
+ error: null,
+ };
+ } catch (error) {
+ return {
+ data: null,
+ status: 500,
+ ok: false,
+ error: {
+ message: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ },
+ };
+ }
+}
+
+export const http = Object.freeze({
+ async get(
+ url: string,
+ { cache, next }: { cache?: CacheOptions; next?: NextOptions } = {}
+ ): Promise> {
+ return apiRequest(url, { cache, next });
+ },
+ async post(
+ url: string,
+ data: D,
+ { cache, next }: { cache?: CacheOptions; next?: NextOptions } = {}
+ ): Promise> {
+ return apiRequest(url, {
+ method: "POST",
+ body: JSON.stringify(data),
+ cache,
+ next,
+ });
+ },
+ async delete(
+ url: string,
+ { cache, next }: { cache?: CacheOptions; next?: NextOptions } = {}
+ ): Promise> {
+ return apiRequest(url, {
+ method: "DELETE",
+ cache,
+ next,
+ });
+ },
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..c1334095
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}