Skip to content
Merged
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,340 changes: 1,340 additions & 0 deletions daniel.html

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/rapier": "^2.2.0",
"chart.js": "^4.4.7",
"chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1",
Expand All @@ -33,6 +36,7 @@
"fast-xml-parser": "^5.3.8",
"jose": "^5.9.6",
"lucide-react": "^1.7.0",
"meshline": "^3.3.1",
"mongoose": "^8.18.0",
"next": "16.1.6",
"next-auth": "4.24.13",
Expand All @@ -53,6 +57,7 @@
"sonner": "^2.0.7",
"swr": "^2.3.8",
"tailwind-merge": "^3.5.0",
"three": "^0.185.1",
"tw-animate-css": "^1.4.0",
"unified": "^11.0.5",
"zustand": "^5.0.12"
Expand All @@ -63,6 +68,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.185.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
Expand Down
503 changes: 503 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/elections/toronto/2026/mayor/amy-rosen.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/elections/toronto/2026/mayor/rick-lee.png
78 changes: 78 additions & 0 deletions src/app/api/elections/pledge/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";

import { API_URL } from "@/lib/api/client";

// "Pledge to vote" submissions — same low-friction pattern as /api/subscribe.
// Forwards {email, name, region} to York Factory, which signs the email up
// as a subscriber and records one pledge per subscriber per election
// (re-pledging just refreshes it). Region is e.g. "ward-5" for ward-scoped
// pledge buttons; defaults to the city-wide "toronto".

const ELECTION_SLUG = "toronto-2026";
const REGION_PATTERN = /^[a-z0-9-]{1,50}$/;
const POSTAL_PATTERN = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/;

// "M5V1A1" / "m5v 1a1" → "M5V 1A1"; anything malformed is dropped rather
// than stored dirty
function normalizePostalCode(raw: unknown): string | undefined {
if (typeof raw !== "string" || !POSTAL_PATTERN.test(raw.trim())) {
return undefined;
}
const compact = raw.trim().toUpperCase().replace(" ", "");
return `${compact.slice(0, 3)} ${compact.slice(3)}`;
}

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email, name, region, postal_code } = body;

if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 },
);
}

const safeRegion =
typeof region === "string" && REGION_PATTERN.test(region)
? region
: "toronto";

const res = await fetch(`${API_URL}/elections/${ELECTION_SLUG}/pledges`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
name: typeof name === "string" ? name.slice(0, 100) : undefined,
region: safeRegion,
postal_code: normalizePostalCode(postal_code),
}),
cache: "no-store",
});

if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
return NextResponse.json(
{ error: errorData.errors?.[0] || "Pledge failed" },
{ status: res.status },
);
}

const data = await res.json();
return NextResponse.json({
success: true,
region: data.region,
regionCount: data.region_count,
shareToken: data.share_token ?? null,
name: data.name ?? null,
});
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
17 changes: 17 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,23 @@ html {
--color-auburn-900: #00266d;
}

/* Toronto 2026 Election tracker — warm "newsprint" broadsheet palette with
the Toronto blue accent. Warm linen panels + hairlines, but the accent
stays the Toronto blue from .theme-toronto (set explicitly here so the
page renders correctly even outside that wrapper). */
.theme-election {
--color-linen-50: #fbf6f1;
--color-linen-100: #f6ece3;
--color-linen-200: #ead2be;
/* Toronto blue accent */
--color-auburn-600: #0040b0;
--color-auburn-700: #003592;
--color-auburn-800: #003086;
--color-auburn-900: #00266d;
/* hairline dividers between cards/sections */
--color-border-light: #cdc4bd;
}

@custom-variant compact (@media (min-width: 425px));
@custom-variant 2xl-memo (@media (min-width: 1200px));
@custom-variant cards (@media (min-width: 612px));
Expand Down
50 changes: 50 additions & 0 deletions src/app/toronto/elections/2026/CandidateSiteLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import posthog from "posthog-js";
import type { ReactNode } from "react";

interface CandidateSiteLinkProps {
href: string;
candidate: string;
candidateKey: string;
race: "mayor" | "councillor";
tag?: string;
ward?: string;
wardName?: string;
className?: string;
children: ReactNode;
}

export function CandidateSiteLink({
href,
candidate,
candidateKey,
race,
tag,
ward,
wardName,
className,
children,
}: CandidateSiteLinkProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={className}
onClick={() => {
posthog.capture("candidate_website_clicked", {
candidate,
candidate_key: candidateKey,
race,
website: href,
tag,
ward,
ward_name: wardName,
});
}}
>
{children}
</a>
);
}
32 changes: 32 additions & 0 deletions src/app/toronto/elections/2026/CountdownDays.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { useEffect, useState } from "react";
import { daysUntilElection } from "./data";

