Skip to content

Commit 71bd3ab

Browse files
radosukalaclaude
andcommitted
Hall Phase 2b: proposals + voting
Members can now vote on proposals. Operators can create, edit, open, and close them. Live weighted results are visible on each proposal page. The first real Hall vote — "Which flagship do we build first?" — is seeded and open. Schemas (migration 0002_smiling_slapstick.sql, applied to shared Neon): - hall_proposals: id, slug, title, body_md, vote_type (yes_no | single_choice), choices jsonb (for single_choice), scope (framework | product), product_slug, status (draft | open | closed), opens_at, closes_at, author_id, created_at, updated_at - hall_votes: id, proposal_id FK, member_id FK (users), choice, weight (captured at cast time), voter_scope, cast_at. Unique index on (proposal_id, member_id) — one vote per member per proposal. Vote weight model: - Today: every signed-in member gets 1× weight. Simple and correct because Stripe isn't live yet — no Patron tier detection possible. - When Stripe ships (Phase 3): getVoterWeight() in lib/voter-weight.ts looks up the user's Patron tier and returns 4× / 3× / 2× / 1× on framework scope, 1× on product scope. Already scaffolded with the tier thresholds documented in the source. - Operator tiebreak authority exists for product-scope votes (handled in tally, not weight). New lib: - lib/proposals.ts — list / get / create / update / delete, results tally by choice, getMyVote, castVote. Vote uniqueness enforced at DB level; friendly error on double-vote attempt. - lib/voter-weight.ts — getVoterWeight + hasTiebreak with clear Phase 3 hooks. Pages: - /inside/proposals — list of Open + Closed proposals (drafts hidden from members). Status badges, scope badges, vote counts. - /inside/proposals/[slug] — full proposal body (markdown rendered), weighted vote UI, live results bar chart. "Your vote" card shows the member's cast choice + weight after voting. - /inside/admin/proposals — operator list with status + vote counts - /inside/admin/proposals/new + /[id]/edit — full editor with vote_type toggle, dynamic choices builder for single_choice, scope + product_slug + status + opens_at/closes_at inputs Components: - proposal-card — list item with status + scope badges, vote counts - proposal-editor — client-side editor with choice builder - vote-form — client component; handles yes/no + single_choice, shows "you voted X with weight N" after submission - vote-results — server-rendered bar chart showing raw counts + weighted totals + percentages Nav: - "Proposals" added to main nav (between Ship feed and Inside) - Admin strip shows Posts + Proposals (list + new for each) Seeded: - First proposal: which-flagship-first. Single-choice between Social feed / Notes & documents / Community chat / Creator subscriptions. Status = open. No close date — operators will close when the result is clear. Body explains the mechanics and what happens after. Also in this push: /sitemap.xml is now dynamic (force-dynamic, revalidate 0) so the Vercel build doesn't try to prerender it with no DB access. Plus try/catch around the DB query as defense in depth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a0a7f96 commit 71bd3ab

21 files changed

Lines changed: 2259 additions & 8 deletions

