Skip to content

Commit

Permalink
Merge pull request #170 from sgcarstrends/167-slugify-urls
Browse files Browse the repository at this point in the history
Slugify URLs
  • Loading branch information
ruchernchong authored Nov 22, 2024
2 parents 2882bb8 + bc29943 commit 28f763c
Show file tree
Hide file tree
Showing 21 changed files with 198 additions and 64 deletions.
29 changes: 18 additions & 11 deletions app/@breadcrumbs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { deslugify } from "@/utils/slugify";

type Params = Promise<{ slug: string[] }>;

Expand All @@ -21,18 +22,24 @@ const BREADCRUMB_MAP: Record<string, string> = {
coe: "COE",
};

const capitaliseWords = (text: string): string =>
text
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
const generateBreadcrumbs = (slug: string[]): BreadcrumbItem[] => {
return slug.map((segment, index) => {
const isMakesPage = slug[index - 1] === "makes";

const generateBreadcrumbs = (slug: string[]): BreadcrumbItem[] =>
slug.map((segment, index) => ({
href: `/${slug.slice(0, index + 1).join("/")}`,
label: BREADCRUMB_MAP[segment] ?? capitaliseWords(segment),
isLastItem: index === slug.length - 1,
}));
let label = deslugify(segment);
if (BREADCRUMB_MAP[segment]) {
label = BREADCRUMB_MAP[segment];
} else if (isMakesPage) {
label = label.toUpperCase();
}

return {
href: `/${slug.slice(0, index + 1).join("/")}`,
label,
isLastItem: index === slug.length - 1,
};
});
};

