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 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
env:
DATABASE_URL: postgresql://fake:fake@localhost:5432/fake
Expand Down
143 changes: 129 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,145 @@
<div align="center">

# FeedbackFlow

> Collect, prioritize, and ship product feedback. A minimalist Canny/Frill alternative.
**Collect, prioritize, and ship product feedback.**
A minimalist open-source alternative to Canny / Frill.

[Live demo](https://feedbackflow.vercel.app) · [Public board](https://feedbackflow.vercel.app/b/demo) · [Roadmap](https://feedbackflow.vercel.app/b/demo/roadmap)

[![CI](https://github.com/<your-handle>/feedbackflow/actions/workflows/ci.yml/badge.svg)](https://github.com/<your-handle>/feedbackflow/actions)
![License](https://img.shields.io/badge/License-MIT-blue.svg)
![Next.js](https://img.shields.io/badge/Next.js-16-black)
![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)

![Screenshot](./docs/hero.png)

[![CI](https://github.com/yentec/feedbackflow/actions/workflows/ci.yml/badge.svg)](https://github.com/yentec/feedbackflow/actions)
</div>

**[Live demo](https://feedbackflow.vercel.app) · [Documentation](#)**
---

## Features

- **Public feedback board** — share a link, collect ideas
- **One-click upvote** with optimistic UI (`useOptimistic`)
- **Comments** on each post
- **Status workflow**: Open → Planned → In Progress → Done / Rejected
- **Public roadmap** auto-generated from post statuses
- **Admin dashboard** with stats, top posts, status breakdown
- **Auth**: GitHub OAuth + magic link (Resend)
- **SEO**: dynamic OG images per board, sitemap, robots
- **Privacy toggle** to switch a board to private
- **TypeScript strict** + Zod validation everywhere
- **Server Actions** (no REST API to maintain)

## Stack

- Next.js 16 (App Router, Server Components, Server Actions)
- TypeScript (strict)
- Auth.js v5 (GitHub + magic link)
- Prisma + Neon PostgreSQL
- Tailwind v4 + shadcn/ui
- Zod, Resend, Vercel
| Layer | Choice |
| ----------- | ------------------------------------- |
| Framework | Next.js 16 (App Router, RSC) |
| Language | TypeScript (strict, noUncheckedIndex) |
| Auth | Auth.js v5 (GitHub + Resend) |
| Database | PostgreSQL on Neon (serverless) |
| ORM | Prisma |
| Validation | Zod |
| Styling | Tailwind v4 + shadcn/ui |
| Email | Resend |
| Deployment | Vercel |
| Testing | Vitest |
| CI | GitHub Actions |

## Screenshots

| Public board | Roadmap |
| ----------------------------- | ------------------------- |
| ![](./docs/board.png) | ![](./docs/roadmap.png) |

| Admin dashboard | Post detail |
| ----------------------------- | ------------------------- |
| ![](./docs/dashboard.png) | ![](./docs/post.png) |

## Architecture
```
src/
├── app/ # App Router (routes, layouts, loading, error, OG)
│ ├── (auth)/ # public auth pages
│ ├── (dashboard)/ # protected admin pages
│ ├── (marketing)/ # landing
│ └── b/[slug]/ # public board + roadmap + post detail
├── components/
│ ├── ui/ # shadcn primitives
│ ├── posts/ board/ # domain components
├── lib/
│ ├── auth.ts # Auth.js config
│ ├── db.ts # Prisma singleton
│ └── validators/ # shared Zod schemas
└── server/
├── actions/ # Server Actions (mutations)
└── queries/ # Read functions for Server Components
```
**Key decisions** — see [docs/decisions.md](./docs/decisions.md) for the full log.

- **Server Actions over REST**: types end-to-end, less boilerplate, fewer files
- **JWT sessions**: required for Auth.js middleware on the Edge runtime
- **Prisma over Drizzle**: better interview signal, mature DX
- **No global client store**: RSC + Server Actions remove the need

## Getting started

Prerequisites: Node 20+, npm, a free [Neon](https://neon.tech) database, a free [Resend](https://resend.com) account, and a [GitHub OAuth app](https://github.com/settings/developers).

```bash
git clone https://github.com/Yentec/FeedbackFlow.git
cd feedbackflow
npm install
cp .env.example .env
# fill in env vars
npm db:migrate
npm dev
cp .env.example .env.local
# fill in DATABASE_URL, AUTH_SECRET, AUTH_GITHUB_*, AUTH_RESEND_KEY, EMAIL_FROM, CRON_SECRET

npm run db:migrate
npm run db:seed
npm run dev
```

Then visit `http://localhost:3000` and `http://localhost:3000/b/demo`.

### Generate `AUTH_SECRET` & `CRON_SECRET`

```bash
openssl rand -base64 32
```

## Scripts

```bash
npm run dev # dev server
npm run build # production build
npm run lint # ESLint
npm run typecheck # tsc --noEmit
npm test # Vitest
npm run db:migrate # apply migrations
npm run db:seed # seed the demo board
npm run db:studio # browse the DB
```

## Deploy

One-click deploy on Vercel:

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Yentec/FeedbackFlow.git)

You will need to set all environment variables from `.env.example` in the Vercel project.

## Roadmap

Out of scope for this MVP, but tracked as issues:

- Stripe billing
- Multi-user teams per board
- Slack / Discord webhooks on new post
- Email digests
- i18n (FR / EN)
- Full-text search

## License

MIT
MIT — see [LICENSE](./LICENSE).
10 changes: 6 additions & 4 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ export default async function LoginPage({ searchParams }: Props) {
<form
action={async (formData) => {
"use server";
await signIn("resend", {
email: formData.get("email") as string,
redirectTo: callbackUrl ?? "/dashboard",
});
const email = formData.get("email") as string;
if (email === "demo@feedbackflow.app" && process.env["DEMO_MODE"] === "true") {
await signIn("demo", { redirectTo: callbackUrl ?? "/dashboard" });
} else {
await signIn("resend", { email, redirectTo: callbackUrl ?? "/dashboard" });
}
}}
className="space-y-3"
>
Expand Down
18 changes: 18 additions & 0 deletions app/(dashboard)/dashboard/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Skeleton } from "@/components/ui/skeleton";

export default function Loading() {
return (
<div>
<Skeleton className="mb-8 h-8 w-40" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full" />
))}
</div>
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions app/(dashboard)/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { useEffect } from "react";
import { Button } from "@/components/ui/button";

export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);

return (
<div className="flex min-h-[50vh] flex-col items-center justify-center text-center">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground mt-2 max-w-md text-sm">
An unexpected error occurred. Please try again.
</p>
<Button onClick={reset} className="mt-6">
Try again
</Button>
</div>
);
}
10 changes: 10 additions & 0 deletions app/(dashboard)/posts/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Skeleton } from "@/components/ui/skeleton";

export default function Loading() {
return (
<div>
<Skeleton className="mb-6 h-8 w-32" />
<Skeleton className="h-96 w-full" />
</div>
);
}
32 changes: 32 additions & 0 deletions app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { getBoardByOwner } from "@/server/queries/boards";
import { SettingsForm } from "@/components/board/settings-form";

export default async function SettingsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");

const board = await getBoardByOwner(session.user.id);
if (!board) redirect("/login");

return (
<div className="max-w-2xl">
<header className="mb-8">
<h1 className="text-2xl font-semibold">Settings</h1>
<p className="text-muted-foreground text-sm">
Configure how your public board appears to visitors.
</p>
</header>

<SettingsForm
initial={{
name: board.name,
description: board.description ?? "",
slug: board.slug,
isPublic: board.isPublic,
}}
/>
</div>
);
}
98 changes: 98 additions & 0 deletions app/api/cron/reset-demo/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { PostStatus } from "@prisma/client";

const SEED_POSTS: { title: string; status: PostStatus; cat: number }[] = [
{ title: "Dark mode for the dashboard", status: PostStatus.PLANNED, cat: 0 },
{ title: "Export feedback as CSV", status: PostStatus.OPEN, cat: 0 },
{ title: "Voting button misaligned on mobile", status: PostStatus.IN_PROGRESS, cat: 1 },
{ title: "Slack integration for new posts", status: PostStatus.OPEN, cat: 0 },
{ title: "Faster page load on large boards", status: PostStatus.DONE, cat: 2 },
{ title: "Spam from disposable emails", status: PostStatus.REJECTED, cat: 1 },
];

const SEED_CATEGORIES = [
{ name: "Feature", color: "#6366f1" },
{ name: "Bug", color: "#ef4444" },
{ name: "Improvement", color: "#10b981" },
];

export async function GET(req: Request) {
const cronSecret = process.env["CRON_SECRET"];
const authHeader = req.headers.get("authorization");
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

if (process.env["DEMO_MODE"] !== "true") {
return NextResponse.json({ error: "Not in demo mode" }, { status: 400 });
}

const demoUser = await db.user.findUnique({ where: { email: "demo@feedbackflow.app" } });
if (!demoUser) {
return NextResponse.json({ error: "Demo user not found" }, { status: 404 });
}

const board = await db.board.findUnique({ where: { ownerId: demoUser.id } });
if (!board) {
return NextResponse.json({ error: "Demo board not found" }, { status: 404 });
}

// Delete all posts — cascades votes and comments
await db.post.deleteMany({ where: { boardId: board.id } });

const categories = await Promise.all(
SEED_CATEGORIES.map((c) =>
db.category.upsert({
where: { boardId_name: { boardId: board.id, name: c.name } },
update: {},
create: { ...c, boardId: board.id },
}),
),
);

const voters = await Promise.all(
Array.from({ length: 8 }).map((_, i) =>
db.user.upsert({
where: { email: `voter${i}@feedbackflow.app` },
update: {},
create: { email: `voter${i}@feedbackflow.app`, name: `Voter ${i + 1}` },
}),
),
);

for (const p of SEED_POSTS) {
const post = await db.post.create({
data: {
title: p.title,
content: `${p.title}. More details about why this matters and what it should do.`,
status: p.status,
boardId: board.id,
authorId: demoUser.id,
categoryId: categories[p.cat]?.id,
},
});

const voteCount = Math.floor(Math.random() * voters.length);
for (let i = 0; i < voteCount; i++) {
const voter = voters[i];
if (!voter) continue;
await db.vote.create({ data: { postId: post.id, userId: voter.id } });
}

const commentCount = Math.floor(Math.random() * 3);
for (let i = 0; i < commentCount; i++) {
const voter = voters[i];
if (!voter) continue;
await db.comment.create({
data: {
postId: post.id,
authorId: voter.id,
content: "Great idea. This would really improve my workflow.",
},
});
}
}

return NextResponse.json({ ok: true, reset: new Date().toISOString() });
}
Loading
Loading