Skip to content

Commit 6463697

Browse files
authored
Merge pull request #909 from PolicyEngine/codex/california-wealth-tax-app-shell
Add website route for California wealth tax tool
2 parents 2f4e8da + b166d53 commit 6463697

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { act, render } from "@testing-library/react";
2+
import { describe, expect, test, vi } from "vitest";
3+
4+
import SyncedAppIframe from "@/components/apps/SyncedAppIframe";
5+
6+
describe("SyncedAppIframe", () => {
7+
test("forwards parent query params into the iframe URL", () => {
8+
window.history.replaceState(
9+
null,
10+
"",
11+
"/us/california-wealth-tax?date=2025-10-17&yield=0.02",
12+
);
13+
14+
const { container } = render(
15+
<SyncedAppIframe
16+
srcPath="/us/california-wealth-tax/embed"
17+
title="California wealth tax"
18+
initialQuery="date=2025-10-17&yield=0.02"
19+
/>,
20+
);
21+
22+
const iframe = container.querySelector("iframe");
23+
24+
expect(iframe).not.toBeNull();
25+
expect(iframe?.getAttribute("src")).toBe(
26+
"/us/california-wealth-tax/embed?date=2025-10-17&yield=0.02",
27+
);
28+
expect(iframe?.getAttribute("allow")).toBe("clipboard-write");
29+
});
30+
31+
test("syncs iframe query param updates back to the parent URL", () => {
32+
window.history.replaceState(null, "", "/us/california-wealth-tax");
33+
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
34+
35+
render(
36+
<SyncedAppIframe
37+
srcPath="/us/california-wealth-tax/embed"
38+
title="California wealth tax"
39+
/>,
40+
);
41+
42+
act(() => {
43+
window.dispatchEvent(
44+
new MessageEvent("message", {
45+
origin: window.location.origin,
46+
data: {
47+
type: "urlUpdate",
48+
params: "date=2025-10-17&yield=0.02",
49+
},
50+
}),
51+
);
52+
});
53+
54+
expect(replaceStateSpy).toHaveBeenCalledWith(
55+
null,
56+
"",
57+
"/us/california-wealth-tax?date=2025-10-17&yield=0.02",
58+
);
59+
});
60+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Metadata } from "next";
2+
import { notFound } from "next/navigation";
3+
import SyncedAppIframe from "@/components/apps/SyncedAppIframe";
4+
5+
export const metadata: Metadata = {
6+
title: "California wealth tax fiscal impact calculator",
7+
description:
8+
"Estimate how California's proposed billionaire wealth tax changes revenue once avoidance, migration, return flows, and income-tax offsets are taken into account.",
9+
};
10+
11+
export default async function CaliforniaWealthTaxPage({
12+
params,
13+
searchParams,
14+
}: {
15+
params: Promise<{ countryId: string }>;
16+
searchParams: Promise<Record<string, string | string[] | undefined>>;
17+
}) {
18+
const { countryId } = await params;
19+
const resolvedSearchParams = await searchParams;
20+
21+
if (countryId !== "us") {
22+
notFound();
23+
}
24+
25+
const initialQuery = new URLSearchParams();
26+
for (const [key, value] of Object.entries(resolvedSearchParams)) {
27+
if (Array.isArray(value)) {
28+
value.forEach((entry) => initialQuery.append(key, entry));
29+
} else if (value !== undefined) {
30+
initialQuery.set(key, value);
31+
}
32+
}
33+
34+
return (
35+
<SyncedAppIframe
36+
srcPath={`/${countryId}/california-wealth-tax/embed`}
37+
title="California wealth tax fiscal impact calculator | PolicyEngine"
38+
initialQuery={initialQuery.toString()}
39+
/>
40+
);
41+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useState } from "react";
4+
5+
interface SyncedAppIframeProps {
6+
srcPath: string;
7+
title: string;
8+
height?: string;
9+
initialQuery?: string;
10+
}
11+
12+
export default function SyncedAppIframe({
13+
srcPath,
14+
title,
15+
height = "calc(100vh - 58px)",
16+
initialQuery = "",
17+
}: SyncedAppIframeProps) {
18+
const [query, setQuery] = useState(initialQuery);
19+
20+
const iframeUrl = useMemo(() => {
21+
return query ? `${srcPath}?${query}` : srcPath;
22+
}, [query, srcPath]);
23+
24+
useEffect(() => {
25+
const basePath = srcPath.replace(/\/embed$/, "");
26+
27+
const syncQueryFromLocation = () => {
28+
const currentQuery = window.location.search.replace(/^\?/, "");
29+
setQuery(currentQuery);
30+
};
31+
32+
const handleMessage = (event: MessageEvent) => {
33+
if (event.origin !== window.location.origin) {
34+
return;
35+
}
36+
37+
if (
38+
event.data?.type === "hashchange" &&
39+
typeof event.data.hash === "string"
40+
) {
41+
const hash = event.data.hash || "";
42+
43+
if (hash && !hash.startsWith("#")) {
44+
const subPath = hash.startsWith("/") ? hash : `/${hash}`;
45+
window.history.replaceState(null, "", `${basePath}${subPath}`);
46+
} else {
47+
window.history.replaceState(null, "", `${basePath}${hash}`);
48+
}
49+
}
50+
51+
if (
52+
event.data?.type === "urlUpdate" &&
53+
typeof event.data.params === "string"
54+
) {
55+
const query = event.data.params ? `?${event.data.params}` : "";
56+
window.history.replaceState(null, "", `${basePath}${query}`);
57+
setQuery(event.data.params || "");
58+
}
59+
};
60+
61+
syncQueryFromLocation();
62+
window.addEventListener("message", handleMessage);
63+
window.addEventListener("popstate", syncQueryFromLocation);
64+
return () => {
65+
window.removeEventListener("message", handleMessage);
66+
window.removeEventListener("popstate", syncQueryFromLocation);
67+
};
68+
}, [srcPath]);
69+
70+
return (
71+
<iframe
72+
src={iframeUrl}
73+
title={title}
74+
allow="clipboard-write"
75+
style={{
76+
width: "100%",
77+
height,
78+
border: "none",
79+
display: "block",
80+
}}
81+
/>
82+
);
83+
}

0 commit comments

Comments
 (0)