const Breadcrumbs = async (props: { params: Params }) => {
const params = await props.params;
Expand Down
17 changes: 10 additions & 7 deletions app/cars/fuel-types/[fuelType]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { capitaliseWords } from "@/utils/capitaliseWords";
import { fetchApi } from "@/utils/fetchApi";
import { mergeCarsByMake } from "@/utils/mergeCarsByMake";
import { deslugify, slugify } from "@/utils/slugify";
import type { Metadata } from "next";
import type { WebPage, WithContext } from "schema-dts";

Expand All @@ -35,8 +36,9 @@ export const generateMetadata = async (props: {
month = latestMonth.cars;
}

const title = `${capitaliseWords(fuelType)} Cars in Singapore`;
const description = `Explore registration trends and statistics for ${fuelType} cars in Singapore.`;
const formattedFuelType = deslugify(fuelType);
const title = `${formattedFuelType} Cars in Singapore`;
const description = `Explore registration trends and statistics for ${formattedFuelType} cars in Singapore.`;
const images = `${SITE_URL}/api/og?type=${fuelType}&month=${month}`;
const pageUrl = `/cars/fuel-types/${fuelType}`;

Expand Down Expand Up @@ -67,7 +69,7 @@ export const generateMetadata = async (props: {
const fuelTypes = ["petrol", "hybrid", "electric", "diesel"];

export const generateStaticParams = () =>
fuelTypes.map((fuelType) => ({ fuelType }));
fuelTypes.map((fuelType) => ({ fuelType: slugify(fuelType) }));

const CarsByFuelTypePage = async (props: {
params: Params;
Expand All @@ -91,11 +93,12 @@ const CarsByFuelTypePage = async (props: {

const filteredCars = mergeCarsByMake(cars);

const formattedFuelType = deslugify(fuelType);
const structuredData: WithContext<WebPage> = {
"@context": "https://schema.org",
"@type": "WebPage",
name: `${capitaliseWords(fuelType)} Car in Singapore`,
description: `Explore registration trends and statistics for ${fuelType} cars in Singapore.`,
name: `${formattedFuelType} Car in Singapore`,
description: `Explore registration trends and statistics for ${formattedFuelType} cars in Singapore.`,
url: `${SITE_URL}/cars/fuel-types/${fuelType}`,
publisher: {
"@type": "Organization",
Expand All @@ -110,7 +113,7 @@ const CarsByFuelTypePage = async (props: {
};

return (
<section>
<>
<StructuredData data={structuredData} />
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
Expand All @@ -127,7 +130,7 @@ const CarsByFuelTypePage = async (props: {
</div>
<CarOverviewTrends cars={filteredCars} />
</div>
</section>
</>
);
};

Expand Down
29 changes: 18 additions & 11 deletions app/cars/makes/[make]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TrendChart } from "@/app/cars/makes/[make]/TrendChart";
import { columns } from "@/app/cars/makes/[make]/columns";
import { MakeSelector } from "@/app/components/MakeSelector";
import { EmptyData } from "@/components/EmptyData";
import { StructuredData } from "@/components/StructuredData";
import Typography from "@/components/Typography";
import {
Expand All @@ -15,6 +16,7 @@ import { API_URL, SITE_TITLE, SITE_URL } from "@/config";
import { type Car, type Make, RevalidateTags } from "@/types";
import { fetchApi } from "@/utils/fetchApi";
import { formatDateToMonthYear } from "@/utils/formatDateToMonthYear";
import { deslugify, slugify } from "@/utils/slugify";
import type { Metadata } from "next";
import type { WebPage, WithContext } from "schema-dts";

Expand All @@ -24,14 +26,15 @@ export const generateMetadata = async (props: {
params: Params;
}): Promise<Metadata> => {
const params = await props.params;
let { make } = params;
make = decodeURIComponent(make);
const description = `Historical trends and monthly breakdown of ${make} cars by fuel and vehicle types in Singapore.`;
const { make } = params;

const formattedMake = deslugify(make).toUpperCase();
const description = `Historical trends and monthly breakdown of ${formattedMake} cars by fuel and vehicle types in Singapore.`;
const images = `/api/og?title=Historical Trend&make=${make}`;
const canonicalUrl = `/cars/makes/${make}`;

return {
title: make,
title: formattedMake,
description,
openGraph: {
images,
Expand All @@ -54,15 +57,15 @@ export const generateStaticParams = async () => {
const makes = await fetchApi<Make[]>(`${API_URL}/make`, {
next: { tags: [RevalidateTags.Cars] },
});
return makes.map((make) => ({ make }));
return makes.map((make) => ({ make: slugify(make) }));
};

const CarMakePage = async (props: { params: Params }) => {
const params = await props.params;
const { make } = params;

const [cars, makes]: [Car[], Make[]] = await Promise.all([
await fetchApi<Car[]>(`${API_URL}/make/${make}`, {
await fetchApi<Car[]>(`${API_URL}/make/${slugify(make)}`, {
next: { tags: [RevalidateTags.Cars] },
}),
await fetchApi<Make[]>(`${API_URL}/cars/makes`, {
Expand All @@ -72,7 +75,7 @@ const CarMakePage = async (props: { params: Params }) => {

const filteredCars = mergeCarData(cars);

const formattedMake = decodeURIComponent(make);
const formattedMake = deslugify(make).toUpperCase();
const structuredData: WithContext<WebPage> = {
"@context": "https://schema.org",
"@type": "WebPage",
Expand All @@ -86,12 +89,16 @@ const CarMakePage = async (props: { params: Params }) => {
},
};

if (cars.length === 0) {
return <EmptyData />;
}

return (
<section>
<>
<StructuredData data={structuredData} />
<div className="flex flex-col gap-y-8">
<div className="flex flex-col gap-4">
<div className="flex flex-col justify-between gap-2 lg:flex-row">
<Typography.H1>{decodeURIComponent(make)}</Typography.H1>
<Typography.H1>{formattedMake}</Typography.H1>
<MakeSelector makes={makes} selectedMake={make} />
</div>
<Card>
Expand All @@ -115,7 +122,7 @@ const CarMakePage = async (props: { params: Params }) => {
</CardContent>
</Card>
</div>
</section>
</>
);
};

Expand Down
17 changes: 10 additions & 7 deletions app/cars/vehicle-types/[vehicleType]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { capitaliseWords } from "@/utils/capitaliseWords";
import { fetchApi } from "@/utils/fetchApi";
import { mergeCarsByMake } from "@/utils/mergeCarsByMake";
import { deslugify, slugify } from "@/utils/slugify";
import type { Metadata } from "next";
import type { WebPage, WithContext } from "schema-dts";

Expand All @@ -25,14 +26,15 @@ export const generateMetadata = async (props: {
params: Params;
}): Promise<Metadata> => {
const params = await props.params;
let { vehicleType } = params;
vehicleType = decodeURIComponent(vehicleType);
const { vehicleType } = params;

const formattedVehicleType = deslugify(vehicleType);
const images = `/api/og?title=Historical Trend&type=${vehicleType}`;
const canonicalUrl = `/cars/vehicle-types/${vehicleType}`;

return {
title: `${capitaliseWords(vehicleType)} Cars in Singapore`,
description: `Explore registration trends and statistics for ${vehicleType} cars in Singapore.`,
title: `${formattedVehicleType} Cars in Singapore`,
description: `Explore registration trends and statistics for ${formattedVehicleType} cars in Singapore.`,
openGraph: {
images,
url: canonicalUrl,
Expand Down Expand Up @@ -60,7 +62,7 @@ const vehicleTypes = [
];

export const generateStaticParams = () =>
vehicleTypes.map((vehicleType) => ({ vehicleType }));
vehicleTypes.map((vehicleType) => ({ vehicleType: slugify(vehicleType) }));

const CarsByVehicleTypePage = async (props: {
params: Params;
Expand All @@ -84,11 +86,12 @@ const CarsByVehicleTypePage = async (props: {

const filteredCars = mergeCarsByMake(cars);

const formattedVehicleType = deslugify(vehicleType);
const structuredData: WithContext<WebPage> = {
"@context": "https://schema.org",
"@type": "WebPage",
name: `${capitaliseWords(vehicleType)} Cars in Singapore`,
description: `Explore registration trends and statistics for ${vehicleType} cars in Singapore.`,
name: `${formattedVehicleType} Cars in Singapore`,
description: `Explore registration trends and statistics for ${formattedVehicleType} cars in Singapore.`,
url: `${SITE_URL}/cars/vehicle-types/${vehicleType}`,
publisher: {
"@type": "Organization",
Expand Down
33 changes: 21 additions & 12 deletions app/components/MakeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useMemo } from "react";
import { useRouter } from "next/navigation";
import {
Select,
Expand All @@ -8,33 +9,41 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { slugify } from "@/utils/slugify";
import type { Make } from "@/types";

interface MakeSelectorProps {
interface Props {
makes: Make[];
selectedMake: Make;
}

export const MakeSelector = ({ makes, selectedMake }: MakeSelectorProps) => {
export const MakeSelector = ({ makes, selectedMake }: Props) => {
const router = useRouter();

const validSelectedMake = useMemo(() => {
const regexSelectedMake = selectedMake.replace(
/[^a-zA-Z0-9]/g,
"[^a-zA-Z0-9]*",
);

return makes.find((make) => new RegExp(regexSelectedMake, "i").test(make));
}, [makes, selectedMake]);

return (
<div>
<Select
defaultValue={decodeURIComponent(selectedMake)}
onValueChange={(make) => router.push(make)}
defaultValue={validSelectedMake}
onValueChange={(make) => router.push(slugify(make))}
>
<SelectTrigger>
<SelectValue placeholder="Select a make" />
<SelectValue placeholder="SELECT MAKE" />
</SelectTrigger>
<SelectContent>
{makes.map((make) => {
return (
<SelectItem key={make} value={make}>
{make}
</SelectItem>
);
})}
{makes.map((make) => (
<SelectItem key={make} value={make}>
{make}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
Expand Down
7 changes: 4 additions & 3 deletions components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar";
import { slugify } from "@/utils/slugify";

type NavItem = {
title: string;
Expand Down Expand Up @@ -231,19 +232,19 @@ const data: Nav = {
},
{
title: "Multi-purpose Vehicle",
url: `/cars/vehicle-types/${encodeURIComponent("multi-purpose vehicle")}`,
url: `/cars/vehicle-types/${slugify("multi-purpose vehicle")}`,
},
{
title: "Station-wagon",
url: "/cars/vehicle-types/station-wagon",
},
{
title: "Sports Utility Vehicle",
url: `/cars/vehicle-types/${encodeURIComponent("sports utility vehicle")}`,
url: `/cars/vehicle-types/${slugify("sports utility vehicle")}`,
},
{
title: "Coupe/Convertible",
url: `/cars/vehicle-types/${encodeURIComponent("coupe/convertible")}`,
url: `/cars/vehicle-types/${slugify("coupe/convertible")}`,
},
],
},
Expand Down
7 changes: 5 additions & 2 deletions components/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import { deslugify, slugify } from "@/utils/slugify";
import type { Car } from "@/types";

interface Category {
Expand Down Expand Up @@ -133,13 +134,15 @@ export const Leaderboard = ({ cars }: LeaderboardProps) => {
return (
<Link
key={make}
href={`/cars/makes/${make}`}
href={`/cars/makes/${slugify(make)}`}
className="group block w-full rounded-lg p-2 transition-colors hover:bg-gray-50"
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">{make}</span>
<span className="font-medium">
{deslugify(make)}
</span>
<ChevronRight className="h-4 w-4 text-primary opacity-0 transition-opacity group-hover:opacity-100" />
</div>
<span className="text-gray-600">{number}</span>
Expand Down
5 changes: 2 additions & 3 deletions components/StatisticsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@/components/ui/table";
import { FUEL_TYPE } from "@/config";
import { formatPercent } from "@/utils/formatPercent";
import { slugify } from "@/utils/slugify";

export const StatisticsCard = ({
title,
Expand All @@ -42,9 +43,7 @@ export const StatisticsCard = ({
const searchParams = useSearchParams();

const handleRowClick = (type: string) => {
router.push(
`/cars/${linkPrefix}/${encodeURIComponent(type.toLowerCase())}?${searchParams}`,
);
router.push(`/cars/${linkPrefix}/${slugify(type)}?${searchParams}`);
};

const getBadgeVariant = (value: number) => {
Expand Down
Loading

0 comments on commit 28f763c

Please sign in to comment.