Skip to content

Commit

Permalink
Merge pull request #116 from sgcarstrends/109-vehicle-type-page
Browse files Browse the repository at this point in the history
Add page for cars by vehicle type
  • Loading branch information
ruchernchong authored Sep 1, 2024
2 parents 5200a3e + bb84eae commit a8f6eb7
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 18 deletions.
15 changes: 5 additions & 10 deletions app/cars/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ interface Props {
}

const VEHICLE_TYPE_MAP: Record<string, string> = {
"Coupe/ Convertible": "Coupe/Convertible",
"Multi-purpose Vehicle": "MPV",
"Multi-purpose Vehicle/Station-wagon": "MPV/Station-wagon",
"Multi-purpose Vehicle/Station-wagon": "MPV",
"Sports Utility Vehicle": "SUV",
};

Expand Down Expand Up @@ -119,13 +120,7 @@ const CarsPage = async ({ searchParams }: Props) => {
const numberByFuelType = aggregateData(cars, "fuel_type");
const [topFuelType, topFuelTypeValue] = findTopEntry(numberByFuelType);

const numberByVehicleType = aggregateData(
cars.map((car) => ({
...car,
vehicle_type: VEHICLE_TYPE_MAP[car.vehicle_type] || car.vehicle_type,
})),
"vehicle_type",
);
const numberByVehicleType = aggregateData(cars, "vehicle_type");
const [topVehicleType, topVehicleTypeValue] =
findTopEntry(numberByVehicleType);

Expand Down Expand Up @@ -285,7 +280,7 @@ const CarsPage = async ({ searchParams }: Props) => {
description="Distribution of vehicles based on vehicle type"
data={numberByVehicleType}
total={total}
linkPrefix="vehicle-make"
linkPrefix="vehicle-type"
/>
</div>
<div className="grid gap-4 lg:col-span-2">
Expand Down Expand Up @@ -344,7 +339,7 @@ const StatisticsCard = ({
className="group cursor-pointer rounded px-2 py-1 transition-colors duration-200 hover:bg-secondary"
>
<Link
href={`${linkPrefix}/${key.toLowerCase()}`}
href={`${linkPrefix}/${encodeURIComponent(key.toLowerCase())}`}
className="flex items-center justify-between"
>
<div className="flex gap-1">
Expand Down
10 changes: 6 additions & 4 deletions app/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Link from "next/link";
import { Facebook, Github, Instagram, Linkedin, Twitter } from "lucide-react";
import { BrandLogo } from "@/components/BrandLogo";
import { Separator } from "@/components/ui/separator";
import { CAR_LINKS } from "@/config";
import { FUEL_TYPE_LINKS, VEHICLE_TYPE_LINKS } from "@/config";
import type { LinkItem } from "@/types";

interface FooterLink {
Expand Down Expand Up @@ -75,7 +75,7 @@ export const Footer = () => {
<footer className="bg-white">
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col gap-y-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-4">
<div className="grid grid-cols-1 gap-8 md:grid-cols-5">
<div>
<BrandLogo />
</div>
Expand Down Expand Up @@ -123,10 +123,12 @@ export const Footer = () => {
};

const footerLinks: FooterLink[] = [
{ title: "Monthly", links: [{ label: "Cars", href: "/cars" }] },
{
title: "Cars",
links: CAR_LINKS,
title: "Fuel Types",
links: FUEL_TYPE_LINKS,
},
{ title: "Vehicle Types", links: VEHICLE_TYPE_LINKS },
// TODO: Coming Soon!
// {
// title: "COE",
Expand Down
212 changes: 212 additions & 0 deletions app/vehicle-type/[type]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { Suspense } from "react";
import Link from "next/link";
import { TrendChart } from "@/app/cars/[type]/TrendChart";
import { DataTable } from "@/components/DataTable";
import { MonthSelector } from "@/components/MonthSelector";
import { StructuredData } from "@/components/StructuredData";
import Typography from "@/components/Typography";
import { UnreleasedFeature } from "@/components/UnreleasedFeature";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { API_URL, EXCLUSION_LIST, SITE_TITLE, SITE_URL } from "@/config";
import { type Car, type LatestMonth, RevalidateTags } from "@/types";
import { capitaliseWords } from "@/utils/capitaliseWords";
import { fetchApi } from "@/utils/fetchApi";
import type { Metadata } from "next";
import type { Dataset, WithContext } from "schema-dts";

interface Props {
params: { type: string };
searchParams?: { [key: string]: string };
}

export const generateMetadata = async ({
params,
}: Props): Promise<Metadata> => {
let { type } = params;
type = decodeURIComponent(type);
const description = `${capitaliseWords(type)} historical trends`;
const images = `/api/og?title=Historical Trend&type=${type}`;
const canonicalUrl = `/vehicle-type/${type}`;

return {
title: capitaliseWords(type),
description,
openGraph: {
images,
url: canonicalUrl,
siteName: SITE_TITLE,
locale: "en_SG",
type: "website",
},
twitter: {
images,
creator: "@sgcarstrends",
},
alternates: {
canonical: canonicalUrl,
},
};
};

const tabItems: Record<string, string> = {
hatchback: "/vehicle-type/hatchback",
sedan: "/vehicle-type/sedan",
"multi-purpose vehicle": "/vehicle-type/multi-purpose vehicle",
"station-wagon": "/vehicle-type/station-wagon",
"sports utility vehicle": "/vehicle-type/sports utility vehicle",
"coupe/ convertible": "/vehicle-type/coupe%2F convertible",
};

export const generateStaticParams = () =>
Object.keys(tabItems).map((key) => ({ type: key }));

const CarsByVehicleTypePage = async ({ params, searchParams }: Props) => {
const { type } = params;

const [months, latestMonth] = await Promise.all([
fetchApi<string[]>(`${API_URL}/months`, {
next: { tags: [RevalidateTags.Cars] },
}),
fetchApi<LatestMonth>(`${API_URL}/months/latest`, {
next: { tags: [RevalidateTags.Cars] },
}),
]);

const month = searchParams?.month ?? latestMonth.cars;
const cars = await fetchApi<Car[]>(
`${API_URL}/cars?vehicle_type=${type}&month=${month}`,
{
next: { tags: [RevalidateTags.Cars] },
},
);

const filteredCars = cars.filter(
({ make, number }) => !EXCLUSION_LIST.includes(make) && number > 0,
);

const structuredData: WithContext<Dataset> = {
"@context": "https://schema.org",
"@type": "Dataset",
name: `${capitaliseWords(type)} Car Registrations in Singapore`,
description: `Overview and registration statistics for ${type} cars in Singapore by vehicle type`,
url: `${SITE_URL}/cars/${type}`,
creator: {
"@type": "Organization",
name: SITE_TITLE,
},
variableMeasured: [
{
"@type": "PropertyValue",
name: "Make",
description: "Car manufacturer",
},
{
"@type": "PropertyValue",
name: "Count",
description: `Number of ${type} car registrations`,
},
{
"@type": "PropertyValue",
name: "Market Share by Type",
description: `Percentage market share of ${type} car registrations by type`,
},
],
// TODO: For future use
// distribution: [
// {
// "@type": "DataDownload",
// encodingFormat: "image/png",
// contentUrl: `${SITE_URL}/images/${type}-car-stats.png`,
// },
// ],
};

return (
<section>
<StructuredData data={structuredData} />
<div className="flex flex-col gap-y-8">
<UnreleasedFeature>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/cars">Cars</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
{capitaliseWords(decodeURIComponent(type))}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</UnreleasedFeature>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-end gap-x-2">
<Typography.H1 className="uppercase">
{capitaliseWords(decodeURIComponent(type))}
</Typography.H1>
</div>
<Suspense fallback={null}>
<MonthSelector months={months} />
</Suspense>
</div>
<Tabs defaultValue={decodeURIComponent(type)}>
<ScrollArea>
<TabsList>
{Object.entries(tabItems).map(([title, href]) => {
return (
<Link key={title} href={href}>
<TabsTrigger value={title}>
{capitaliseWords(title)}
</TabsTrigger>
</Link>
);
})}
</TabsList>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</Tabs>
<div className="grid grid-cols-1 gap-4">
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent>
<Suspense fallback={null}>
<TrendChart data={filteredCars} />
</Suspense>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Registrations</CardTitle>
</CardHeader>
<CardContent>
<DataTable data={filteredCars} />
</CardContent>
</Card>
</div>
</div>
</section>
);
};

export default CarsByVehicleTypePage;
34 changes: 30 additions & 4 deletions config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ export const MEDAL_MAPPING: Record<number, string> = {
export const FEATURE_FLAG_UNRELEASED =
process.env.NEXT_PUBLIC_FEATURE_FLAG_UNRELEASED === "true";

export const CAR_LINKS: LinkItem[] = [
{ label: "Monthly", href: "/cars" },
export const FUEL_TYPE_LINKS: LinkItem[] = [
{
label: "Petrol",
href: "/cars/petrol",
Expand All @@ -63,14 +62,41 @@ export const CAR_LINKS: LinkItem[] = [
label: "Diesel",
href: "/cars/diesel",
},
];
].sort((a, b) => a.label.localeCompare(b.label));

export const VEHICLE_TYPE_LINKS: LinkItem[] = [
{
label: "Hatchback",
href: "/vehicle-type/hatchback",
},
{
label: "Sedan",
href: "/vehicle-type/sedan",
},
{
label: "Multi-purpose Vehicle",
href: "/vehicle-type/multi-purpose vehicle",
},
{
label: "Station-wagon",
href: "/vehicle-type/station-wagon",
},
{
label: "Sports Utility Vehicle",
href: "/vehicle-type/sports utility vehicle",
},
{
label: "Coupe/Convertible",
href: `/vehicle-type/${encodeURIComponent("coupe/ convertible")}`,
},
].sort((a, b) => a.label.localeCompare(b.label));

export const COE_LINKS: LinkItem[] = [
{ href: "/coe/prices", label: "COE Prices" },
{ href: "/coe/bidding", label: "COE Bidding" },
];

export const SITE_LINKS: LinkItem[] = [
...CAR_LINKS,
...FUEL_TYPE_LINKS,
// ...COE_LINKS
];

0 comments on commit a8f6eb7

Please sign in to comment.