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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# Match the local dev / lockfile npm version — node 22's older npm
# resolves the SWC plugin's transitive @swc/helpers differently and
# rejects the lockfile under `npm ci`.
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ tsconfig.tsbuildinfo
# debug
npm-debug.log*

# test artifacts
coverage/
playwright-report/
test-results/
.playwright/

# vercel
.vercel
node_modules
Expand Down
32 changes: 3 additions & 29 deletions app/wizard/[phase]/[step]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
addMessage,
} from "@/lib/store/chat-history";
import { toggleChecklistItem, markStepVisited } from "@/lib/store/progress";
import { readChatStream } from "@/lib/chat/stream";
import {
getPhaseContent,
getNextStep,
Expand Down Expand Up @@ -474,36 +475,9 @@ export default function WizardStepPage({
});

if (!res.ok) throw new Error("Chat request failed");
if (!res.body) throw new Error("No response body");

const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");

const decoder = new TextDecoder();
let fullContent = "";

while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");

for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
fullContent += parsed.text;
setStreamingContent(fullContent);
}
} catch {
// Skip malformed JSON
}
}
}
}
const fullContent = await readChatStream(res.body, setStreamingContent);

const assistantMsg: ChatMessage = {
id: `msg-${Date.now()}-assistant`,
Expand Down
81 changes: 67 additions & 14 deletions app/wizard/summary/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import type { StateCode } from "@/content/phases";
import type { UserState } from "@/lib/types/wizard";
import { DEFAULT_STATE } from "@/lib/types/wizard";
import { STATE_REGISTRY } from "@/content/state-registry";
import {
toProgressJson,
toProgressCsv,
downloadTextFile,
type ProgressExport,
} from "@/lib/export/progress-export";

import { IBeamIcon } from "@/components/ui/ibeam-icon";
import { CountUp } from "@/components/ui/count-up";
Expand Down Expand Up @@ -173,6 +179,46 @@ export default function SummaryPage() {
const stateCode = userState.profile.state as StateCode;
const stateData = STATE_REGISTRY[stateCode];

const buildExport = (): ProgressExport => ({
generatedAt: new Date().toISOString(),
state: stateCode,
stateName: stateData?.name ?? stateCode,
businessName: userState.profile.businessName || "",
overallPercent: summary.overallPercent,
completedItems: summary.completedItems,
totalItems: summary.totalItems,
phasesComplete: summary.phasesComplete,
totalPhases: PHASE_DEFINITIONS.length,
startedAt: summary.startedAtIso,
phases: summary.phaseSummaries.map((p) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include step-level checklist state in JSON export

When a user downloads JSON to preserve or audit their wizard progress, this builder serializes summary.phaseSummaries rather than userState.progress, so the file only has per-phase counts and loses every visited step id and completedChecklist item id. Two different users can produce identical exports even though they checked different items, making the progress export unrecoverable/incomplete; include the step-level progress (or raw userState.progress) in the JSON payload.

Useful? React with 👍 / 👎.

id: p.id,
title: p.title,
totalSteps: p.totalSteps,
visitedSteps: p.visitedSteps,
totalItems: p.totalItems,
completedItems: p.completedItems,
minCost: p.minCost,
maxCost: p.maxCost,
})),
});

const handleExport = (format: "json" | "csv") => {
const data = buildExport();
if (format === "json") {
downloadTextFile(
`ironforge-progress-${stateCode}.json`,
toProgressJson(data),
"application/json",
);
} else {
downloadTextFile(
`ironforge-progress-${stateCode}.csv`,
toProgressCsv(data),
"text/csv",
);
}
};