const DEFAULT_CLASS =
"font-sans font-semibold leading-[0.8] tracking-[-0.05em] text-[clamp(6rem,20vw,15rem)] tabular-nums";

/**
* Renders the "days until polls open" number. The server passes an
* initialDays computed at request time so first paint matches hydration;
* the client then keeps it current across day boundaries. `className`
* overrides the default hero-sized styling (e.g. for the ward stat row).
*/
export default function CountdownDays({
initialDays,
className,
}: {
initialDays: number;
className?: string;
}) {
const [days, setDays] = useState(initialDays);

useEffect(() => {
const tick = () => setDays(daysUntilElection());
tick();
const id = setInterval(tick, 60_000);
return () => clearInterval(id);
}, []);

return <span className={className ?? DEFAULT_CLASS}>{days}</span>;
}
64 changes: 64 additions & 0 deletions src/app/toronto/elections/2026/WardMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { WARD_SHAPES, WARD_MAP_VIEWBOX } from "./wardGeo";

const BASE_ID = "toronto-ward-map-base";

/**
* Defines the full 25-ward Toronto map once as a reusable <g>. Render this
* a single time on the page; each WardMap references it via <use> so the
* geometry is not duplicated per card.
*/
export function WardMapDefs() {
return (
<svg width="0" height="0" aria-hidden="true" className="absolute">
<defs>
<g id={BASE_ID}>
{WARD_SHAPES.map((w) => (
<path
key={w.n}
d={w.d}
fill="#efe4da"
stroke="#cdc4bd"
strokeWidth={0.5}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
))}
</g>
</defs>
</svg>
);
}

/**
* A compact locator map of Toronto with `activeWard` filled in the accent
* colour. Requires <WardMapDefs /> to be present once on the page.
*/
export function WardMap({
activeWard,
className,
}: {
activeWard: string;
className?: string;
}) {
const active = WARD_SHAPES.find((w) => w.n === activeWard);

return (
<svg
viewBox={WARD_MAP_VIEWBOX}
className={className}
role="img"
aria-label={`Location of ward ${activeWard} within the City of Toronto`}
>
<use href={`#${BASE_ID}`} />
{active && (
<path
d={active.d}
className="fill-accent stroke-accent"
strokeWidth={0.5}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
);
}
65 changes: 65 additions & 0 deletions src/app/toronto/elections/2026/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Toronto 2026 election — York Factory API client.
//
// The york_factory pipeline pulls the City Clerk's registered-candidate JSON
// feeds daily and serves the assembled election (races + candidates) from
// GET /api/v1/elections/toronto-2026. This module fetches it with ISR;
// callers fall back to the hand-maintained data in ./candidates when the API
// is unreachable.

import { apiFetch } from "@/lib/api/client";

export type ApiSocialLink = { name: string; url: string };

export type ApiCandidate = {
/** as published by the City Clerk, "Last, First" */
full_name: string;
first_name: string | null;
last_name: string | null;
status: "active" | "withdrawn";
nomination_date: string | null;
withdrawn_date: string | null;
website: string | null;
social_links: ApiSocialLink[];
/** admin-reviewed portrait served from York Factory storage */
photo_url: string | null;
photo_attribution: string | null;
};

export type ApiRace = {
office_type: "mayor" | "councillor" | "trustee" | "mp" | "mpp";
district_type: string;
district_number: number | null;
district_name: string | null;
office_body: string | null;
candidates: ApiCandidate[];
};

export type ApiElection = {
slug: string;
name: string;
kind: string;
election_date: string;
nomination_close_date: string | null;
jurisdiction: { name: string; slug: string; level: string };
races: ApiRace[];
};

const ELECTION_SLUG = "toronto-2026";
// The upstream feeds refresh daily, so hourly ISR is plenty. Next dedupes
// the fetch across the components that call this within one render.
const REVALIDATE_SECONDS = 3600;

/**
* The toronto-2026 election with all races and candidates, or null when the
* API is unreachable.
*/
export async function fetchToronto2026(): Promise<ApiElection | null> {
try {
return await apiFetch<ApiElection>(`/elections/${ELECTION_SLUG}`, {
revalidate: REVALIDATE_SECONDS,
});
} catch (error) {
console.error("[elections] API unavailable, using local candidate data:", error);
return null;
}
}
Loading
Loading