Skip to content

Commit 091f27f

Browse files
authored
Better SEO titles/descriptions in Contract DB (#256)
1 parent 7dcc4cb commit 091f27f

File tree

2 files changed

+299
-1
lines changed

2 files changed

+299
-1
lines changed
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { Metadata } from "next";
2+
import { generateHreflangAlternates } from "@/lib/utils";
3+
4+
const BASE = "https://api.canadasbuilding.com/canada-spends";
5+
6+
function jsonFetcher(url: string) {
7+
return fetch(url, { cache: "no-store" }).then((res) =>
8+
res.ok ? res.json() : null,
9+
);
10+
}
11+
12+
// Helper function to format currency
13+
function formatCurrency(value: number): string {
14+
return new Intl.NumberFormat("en-US", {
15+
style: "currency",
16+
currency: "CAD",
17+
minimumFractionDigits: 0,
18+
maximumFractionDigits: 0,
19+
}).format(value);
20+
}
21+
22+
// Helper function to optimize title length (keep under 70 chars)
23+
function optimizeTitle(
24+
baseTitle: string,
25+
suffix: string = " | Canada Spends",
26+
): string {
27+
let title = baseTitle + suffix;
28+
29+
if (title.length > 70) {
30+
// Try removing optional parts from baseTitle (like periods in parentheses)
31+
// This is handled by the caller, so if still too long, truncate
32+
const maxBaseLength = 70 - suffix.length;
33+
if (maxBaseLength > 0) {
34+
return baseTitle.substring(0, maxBaseLength - 3) + "..." + suffix;
35+
}
36+
// If suffix itself is too long, just return truncated baseTitle
37+
return baseTitle.substring(0, 67) + "...";
38+
}
39+
40+
return title;
41+
}
42+
43+
// Generate metadata for SEO
44+
export async function generateMetadata({
45+
params,
46+
}: {
47+
params: Promise<{ id: string; database: string; lang: string }>;
48+
}): Promise<Metadata> {
49+
const { id, database, lang } = await params;
50+
51+
// Fetch data based on database type
52+
const url = `${BASE}/${database}/${id}.json?_shape=array`;
53+
const data = await jsonFetcher(url);
54+
55+
if (!data || data.length === 0) {
56+
return {
57+
title: "Record Not Found | Canada Spends",
58+
alternates: generateHreflangAlternates(lang, "/search/[database]/[id]", {
59+
database,
60+
id,
61+
}),
62+
};
63+
}
64+
65+
const record = data[0];
66+
let title = "";
67+
let description = "";
68+
let ogTitle = "";
69+
70+
// Generate metadata based on database type
71+
switch (database) {
72+
case "contracts-over-10k": {
73+
const vendorName = record.vendor_name || "Unknown Vendor";
74+
const contractValue = record.contract_value || 0;
75+
const reportingPeriod = record.reporting_period || "";
76+
const contractDescription =
77+
record.description_en || "Government Contract";
78+
const comments =
79+
record.comments_en || record.additional_comments_en || "";
80+
81+
const formattedValue = formatCurrency(contractValue);
82+
// Build title with optional period, optimizeTitle will truncate if needed
83+
let baseTitle = `${vendorName} - ${formattedValue} Contract`;
84+
if (reportingPeriod) {
85+
baseTitle += ` (${reportingPeriod})`;
86+
}
87+
title = optimizeTitle(baseTitle);
88+
89+
description = `${vendorName} - ${formattedValue} government contract`;
90+
if (
91+
contractDescription &&
92+
contractDescription !== "Government Contract"
93+
) {
94+
description += ` for ${contractDescription}`;
95+
}
96+
if (reportingPeriod) {
97+
description += ` (${reportingPeriod})`;
98+
}
99+
if (comments && description.length < 100) {
100+
const availableSpace = 155 - description.length - 3;
101+
const truncatedComments =
102+
comments.length > availableSpace
103+
? comments.substring(0, availableSpace).trim() + "..."
104+
: comments;
105+
description += `. ${truncatedComments}`;
106+
}
107+
108+
ogTitle = `${vendorName} - ${formattedValue} Government Contract`;
109+
break;
110+
}
111+
112+
case "nserc_grants": {
113+
const institution = record.institution || "Unknown Institution";
114+
const amount = record.award_amount || 0;
115+
const projectTitle = record.title || "Research Grant";
116+
const fiscalYear = record.fiscal_year || "";
117+
const investigator = record.project_lead_name || "";
118+
119+
const formattedAmount = formatCurrency(amount);
120+
const baseTitle = `${institution} - ${formattedAmount} NSERC Grant${fiscalYear ? ` (${fiscalYear})` : ""}`;
121+
title = optimizeTitle(baseTitle);
122+
123+
description = `${institution} - ${formattedAmount} NSERC research grant`;
124+
if (investigator) {
125+
description += ` awarded to ${investigator}`;
126+
}
127+
if (fiscalYear) {
128+
description += ` (${fiscalYear})`;
129+
}
130+
if (projectTitle && description.length < 100) {
131+
const availableSpace = 155 - description.length - 3;
132+
const truncatedTitle =
133+
projectTitle.length > availableSpace
134+
? projectTitle.substring(0, availableSpace).trim() + "..."
135+
: projectTitle;
136+
description += `. ${truncatedTitle}`;
137+
}
138+
139+
ogTitle = `${institution} - ${formattedAmount} NSERC Research Grant`;
140+
break;
141+
}
142+
143+
case "cihr_grants": {
144+
const institution = record.institution || "Unknown Institution";
145+
const amount = record.award_amount || 0;
146+
const projectTitle = record.title || "Research Grant";
147+
const competitionYear = record.competition_year || "";
148+
const investigator = record.project_lead_name || "";
149+
150+
const formattedAmount = formatCurrency(amount);
151+
const baseTitle = `${institution} - ${formattedAmount} CIHR Grant${competitionYear ? ` (${competitionYear})` : ""}`;
152+
title = optimizeTitle(baseTitle);
153+
154+
description = `${institution} - ${formattedAmount} CIHR research grant`;
155+
if (investigator) {
156+
description += ` awarded to ${investigator}`;
157+
}
158+
if (competitionYear) {
159+
description += ` (${competitionYear})`;
160+
}
161+
if (projectTitle && description.length < 100) {
162+
const availableSpace = 155 - description.length - 3;
163+
const truncatedTitle =
164+
projectTitle.length > availableSpace
165+
? projectTitle.substring(0, availableSpace).trim() + "..."
166+
: projectTitle;
167+
description += `. ${truncatedTitle}`;
168+
}
169+
170+
ogTitle = `${institution} - ${formattedAmount} CIHR Research Grant`;
171+
break;
172+
}
173+
174+
case "sshrc_grants": {
175+
const applicant =
176+
record.applicant || record.organization || "Unknown Applicant";
177+
const amount = record.amount || 0;
178+
const projectTitle = record.title || "Research Grant";
179+
const fiscalYear = record.fiscal_year || "";
180+
const organization = record.organization || "";
181+
182+
const formattedAmount = formatCurrency(amount);
183+
const baseTitle = `${applicant} - ${formattedAmount} SSHRC Grant${fiscalYear ? ` (${fiscalYear})` : ""}`;
184+
title = optimizeTitle(baseTitle);
185+
186+
description = `${applicant} - ${formattedAmount} SSHRC research grant`;
187+
if (organization && organization !== applicant) {
188+
description += ` at ${organization}`;
189+
}
190+
if (fiscalYear) {
191+
description += ` (${fiscalYear})`;
192+
}
193+
if (projectTitle && description.length < 100) {
194+
const availableSpace = 155 - description.length - 3;
195+
const truncatedTitle =
196+
projectTitle.length > availableSpace
197+
? projectTitle.substring(0, availableSpace).trim() + "..."
198+
: projectTitle;
199+
description += `. ${truncatedTitle}`;
200+
}
201+
202+
ogTitle = `${applicant} - ${formattedAmount} SSHRC Research Grant`;
203+
break;
204+
}
205+
206+
case "global_affairs_grants": {
207+
const recipient = record.executingAgencyPartner || "Unknown Partner";
208+
const amount = parseFloat(record.maximumContribution) || 0;
209+
const projectTitle = record.title || "International Grant";
210+
const programName = record.programName || "";
211+
const year = record.start
212+
? new Date(record.start).getFullYear().toString()
213+
: "";
214+
215+
const formattedAmount = formatCurrency(amount);
216+
const baseTitle = `${recipient} - ${formattedAmount} Global Affairs Grant${year ? ` (${year})` : ""}`;
217+
title = optimizeTitle(baseTitle);
218+
219+
description = `${recipient} - ${formattedAmount} Global Affairs Canada grant`;
220+
if (programName) {
221+
description += ` under ${programName}`;
222+
}
223+
if (year) {
224+
description += ` (${year})`;
225+
}
226+
if (projectTitle && description.length < 100) {
227+
const availableSpace = 155 - description.length - 3;
228+
const truncatedTitle =
229+
projectTitle.length > availableSpace
230+
? projectTitle.substring(0, availableSpace).trim() + "..."
231+
: projectTitle;
232+
description += `. ${truncatedTitle}`;
233+
}
234+
235+
ogTitle = `${recipient} - ${formattedAmount} Global Affairs Grant`;
236+
break;
237+
}
238+
239+
case "transfers": {
240+
const recipient = record.RCPNT_NML_EN_DESC || "Unknown Recipient";
241+
const amount = record.AGRG_PYMT_AMT || 0;
242+
const recipientClass = record.RCPNT_CLS_EN_DESC || "Transfer Payment";
243+
const fiscalYear = record.FSCL_YR || "";
244+
const department = record.DEPT_EN_DESC || "";
245+
246+
const formattedAmount = formatCurrency(amount);
247+
const baseTitle = `${recipient} - ${formattedAmount} Federal Transfer${fiscalYear ? ` (${fiscalYear})` : ""}`;
248+
title = optimizeTitle(baseTitle);
249+
250+
description = `${recipient} - ${formattedAmount} federal transfer payment`;
251+
if (recipientClass && recipientClass !== "Transfer Payment") {
252+
description += ` (${recipientClass})`;
253+
}
254+
if (department) {
255+
description += ` from ${department}`;
256+
}
257+
if (fiscalYear) {
258+
description += ` (${fiscalYear})`;
259+
}
260+
261+
ogTitle = `${recipient} - ${formattedAmount} Federal Transfer`;
262+
break;
263+
}
264+
265+
default:
266+
return {
267+
title: "Spending Details | Canada Spends",
268+
description:
269+
"View detailed information about government spending on Canada Spends.",
270+
alternates: generateHreflangAlternates(
271+
lang,
272+
"/search/[database]/[id]",
273+
{
274+
database,
275+
id,
276+
},
277+
),
278+
};
279+
}
280+
281+
return {
282+
title,
283+
description,
284+
alternates: generateHreflangAlternates(lang, "/search/[database]/[id]", {
285+
database,
286+
id,
287+
}),
288+
openGraph: {
289+
title: ogTitle,
290+
description,
291+
type: "website",
292+
},
293+
};
294+
}

src/app/[lang]/(main)/search/[database]/[id]/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
22
import { Trans } from "@lingui/react/macro";
33
import { DetailsPage } from "./DetailsPage";
44
import { ContractsOver10k } from "./Contracts";
5+
import { generateMetadata } from "./metadata";
56

67
interface Props {
78
id: string;
@@ -17,12 +18,15 @@ function jsonFetcher(url: string) {
1718
);
1819
}
1920

21+
// Re-export generateMetadata from metadata.ts
22+
export { generateMetadata };
23+
2024
// ... KeyValueTable, NSERCGrants, CIHRGrants, etc. ...
2125

2226
export default async function Page({
2327
params,
2428
}: {
25-
params: { id: string; database: string };
29+
params: Promise<{ id: string; database: string; lang: string }>;
2630
}) {
2731
const { id, database } = await params;
2832

0 commit comments

Comments
 (0)