Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"start": "next start -p $PORT",
"lint": "next lint",
"check-types": "tsc --noEmit",
"test": "tsx --test src/components/locations-pagination-cache.test.ts src/components/locations-pagination-navigation.test.ts src/lib/locations-pagination-request.test.ts",
"format": "prettier . --write",
"check-format": "prettier --check .",
"all-checks": "npm run check-format && npm run check-types && npm run lint",
Expand Down
143 changes: 143 additions & 0 deletions src/app/api/locations-pagination/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) 2024 Streetlives, Inc.
//
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
REQUIREMENT_PARAM,
RESOURCE_ROUTES,
SHELTER_PARAM_SINGLE_VALUE,
SHELTER_PARAM_YOUTH_VALUE,
SORT_BY_QUERY_PARAM,
SearchParams,
SubRouteParams,
parseCategoryFromRoute,
parseRequest,
} from "@/components/common";
import {
getFullLocationData,
getTaxonomies,
map_gogetta_to_yourpeer,
} from "@/components/streetlives-api-service";
import {
parseLocationsBackgroundPageNumber,
parseLocationsBackgroundPageSize,
} from "@/lib/locations-pagination-request";

function toSearchParamsObject(
urlSearchParams: URLSearchParams,
excludedKeys: Set<string>,
): SearchParams {
const entries = new Map<string, string[]>();

urlSearchParams.forEach((value, key) => {
if (excludedKeys.has(key)) {
return;
}

const existing = entries.get(key);
if (existing) {
existing.push(value);
return;
}

entries.set(key, [value]);
});

return Object.fromEntries(
Array.from(entries.entries()).map(([key, values]) => [
key,
values.length === 1 ? values[0] : values,
]),
);
}

export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url);
const route = requestUrl.searchParams.get("route");

if (!route || !RESOURCE_ROUTES.includes(route)) {
return NextResponse.json(
{ error: "Expected a valid route query parameter." },
{ status: 400 },
);
}

const locationSlugOrPersonalCareSubCategory =
requestUrl.searchParams.get("locationSlugOrPersonalCareSubCategory") ||
undefined;

const params: SubRouteParams | { route: string } =
locationSlugOrPersonalCareSubCategory
? {
route,
locationSlugOrPersonalCareSubCategory,
}
: { route };

const page = parseLocationsBackgroundPageNumber(
requestUrl.searchParams.get("pageNumber"),
);
if (page === null) {
return NextResponse.json(
{
error: "pageNumber exceeds the supported background pagination range.",
},
{ status: 400 },
);
}
const pageSize = parseLocationsBackgroundPageSize(
requestUrl.searchParams.get("pageSize"),
);

const searchParams = toSearchParamsObject(
requestUrl.searchParams,
new Set([
"route",
"locationSlugOrPersonalCareSubCategory",
"page",
"pageNumber",
"pageSize",
]),
);

const parsedSearchParams = parseRequest({
params,
searchParams,
cookies: await cookies(),
});
const category = parseCategoryFromRoute(route);
const taxonomiesResults = await getTaxonomies(category, parsedSearchParams);

const locationParams = {
...parsedSearchParams,
...parsedSearchParams[REQUIREMENT_PARAM],
...taxonomiesResults,
page,
pageSize,
sortBy: parsedSearchParams[SORT_BY_QUERY_PARAM],
...(locationSlugOrPersonalCareSubCategory === SHELTER_PARAM_YOUTH_VALUE && {
ageMin: 16,
ageMax: 24,
}),
...(locationSlugOrPersonalCareSubCategory ===
SHELTER_PARAM_SINGLE_VALUE && {
ageMin: 18,
ageMax: 99,
}),
};

const { locations, resultCount } = await getFullLocationData(locationParams);

return NextResponse.json({
pageNumber: page,
pageSize,
resultCount,
locations: locations.map((location) =>
map_gogetta_to_yourpeer(location, false),
),
});
}
91 changes: 63 additions & 28 deletions src/components/locations-container-pager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,81 @@

"use client";

