From f49bf88a9d214123503f917d3529a7e668a1d40b Mon Sep 17 00:00:00 2001 From: xrendan Date: Tue, 28 Apr 2026 12:50:22 -0600 Subject: [PATCH 1/2] trade barriers: add /trade-barriers tracker UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to york_factory#33. Renders the interprovincial trade barriers tracker against the new york_factory CMS endpoints. - Index at /trade-barriers: KPIs, status overview, activity chart, filterable list of agreements, FAQ modal. - Detail page at /trade-barriers/: agreement summary, description, jurisdiction status table, history timeline. Per-agreement SEO metadata via generateMetadata. Replaces the original Supabase AgreementModal with a real page. - src/lib/api/trade-barriers.ts wraps the new york_factory endpoints (/api/v1/trade_barriers/{agreements,themes}, /api/v1/warehouse/jurisdictions) via the existing apiFetch client. - Pages are force-dynamic so the build does not depend on the API being live yet; they SSR at request time once york_factory deploys. - Sitemap includes /trade-barriers and per-slug entries (silently skipped if the API is unavailable at build). - Components reuse TradingPost's existing chart.js/lucide-react/base-ui deps and avoid pulling in shadcn primitives the project does not ship. AgreementForm/Modal/auth/SimpleAnalytics from the source repo are intentionally omitted. To surface this on /projects, add a Tool entry in york_factory admin with externalUrl=/trade-barriers — the projects grid is data-driven. --- public/trade-barriers/buildcanada-logo.svg | 15 + public/trade-barriers/og.png | Bin 0 -> 20272 bytes src/app/sitemap.ts | 16 + src/app/trade-barriers/[slug]/page.tsx | 60 ++++ src/app/trade-barriers/layout.tsx | 32 ++ src/app/trade-barriers/page.tsx | 25 ++ .../trade-barriers/ActivityChart.tsx | 215 +++++++++++++ .../trade-barriers/AgreementDetail.tsx | 197 ++++++++++++ .../trade-barriers/AgreementsList.tsx | 121 ++++++++ src/components/trade-barriers/FAQModal.tsx | 125 ++++++++ .../trade-barriers/FiltersPanel.tsx | 255 ++++++++++++++++ src/components/trade-barriers/KPICards.tsx | 100 +++++++ src/components/trade-barriers/Timeline.tsx | 119 ++++++++ .../trade-barriers/TradeBarriersPage.tsx | 283 ++++++++++++++++++ src/components/trade-barriers/utils.ts | 143 +++++++++ src/lib/api/trade-barriers.ts | 50 ++++ src/lib/api/types.ts | 66 ++++ 17 files changed, 1822 insertions(+) create mode 100644 public/trade-barriers/buildcanada-logo.svg create mode 100644 public/trade-barriers/og.png create mode 100644 src/app/trade-barriers/[slug]/page.tsx create mode 100644 src/app/trade-barriers/layout.tsx create mode 100644 src/app/trade-barriers/page.tsx create mode 100644 src/components/trade-barriers/ActivityChart.tsx create mode 100644 src/components/trade-barriers/AgreementDetail.tsx create mode 100644 src/components/trade-barriers/AgreementsList.tsx create mode 100644 src/components/trade-barriers/FAQModal.tsx create mode 100644 src/components/trade-barriers/FiltersPanel.tsx create mode 100644 src/components/trade-barriers/KPICards.tsx create mode 100644 src/components/trade-barriers/Timeline.tsx create mode 100644 src/components/trade-barriers/TradeBarriersPage.tsx create mode 100644 src/components/trade-barriers/utils.ts create mode 100644 src/lib/api/trade-barriers.ts diff --git a/public/trade-barriers/buildcanada-logo.svg b/public/trade-barriers/buildcanada-logo.svg new file mode 100644 index 0000000..5145156 --- /dev/null +++ b/public/trade-barriers/buildcanada-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/trade-barriers/og.png b/public/trade-barriers/og.png new file mode 100644 index 0000000000000000000000000000000000000000..f03b6e70d600437ad531b6506e70ca44f2ad3e5f GIT binary patch literal 20272 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 UN*J`Bxwx+DnfzV+*WIW83n~3R@Bjb+ literal 0 HcmV?d00001 diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index f992ca0..eb90fec 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,5 +1,6 @@ import type { MetadataRoute } from "next"; import { fetchBuilders, fetchFeedItems, fetchMemos, fetchTools } from "@/lib/api"; +import { fetchAgreements } from "@/lib/api/trade-barriers"; import { fetchApi } from "@/lib/tracker-api"; import type { CommitmentsResponse, @@ -16,6 +17,7 @@ export default async function sitemap(): Promise { { url: `${baseUrl}/tracker`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.8 }, { url: `${baseUrl}/tracker/commitments`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 }, { url: `${baseUrl}/tracker/faq`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.4 }, + { url: `${baseUrl}/trade-barriers`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.8 }, { url: `${baseUrl}/builders`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 }, { url: `${baseUrl}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 }, { url: `${baseUrl}/privacy-notice`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 }, @@ -89,6 +91,19 @@ export default async function sitemap(): Promise { priority: 0.6, })); + let agreementPages: MetadataRoute.Sitemap = []; + try { + const agreements = await fetchAgreements(); + agreementPages = agreements.map((a) => ({ + url: `${baseUrl}/trade-barriers/${a.slug}`, + lastModified: a.updated_at ? new Date(a.updated_at) : new Date(), + changeFrequency: "weekly", + priority: 0.6, + })); + } catch { + // Trade barriers API may not be available yet — skip without failing the sitemap. + } + return [ ...staticPages, ...projectPages, @@ -97,5 +112,6 @@ export default async function sitemap(): Promise { ...builderPages, ...memoPages, ...feedPages, + ...agreementPages, ]; } diff --git a/src/app/trade-barriers/[slug]/page.tsx b/src/app/trade-barriers/[slug]/page.tsx new file mode 100644 index 0000000..847e554 --- /dev/null +++ b/src/app/trade-barriers/[slug]/page.tsx @@ -0,0 +1,60 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { fetchAgreement } from "@/lib/api/trade-barriers"; +import AgreementDetail from "@/components/trade-barriers/AgreementDetail"; + +export const dynamic = "force-dynamic"; +export const revalidate = 300; + +interface Params { + slug: string; +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { slug } = await params; + const agreement = await fetchAgreement(slug); + if (!agreement) return { title: "Not found" }; + + const description = + agreement.summary?.slice(0, 200) ?? "Interprovincial trade agreement"; + const url = `https://www.buildcanada.com/trade-barriers/${slug}`; + + return { + title: agreement.title, + description, + alternates: { canonical: url }, + openGraph: { + type: "article", + title: agreement.title, + description, + url, + images: ["/trade-barriers/og.png"], + }, + twitter: { + card: "summary_large_image", + title: agreement.title, + description, + images: ["/trade-barriers/og.png"], + }, + }; +} + +export default async function Page({ + params, +}: { + params: Promise; +}) { + const { slug } = await params; + const agreement = await fetchAgreement(slug); + if (!agreement) notFound(); + + return ( +
+ +
+ ); +} diff --git a/src/app/trade-barriers/layout.tsx b/src/app/trade-barriers/layout.tsx new file mode 100644 index 0000000..4f00622 --- /dev/null +++ b/src/app/trade-barriers/layout.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: { + default: "Trade Barriers Tracker · Build Canada", + template: "%s · Build Canada Trade Barriers", + }, + description: + "Tracking progress of interprovincial trade agreements across Canada — agreements between provinces and territories that reduce barriers to trade and labour mobility.", + openGraph: { + type: "website", + title: "Trade Barriers Tracker · Build Canada", + description: + "Tracking progress of interprovincial trade agreements across Canada.", + images: ["/trade-barriers/og.png"], + }, + twitter: { + card: "summary_large_image", + title: "Trade Barriers Tracker · Build Canada", + description: + "Tracking progress of interprovincial trade agreements across Canada.", + images: ["/trade-barriers/og.png"], + }, +}; + +export default function TradeBarriersLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/trade-barriers/page.tsx b/src/app/trade-barriers/page.tsx new file mode 100644 index 0000000..8db5bc0 --- /dev/null +++ b/src/app/trade-barriers/page.tsx @@ -0,0 +1,25 @@ +import { + fetchAgreements, + fetchJurisdictions, + fetchThemes, +} from "@/lib/api/trade-barriers"; +import TradeBarriersPage from "@/components/trade-barriers/TradeBarriersPage"; + +export const dynamic = "force-dynamic"; +export const revalidate = 300; + +export default async function Page() { + const [agreements, jurisdictions, themes] = await Promise.all([ + fetchAgreements(), + fetchJurisdictions(), + fetchThemes(), + ]); + + return ( + + ); +} diff --git a/src/components/trade-barriers/ActivityChart.tsx b/src/components/trade-barriers/ActivityChart.tsx new file mode 100644 index 0000000..b99787c --- /dev/null +++ b/src/components/trade-barriers/ActivityChart.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LinearScale, + Title, + Tooltip, +} from "chart.js"; +import { Bar } from "react-chartjs-2"; +import type { YFAgreement, YFAgreementStatus } from "@/lib/api/types"; +import { AGREEMENT_STATUS_LABEL } from "./utils"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); + +const STATUS_COLORS: Record = { + awaiting_sponsorship: "#f59e0b", + under_negotiation: "#3b82f6", + agreement_reached: "#10b981", + partially_implemented: "#8b5cf6", + implemented: "#059669", + deferred: "#ef4444", +}; + +interface MonthlyData { + month: string; + year: number; + monthName: string; + changes: number; + statusBreakdown: Partial>; +} + +export default function ActivityChart({ + agreements, +}: { + agreements: YFAgreement[]; +}) { + const [timeRange, setTimeRange] = useState<"12months" | "alltime">( + "12months", + ); + + const allChanges = useMemo(() => { + const out: { date: Date; status: YFAgreementStatus }[] = []; + agreements.forEach((a) => + a.history.forEach((h) => + out.push({ date: new Date(h.date_entered), status: h.status }), + ), + ); + return out; + }, [agreements]); + + const earliestDate = useMemo(() => { + if (!allChanges.length) return new Date("2018-01-01"); + const min = new Date(Math.min(...allChanges.map((c) => c.date.getTime()))); + min.setDate(1); + return min; + }, [allChanges]); + + const filteredChanges = useMemo(() => { + if (timeRange === "12months") { + const twelveMonthsAgo = new Date(); + twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 12); + return allChanges.filter((c) => c.date >= twelveMonthsAgo); + } + return allChanges.filter((c) => c.date >= earliestDate); + }, [allChanges, timeRange, earliestDate]); + + const monthlyData = useMemo(() => { + const map = new Map< + string, + { total: number; statusBreakdown: Partial> } + >(); + filteredChanges.forEach((c) => { + const key = `${c.date.getFullYear()}-${String(c.date.getMonth() + 1).padStart(2, "0")}`; + const entry = map.get(key) ?? { total: 0, statusBreakdown: {} }; + entry.total += 1; + entry.statusBreakdown[c.status] = (entry.statusBreakdown[c.status] ?? 0) + 1; + map.set(key, entry); + }); + + const data: MonthlyData[] = []; + const now = new Date(); + if (timeRange === "12months") { + for (let i = 11; i >= 0; i--) { + const d = new Date(); + d.setMonth(now.getMonth() - i); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const entry = map.get(key) ?? { total: 0, statusBreakdown: {} }; + data.push({ + month: key, + year: d.getFullYear(), + monthName: d.toLocaleDateString("en-US", { month: "short" }), + changes: entry.total, + statusBreakdown: entry.statusBreakdown, + }); + } + } else { + const d = new Date(earliestDate); + while (d <= now) { + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const entry = map.get(key) ?? { total: 0, statusBreakdown: {} }; + data.push({ + month: key, + year: d.getFullYear(), + monthName: d.toLocaleDateString("en-US", { month: "short" }), + changes: entry.total, + statusBreakdown: entry.statusBreakdown, + }); + d.setMonth(d.getMonth() + 1); + } + } + return data; + }, [filteredChanges, timeRange, earliestDate]); + + const chartData = useMemo(() => { + const labels = monthlyData.map((m, i) => { + const prev = monthlyData[i - 1]; + const showYear = m.monthName === "Jan" || !prev || prev.year !== m.year; + return showYear ? `${m.monthName} ${m.year}` : m.monthName; + }); + const allStatuses = new Set(); + monthlyData.forEach((m) => + (Object.keys(m.statusBreakdown) as YFAgreementStatus[]).forEach((s) => + allStatuses.add(s), + ), + ); + const datasets = Array.from(allStatuses).map((status) => ({ + label: AGREEMENT_STATUS_LABEL[status], + data: monthlyData.map((m) => m.statusBreakdown[status] ?? 0), + backgroundColor: STATUS_COLORS[status], + borderColor: STATUS_COLORS[status], + borderWidth: 1, + })); + return { labels, datasets }; + }, [monthlyData]); + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: true, position: "top" as const }, + }, + scales: { + x: { stacked: true, grid: { display: false } }, + y: { + stacked: true, + beginAtZero: true, + title: { display: true, text: "# of changes" }, + }, + }, + interaction: { intersect: false, mode: "index" as const }, + }; + + return ( +
+
+
+

+ Activity Timeline +

+
+ {timeRange === "12months" + ? "Status changes over the last 12 months" + : "Status changes since earliest recorded agreement"} +
+
+
+ setTimeRange("12months")} + > + 12 Months + + setTimeRange("alltime")} + > + All Time + +
+
+
+
+ +
+
+
+ ); +} + +function RangeButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/components/trade-barriers/AgreementDetail.tsx b/src/components/trade-barriers/AgreementDetail.tsx new file mode 100644 index 0000000..7aa7b72 --- /dev/null +++ b/src/components/trade-barriers/AgreementDetail.tsx @@ -0,0 +1,197 @@ +import Link from "next/link"; +import { ArrowLeft, Calendar, ExternalLink, MapPin, Tag } from "lucide-react"; +import type { YFAgreementDetail } from "@/lib/api/types"; +import Timeline from "./Timeline"; +import { + AGREEMENT_STATUS_LABEL, + formatDate, + getAgreementStatusColor, + getJurisdictionStatusColor, + isOverdue, + JURISDICTION_STATUS_LABEL, +} from "./utils"; + +export default function AgreementDetail({ + agreement, +}: { + agreement: YFAgreementDetail; +}) { + const overdue = isOverdue(agreement.deadline, agreement.status); + + return ( +
+ + Back to Trade Barriers + + +

{agreement.title}

+ +
+
+
+ {AGREEMENT_STATUS_LABEL[agreement.status]} +
+ {agreement.theme && ( +
+ + {agreement.theme.name} +
+ )} +
+ +
+
+ + + {agreement.status === "implemented" ? "Completed" : "Deadline"}: + {" "} + {formatDate(agreement.deadline)} + {overdue && (Overdue)} +
+
+ + Launch Date:{" "} + {formatDate(agreement.launch_date)} +
+
+ + {agreement.history.length > 0 && ( +
+

Timeline

+ +
+ )} + + {agreement.summary && ( +
+

Summary

+

{agreement.summary}

+
+ )} + + {agreement.description && ( +
+

Description

+

{agreement.description}

+
+ )} + +
+

+ + Jurisdiction Status +

+
+ + + + + + + + + + {agreement.jurisdictions.map((j, i) => ( + + + + + + ))} + +
+ Jurisdiction + + Status + + Notes +
{j.name} + + {JURISDICTION_STATUS_LABEL[j.status]} + + +
{j.notes}
+ {j.history && j.history.length > 0 && ( +
+
Recent:
+ {j.history.slice(0, 1).map((h, idx) => ( +
+ + {JURISDICTION_STATUS_LABEL[h.status]} + + {formatDate(h.date_entered)} +
+ ))} +
+ )} +
+
+
+ + {agreement.history.length > 0 && ( +
+

+ Agreement History +

+
+ {agreement.history.map((h, i) => ( +
+
+
+
+ {AGREEMENT_STATUS_LABEL[h.status]} +
+
+ {formatDate(h.date_entered)} +
+
+
+ ))} +
+
+ )} + +
+
+
+
+ Updated:{" "} + {formatDate(agreement.updated_at)} +
+
+ {agreement.source_url && ( + + + View Source + + )} +
+
+
+
+ ); +} diff --git a/src/components/trade-barriers/AgreementsList.tsx b/src/components/trade-barriers/AgreementsList.tsx new file mode 100644 index 0000000..3f458b6 --- /dev/null +++ b/src/components/trade-barriers/AgreementsList.tsx @@ -0,0 +1,121 @@ +import Link from "next/link"; +import { Calendar, Tag } from "lucide-react"; +import type { YFAgreement } from "@/lib/api/types"; +import { + AGREEMENT_STATUS_LABEL, + formatDate, + getAgreementStatusColor, + getParticipatingJurisdictions, + isOverdue, +} from "./utils"; + +export default function AgreementsList({ + agreements, +}: { + agreements: YFAgreement[]; +}) { + return ( + <> + {agreements.map((item) => { + const overdue = isOverdue(item.deadline, item.status); + const sortedHistory = [...item.history].sort( + (a, b) => + new Date(a.date_entered).getTime() - + new Date(b.date_entered).getTime(), + ); + const recent = sortedHistory.at(-1); + const participating = getParticipatingJurisdictions(item.jurisdictions); + + return ( + +
+

+ {item.title} +

+
+ + {AGREEMENT_STATUS_LABEL[item.status]} + + {item.theme && ( +
+ + {item.theme.name} +
+ )} +
+
+ +
+
+
+ {participating.slice(0, 3).map((j) => ( + + {j.name} + {j.history && j.history.length > 0 && ( + + )} + + ))} + {participating.length > 3 && ( + + +{participating.length - 3} more + + )} +
+
+ + {recent && ( +
+
+ Recent History: +
+
+
+ + + {AGREEMENT_STATUS_LABEL[recent.status]} + + + {formatDate(recent.date_entered)} +
+ {item.history.length > 1 && ( +
+ +{item.history.length - 1} more entries +
+ )} +
+
+ )} + +
+
+
+ + + {item.status === "implemented" ? "Completed" : "Deadline"}: + {" "} + {formatDate(item.deadline)} + {overdue && (Overdue)} +
+
+
+
+ + ); + })} + + ); +} diff --git a/src/components/trade-barriers/FAQModal.tsx b/src/components/trade-barriers/FAQModal.tsx new file mode 100644 index 0000000..17e6eb9 --- /dev/null +++ b/src/components/trade-barriers/FAQModal.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +interface FAQItem { + question: string; + answer: string; +} + +const faqData: FAQItem[] = [ + { + question: "What is the Trade Barriers Tracker?", + answer: + "This dashboard tracks agreements amongst Canadian provinces and territories that reduce or eliminate barriers to trade and labour mobility.\n\nIt shows commitments that have been made, the scope of each agreement, which governments are participating, and the current status of implementation.", + }, + { + question: "What is an interprovincial trade barrier?", + answer: + "Internal trade barriers should not be thought of as light switches (i.e. either trade is permitted or it is not). Rather, they are costs incurred on account of transacting across internal borders.\n\nExamples include: varying driver qualifications for long-combination vehicles, inconsistent definition of sunrise and sunset for trucking restrictions, divergent technical safety rules, duplicative end-of-life reporting requirements for producers of electronics.", + }, + { + question: "What do the statuses mean?", + answer: + "- Awaiting Sponsorship: These are known barriers that have not been added as an item to an agenda\n- Under Negotiation: Jurisdictions are negotiating the item, but an agreement has not yet been reached\n- Agreement Reached: The jurisdictions have reached an agreement to address the item\n- Partially Implemented: At least one jurisdiction has implemented the agreement\n- Implemented: All jurisdictions have fully implemented the agreement\n- Deferred: Jurisdictions have deferred addressing the item", + }, + { + question: "Why does this matter?", + answer: + "Canada's internal trade barriers are estimated to cost over $200 billion each year (equivalent to 7.9% of Canada's GDP) by limiting the free movement of goods, services, and people across provincial borders.\n\nTracking agreements helps citizens, businesses, and policymakers see where progress is being made—and where more work is needed.", + }, + { + question: "Where does the data come from?", + answer: + "We aggregate across reports and press releases of individual governments and agencies, the Council of the Federation, the Committee on Internal Trade, and the Canadian Free Trade Agreement's (i) Internal Trade Secretariat and (ii) Regulatory Reconciliation and Cooperation Table, and more.", + }, + { + question: "How often is the Tracker updated?", + answer: + "Updates are published as new agreements are announced or progress is verified.", + }, + { + question: "Is this an official government site?", + answer: + "No. Build Canada is a non-partisan civic initiative. The Tracker compiles publicly available information to increase transparency.", + }, + { + question: "What should I do if I notice something wrong or incomplete?", + answer: + "We make our best efforts to be accurate, but may not get everything right! We'd love your help with improvements or corrections. Email us at hi@buildcanada.com.", + }, +]; + +export default function FAQModal({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = (i: number) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); + else next.add(i); + return next; + }); + + return ( + + + + + + Frequently Asked Questions + + +
+ {faqData.map((item, i) => ( +
+ + {expanded.has(i) && ( +
+
+ {item.answer} +
+
+ )} +
+ ))} +
+ + + + + + +
+
+
+ ); +} diff --git a/src/components/trade-barriers/FiltersPanel.tsx b/src/components/trade-barriers/FiltersPanel.tsx new file mode 100644 index 0000000..1491d1c --- /dev/null +++ b/src/components/trade-barriers/FiltersPanel.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import type { + YFAgreement, + YFAgreementStatus, + YFJurisdiction, + YFTheme, +} from "@/lib/api/types"; +import { + AGREEMENT_STATUS_LABEL, + getDaysUntilDeadline, +} from "./utils"; + +const DEADLINE_TYPES = [ + "Overdue", + "Due Soon (30 days)", + "On Track", + "No Deadline", +] as const; +type DeadlineType = (typeof DEADLINE_TYPES)[number]; + +const STATUS_OPTIONS: YFAgreementStatus[] = [ + "awaiting_sponsorship", + "under_negotiation", + "agreement_reached", + "partially_implemented", + "implemented", + "deferred", +]; + +interface FiltersPanelProps { + agreements: YFAgreement[]; + jurisdictions: YFJurisdiction[]; + themes: YFTheme[]; + onFiltersChange: (filtered: YFAgreement[]) => void; + onClearAll?: () => void; +} + +interface Filters { + statuses: YFAgreementStatus[]; + deadlineTypes: DeadlineType[]; + jurisdictionCodes: string[]; + themeIds: number[]; +} + +export default function FiltersPanel({ + agreements, + jurisdictions, + themes, + onFiltersChange, + onClearAll, +}: FiltersPanelProps) { + const [filters, setFilters] = useState({ + statuses: [], + deadlineTypes: [], + jurisdictionCodes: [], + themeIds: [], + }); + + const applyFilters = useCallback( + (f: Filters, list: YFAgreement[]) => { + let result = [...list]; + if (f.statuses.length) { + result = result.filter((a) => f.statuses.includes(a.status)); + } + if (f.deadlineTypes.length) { + result = result.filter((a) => { + const days = getDaysUntilDeadline(a.deadline); + if ( + f.deadlineTypes.includes("Overdue") && + days !== null && + days < 0 && + a.status !== "implemented" + ) + return true; + if ( + f.deadlineTypes.includes("Due Soon (30 days)") && + days !== null && + days <= 30 && + days >= 0 + ) + return true; + if ( + f.deadlineTypes.includes("On Track") && + days !== null && + days > 30 + ) + return true; + if (f.deadlineTypes.includes("No Deadline") && days === null) + return true; + return false; + }); + } + if (f.jurisdictionCodes.length) { + result = result.filter((a) => + a.jurisdictions.some( + (j) => + f.jurisdictionCodes.includes(j.code) && + j.status !== "declined" && + j.status !== "not_applicable" && + j.status !== "unknown", + ), + ); + } + if (f.themeIds.length) { + result = result.filter( + (a) => a.theme && f.themeIds.includes(a.theme.id), + ); + } + return result; + }, + [], + ); + + useEffect(() => { + onFiltersChange(applyFilters(filters, agreements)); + }, [filters, agreements, applyFilters, onFiltersChange]); + + function toggle(key: keyof Filters, value: T) { + setFilters((prev) => { + const arr = prev[key] as unknown as T[]; + const isIn = arr.includes(value); + return { + ...prev, + [key]: isIn ? arr.filter((v) => v !== value) : [...arr, value], + }; + }); + } + + function clearAll() { + setFilters({ + statuses: [], + deadlineTypes: [], + jurisdictionCodes: [], + themeIds: [], + }); + onClearAll?.(); + } + + const activeCount = + filters.statuses.length + + filters.deadlineTypes.length + + filters.jurisdictionCodes.length + + filters.themeIds.length; + + return ( +
+
+ + {STATUS_OPTIONS.map((s) => ( + toggle("statuses", s)} + label={AGREEMENT_STATUS_LABEL[s]} + /> + ))} + + + + {DEADLINE_TYPES.map((d) => ( + toggle("deadlineTypes", d)} + label={d} + /> + ))} + + + + {jurisdictions.map((j) => ( + toggle("jurisdictionCodes", j.code)} + label={j.name} + /> + ))} + + + {themes.length > 0 && ( + + {themes.map((t) => ( + toggle("themeIds", t.id)} + label={t.name} + /> + ))} + + )} + + {activeCount > 0 && ( +
+
+ Active filters: {activeCount} +
+
+ )} +
+ {activeCount > 0 && ( +
+ +
+ )} +
+ ); +} + +function FilterSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function CheckboxRow({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: () => void; + label: string; +}) { + return ( + + ); +} diff --git a/src/components/trade-barriers/KPICards.tsx b/src/components/trade-barriers/KPICards.tsx new file mode 100644 index 0000000..66ba3d4 --- /dev/null +++ b/src/components/trade-barriers/KPICards.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useMemo } from "react"; +import type { YFAgreement } from "@/lib/api/types"; + +export default function KPICards({ agreements }: { agreements: YFAgreement[] }) { + const kpiData = useMemo(() => { + const now = new Date(); + const currentYear = now.getFullYear(); + const lastFullYear = currentYear - 1; + + const currentYearStart = new Date(currentYear, 0, 1); + const previousYearStart = new Date(lastFullYear, 0, 1); + const previousYearEnd = new Date(lastFullYear, 11, 31, 23, 59, 59); + + const getStaleAgreementsAtTime = (asOfDate: Date) => + agreements.filter((a) => { + if (a.status === "awaiting_sponsorship" || a.status === "implemented") { + return false; + } + if (!a.history.length) return true; + const last = a.history[a.history.length - 1]; + const lastDate = new Date(last.date_entered); + const cutoff = new Date(asOfDate); + cutoff.setFullYear(asOfDate.getFullYear() - 1); + return lastDate < cutoff; + }); + + const getNegotiationsInPeriod = (start: Date, end: Date) => + agreements.filter((a) => { + const entry = a.history.find((h, i) => { + if (h.status !== "under_negotiation") return false; + if (i === 0) return true; + return a.history[i - 1].status !== "under_negotiation"; + }); + if (!entry) return false; + const d = new Date(entry.date_entered); + return d >= start && d <= end; + }); + + const currentStale = getStaleAgreementsAtTime(now); + const currentNegotiations = getNegotiationsInPeriod(currentYearStart, now); + const previousStale = getStaleAgreementsAtTime(previousYearEnd); + const previousNegotiations = getNegotiationsInPeriod( + previousYearStart, + previousYearEnd, + ); + + const currentStalePct = + agreements.length > 0 ? (currentStale.length / agreements.length) * 100 : 0; + const previousStalePct = + agreements.length > 0 ? (previousStale.length / agreements.length) * 100 : 0; + + return { + staleAgreementsCount: currentStale.length, + recentNegotiationsCount: currentNegotiations.length, + stalePercentageChange: currentStalePct - previousStalePct, + negotiationsChange: + currentNegotiations.length - previousNegotiations.length, + }; + }, [agreements]); + + return ( +
+
+
+ Stale Agreements +
+
+ {agreements.length === 0 + ? "--%" + : `${Math.round((kpiData.staleAgreementsCount / agreements.length) * 100)}%`} +
+
+ % of Agreements Stagnant for >12 Months +
+
+ ({kpiData.stalePercentageChange >= 0 ? "+" : ""} + {Math.round(kpiData.stalePercentageChange)}% v{" "} + {new Date().getFullYear() - 1}) +
+
+
+
+ Recent Negotiations +
+
+ {kpiData.recentNegotiationsCount} +
+
+ Negotiations Started on New Barriers (Last 12 Months) +
+
+ ({kpiData.negotiationsChange >= 0 ? "+" : ""} + {kpiData.negotiationsChange} v {new Date().getFullYear() - 1}) +
+
+
+ ); +} diff --git a/src/components/trade-barriers/Timeline.tsx b/src/components/trade-barriers/Timeline.tsx new file mode 100644 index 0000000..49a7df6 --- /dev/null +++ b/src/components/trade-barriers/Timeline.tsx @@ -0,0 +1,119 @@ +import type { + YFAgreementHistoryEntry, + YFAgreementStatus, +} from "@/lib/api/types"; +import { AGREEMENT_STATUS_LABEL, formatDate } from "./utils"; + +const STATUS_BAR_COLOR: Record = { + awaiting_sponsorship: "bg-gray-400", + under_negotiation: "bg-yellow-400", + agreement_reached: "bg-orange-400", + partially_implemented: "bg-green-400", + implemented: "bg-green-600", + deferred: "bg-red-400", +}; + +export default function Timeline({ + history, +}: { + history: YFAgreementHistoryEntry[]; +}) { + if (!history.length) return null; + + const sorted = [...history].sort( + (a, b) => + new Date(a.date_entered).getTime() - new Date(b.date_entered).getTime(), + ); + + const startDate = new Date(sorted[0].date_entered); + const endDate = new Date(); + const totalDays = Math.max( + 1, + Math.ceil( + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), + ), + ); + + const minSpacing = 7; + const maxPosition = 95; + const adjustedPositions: number[] = []; + for (let i = 0; i < sorted.length; i++) { + const entryDate = new Date(sorted[i].date_entered); + const daysFromStart = Math.ceil( + (entryDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), + ); + const original = (daysFromStart / totalDays) * 100; + let adjusted = original; + if (i === 0) { + adjusted = 0; + } else { + const prev = adjustedPositions[i - 1]; + const minRequired = prev + minSpacing; + if (adjusted < minRequired) adjusted = minRequired; + adjusted = Math.max(2, Math.min(maxPosition, adjusted)); + } + adjustedPositions.push(adjusted); + } + + const segments: { width: number; color: string; status: YFAgreementStatus }[] = []; + for (let i = 0; i < sorted.length; i++) { + const current = sorted[i]; + const next = sorted[i + 1]; + const segStart = i === 0 ? 0 : adjustedPositions[i]; + const segEnd = next ? adjustedPositions[i + 1] : 100; + if (current.status === "deferred") { + if (segments.length) { + segments[segments.length - 1].width = + 100 - (i === 1 ? 0 : adjustedPositions[i - 1]); + } + break; + } + segments.push({ + width: segEnd - segStart, + color: STATUS_BAR_COLOR[current.status], + status: current.status, + }); + } + + return ( +
+
+
+ {segments.map((s, i) => ( +
+ ))} +
+
+ {sorted.map((entry, i) => { + if (entry.status === "deferred") return null; + return ( +
+
+
+
+ {formatDate(entry.date_entered)} +
+
+ {AGREEMENT_STATUS_LABEL[entry.status]} +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/trade-barriers/TradeBarriersPage.tsx b/src/components/trade-barriers/TradeBarriersPage.tsx new file mode 100644 index 0000000..b37f6ad --- /dev/null +++ b/src/components/trade-barriers/TradeBarriersPage.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Image from "next/image"; +import { ChevronDown, ChevronUp, CircleHelp, Mail, Search } from "lucide-react"; +import type { + YFAgreement, + YFJurisdiction, + YFTheme, +} from "@/lib/api/types"; +import ActivityChart from "./ActivityChart"; +import AgreementsList from "./AgreementsList"; +import FAQModal from "./FAQModal"; +import FiltersPanel from "./FiltersPanel"; +import KPICards from "./KPICards"; +import { getAgreementStats } from "./utils"; + +interface Props { + initialAgreements: YFAgreement[]; + jurisdictions: YFJurisdiction[]; + themes: YFTheme[]; +} + +export default function TradeBarriersPage({ + initialAgreements, + jurisdictions, + themes, +}: Props) { + const [filteredByFilters, setFilteredByFilters] = + useState(initialAgreements); + const [filteredAgreements, setFilteredAgreements] = + useState(initialAgreements); + const [stats, setStats] = useState(getAgreementStats(initialAgreements)); + const [searchQuery, setSearchQuery] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const [faqOpen, setFaqOpen] = useState(false); + + useEffect(() => { + setStats(getAgreementStats(filteredAgreements)); + }, [filteredAgreements]); + + useEffect(() => { + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + setFilteredAgreements( + filteredByFilters.filter((a) => a.title.toLowerCase().includes(q)), + ); + } else { + setFilteredAgreements(filteredByFilters); + } + }, [searchQuery, filteredByFilters]); + + const handleFiltersChange = useCallback((next: YFAgreement[]) => { + setFilteredByFilters(next); + }, []); + + const clearAll = useCallback(() => { + setSearchQuery(""); + setFilteredByFilters(initialAgreements); + }, [initialAgreements]); + + return ( +
+
+
+ Build Canada +
+ +
+

+ Trade Barriers Tracker +

+

+ Tracking progress of interprovincial trade agreements across Canada. +

+
+ +
+ +
+ + +
+
+ +
+ +

+ Filters +

+ +
+ +
+
+
+ +
+
+
+

+ Overview +

+ + {stats.total} total trade agreements + +
+
+ + + + + + +
+ +
+
+ + {stats.total > 0 + ? ((stats.implemented / stats.total) * 100).toFixed(0) + : 0} + % Implemented + +
+
+ + + + + + +
+
+
+ + + + +
+
+
+

+ Agreements ({filteredAgreements.length}) +

+ +
+
+

+ Agreements ({filteredAgreements.length}) +

+ +
+ {filteredAgreements.length !== initialAgreements.length && ( +

+ Showing {filteredAgreements.length} of {initialAgreements.length} agreements +

+ )} +
+ +
+ +
+ + {filteredAgreements.length === 0 && ( +
+
+ No agreements match your filters +
+
+ Try adjusting your filter criteria +
+
+ )} +
+
+ + +
+ ); +} + +function pct(part: number, total: number) { + return total > 0 ? (part / total) * 100 : 0; +} + +function ProgressSegment({ + offset, + width, + className, +}: { + offset: number; + width: number; + className: string; +}) { + return ( +
+ ); +} + +function StatCard({ + label, + value, + color, +}: { + label: string; + value: number; + color: string; +}) { + return ( +
+
+
{value}
+
+ {label} +
+
+
+ ); +} + +function SearchInput({ + value, + onChange, + fullWidth, +}: { + value: string; + onChange: (v: string) => void; + fullWidth?: boolean; +}) { + return ( +
+ + onChange(e.target.value)} + className={`pl-10 pr-4 py-2 border border-[#cdc4bd] bg-white text-sm font-mono uppercase tracking-wide text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${fullWidth ? "w-full" : "w-64"}`} + /> +
+ ); +} diff --git a/src/components/trade-barriers/utils.ts b/src/components/trade-barriers/utils.ts new file mode 100644 index 0000000..1cadc1c --- /dev/null +++ b/src/components/trade-barriers/utils.ts @@ -0,0 +1,143 @@ +import type { + YFAgreement, + YFAgreementJurisdiction, + YFAgreementJurisdictionStatus, + YFAgreementStatus, +} from "@/lib/api/types"; + +export const AGREEMENT_STATUS_LABEL: Record = { + awaiting_sponsorship: "Awaiting Sponsorship", + under_negotiation: "Under Negotiation", + agreement_reached: "Agreement Reached", + partially_implemented: "Partially Implemented", + implemented: "Implemented", + deferred: "Deferred", +}; + +export const JURISDICTION_STATUS_LABEL: Record< + YFAgreementJurisdictionStatus, + string +> = { + unknown: "Unknown", + aware: "Aware", + considering: "Considering", + engaged: "Engaged", + committed: "Committed", + implementing: "Implementing", + complete: "Complete", + declined: "Declined", + not_applicable: "Not Applicable", +}; + +export function getAgreementStatusColor(status: YFAgreementStatus): string { + switch (status) { + case "deferred": + return "bg-red-100 text-red-700 border-red-300"; + case "awaiting_sponsorship": + return "bg-gray-100 text-gray-600 border-gray-300"; + case "under_negotiation": + return "bg-yellow-100 text-yellow-700 border-yellow-300"; + case "agreement_reached": + return "bg-orange-100 text-orange-700 border-orange-300"; + case "partially_implemented": + return "bg-blue-100 text-blue-700 border-blue-300"; + case "implemented": + return "bg-green-100 text-green-700 border-green-300"; + } +} + +export function getJurisdictionStatusColor( + status: YFAgreementJurisdictionStatus, +): string { + switch (status) { + case "unknown": + return "bg-gray-100 text-gray-700 border-gray-300"; + case "aware": + return "bg-blue-50 text-blue-700 border-blue-200"; + case "considering": + return "bg-yellow-50 text-yellow-700 border-yellow-200"; + case "engaged": + return "bg-purple-50 text-purple-700 border-purple-200"; + case "committed": + return "bg-green-50 text-green-700 border-green-200"; + case "implementing": + return "bg-orange-50 text-orange-700 border-orange-200"; + case "complete": + return "bg-green-100 text-green-800 border-green-300"; + case "declined": + return "bg-red-50 text-red-700 border-red-200"; + case "not_applicable": + return "bg-gray-50 text-gray-600 border-gray-200"; + } +} + +export function formatDate(dateString: string | null): string { + if (!dateString) return "No date set"; + return new Date(dateString).toLocaleDateString("en-CA", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function getDaysUntilDeadline(deadline: string | null): number | null { + if (!deadline) return null; + const today = new Date(); + const deadlineDate = new Date(deadline); + const diffTime = deadlineDate.getTime() - today.getTime(); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); +} + +export function isOverdue( + deadline: string | null, + status: YFAgreementStatus, +): boolean { + const days = getDaysUntilDeadline(deadline); + return days !== null && days < 0 && status !== "implemented"; +} + +export interface AgreementStats { + total: number; + awaitingSponsorship: number; + underNegotiation: number; + agreementReached: number; + partiallyImplemented: number; + implemented: number; + deferred: number; +} + +export function getAgreementStats(agreements: YFAgreement[]): AgreementStats { + return { + total: agreements.length, + awaitingSponsorship: agreements.filter( + (a) => a.status === "awaiting_sponsorship", + ).length, + underNegotiation: agreements.filter((a) => a.status === "under_negotiation") + .length, + agreementReached: agreements.filter((a) => a.status === "agreement_reached") + .length, + partiallyImplemented: agreements.filter( + (a) => a.status === "partially_implemented", + ).length, + implemented: agreements.filter((a) => a.status === "implemented").length, + deferred: agreements.filter((a) => a.status === "deferred").length, + }; +} + +export function getParticipatingJurisdictions( + jurisdictions: YFAgreementJurisdiction[], +): YFAgreementJurisdiction[] { + return jurisdictions.filter( + (j) => + j.status !== "declined" && + j.status !== "not_applicable" && + j.status !== "unknown", + ); +} + +export function getUniqueThemeNames(agreements: YFAgreement[]): string[] { + const names = agreements + .map((a) => a.theme?.name) + .filter((n): n is string => Boolean(n && n.trim())); + return Array.from(new Set(names)).sort(); +} diff --git a/src/lib/api/trade-barriers.ts b/src/lib/api/trade-barriers.ts new file mode 100644 index 0000000..0de7b51 --- /dev/null +++ b/src/lib/api/trade-barriers.ts @@ -0,0 +1,50 @@ +import { apiFetch } from "./client"; +import type { + YFAgreement, + YFAgreementDetail, + YFJurisdiction, + YFListResponse, + YFTheme, +} from "./types"; + +export async function fetchAgreements(): Promise { + const res = await apiFetch>( + "/trade_barriers/agreements", + { revalidate: 300 }, + ); + return res.data; +} + +export async function fetchAgreement( + slug: string, +): Promise { + try { + return await apiFetch( + `/trade_barriers/agreements/${encodeURIComponent(slug)}`, + { revalidate: 300 }, + ); + } catch (err) { + if (err instanceof Error && /404/.test(err.message)) return null; + throw err; + } +} + +export async function fetchAgreementSlugs(): Promise { + const agreements = await fetchAgreements(); + return agreements.map((a) => a.slug); +} + +export async function fetchThemes(): Promise { + const res = await apiFetch>("/trade_barriers/themes", { + revalidate: 3600, + }); + return res.data; +} + +export async function fetchJurisdictions(): Promise { + const res = await apiFetch>( + "/warehouse/jurisdictions", + { revalidate: 3600 }, + ); + return res.data; +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index b055c92..38abb24 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -111,3 +111,69 @@ export interface YFFeedItemDetail extends YFFeedItem { embed_code: string | null; author_photo_url: string | null; } + +export type YFAgreementStatus = + | "awaiting_sponsorship" + | "under_negotiation" + | "agreement_reached" + | "partially_implemented" + | "implemented" + | "deferred"; + +export type YFAgreementJurisdictionStatus = + | "unknown" + | "aware" + | "considering" + | "engaged" + | "committed" + | "implementing" + | "complete" + | "declined" + | "not_applicable"; + +export interface YFTheme { + id: number; + name: string; +} + +export interface YFJurisdiction { + id: number; + name: string; + code: string; + level: "federal" | "provincial" | "territorial"; +} + +export interface YFAgreementHistoryEntry { + status: YFAgreementStatus; + date_entered: string; +} + +export interface YFJurisdictionHistoryEntry { + status: YFAgreementJurisdictionStatus; + date_entered: string; +} + +export interface YFAgreementJurisdiction extends YFJurisdiction { + status: YFAgreementJurisdictionStatus; + notes: string | null; + history?: YFJurisdictionHistoryEntry[]; +} + +export interface YFAgreement { + id: number; + slug: string; + title: string; + summary: string | null; + status: YFAgreementStatus; + theme: YFTheme | null; + deadline: string | null; + launch_date: string | null; + source_url: string | null; + jurisdictions: YFAgreementJurisdiction[]; + history: YFAgreementHistoryEntry[]; + updated_at: string; +} + +export interface YFAgreementDetail extends YFAgreement { + description: string | null; +} From e05572c189c0d8f4aec394ad34979956ae9604ec Mon Sep 17 00:00:00 2001 From: xrendan Date: Wed, 29 Apr 2026 08:28:08 -0600 Subject: [PATCH 2/2] posts: add /posts section backed by york_factory posts API Reuses the memos list layout (featured + latest, search, results grid) with author info hidden and category filter disabled. Posts have their own detail page without author card or key messages. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/memos/MemoResultsList.tsx | 66 ++++++++----- src/app/memos/MemoSearch.tsx | 4 +- src/app/memos/MemosListClient.tsx | 18 ++-- src/app/memos/types.ts | 2 +- src/app/posts/[slug]/page.tsx | 149 ++++++++++++++++++++++++++++++ src/app/posts/page.tsx | 58 ++++++++++++ src/app/sitemap.ts | 12 ++- src/components/ui/memo-card.tsx | 43 +++++---- src/lib/api/index.ts | 2 + src/lib/api/posts.ts | 65 +++++++++++++ src/lib/api/types.ts | 14 +++ 11 files changed, 379 insertions(+), 54 deletions(-) create mode 100644 src/app/posts/[slug]/page.tsx create mode 100644 src/app/posts/page.tsx create mode 100644 src/lib/api/posts.ts diff --git a/src/app/memos/MemoResultsList.tsx b/src/app/memos/MemoResultsList.tsx index db5d1dd..a7770e0 100644 --- a/src/app/memos/MemoResultsList.tsx +++ b/src/app/memos/MemoResultsList.tsx @@ -8,37 +8,49 @@ import { useMemosFilter } from "./store"; import { MemoItem, formatDate, shortenName } from "./types"; function MemoGridRow({ memo, basePath }: { memo: MemoItem; basePath: string }) { + const author = memo.author; + const hasMedia = Boolean(author?.photo); return ( -
-
- {memo.author.photo && ( - {memo.author.name} - )} -
+
+ {hasMedia && ( +
+ {author?.photo && ( + {author.name} + )} +
+ )}

{memo.title}

-

- {memo.author.name} - - {shortenName(memo.author.name)} - -

- · + {author && ( + <> +

+ {author.name} + {shortenName(author.name)} +

+ · + + )}

{formatDate(memo.publishedAt, memo.createdAt)}

@@ -69,9 +81,11 @@ function MemoGridRow({ memo, basePath }: { memo: MemoItem; basePath: string }) { export default function MemoResultsList({ memos, basePath = "/memos", + resultsLabel = "Memos", }: { memos: MemoItem[]; basePath?: string; + resultsLabel?: string; }) { const search = useMemosFilter((s) => s.search); const activeCategory = useMemosFilter((s) => s.activeCategory); @@ -87,7 +101,7 @@ export default function MemoResultsList({ list = list.filter( (m) => m.title.toLowerCase().includes(q) || - m.author.name.toLowerCase().includes(q) || + m.author?.name.toLowerCase().includes(q) || m.keyMessage1?.toLowerCase().includes(q) ); } @@ -95,20 +109,22 @@ export default function MemoResultsList({ return list; }, [memos, search, activeCategory]); + const lowerLabel = resultsLabel.toLowerCase(); + return (
{activeCategory - ? `${activeCategory.replace(/-/g, " ")} Memos` - : "All Memos"} + ? `${activeCategory.replace(/-/g, " ")} ${resultsLabel}` + : `All ${resultsLabel}`} {filtered.length === 0 && (

{memos.length === 0 - ? "No memos yet." - : "No memos match your filters."} + ? `No ${lowerLabel} yet.` + : `No ${lowerLabel} match your filters.`}

)}
diff --git a/src/app/memos/MemoSearch.tsx b/src/app/memos/MemoSearch.tsx index 9273504..cc6529c 100644 --- a/src/app/memos/MemoSearch.tsx +++ b/src/app/memos/MemoSearch.tsx @@ -3,7 +3,7 @@ import SectionLabel from "@/components/SectionLabel"; import { useMemosFilter } from "./store"; -export default function MemoSearch() { +export default function MemoSearch({ placeholder = "Search memos..." }: { placeholder?: string } = {}) { const search = useMemosFilter((s) => s.search); const setSearch = useMemosFilter((s) => s.setSearch); @@ -38,7 +38,7 @@ export default function MemoSearch() { type="text" value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="Search memos..." + placeholder={placeholder} className="flex-1 bg-transparent font-mono text-base tracking-normal outline-none placeholder:text-text-secondary" /> {search && ( diff --git a/src/app/memos/MemosListClient.tsx b/src/app/memos/MemosListClient.tsx index eeb1434..1583acc 100644 --- a/src/app/memos/MemosListClient.tsx +++ b/src/app/memos/MemosListClient.tsx @@ -28,9 +28,13 @@ function CategoryFromSearchParams({ categories }: { categories: string[] }) { export default function MemosListClient({ memos, basePath = "/memos", + showCategoryFilter = true, + resultsLabel, }: { memos: MemoItem[]; basePath?: string; + showCategoryFilter?: boolean; + resultsLabel?: string; }) { const categories = useMemo(() => { const cats = new Set(); @@ -42,13 +46,15 @@ export default function MemosListClient({ return ( <> - - - + {showCategoryFilter && ( + + + + )} - - - + {showCategoryFilter && } + + ); } diff --git a/src/app/memos/types.ts b/src/app/memos/types.ts index 95dd14c..1708082 100644 --- a/src/app/memos/types.ts +++ b/src/app/memos/types.ts @@ -5,7 +5,7 @@ export interface MemoItem { author: { name: string; photo: string | null; - }; + } | null; keyMessage1: string | null; keyMessage2: string | null; keyMessage3: string | null; diff --git a/src/app/posts/[slug]/page.tsx b/src/app/posts/[slug]/page.tsx new file mode 100644 index 0000000..583b27f --- /dev/null +++ b/src/app/posts/[slug]/page.tsx @@ -0,0 +1,149 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { fetchPost, fetchPosts, getSiteConfig } from "@/lib/api"; +import { extractHeadings } from "@/lib/extract-headings"; +import { ShareSection } from "@/components/share"; +import { Signpost } from "@/components/custom/signpost"; +import { SubscribeButton } from "@/components/ui/subscribe-button"; +import { buildGraph } from "@/lib/schemas/graph"; +import { generateBreadcrumbSchema } from "@/lib/schemas/generators/breadcrumb"; +import { generateOrganizationSchema } from "@/lib/schemas/generators/organization"; + +export async function generateStaticParams() { + try { + const posts = await fetchPosts(); + return posts.map((p) => ({ slug: p.slug })); + } catch { + return []; + } +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + let post; + try { + post = await fetchPost(slug); + } catch { + return { title: "Post Not Found | Build Canada" }; + } + + const title = `${post.title} | Build Canada`; + const description = post.summary ?? undefined; + const image = post.seoImage || undefined; + + return { + title, + description, + openGraph: { + title, + description, + type: "article", + publishedTime: post.publishedAt ?? post.createdAt, + ...(image && { images: [{ url: image }] }), + }, + twitter: { + card: "summary_large_image", + title, + description, + ...(image && { images: [image] }), + }, + }; +} + +export default async function PostDetailPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + let post; + try { + post = await fetchPost(slug); + } catch { + notFound(); + } + + const date = new Date(post.publishedAt || post.createdAt).toLocaleDateString( + "en-CA", + { year: "numeric", month: "long", day: "numeric" }, + ); + + const configData = getSiteConfig(); + const fullUrl = `${configData.siteUrl}/posts/${post.slug}`; + + const jsonLd = buildGraph( + generateOrganizationSchema(configData), + generateBreadcrumbSchema(`/posts/${post.slug}`, post.title, configData.siteUrl), + ); + + const { headings, html: bodyHtml } = extractHeadings(post.body ?? ""); + + const sidebar = ( +
+ + + Subscribe + +
+ ); + + return ( +
+