diff --git a/package-lock.json b/package-lock.json
index 934540f..c60b2d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,10 +13,12 @@
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"classnames": "2.3.2",
+ "date-fns": "^4.1.0",
"json-server": "0.17.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.45.1",
+ "swr": "^2.3.6",
"ts-node": "^10.9.1",
"typescript": "5.1.6"
},
@@ -2454,6 +2456,23 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
+ "node_modules/concurrently/node_modules/date-fns": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0"
+ },
+ "engines": {
+ "node": ">=0.11"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/date-fns"
+ }
+ },
"node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -2637,19 +2656,13 @@
}
},
"node_modules/date-fns": {
- "version": "2.30.0",
- "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
- "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
- "dev": true,
- "dependencies": {
- "@babel/runtime": "^7.21.0"
- },
- "engines": {
- "node": ">=0.11"
- },
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/date-fns"
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-time": {
@@ -2763,6 +2776,14 @@
"node": ">= 0.8"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -6549,6 +6570,18 @@
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
"dev": true
},
+ "node_modules/swr": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
+ "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
+ "dependencies": {
+ "dequal": "^2.0.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -6877,6 +6910,14 @@
"requires-port": "^1.0.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
diff --git a/package.json b/package.json
index c3493bc..8afa1c5 100644
--- a/package.json
+++ b/package.json
@@ -16,10 +16,12 @@
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"classnames": "2.3.2",
+ "date-fns": "^4.1.0",
"json-server": "0.17.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.45.1",
+ "swr": "^2.3.6",
"ts-node": "^10.9.1",
"typescript": "5.1.6"
},
diff --git a/src/Aplications.module.css b/src/Aplications.module.css
deleted file mode 100644
index e69de29..0000000
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 4db6ab8..f97c93c 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,8 +1,14 @@
-import { render, screen } from '@testing-library/react';
-import App from './App';
+import { render, screen } from "@testing-library/react";
+import App from "./App";
test('renders "Application Portal" title', () => {
render();
const linkElement = screen.getByText(/Application portal/i);
expect(linkElement).toBeInTheDocument();
});
+
+test('renders "Load more" button', () => {
+ render();
+ const loadMoreButton = screen.getByRole("button", { name: /loading\.\.\./i });
+ expect(loadMoreButton).toBeInTheDocument();
+});
diff --git a/src/App.tsx b/src/App.tsx
index de815c2..2bb3386 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,12 +1,30 @@
import "./App.css";
import Applications from "./Applications";
-import Header from "./Header";
+import Header from "./components/Header";
+import Footer from "./components/Footer";
+import { useMemo, useState } from "react";
+import { useApplications } from "./network/applications";
+
+const PAGE_SIZE = 5;
function App() {
+ const [currentPage, setCurrentPage] = useState(0);
+ const { data, isLoading, isValidating, size, setSize } = useApplications(
+ currentPage,
+ PAGE_SIZE
+ );
+ const flatData = useMemo(() => data?.flatMap((page) => page), [data]);
return (
-
+
+
);
}
diff --git a/src/Applications.test.tsx b/src/Applications.test.tsx
new file mode 100644
index 0000000..2210e8c
--- /dev/null
+++ b/src/Applications.test.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from "@testing-library/react";
+import Applications from "./Applications";
+import { getSingleApplicationFixture } from "./__fixtures__/applications.fixture";
+
+test("renders loading state when isLoading is true", () => {
+ render();
+ const loadingElement = screen.getByText(/loading/i);
+ expect(loadingElement).toBeInTheDocument();
+});
+
+test("renders application items when data is provided", () => {
+ const mockData = getSingleApplicationFixture;
+ render();
+
+ screen.logTestingPlaygroundURL();
+ const company = screen.getByText(/company/i);
+ const companyName = screen.getByText("Qnekt");
+
+ expect(companyName).toBeInTheDocument();
+ expect(company).toBeInTheDocument();
+
+ const user = screen.getByText(/name/i);
+ const userName = screen.getByText("Miles Espinoza");
+
+ expect(user).toBeInTheDocument();
+ expect(userName).toBeInTheDocument();
+
+ const email = screen.getByText(/email/i);
+ const emailName = screen.getByText(/milesespinoza@qnekt\.com/i);
+
+ expect(email).toBeInTheDocument();
+ expect(emailName).toBeInTheDocument();
+
+ const loan = screen.getByText(/loan amount/i);
+ const loanName = screen.getByText(/£37,597/i);
+
+ expect(loan).toBeInTheDocument();
+ expect(loanName).toBeInTheDocument();
+
+ const applicationDate = screen.getByText(/application date/i);
+ const applicationDateValue = screen.getByText(/10-08-2021/i);
+
+ expect(applicationDate).toBeInTheDocument();
+ expect(applicationDateValue).toBeInTheDocument();
+
+ const expiryDate = screen.getByText(/expiry date/i);
+ const expiryDateValue = screen.getByText(/02-12-2021/i);
+
+ expect(expiryDate).toBeInTheDocument();
+ expect(expiryDateValue).toBeInTheDocument();
+});
diff --git a/src/Applications.tsx b/src/Applications.tsx
index 8c9f646..c5027c8 100644
--- a/src/Applications.tsx
+++ b/src/Applications.tsx
@@ -1,14 +1,25 @@
-import React from "react";
+import React, { useMemo } from "react";
import SingleApplication from "./SingleApplication";
-import { getSingleApplicationFixture } from "./__fixtures__/applications.fixture";
import styles from "./Applications.module.css";
+import { Loading } from "./components/Loading/Loading";
+import { ApplicationDTO } from "./model/Applications";
-const Applications = () => {
- const applications = getSingleApplicationFixture;
-
+const Applications = ({
+ data,
+ isLoading,
+}: {
+ data: ApplicationDTO[];
+ isLoading: boolean;
+}) => {
return (
-
+ {isLoading ? (
+
+ ) : (
+ data?.map((application) => (
+
+ ))
+ )}
);
};
diff --git a/src/SingleApplication.module.css b/src/SingleApplication.module.css
index 5e8ceb3..d673de2 100644
--- a/src/SingleApplication.module.css
+++ b/src/SingleApplication.module.css
@@ -11,7 +11,7 @@
.cell {
text-align: left;
- border-right: 1px solid #eee;
+ font-weight: 600;
padding: 0 15px;
}
@@ -21,3 +21,7 @@
color: #999;
margin-bottom: 5px;
}
+
+.highlight {
+ color: #143b6b;
+}
diff --git a/src/SingleApplication.tsx b/src/SingleApplication.tsx
index e664745..28f01d7 100644
--- a/src/SingleApplication.tsx
+++ b/src/SingleApplication.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { format } from "date-fns";
import styles from "./SingleApplication.module.css";
const SingleApplication = ({ application }) => {
@@ -14,19 +15,24 @@ const SingleApplication = ({ application }) => {
Email
- {application.email}
+ {application.email}
Loan Amount
- {application.loan_amount}
+ {(application.loan_amount || 0).toLocaleString("en-GB", {
+ style: "currency",
+ currency: "GBP",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ })}
Application Date
- {application.date_created}
+ {format(new Date(application.date_created), "dd-MM-yyyy")}
Expiry date
- {application.expiry_date}
+ {format(new Date(application.expiry_date), "dd-MM-yyyy")}
);
diff --git a/src/logo.svg.tsx b/src/assets/logo.svg.tsx
similarity index 100%
rename from src/logo.svg.tsx
rename to src/assets/logo.svg.tsx
diff --git a/src/components/Footer/Footer.module.css b/src/components/Footer/Footer.module.css
new file mode 100644
index 0000000..edc1ced
--- /dev/null
+++ b/src/components/Footer/Footer.module.css
@@ -0,0 +1,14 @@
+.FooterContainer {
+ display: flex;
+ justify-content: center;
+
+ .LoadMoreButton {
+ background-color: #0c2340;
+ color: white;
+ border-radius: 8px;
+ padding: 8px 24px;
+ border: 1px solid #0c2340;
+ font-weight: bold;
+ cursor: pointer;
+ }
+}
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
new file mode 100644
index 0000000..7bbe218
--- /dev/null
+++ b/src/components/Footer/Footer.tsx
@@ -0,0 +1,23 @@
+import styles from "./Footer.module.css";
+
+const Footer = ({
+ onLoadMoreClick,
+ isLoading,
+}: {
+ onLoadMoreClick: () => void;
+ isLoading: boolean;
+}) => {
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts
new file mode 100644
index 0000000..3738288
--- /dev/null
+++ b/src/components/Footer/index.ts
@@ -0,0 +1 @@
+export { default } from "./Footer";
diff --git a/src/Header.module.css b/src/components/Header/Header.module.css
similarity index 100%
rename from src/Header.module.css
rename to src/components/Header/Header.module.css
diff --git a/src/Header.tsx b/src/components/Header/Header.tsx
similarity index 85%
rename from src/Header.tsx
rename to src/components/Header/Header.tsx
index 206a084..62e619f 100644
--- a/src/Header.tsx
+++ b/src/components/Header/Header.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import LogoSvg from "./logo.svg";
+import LogoSvg from "../../assets/logo.svg";
import styles from "./Header.module.css";
const Header = () => {
diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts
new file mode 100644
index 0000000..2764567
--- /dev/null
+++ b/src/components/Header/index.ts
@@ -0,0 +1 @@
+export { default } from "./Header";
diff --git a/src/components/Loading/Loading.module.css b/src/components/Loading/Loading.module.css
new file mode 100644
index 0000000..4cf269b
--- /dev/null
+++ b/src/components/Loading/Loading.module.css
@@ -0,0 +1,9 @@
+.LoadingContainer {
+ background-color: white;
+ box-shadow: 0px 5px 16px 0px rgba(173, 200, 215, 0.25);
+ border-radius: 10px;
+ margin-bottom: 1rem;
+ padding: 1rem;
+ text-align: center;
+ font-weight: bold;
+}
diff --git a/src/components/Loading/Loading.tsx b/src/components/Loading/Loading.tsx
new file mode 100644
index 0000000..3a78aa3
--- /dev/null
+++ b/src/components/Loading/Loading.tsx
@@ -0,0 +1,5 @@
+import styles from "./Loading.module.css";
+
+export const Loading = () => {
+ return Loading...
;
+};
diff --git a/src/model/Applications.ts b/src/model/Applications.ts
new file mode 100644
index 0000000..54fd49b
--- /dev/null
+++ b/src/model/Applications.ts
@@ -0,0 +1,10 @@
+export interface ApplicationDTO {
+ guid: string;
+ loan_amount: number;
+ first_name: string;
+ last_name: string;
+ company: string;
+ email: string;
+ date_created: string;
+ expiry_date: string;
+}
diff --git a/src/network/applications.ts b/src/network/applications.ts
new file mode 100644
index 0000000..68d2d1e
--- /dev/null
+++ b/src/network/applications.ts
@@ -0,0 +1,25 @@
+import useSWRInfinite from "swr/infinite";
+import { ApplicationDTO } from "../model/Applications";
+import { BASE_URL } from "./common";
+
+export const getApplications = ({
+ page,
+ limit,
+}: {
+ page: number;
+ limit: number;
+}) =>
+ fetch(`${BASE_URL}/applications?_page=${page}&_limit=${limit}`).then((res) =>
+ res.json()
+ ) as Promise;
+
+const getKey = (pageIndex: number, previousPageData: ApplicationDTO[]) => {
+ if (previousPageData && !previousPageData.length) return null; // reached the end
+ return `${BASE_URL}/applications?_page=${pageIndex}`; // SWR key
+};
+
+export const useApplications = (page: number, limit: number) => {
+ return useSWRInfinite(getKey, () =>
+ getApplications({ page, limit })
+ );
+};
diff --git a/src/network/common.ts b/src/network/common.ts
new file mode 100644
index 0000000..18a479f
--- /dev/null
+++ b/src/network/common.ts
@@ -0,0 +1 @@
+export const BASE_URL = "http://localhost:3001/api";