scripts/seed-first-proposal.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* One-shot script to seed the first Hall proposal: the flagship vote.
3+
*
4+
* Run: npx dotenv -e .env.local -- tsx scripts/seed-first-proposal.ts
5+
*
6+
* Idempotent: skips if a proposal with the slug already exists.
7+
*/
8+
9+
import { findProposalBySlug, createProposal } from "../src/lib/proposals";
10+
11+
const SEED = {
12+
slug: "first-flagship",
13+
title: "Which flagship do we build first?",
14+
bodyMd: `This is the first binding vote on Our.one / Hall. Members choose which flagship product Our.one builds first, after Hall itself.
15+
16+
## What this vote decides
17+
18+
The winning category becomes Our.one's first community-built flagship. Operators will scope it, AI will build it, and the weekly ship feed will show every step. Members who vote will earn per that product's declared constitution (flagship default: Users 40% / Ambassadors 25% / Patrons 15% / Commons 10% / Our.one Fee 10%).
19+
20+
## The choices
21+
22+
Each option names a category Our.one would build into, competing with a well-known incumbent. The brand will be **Our.one / [Common English Word]** — to be picked by a follow-up vote after this one closes.
23+
24+
- **Social feed** — a social timeline product. Incumbents: Instagram, X. Distribution moat: strongest in this category.
25+
- **Notes & documents** — a productivity-document product. Incumbents: Notion, Google Docs. Highest-willingness-to-pay per user.
26+
- **Community / chat** — a real-time discussion product. Incumbents: Discord, Slack. Best fit for the ecosystem we're building (Hall itself is in this adjacent space).
27+
- **Creator subscriptions** — a Patreon / Substack-style product. Incumbents: Substack, Patreon. Direct fit for Our.one's revenue-share DNA — creators and their audiences both earn.
28+
29+
## How voting works
30+
31+
- Every signed-in member gets 1× weight on this product-scope vote.
32+
- Single choice — you pick one. Voting once is final; no changes, no re-votes.
33+
- Results visible live.
34+
- Operator tiebreak authority applies on product-scope votes if needed; in practice this vote is unlikely to tie meaningfully.
35+
36+
## What happens after
37+
38+
When this vote closes, the winning category gets:
39+
40+
1. A follow-up vote on the product's brand name (e.g. Our.one / Feed vs. Our.one / Loop for social).
41+
2. A new repo at \`github.com/Our-One/[slug]\` with the same stack as Hall.
42+
3. A published product constitution declaring the role mix.
43+
4. Regular ship-feed posts here documenting the build.
44+
45+
This is the moment where Our.one stops being "a manifesto + a preorder" and starts being "a thing you can watch get built, with your vote on it."`,
46+
voteType: "single_choice" as const,
47+
choices: [
48+
{
49+
id: "social",
50+
label: "Social feed",
51+
description: "Instagram / X category. Distribution is the moat here.",
52+
},
53+
{
54+
id: "notes",
55+
label: "Notes & documents",
56+
description: "Notion / Docs category. Highest willingness to pay.",
57+
},
58+
{
59+
id: "community",
60+
label: "Community / chat",
61+
description: "Discord / Slack category. Ecosystem-adjacent.",
62+
},
63+
{
64+
id: "creator",
65+
label: "Creator subscriptions",
66+
description: "Substack / Patreon category. Direct fit for our revenue model.",
67+
},
68+
],
69+
scope: "product" as const,
70+
productSlug: null,
71+
status: "open" as const,
72+
opensAt: new Date("2026-04-23T10:00:00Z"),
73+
closesAt: null,
74+
authorId: null,
75+
} satisfies Parameters<typeof createProposal>[0];
76+
77+
async function main() {
78+
const existing = await findProposalBySlug(SEED.slug);
79+
if (existing) {
80+
console.log(`Proposal '${SEED.slug}' already exists. Skipping.`);
81+
return;
82+
}
83+
const created = await createProposal(SEED);
84+
console.log(`Created proposal '${created.slug}' (id ${created.id})`);
85+
}
86+
87+
main().then(
88+
() => process.exit(0),
89+
(err) => {
90+
console.error(err);
91+
process.exit(1);
92+
},
93+
);