import { usePathname, useSearchParams } from "next/navigation";
import { usePathname } from "next/navigation";
import { SearchParams } from "./common";
import {
getFirstPageHref,
getLastPageHref,
getUrlToNextOrPreviousPage,
} from "./navigation";
getPaginationUrl,
getSearchParamEntries,
} from "./locations-pagination-cache";
import { TranslatableText } from "./translatable-text";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import React from "react";

export function LocationsContainerPager({
resultCount,
searchParams,
numberOfPages,
currentPage,
onPageChange,
}: {
resultCount: number;
searchParams: SearchParams;
numberOfPages: number;
currentPage: number;
onPageChange?: (pageNumber: number) => void;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const baseSearchParams = React.useMemo(
() => getSearchParamEntries(searchParams),
[searchParams],
);
const hasPreviousPage = currentPage > 0;
const hasNextPage = currentPage < numberOfPages;

const firstPageHref = hasPreviousPage
? getPaginationUrl({
pathname,
searchParams: baseSearchParams,
pageNumber: 0,
})
: undefined;
const previousPageHref = hasPreviousPage
? getPaginationUrl({
pathname,
searchParams: baseSearchParams,
pageNumber: currentPage - 1,
})
: undefined;
const nextPageHref = hasNextPage
? getPaginationUrl({
pathname,
searchParams: baseSearchParams,
pageNumber: currentPage + 1,
})
: undefined;
const lastPageHref = hasNextPage
? getPaginationUrl({
pathname,
searchParams: baseSearchParams,
pageNumber: numberOfPages,
})
: undefined;

function handlePageChange(
event: React.MouseEvent<HTMLAnchorElement>,
pageNumber: number,
) {
if (!onPageChange || pageNumber < 0 || pageNumber > numberOfPages) {
return;
}

event.preventDefault();
onPageChange(pageNumber);
}

return (
<div className="p-6 border-t border-neutral-100 mb-14 md:mb-0">
<div className="flex items-center justify-between">
Expand All @@ -42,11 +89,8 @@ export function LocationsContainerPager({
className={`text-dark inline-flex space-x-1 disabled:text-muted ${
!hasPreviousPage ? "text-muted cursor-not-allowed" : ""
}`}
href={
hasPreviousPage
? getFirstPageHref(pathname, searchParams)
: undefined
}
href={firstPageHref}
onClick={(event) => handlePageChange(event, 0)}
>
<ChevronsLeft className="w-6 h-6" />
</a>
Expand All @@ -55,11 +99,8 @@ export function LocationsContainerPager({
className={`text-dark inline-flex space-x-1 disabled:text-muted ${
!hasPreviousPage ? "text-muted cursor-not-allowed" : ""
}`}
href={
hasPreviousPage
? getUrlToNextOrPreviousPage(pathname, searchParams, false)
: undefined
}
href={previousPageHref}
onClick={(event) => handlePageChange(event, currentPage - 1)}
>
<ChevronLeft className="w-6 h-6" />
<TranslatableText text="Previous" />
Expand All @@ -77,11 +118,8 @@ export function LocationsContainerPager({
className={`inline-flex space-x-1 disabled:text-muted ${
hasNextPage ? "text-dark" : "text-muted cursor-not-allowed"
}`}
href={
hasNextPage
? getUrlToNextOrPreviousPage(pathname, searchParams, true)
: undefined
}
href={nextPageHref}
onClick={(event) => handlePageChange(event, currentPage + 1)}
>
<TranslatableText text="Next" />
<ChevronRight className="w-6 h-6" />
Expand All @@ -91,11 +129,8 @@ export function LocationsContainerPager({
className={`inline-flex space-x-1 disabled:text-muted ${
hasNextPage ? "text-dark" : "text-muted cursor-not-allowed"
}`}
href={
hasNextPage
? getLastPageHref(pathname, searchParams, numberOfPages)
: undefined
}
href={lastPageHref}
onClick={(event) => handlePageChange(event, numberOfPages)}
>
<ChevronsRight className="w-6 h-6" />
</a>
Expand Down
Loading