Skip to content

Commit b2ab9cb

Browse files
louzoshiclaude
andauthored
feat(nogglesrails): auto-link propdates to rail pages (#123)
* feat(nogglesrails): auto-link propdates to rail pages Each rail funded by a Base proposal now surfaces that proposal's onchain propdates in an "Updates" section, instead of hand-written copy. Adds a proposalNumber field to the 9 Base-funded rails and a client-side section that reuses the already-cached /api/propdates/enriched endpoint, keeping the rail page statically prerendered (no added SSR/ISR cost). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(proposals): honor ?tab= deep links on proposal detail tabs The nogglesrails 'View all updates' link points to /proposals/base/{n}?tab=propdates, but the detail tabs were uncontrolled (defaultValue="details") and nothing read the param, so users landed on Details. Tabs are now controlled and honor ?tab=votes|propdates once the target tab is revealed post-mount; unknown values fall back to Details. Adds an e2e regression spec that exercises the deep link against a proposal with real propdates (auto-skips when the env has none). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f7cde44 commit b2ab9cb

7 files changed

Lines changed: 197 additions & 4 deletions

File tree

messages/en/installations.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@
9090
"viewProposal": "View Proposal",
9191
"organicProliferation": "Organic proliferation — no formal proposal.",
9292
"wantRailCta": "Want a NogglesRail in your city?",
93-
"submitProposal": "Submit a Proposal"
93+
"submitProposal": "Submit a Proposal",
94+
"updates": "Updates",
95+
"updatesSubtitle": "Onchain progress updates posted to the funding proposal.",
96+
"viewAllUpdates": "View all updates"
9497
}
9598
}
9699
}

messages/pt-br/installations.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@
9090
"viewProposal": "Ver Proposta",
9191
"organicProliferation": "Proliferação orgânica — sem proposta formal.",
9292
"wantRailCta": "Quer um NogglesRail na sua cidade?",
93-
"submitProposal": "Enviar uma Proposta"
93+
"submitProposal": "Enviar uma Proposta",
94+
"updates": "Atualizações",
95+
"updatesSubtitle": "Atualizações de progresso onchain publicadas na proposta de financiamento.",
96+
"viewAllUpdates": "Ver todas as atualizações"
9497
}
9598
}
9699
}

