From 443b277feb23a1b4ee955e273c4371ae70beb904 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 07:53:18 +0800 Subject: [PATCH 01/15] feat: fetches applications from api --- src/api/applications.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/api/applications.ts diff --git a/src/api/applications.ts b/src/api/applications.ts new file mode 100644 index 0000000..775eaff --- /dev/null +++ b/src/api/applications.ts @@ -0,0 +1,37 @@ +const API_BASE = "http://localhost:3001/api"; +const PAGE_LIMIT = 5; + +export type Application = { + id: number; + loan_amount: number; + first_name: string; + last_name: string; + company: string; + email: string; + date_created: string; + expiry_date: string; +}; + +function hasNextLink(linkHeader: string | null): boolean { + if (!linkHeader) return false; + + return /rel="?next"?/.test(linkHeader); +} + +export async function fetchApplications( + page = 1 +): Promise<{ data: Application[]; hasNext: boolean }> { + const res = await fetch( + `${API_BASE}/applications?_page=${page}&_limit=${PAGE_LIMIT}` + ); + + if (!res.ok) { + throw new Error("Failed to fetch applications"); + } + + const data = (await res.json()) as Application[]; + const linkHeader = res.headers.get("Link"); + const hasNext = hasNextLink(linkHeader); + + return { data, hasNext }; +} From 0d6828a61cfa54f91aa8ffeb479e8f2dd5521a4a Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 07:53:39 +0800 Subject: [PATCH 02/15] feat: implement paginated applications list with API --- src/Applications.tsx | 71 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/src/Applications.tsx b/src/Applications.tsx index 8c9f646..6f8f86b 100644 --- a/src/Applications.tsx +++ b/src/Applications.tsx @@ -1,14 +1,77 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import SingleApplication from "./SingleApplication"; -import { getSingleApplicationFixture } from "./__fixtures__/applications.fixture"; +import { Button } from "./ui/Button/Button"; +import { fetchApplications, type Application } from "./api/applications"; import styles from "./Applications.module.css"; const Applications = () => { - const applications = getSingleApplicationFixture; + const [applications, setApplications] = useState([]); + const [page, setPage] = useState(1); + const [hasNext, setHasNext] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + loadPage(1); + }, []); + + async function loadPage(pageNumber: number) { + setLoading(true); + setError(null); + + try { + const { data, hasNext } = await fetchApplications(pageNumber); + + setApplications((prev) => + pageNumber === 1 ? data : [...prev, ...data] + ); + + setHasNext(hasNext); + setPage(pageNumber); + } catch { + setError("Sorry, we couldn’t load applications. Please try again."); + } finally { + setLoading(false); + } + } + + function handleLoadMore() { + loadPage(page + 1); + } + + const isInitialLoading = loading && applications.length === 0; return (
- + {error && ( +

+ {error} +

+ )} + + {isInitialLoading ? ( +

Loading applications…

+ ) : ( +
    + {applications.map((app) => ( +
  • + +
  • + ))} +
+ )} + + {hasNext && ( +
+ +
+ )}
); }; From fb69bcc42a3908509ce9f5c723964f54d49d3fda Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 07:53:57 +0800 Subject: [PATCH 03/15] refactor: type and improve SingleApplication with formatting and accessibility --- src/SingleApplication.tsx | 62 +++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/src/SingleApplication.tsx b/src/SingleApplication.tsx index e664745..1b08a5a 100644 --- a/src/SingleApplication.tsx +++ b/src/SingleApplication.tsx @@ -1,32 +1,62 @@ import React from "react"; import styles from "./SingleApplication.module.css"; +import type { Application } from "./api/applications"; + +const formatGBP = (value: number) => + new Intl.NumberFormat("en-GB", { + style: "currency", + currency: "GBP", + maximumFractionDigits: 0, + }).format(value); + +const formatDate = (raw: string) => { + if (!raw || typeof raw !== "string") return raw; + + const datePart = raw.split("T")[0]; + const [y, m, d] = datePart.split("-"); + if (!y || !m || !d) return raw; + + return `${d}-${m}-${y}`; +}; + +const SingleApplication = ({ application }: { application: Application }) => { + const fullName = `${application.first_name} ${application.last_name}`; -const SingleApplication = ({ application }) => { return ( -
+
- Company + Company {application.company}
+
- Name - {application.first_name} {application.last_name} + Name + {fullName}
+
- Email - {application.email} + Email + + {application.email} +
-
- Loan Amount - {application.loan_amount} + +
+ Loan amount + {formatGBP(application.loan_amount)}
-
- Application Date - {application.date_created} + +
+ Application date + {formatDate(application.date_created)}
-
- Expiry date - {application.expiry_date} + +
+ Expiry date + {formatDate(application.expiry_date)}
); From 39b77fffdeb829c6bddbc0c9862fe4514d046e83 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 07:57:46 +0800 Subject: [PATCH 04/15] test: add tests for fetchApplications helper --- src/api/applications.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/api/applications.test.ts diff --git a/src/api/applications.test.ts b/src/api/applications.test.ts new file mode 100644 index 0000000..cada04d --- /dev/null +++ b/src/api/applications.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchApplications } from "./applications"; + +describe("fetchApplications", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("sets hasNext=true when Link header includes rel=next, and calls correct URL", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch" as any).mockResolvedValue({ + ok: true, + json: async () => [{ id: 1 }], + headers: { + get: (name: string) => + name.toLowerCase() === "link" + ? '; rel="next"' + : null, + }, + } as any); + + const result = await fetchApplications(1); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:3001/api/applications?_page=1&_limit=5" + ); + expect(result.hasNext).toBe(true); + }); +}); From e81d0e78cf777cd6f9e0cc33b7156dc96c060712 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 07:59:29 +0800 Subject: [PATCH 05/15] test: add tests for Applications pagination behaviour --- src/Applications.test.tsx | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/Applications.test.tsx diff --git a/src/Applications.test.tsx b/src/Applications.test.tsx new file mode 100644 index 0000000..1914953 --- /dev/null +++ b/src/Applications.test.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Applications from "./Applications"; + +vi.mock("./api/applications", () => ({ + fetchApplications: vi.fn(), +})); + +import { fetchApplications } from "./api/applications"; + +const makeApp = (id: number) => ({ + id, + loan_amount: 1000 + id, + first_name: "First", + last_name: `User${id}`, + company: `Company ${id}`, + email: `user${id}@example.com`, + date_created: "2022-01-27T15:37:16.891Z", + expiry_date: "2025-05-12T07:10:53.489Z", +}); + +describe("", () => { + it("loads first page then appends next page on Load more, and hides button when no next page", async () => { + (fetchApplications as any) + .mockResolvedValueOnce({ + data: [1, 2, 3, 4, 5].map(makeApp), + hasNext: true, + }) + .mockResolvedValueOnce({ + data: [6, 7, 8, 9, 10].map(makeApp), + hasNext: false, + }); + + const user = userEvent.setup(); + render(); + + expect(await screen.findByText("Company 1")).toBeInTheDocument(); + expect(screen.getByText("Company 5")).toBeInTheDocument(); + + expect(fetchApplications).toHaveBeenNthCalledWith(1, 1); + + await user.click(screen.getByRole("button", { name: /load more/i })); + + expect(await screen.findByText("Company 10")).toBeInTheDocument(); + expect(fetchApplications).toHaveBeenNthCalledWith(2, 2); + + expect( + screen.queryByRole("button", { name: /load more/i }) + ).not.toBeInTheDocument(); + }); + + it("shows an error message when the initial load fails", async () => { + (fetchApplications as any).mockRejectedValueOnce(new Error("boom")); + + render(); + + expect( + await screen.findByRole("alert") + ).toHaveTextContent(/couldn’t load applications/i); + }); +}); From ac543b823141a1808dfabda8b8d2cb5c08ecbb37 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:02:08 +0800 Subject: [PATCH 06/15] test: isolate App test by mocking API layer --- src/App.test.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 4db6ab8..0b3c181 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,8 +1,15 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; +import { render, screen } from "@testing-library/react"; +import { vi } from "vitest"; +import App from "./App"; + +vi.mock("./api/applications", () => ({ + fetchApplications: vi.fn().mockResolvedValue({ + data: [], + hasNext: false, + }), +})); test('renders "Application Portal" title', () => { render(); - const linkElement = screen.getByText(/Application portal/i); - expect(linkElement).toBeInTheDocument(); + expect(screen.getByText(/application portal/i)).toBeInTheDocument(); }); From 8f0271d6adc9aa53d45b4ec53274fdf0175b9c18 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:02:42 +0800 Subject: [PATCH 07/15] chore: remove unused css file --- src/Aplications.module.css | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/Aplications.module.css diff --git a/src/Aplications.module.css b/src/Aplications.module.css deleted file mode 100644 index e69de29..0000000 From 97dfc5204c9e6c2c3432895226d1bc55563f9198 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:04:56 +0800 Subject: [PATCH 08/15] feat: add styling for applications --- src/Applications.module.css | 51 ++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Applications.module.css b/src/Applications.module.css index fea5c60..7669a6c 100644 --- a/src/Applications.module.css +++ b/src/Applications.module.css @@ -1,5 +1,54 @@ .Applications { width: 100%; - max-width: 1200px; + max-width: 1152px; margin: 0 auto; + display: flex; + flex-direction: column; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + + display: flex; + flex-direction: column; + gap: 24px; +} + +.listItem { + margin: 0; +} + +.loadMore { + display: flex; + justify-content: center; + padding: 24px 0 48px; +} + +.loadMoreButton { + width: 200px; + min-height: 39px; + box-shadow: 0px 15px 25px rgba(170, 190, 209, 0.2); +} + +/* ================= MOBILE ================= */ + +@media (max-width: 768px) { + .Applications { + padding: 0 16px; + } + + .list { + gap: 16px; + } + + .loadMore { + padding: 16px 0 32px; + } + + .loadMoreButton { + width: 100%; + max-width: 360px; + } } From 5cd078ea426a2fdcf6681658aa20b58d9c591382 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:07:52 +0800 Subject: [PATCH 09/15] style: update single application layout to match Figma and be responsive --- src/SingleApplication.module.css | 76 ++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/src/SingleApplication.module.css b/src/SingleApplication.module.css index 5e8ceb3..14cd37b 100644 --- a/src/SingleApplication.module.css +++ b/src/SingleApplication.module.css @@ -1,23 +1,73 @@ .SingleApplication { display: grid; - grid-template-columns: 15% 15% auto 15% 15% 15%; - padding: 20px; - background-color: white; - box-shadow: 0px 5px 16px 0px rgba(173, 200, 215, 0.25); - border-radius: 10px; - margin-bottom: 1rem; + grid-template-columns: 2fr 1.5fr 2fr 1fr 1fr 1fr; + + gap: 0 24px; + align-items: center; + + padding: 20px 24px; + background: var(--color-white-100); + border: 1px solid var(--color-london-90); + box-shadow: 0 5px 16px rgba(0,0,0,.05); + border-radius: 16px; + + color: var(--color-denim-15); } .cell { - text-align: left; - border-right: 1px solid #eee; - padding: 0 15px; + min-width: 0; } -.cell sub { +.label { display: block; - font-size: 0.8rem; - color: #999; - margin-bottom: 5px; + font-size: 12px; + line-height: 150%; + font-weight: 500; + color: var(--color-white-45); + margin-bottom: 6px; +} + +.right { + text-align: right; +} + +.email { + color: var(--color-denim-45); + text-decoration: none; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.email:hover { + text-decoration: underline; +} + +@media (min-width: 1200px) { + .SingleApplication { + grid-template-columns: 208px 160px 248px 120px 120px 128px; + } +} + +/* ================= MOBILE ================= */ + +@media (max-width: 768px) { + .SingleApplication { + grid-template-columns: 1fr; + row-gap: 14px; + align-items: start; + padding: 16px; + } + + .right { + text-align: left; + } + + .email { + white-space: normal; + overflow: visible; + text-overflow: unset; + } } From ed22f4a3a36be193b2087dcf730400cf2e25e143 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:10:54 +0800 Subject: [PATCH 10/15] style: fix Aesop font weights --- src/css/fonts.css | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/css/fonts.css b/src/css/fonts.css index ff98a73..fdbec8d 100644 --- a/src/css/fonts.css +++ b/src/css/fonts.css @@ -1,20 +1,23 @@ +/* Light */ @font-face { - font-family: 'Aesop'; - src: url('../fonts/Aesop-Light.woff2') format('woff2'), - url('../fonts/Aesop-Light.woff') format('woff'); - font-weight: 500; + font-family: 'Aesop'; + src: url('../fonts/Aesop-Light.woff2') format('woff2'), + url('../fonts/Aesop-Light.woff') format('woff'); + font-weight: 300; } +/* Medium / Regular */ @font-face { - font-family: 'Aesop'; - src: url('../fonts/Aesop-Medium.woff2') format('woff2'), - url('../fonts/Aesop-Medium.woff') format('woff'); - font-weight: 700; + font-family: 'Aesop'; + src: url('../fonts/Aesop-Medium.woff2') format('woff2'), + url('../fonts/Aesop-Medium.woff') format('woff'); + font-weight: 500; } +/* Bold */ @font-face { - font-family: 'Aesop'; - src: url('../fonts/Aesop-Bold.woff2') format('woff2'), - url('../fonts/Aesop-Bold.woff') format('woff'); - font-weight: 900; + font-family: 'Aesop'; + src: url('../fonts/Aesop-Bold.woff2') format('woff2'), + url('../fonts/Aesop-Bold.woff') format('woff'); + font-weight: 700; } From d1e7d6e881cf2523e2d8084dfbb8cf99d5bb5f6e Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:12:02 +0800 Subject: [PATCH 11/15] style: header to match figma and include responsivity --- src/Header.module.css | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/Header.module.css b/src/Header.module.css index b9b5ffd..8fd9912 100644 --- a/src/Header.module.css +++ b/src/Header.module.css @@ -1,14 +1,45 @@ .Header { - padding: 20px 0; - margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + + padding-top: 48px; text-align: center; } +.logo { + width: 198px; + height: 64px; +} + + .Header h1 { - color: #143b6b; - font-weight: 800; + margin-top: 24px; + font-size: 32px; + line-height: 120%; + font-weight: 700; + color: var(--color-denim-15); } .logo path { - fill: #fb534a; + fill: var(--color-coral-65); +} + +/* ================= MOBILE ================= */ + +@media (max-width: 768px) { + .Header { + padding-top: 32px; + padding-bottom: 24px; + } + + .logo { + width: 150px; + height: auto; + } + + .Header h1 { + font-size: 26px; + margin-top: 16px; + } } From 3c54ca12097f74fc7362ba3543aa5b0cfaed655a Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:14:00 +0800 Subject: [PATCH 12/15] feat: added comments section --- COMMENTS.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/COMMENTS.md b/COMMENTS.md index 7fe6aa7..5e6d335 100644 --- a/COMMENTS.md +++ b/COMMENTS.md @@ -1 +1,31 @@ - +# Comments + +## Implementation notes + +### Data fetching & pagination + +- Replaced the hard-coded fixture with real API calls to `/api/applications`. +- Implemented pagination using `?_page` and `?_limit=5` as required. +- The "Load more" button fetches the next page and appends results to the existing list. +- Used the `Link` response header (`rel="next"`) to determine whether more pages exist and conditionally render the button. + +### Architecture + +- Extracted API logic into `src/api/applications.ts` to keep components focused on UI and make the fetch logic easier to test. +- Added a shared `Application` TypeScript type for stronger typing across components. +- Kept `SingleApplication` as a presentational component only. + +### UX considerations + +- Loading state shown during fetches. +- Button disabled while loading to prevent duplicate requests. +- Basic error message shown if requests fail. +- Added responsive layout for mobile screens. + +### Testing + +- Added Vitest + React Testing Library tests for: + - API helper (`fetchApplications`) + - Initial render + - Pagination behaviour (append + hide button when no next page) + - Error state From a8c2e06cf0041c3427950cea2e52800d3f9a4c65 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:14:54 +0800 Subject: [PATCH 13/15] refactor: wrap application in main tag for accessibility --- src/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index de815c2..6b464a3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,9 @@ function App() { return (
- +
+ +
); } From 75f8c3ee6c6f2fe23b47f923f97693c3835829bf Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 08:15:32 +0800 Subject: [PATCH 14/15] chore: remove unused app css --- src/App.css | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 src/App.css diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 2d26ac5..0000000 --- a/src/App.css +++ /dev/null @@ -1,34 +0,0 @@ -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} From 581ed8cf0c6046f8549ef64f8e2f8a2627df9200 Mon Sep 17 00:00:00 2001 From: Izzy Thorpe Date: Thu, 5 Feb 2026 09:12:16 +0800 Subject: [PATCH 15/15] chore: remove app css import --- src/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 6b464a3..ae3d746 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,3 @@ -import "./App.css"; import Applications from "./Applications"; import Header from "./Header";