src/app/inside/admin/layout.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default async function AdminLayout({
1515
return (
1616
<div className="border-t border-stone-200 bg-[#F5F2EB]">
1717
<div className="mx-auto max-w-[64rem] px-6 py-6">
18-
<div className="flex items-center gap-5 font-sans text-xs">
18+
<div className="flex flex-wrap items-center gap-5 font-sans text-xs">
1919
<span className="text-[10px] font-medium uppercase tracking-[0.2em] text-stone-500">
2020
Operator
2121
</span>
@@ -25,6 +25,19 @@ export default async function AdminLayout({
2525
<Link href="/inside/admin/posts/new" className="text-stone-700 hover:text-stone-900">
2626
New post
2727
</Link>
28+
<span className="text-stone-300">·</span>
29+
<Link
30+
href="/inside/admin/proposals"
31+
className="text-stone-700 hover:text-stone-900"
32+
>
33+
Proposals
34+
</Link>
35+
<Link
36+
href="/inside/admin/proposals/new"
37+
className="text-stone-700 hover:text-stone-900"
38+
>
39+
New proposal
40+
</Link>
2841
</div>
2942
</div>
3043
{children}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { notFound } from "next/navigation";
2+
import { getProposalById } from "@/lib/proposals";
3+
import { ProposalEditor } from "@/components/proposal-editor";
4+
import { updateProposalAction, deleteProposalAction } from "../../_actions";
5+
6+
interface Props {
7+
params: Promise<{ id: string }>;
8+
searchParams: Promise<{ saved?: string }>;
9+
}
10+
11+
export const metadata = { title: "Edit proposal" };
12+
13+
export default async function EditProposalPage({ params, searchParams }: Props) {
14+
const { id } = await params;
15+
const { saved } = await searchParams;
16+
const proposal = await getProposalById(id);
17+
if (!proposal) notFound();
18+
19+
const update = updateProposalAction.bind(null, id);
20+
const remove = deleteProposalAction.bind(null, id);
21+
22+
return (
23+
<div className="px-6 py-12 md:py-16">
24+
<div className="mx-auto max-w-[44rem]">
25+
<h1 className="font-serif text-3xl font-bold tracking-tight text-stone-900 md:text-4xl">
26+
Edit proposal
27+
</h1>
28+
<p className="mt-3 font-sans text-sm text-stone-600">
29+
Editing &ldquo;{proposal.title}&rdquo; —{" "}
30+
{proposal.status === "draft" ? (
31+
<span>draft (not visible to members)</span>
32+
) : (
33+
<>at /inside/proposals/{proposal.slug}</>
34+
)}
35+
</p>
36+
37+
<div className="mt-10">
38+
<ProposalEditor
39+
initial={proposal}
40+
action={update}
41+
onDelete={remove}
42+
saved={saved === "1"}
43+
/>
44+
</div>
45+
</div>
46+
</div>
47+
);
48+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"use server";
2+
3+
import { revalidatePath } from "next/cache";
4+
import { redirect } from "next/navigation";
5+
import { eq } from "drizzle-orm";
6+
import { getOperatorSession } from "@/lib/operator";
7+
import {
8+
createProposal,
9+
updateProposal,
10+
deleteProposal,
11+
findProposalBySlug,
12+
} from "@/lib/proposals";
13+
import type { ProposalChoice } from "@/db/schema/proposals";
14+
import { getDb } from "@/db/client";
15+
import { users } from "@/db/external/auth";
16+
17+
interface ProposalFormValues {
18+
slug: string;
19+
title: string;
20+
bodyMd: string;
21+
voteType: "yes_no" | "single_choice";
22+
scope: "framework" | "product";
23+
productSlug: string | null;
24+
status: "draft" | "open" | "closed";
25+
opensAt: Date | null;
26+
closesAt: Date | null;
27+
choices: ProposalChoice[] | null;
28+
}
29+
30+
function parseValues(form: FormData): ProposalFormValues {
31+
const strOrNull = (v: FormDataEntryValue | null) => {
32+
if (v === null) return null;
33+
const s = String(v).trim();
34+
return s.length === 0 ? null : s;
35+
};
36+
const dateOrNull = (v: FormDataEntryValue | null) => {
37+
const s = strOrNull(v);
38+
if (!s) return null;
39+
const d = new Date(s);
40+
return Number.isNaN(d.getTime()) ? null : d;
41+
};
42+
43+
const voteTypeRaw = String(form.get("voteType") ?? "yes_no");
44+
const voteType: ProposalFormValues["voteType"] =
45+
voteTypeRaw === "single_choice" ? "single_choice" : "yes_no";
46+
47+
const scopeRaw = String(form.get("scope") ?? "product");
48+
const scope: ProposalFormValues["scope"] =
49+
scopeRaw === "framework" ? "framework" : "product";
50+
51+
const statusRaw = String(form.get("status") ?? "draft");
52+
const status: ProposalFormValues["status"] =
53+
statusRaw === "open" || statusRaw === "closed" ? statusRaw : "draft";
54+
55+
let choices: ProposalChoice[] | null = null;
56+
if (voteType === "single_choice") {
57+
const raw = String(form.get("choicesJson") ?? "[]");
58+
try {
59+
const parsed = JSON.parse(raw);
60+
if (Array.isArray(parsed)) {
61+
choices = parsed
62+
.filter(
63+
(c): c is ProposalChoice =>
64+
!!c && typeof c.id === "string" && typeof c.label === "string",
65+
)
66+
.slice(0, 20);
67+
}
68+
} catch {
69+
choices = null;
70+
}
71+
}
72+
73+
return {
74+
slug: String(form.get("slug") ?? "").trim(),
75+
title: String(form.get("title") ?? "").trim(),
76+
bodyMd: String(form.get("bodyMd") ?? ""),
77+
voteType,
78+
scope,
79+
productSlug: strOrNull(form.get("productSlug")),
80+
status,
81+
opensAt: dateOrNull(form.get("opensAt")),
82+
closesAt: dateOrNull(form.get("closesAt")),
83+
choices,
84+
};
85+
}
86+
87+
async function ensureUserId(email: string): Promise<string | null> {
88+
const db = getDb();
89+
const [u] = await db
90+
.select({ id: users.id })
91+
.from(users)
92+
.where(eq(users.email, email))
93+
.limit(1);
94+
return u?.id ?? null;
95+
}
96+
97+
export async function createProposalAction(form: FormData) {
98+
const session = await getOperatorSession();
99+
if (!session?.user?.email) throw new Error("Not authorized");
100+
101+
const v = parseValues(form);
102+
if (!v.title || !v.slug || !v.bodyMd) {
103+
throw new Error("Title, slug, and body are required");
104+
}
105+
if (v.voteType === "single_choice" && (!v.choices || v.choices.length < 2)) {
106+
throw new Error("Single-choice proposals need at least two choices");
107+
}
108+
const existing = await findProposalBySlug(v.slug);
109+
if (existing) {
110+
throw new Error(`A proposal with slug '${v.slug}' already exists`);
111+
}
112+
113+
const authorId = await ensureUserId(session.user.email);
114+
115+
const created = await createProposal({
116+
slug: v.slug,
117+
title: v.title,
118+
bodyMd: v.bodyMd,
119+
voteType: v.voteType,
120+
scope: v.scope,
121+
productSlug: v.productSlug,
122+
status: v.status,
123+
opensAt: v.opensAt,
124+
closesAt: v.closesAt,
125+
choices: v.choices,
126+
authorId,
127+
});
128+
129+
revalidatePath("/inside/proposals");
130+
revalidatePath("/inside/admin/proposals");
131+
redirect(`/inside/admin/proposals/${created.id}/edit`);
132+
}
133+
134+
export async function updateProposalAction(id: string, form: FormData) {
135+
const session = await getOperatorSession();
136+
if (!session?.user?.email) throw new Error("Not authorized");
137+
138+
const v = parseValues(form);
139+
if (!v.title || !v.slug || !v.bodyMd) {
140+
throw new Error("Title, slug, and body are required");
141+
}
142+
if (v.voteType === "single_choice" && (!v.choices || v.choices.length < 2)) {
143+
throw new Error("Single-choice proposals need at least two choices");
144+
}
145+
146+
await updateProposal(id, {
147+
slug: v.slug,
148+
title: v.title,
149+
bodyMd: v.bodyMd,
150+
voteType: v.voteType,
151+
scope: v.scope,
152+
productSlug: v.productSlug,
153+
status: v.status,
154+
opensAt: v.opensAt,
155+
closesAt: v.closesAt,
156+
choices: v.choices,
157+
});
158+
159+
revalidatePath("/inside/proposals");
160+
revalidatePath(`/inside/proposals/${v.slug}`);
161+
revalidatePath("/inside/admin/proposals");
162+
redirect(`/inside/admin/proposals/${id}/edit?saved=1`);
163+
}
164+
165+
export async function deleteProposalAction(id: string) {
166+
const session = await getOperatorSession();
167+
if (!session?.user?.email) throw new Error("Not authorized");
168+
await deleteProposal(id);
169+
revalidatePath("/inside/proposals");
170+
revalidatePath("/inside/admin/proposals");
171+
redirect("/inside/admin/proposals");
172+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ProposalEditor } from "@/components/proposal-editor";
2+
import { createProposalAction } from "../_actions";
3+
4+
export const metadata = { title: "New proposal" };
5+
6+
export default function NewProposalPage() {
7+
return (
8+
<div className="px-6 py-12 md:py-16">
9+
<div className="mx-auto max-w-[44rem]">
10+
<h1 className="font-serif text-3xl font-bold tracking-tight text-stone-900 md:text-4xl">
11+
New proposal
12+
</h1>
13+
<p className="mt-3 font-sans text-sm text-stone-600">
14+
Create a proposal. Draft stays hidden until you switch status to Open.
15+
</p>
16+
17+
<div className="mt-10">
18+
<ProposalEditor action={createProposalAction} />
19+
</div>
20+
</div>
21+
</div>
22+
);
23+
}

0 commit comments

Comments
 (0)