// Cost rows for breakdown
const costRows: CostRow[] = summary.phaseSummaries.map((p) => ({
phaseId: p.id,
Expand Down Expand Up @@ -440,26 +486,33 @@ export default function SummaryPage() {
</div>
</button>

{/* Export placeholder */}
<button
disabled
title="Export feature coming soon"
className="group relative text-left bg-cyber-dark border border-cyber-border rounded-xl p-5 transition-all overflow-hidden opacity-60 cursor-not-allowed"
>
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-text-muted/40 to-transparent" />
{/* Export */}
<div className="group relative text-left bg-cyber-dark border border-cyber-border hover:border-neon-green/40 rounded-xl p-5 transition-all overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-neon-green/40 to-transparent" />
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-mono uppercase tracking-widest text-text-muted">
Soon
<span className="text-[10px] font-mono uppercase tracking-widest text-neon-green">
Export
</span>
<Download className="w-4 h-4 text-text-muted" />
<Download className="w-4 h-4 text-neon-green" />
</div>
<div className="text-sm font-mono font-semibold text-text-secondary mb-1">
<div className="text-sm font-mono font-semibold text-text-primary mb-3">
Export Progress
</div>
<div className="text-[10px] font-mono text-text-muted">
JSON/CSV download (coming soon)
<div className="flex gap-2">
<button
onClick={() => handleExport("json")}
className="flex-1 px-3 py-1.5 rounded-lg font-mono text-[11px] tracking-wide border border-neon-green/30 text-neon-green hover:bg-neon-green/10 transition-all"
>
JSON
</button>
<button
onClick={() => handleExport("csv")}
className="flex-1 px-3 py-1.5 rounded-lg font-mono text-[11px] tracking-wide border border-neon-green/30 text-neon-green hover:bg-neon-green/10 transition-all"
>
CSV
</button>
</div>
</button>
</div>
</section>

{/* ═══════════ FOOTER NAV ═══════════ */}
Expand Down
12 changes: 12 additions & 0 deletions components/ui/badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Badge } from "@/components/ui/badge";

describe("Badge", () => {
it("renders its children", () => {
render(<Badge>Active</Badge>);
expect(screen.getByText("Active")).toBeInTheDocument();
});
});
29 changes: 29 additions & 0 deletions components/wizard/legal-disclaimer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { LegalDisclaimer } from "@/components/wizard/legal-disclaimer";

describe("LegalDisclaimer", () => {
it("always shows the not-legal-advice notice", () => {
render(<LegalDisclaimer stateCode="WA" />);
expect(screen.getByRole("note")).toHaveTextContent(/not legal advice/i);
});

it("shows the last-verified date for a verified state", () => {
render(<LegalDisclaimer stateCode="WA" />);
expect(screen.getByRole("note")).toHaveTextContent(/last verified/i);
});

it("flags a machine-generated state as not independently verified", () => {
render(<LegalDisclaimer stateCode="AL" />);
const note = screen.getByRole("note");
expect(note).toHaveTextContent(/not independently verified/i);
expect(note).not.toHaveTextContent(/last verified/i);
});

it("renders a generic notice when no state is known", () => {
render(<LegalDisclaimer stateCode={null} />);
expect(screen.getByRole("note")).toHaveTextContent(/not legal advice/i);
});
});
86 changes: 86 additions & 0 deletions components/wizard/legal-disclaimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Steel-Tech / StructuPath
import { ShieldAlert } from "lucide-react";
import { getStateVerification } from "@/content/state-registry";

interface LegalDisclaimerProps {
/** Two-letter state code from the user's profile, if known. */
stateCode?: string | null;
}

/** Format an ISO date (YYYY-MM-DD) without UTC timezone drift. */
function formatVerifiedDate(iso?: string): string {
if (!iso) return "";
const [y, m, d] = iso.split("-").map(Number);
if (!y || !m || !d) return iso;
return new Date(y, m - 1, d).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}

/**
* Persistent, non-dismissable legal notice shown on state-facing content.
* Surfaces whether the state's regulatory data was human-verified (with the
* date) or is machine-generated and unverified.
*/
export function LegalDisclaimer({ stateCode }: LegalDisclaimerProps) {
const v = stateCode ? getStateVerification(stateCode) : null;
const agency = v?.stateName ?? "your state";

return (
<div
role="note"
aria-label="Legal disclaimer"
className="flex items-start gap-2.5 rounded-lg border border-neon-amber/25 bg-neon-amber/5 px-3.5 py-2.5 text-xs leading-relaxed"
>
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-neon-amber" />
<div className="space-y-1 text-text-secondary">
<p>
<span className="font-semibold text-neon-amber">Not legal advice.</span>{" "}
Fees, rules, and links change — verify with the official {agency}{" "}
agency before acting.
</p>
{v &&
(v.verified ? (
<p className="text-text-muted">
Last verified {formatVerifiedDate(v.lastVerified)}.
{v.sourceUrl && (
<>
{" "}
<a
href={v.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-neon-blue hover:underline"
>
Source
</a>
</>
)}
</p>
) : (
<p className="text-text-muted">
Auto-generated for {v.stateName} — not independently verified.
{v.sourceUrl && (
<>
{" "}
<a
href={v.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-neon-blue hover:underline"
>
Confirm with the state agency
</a>
</>
)}
</p>
))}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions components/wizard/step-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Checklist } from "./checklist";
import { CostCard } from "./cost-card";
import { ResourceLink } from "./resource-link";
import { CoachTip } from "./coach-tip";
import { LegalDisclaimer } from "./legal-disclaimer";
import {
ChevronLeft,
ChevronRight,
Expand Down Expand Up @@ -55,6 +56,7 @@ export function StepContent({
<div className="flex-1 overflow-y-auto scrollbar-thin bg-cyber-black">
<div className="max-w-3xl mx-auto p-6 md:p-8 space-y-8">
{headerExtra}
<LegalDisclaimer stateCode={profile?.state} />
{/* Header */}
<div className="space-y-3 animate-fade-in-up">
<div className="text-xs text-neon-cyan font-mono tracking-widest uppercase">
Expand Down
Loading
Loading