src/app/[locale]/nogglesrails/[slug]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from "next-intl/server";
33
import { notFound } from "next/navigation";
44
import { ArrowLeft, ExternalLink } from "lucide-react";
55
import { DroposalEmbed } from "@/components/nogglesrails/DroposalEmbed";
6+
import { RailPropdates } from "@/components/nogglesrails/RailPropdates";
67
import { Badge } from "@/components/ui/badge";
78
import { Button } from "@/components/ui/button";
89
import { getRailBySlug, NOGGLES_RAILS } from "@/content/nogglesrails";
@@ -146,6 +147,9 @@ export default async function NogglesRailDetailPage({ params }: PageProps) {
146147
<p className="leading-relaxed text-muted-foreground">{rail.description}</p>
147148
</div>
148149

150+
{/* Onchain updates (auto-linked from the funding proposal's propdates) */}
151+
{rail.proposalNumber != null && <RailPropdates proposalNumber={rail.proposalNumber} />}
152+
149153
{/* Gallery */}
150154
{gallery.length > 0 && (
151155
<div>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use client";
2+
3+
import { useTranslations } from "next-intl";
4+
import { useQuery } from "@tanstack/react-query";
5+
import { ArrowRight } from "lucide-react";
6+
import { PropdateCard } from "@/components/proposals/detail/PropdateCard";
7+
import { Link } from "@/i18n/navigation";
8+
import type { Propdate } from "@/services/propdates";
9+
10+
/** Max propdates shown inline on a rail page before linking out to the full list. */
11+
const MAX_INLINE = 3;
12+
13+
interface ProposalWithPropdatesJSON {
14+
proposal: { proposalNumber: number };
15+
propdates: Propdate[];
16+
updateCount: number;
17+
}
18+
19+
interface RailPropdatesProps {
20+
proposalNumber: number;
21+
}
22+
23+
/**
24+
* Auto-links a rail to the onchain propdates of its funding proposal.
25+
*
26+
* Client component on purpose: it reuses the already-cached
27+
* `/api/propdates/enriched` endpoint (revalidate=300 + CDN) so the rail page
28+
* stays fully static — no extra SSR/ISR cost. Renders nothing when the proposal
29+
* has no propdates, so Snapshot/Nouns/organic rails are no-ops.
30+
*/
31+
export function RailPropdates({ proposalNumber }: RailPropdatesProps) {
32+
const t = useTranslations("installations");
33+
const { data } = useQuery<ProposalWithPropdatesJSON[]>({
34+
queryKey: ["propdates-feed-enriched"],
35+
queryFn: () => fetch("/api/propdates/enriched").then((r) => r.json()),
36+
});
37+
38+
const entry = data?.find((e) => e.proposal.proposalNumber === proposalNumber);
39+
if (!entry || entry.propdates.length === 0) return null;
40+
41+
const visible = entry.propdates.slice(0, MAX_INLINE);
42+
const allUpdatesHref = `/proposals/base/${proposalNumber}?tab=propdates`;
43+
44+
return (
45+
<div>
46+
<div className="mb-3 flex items-end justify-between gap-2">
47+
<div>
48+
<h2 className="text-lg font-semibold">{t("nogglesrails.detail.updates")}</h2>
49+
<p className="text-sm text-muted-foreground">
50+
{t("nogglesrails.detail.updatesSubtitle")}
51+
</p>
52+
</div>
53+
{entry.updateCount > MAX_INLINE && (
54+
<Link
55+
href={allUpdatesHref}
56+
className="inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline"
57+
>
58+
{t("nogglesrails.detail.viewAllUpdates")}
59+
<ArrowRight className="size-3.5" />
60+
</Link>
61+
)}
62+
</div>
63+
<div className="space-y-3">
64+
{visible.map((propdate) => (
65+
<PropdateCard key={propdate.txid} propdate={propdate} />
66+
))}
67+
</div>
68+
</div>
69+
);
70+
}

src/components/proposals/detail/ProposalDetail.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3-
import { useCallback, useEffect, useMemo, useState } from "react";
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import { useTranslations } from "next-intl";
5+
import { useSearchParams } from "next/navigation";
56
import { VotingControls } from "@/components/common/VotingControls";
67
import { Propdates } from "@/components/proposals/detail/Propdates";
78
import { ProposalActions } from "@/components/proposals/detail/ProposalActions";
@@ -236,6 +237,24 @@ export function ProposalDetail({ proposal }: ProposalDetailProps) {
236237
const visibleTabsCount = 1 + (shouldShowVotesTab ? 1 : 0) + (shouldShowPropdatesTab ? 1 : 0);
237238
const shouldShowTabsList = visibleTabsCount > 1;
238239

240+
// Deep links (?tab=votes|propdates) can't use <Tabs defaultValue> because
241+
// those tabs are revealed post-mount; honor the param once, when the target
242+
// tab becomes visible, then hand control back to the user.
243+
const searchParams = useSearchParams();
244+
const requestedTab = searchParams.get("tab");
245+
const [activeTab, setActiveTab] = useState("details");
246+
const honoredTabParam = useRef(false);
247+
useEffect(() => {
248+
if (honoredTabParam.current) return;
249+
if (
250+
(requestedTab === "votes" && shouldShowVotesTab) ||
251+
(requestedTab === "propdates" && shouldShowPropdatesTab)
252+
) {
253+
honoredTabParam.current = true;
254+
setActiveTab(requestedTab);
255+
}
256+
}, [requestedTab, shouldShowVotesTab, shouldShowPropdatesTab]);
257+
239258
// Show voting card for active proposals (connection check moved to VotingControls to avoid hydration issues)
240259
// Hide voting for read-only proposals (Snapshot and Ethereum)
241260
const isProposalActive = proposal.status === "Active";
@@ -303,7 +322,7 @@ export function ProposalDetail({ proposal }: ProposalDetailProps) {
303322
{isProposalSuccessful(proposal.status) && (
304323
<ProposalActions proposal={proposal} onActionSuccess={handleActionSuccess} />
305324
)}
306-
<Tabs defaultValue="details" className="w-full">
325+
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
307326
{shouldShowTabsList && (
308327
<div className="overflow-x-auto">
309328
<TabsList

src/content/nogglesrails.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export interface NogglesRailLocation {
1616
name: string;
1717
link: string;
1818
};
19+
/**
20+
* Base (Gnars DAO) proposal number used to auto-link onchain propdates to
21+
* this rail. Only set for rails funded by a Base proposal — Snapshot/Nouns/
22+
* organic rails leave it undefined and simply show no Updates section.
23+
*/
24+
proposalNumber?: number;
1925
video?: string;
2026
droposals?: number[];
2127
thumbnailPosition?: string;
@@ -116,6 +122,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
116122
name: "Gnars Proposal 20",
117123
link: "https://www.gnars.com/proposals/20",
118124
},
125+
proposalNumber: 20,
119126
slug: "rio-de-janeiro-delta",
120127
},
121128
{
@@ -176,6 +183,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
176183
name: "Gnars Proposal",
177184
link: "https://gnars.com/proposals/73",
178185
},
186+
proposalNumber: 73,
179187
slug: "nairobi",
180188
},
181189
{
@@ -194,6 +202,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
194202
name: "Gnars Proposal",
195203
link: "https://www.gnars.com/dao/proposal/4",
196204
},
205+
proposalNumber: 4,
197206
slug: "sao-paulo-sopa-de-letras",
198207
},
199208
{
@@ -263,6 +272,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
263272
name: "Gnars Proposal",
264273
link: "https://www.gnars.com/dao/proposal/25",
265274
},
275+
proposalNumber: 25,
266276
slug: "medellin",
267277
},
268278
{
@@ -286,6 +296,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
286296
name: "Gnars Proposal",
287297
link: "https://www.gnars.com/dao/proposal/33",
288298
},
299+
proposalNumber: 33,
289300
slug: "london",
290301
},
291302
{
@@ -309,6 +320,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
309320
name: "Gnars Proposal",
310321
link: "https://www.gnars.com/proposals/41",
311322
},
323+
proposalNumber: 41,
312324
slug: "buenos-aires",
313325
},
314326
{
@@ -332,6 +344,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
332344
name: "Gnars Proposal",
333345
link: "https://www.gnars.com/dao/proposal/68",
334346
},
347+
proposalNumber: 68,
335348
slug: "milan",
336349
},
337350
{
@@ -353,6 +366,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
353366
name: "Gnars Proposal",
354367
link: "https://www.gnars.com/proposals/63",
355368
},
369+
proposalNumber: 63,
356370
slug: "oc-ramp",
357371
},
358372
{
@@ -376,6 +390,7 @@ export const NOGGLES_RAILS: NogglesRailLocation[] = [
376390
name: "Gnars Proposal",
377391
link: "https://www.gnars.com/proposals/89",
378392
},
393+
proposalNumber: 89,
379394
slug: "sao-paulo-itapetininga",
380395
},
381396
];
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
/**
4+
* Regression tests for ?tab= deep links on the proposal detail page
5+
* (e.g. the "View all updates" link on nogglesrails pages points to
6+
* /proposals/base/{n}?tab=propdates).
7+
*
8+
* The votes/propdates tabs are revealed post-mount from fetched data, so the
9+
* deep link is honored asynchronously — assertions wait for the trigger to
10+
* appear before checking it is active.
11+
*/
12+
13+
interface EnrichedEntry {
14+
proposal: { proposalNumber: number };
15+
propdates: unknown[];
16+
}
17+
18+
async function findProposalWithPropdates(
19+
request: Parameters<Parameters<typeof test>[2]>[0]["request"],
20+
): Promise<number | null> {
21+
const res = await request.get("/api/propdates/enriched");
22+
if (!res.ok()) return null;
23+
const entries = (await res.json()) as EnrichedEntry[];
24+
const entry = entries.find((e) => (e.propdates?.length ?? 0) > 0);
25+
return entry ? entry.proposal.proposalNumber : null;
26+
}
27+
28+
// Serial: three parallel first-loads of the same heavy route stall the dev
29+
// server past the goto timeout; these are fast (<5s each) once compiled.
30+
test.describe.configure({ mode: "serial" });
31+
32+
test.describe("Proposal tab deep links", () => {
33+
test("?tab=propdates activates the Propdates tab", async ({ page, request }) => {
34+
const proposalNumber = await findProposalWithPropdates(request);
35+
test.skip(proposalNumber === null, "No proposal with propdates in this environment");
36+
37+
await page.goto(`/proposals/base/${proposalNumber}?tab=propdates`, {
38+
waitUntil: "domcontentloaded",
39+
timeout: 60000,
40+
});
41+
42+
const propdatesTab = page.getByRole("tab", { name: /propdates/i });
43+
await expect(propdatesTab).toBeVisible({ timeout: 30000 });
44+
await expect(propdatesTab).toHaveAttribute("data-state", "active", { timeout: 15000 });
45+
await expect(page.getByRole("tab", { name: /details/i })).toHaveAttribute(
46+
"data-state",
47+
"inactive",
48+
);
49+
});
50+
51+
test("without ?tab the Details tab stays default", async ({ page, request }) => {
52+
const proposalNumber = await findProposalWithPropdates(request);
53+
test.skip(proposalNumber === null, "No proposal with propdates in this environment");
54+
55+
await page.goto(`/proposals/base/${proposalNumber}`, {
56+
waitUntil: "domcontentloaded",
57+
timeout: 60000,
58+
});
59+
60+
// Wait for the tab list to be revealed post-mount, then assert default.
61+
const detailsTab = page.getByRole("tab", { name: /details/i });
62+
await expect(detailsTab).toBeVisible({ timeout: 30000 });
63+
await expect(detailsTab).toHaveAttribute("data-state", "active");
64+
});
65+
66+
test("unknown ?tab value is ignored and falls back to Details", async ({ page, request }) => {
67+
const proposalNumber = await findProposalWithPropdates(request);
68+
test.skip(proposalNumber === null, "No proposal with propdates in this environment");
69+
70+
await page.goto(`/proposals/base/${proposalNumber}?tab=bogus`, {
71+
waitUntil: "domcontentloaded",
72+
timeout: 60000,
73+
});
74+
75+
const detailsTab = page.getByRole("tab", { name: /details/i });
76+
await expect(detailsTab).toBeVisible({ timeout: 30000 });
77+
await expect(detailsTab).toHaveAttribute("data-state", "active");
78+
});
79+
});

0 commit comments

Comments
 (0)