diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ae15ec..86e4fe0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 2954ce7..a4677e2 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,145 @@ +
+ # 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//feedbackflow/actions/workflows/ci.yml/badge.svg)](https://github.com//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) +
-**[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 \ No newline at end of file +MIT — see [LICENSE](./LICENSE). \ No newline at end of file diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 3f735a3..d4e0ba5 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -42,10 +42,12 @@ export default async function LoginPage({ searchParams }: Props) {
{ "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" > diff --git a/app/(dashboard)/dashboard/loading.tsx b/app/(dashboard)/dashboard/loading.tsx new file mode 100644 index 0000000..64f6a58 --- /dev/null +++ b/app/(dashboard)/dashboard/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + +
+
+ ); +} diff --git a/app/(dashboard)/error.tsx b/app/(dashboard)/error.tsx new file mode 100644 index 0000000..5b6f3a0 --- /dev/null +++ b/app/(dashboard)/error.tsx @@ -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 ( +
+

Something went wrong

+

+ An unexpected error occurred. Please try again. +

+ +
+ ); +} diff --git a/app/(dashboard)/posts/loading.tsx b/app/(dashboard)/posts/loading.tsx new file mode 100644 index 0000000..e7f3037 --- /dev/null +++ b/app/(dashboard)/posts/loading.tsx @@ -0,0 +1,10 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ + +
+ ); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..8ef4c6e --- /dev/null +++ b/app/(dashboard)/settings/page.tsx @@ -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 ( +
+
+

Settings

+

+ Configure how your public board appears to visitors. +

+
+ + +
+ ); +} diff --git a/app/api/cron/reset-demo/route.ts b/app/api/cron/reset-demo/route.ts new file mode 100644 index 0000000..ac8a157 --- /dev/null +++ b/app/api/cron/reset-demo/route.ts @@ -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() }); +} diff --git a/app/b/[slug]/error.tsx b/app/b/[slug]/error.tsx new file mode 100644 index 0000000..dc04049 --- /dev/null +++ b/app/b/[slug]/error.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; + +export default function BoardError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+

Something went wrong

+

+ An unexpected error occurred. Please try again. +

+ +
+ ); +} diff --git a/app/b/[slug]/opengraph-image.tsx b/app/b/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..f02f908 --- /dev/null +++ b/app/b/[slug]/opengraph-image.tsx @@ -0,0 +1,74 @@ +import { ImageResponse } from "next/og"; +import { db } from "@/lib/db"; + +export const runtime = "nodejs"; // Prisma requires Node runtime +export const alt = "FeedbackFlow board"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default async function OG({ params }: { params: { slug: string } }) { + const board = await db.board.findUnique({ + where: { slug: params.slug }, + select: { name: true, description: true, isPublic: true, _count: { select: { posts: true } } }, + }); + + const name = board?.isPublic ? board.name : "FeedbackFlow"; + const description = board?.isPublic + ? (board.description ?? "Feedback board") + : "Open source feedback board"; + const postCount = board?.isPublic ? board._count.posts : 0; + + return new ImageResponse( +
+
feedbackflow.app
+
+ {name} +
+
+ {description} +
+ {postCount > 0 && ( +
+ {postCount} {postCount === 1 ? "post" : "posts"} +
+ )} +
, + size, + ); +} diff --git a/app/b/[slug]/posts/[id]/loading.tsx b/app/b/[slug]/posts/[id]/loading.tsx new file mode 100644 index 0000000..a3cee04 --- /dev/null +++ b/app/b/[slug]/posts/[id]/loading.tsx @@ -0,0 +1,12 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ + + + +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 96f85c6..5eba012 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,13 @@ const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); export const metadata: Metadata = { title: { default: "FeedbackFlow", template: "%s | FeedbackFlow" }, description: "Collect, prioritize, and ship product feedback.", + metadataBase: new URL(process.env["NEXT_PUBLIC_APP_URL"] ?? "http://localhost:3000"), + openGraph: { + title: "FeedbackFlow", + description: "Collect, prioritize, and ship product feedback.", + type: "website", + }, + twitter: { card: "summary_large_image" }, }; export default function RootLayout({ diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..77c03e1 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+

404

+

Page not found

+

+ The page you’re looking for doesn’t exist or is private. +

+ +
+ ); +} diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx new file mode 100644 index 0000000..e673fd5 --- /dev/null +++ b/app/opengraph-image.tsx @@ -0,0 +1,42 @@ +import { ImageResponse } from "next/og"; + +export const runtime = "edge"; +export const alt = "FeedbackFlow — Ship what your users actually want"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default function OG() { + return new ImageResponse( +
+
FeedbackFlow
+
+ Ship what your users actually want. +
+
+ Open source feedback board · Next.js 16 +
+
, + size, + ); +} diff --git a/app/page.tsx b/app/page.tsx index 684da6a..e482751 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,25 +1,104 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; +import { ChevronUp, MessageSquare, Map, ArrowRight } from "lucide-react"; +import { FaGithub } from "react-icons/fa"; export default function Home() { return ( -
-
-

- Ship what your users actually want. -

-

- FeedbackFlow is the simplest way to collect, prioritize, and act on product feedback. -

-
- - +
+
+
+ + FeedbackFlow + +
+
+ +
+
+

Open source · Built with Next.js 16

+

+ Ship what your users actually want. +

+

+ FeedbackFlow is a minimalist feedback board. Collect ideas, let users vote, share a + public roadmap. +

+
+ + +
+
+ +
+
+ } + title="Public board" + description="Anyone with the link can submit ideas. Authenticated users post, comment, vote." + /> + } + title="Upvote what matters" + description="One vote per user. Optimistic UI. Sort by votes to surface what your users want most." + /> + } + title="Public roadmap" + description="Planned, in progress, done. Three columns auto-generated from your post statuses." + /> +
+
+
+ + +
+ ); +} + +function Feature({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) { + return ( +
+
+ {icon}
-
+

{title}

+

{description}

+ ); } diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..05682ec --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,9 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + const base = process.env["NEXT_PUBLIC_APP_URL"] ?? "http://localhost:3000"; + return { + rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/settings", "/posts", "/api"] }, + sitemap: `${base}/sitemap.xml`, + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..fc66565 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next"; +import { db } from "@/lib/db"; + +export default async function sitemap(): Promise { + const base = process.env["NEXT_PUBLIC_APP_URL"] ?? "http://localhost:3000"; + + const boards = await db.board.findMany({ + where: { isPublic: true }, + select: { slug: true }, + }); + + return [ + { url: base, lastModified: new Date(), changeFrequency: "weekly" }, + ...boards.flatMap((b) => [ + { + url: `${base}/b/${b.slug}`, + changeFrequency: "daily" as const, + }, + { + url: `${base}/b/${b.slug}/roadmap`, + changeFrequency: "weekly" as const, + }, + ]), + ]; +} diff --git a/auth.ts b/auth.ts index c1c493f..abb6c0d 100644 --- a/auth.ts +++ b/auth.ts @@ -1,6 +1,7 @@ import NextAuth from "next-auth"; import { PrismaAdapter } from "@auth/prisma-adapter"; import Resend from "next-auth/providers/resend"; +import Credentials from "next-auth/providers/credentials"; import { db } from "@/lib/db"; import authConfig from "./auth.config"; import { slugify } from "@/lib/slug"; @@ -43,5 +44,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }, ...authConfig, - providers: [...authConfig.providers, Resend({ from: process.env["EMAIL_FROM"] })], + providers: [ + ...authConfig.providers, + Resend({ from: process.env["EMAIL_FROM"] }), + Credentials({ + id: "demo", + credentials: {}, + async authorize() { + if (process.env["DEMO_MODE"] !== "true") return null; + const demoEmail = "demo@feedbackflow.app"; + let user = await db.user.findUnique({ where: { email: demoEmail } }); + if (!user) { + user = await db.user.create({ + data: { email: demoEmail, name: "Demo User", emailVerified: new Date() }, + }); + await db.board.create({ + data: { slug: "demo", name: "Demo board", description: "Try FeedbackFlow with a pre-seeded board.", ownerId: user.id }, + }); + } + return user; + }, + }), + ], }); diff --git a/components/board/settings-form.tsx b/components/board/settings-form.tsx new file mode 100644 index 0000000..d8b6852 --- /dev/null +++ b/components/board/settings-form.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; +import { updateBoard } from "@/server/actions/boards"; + +type Props = { + initial: { name: string; description: string; slug: string; isPublic: boolean }; +}; + +export function SettingsForm({ initial }: Props) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [form, setForm] = useState(initial); + const [errors, setErrors] = useState>({}); + + const dirty = + form.name !== initial.name || + form.description !== initial.description || + form.slug !== initial.slug || + form.isPublic !== initial.isPublic; + + function handleSubmit() { + setErrors({}); + startTransition(async () => { + const result = await updateBoard({ + name: form.name, + description: form.description || null, + slug: form.slug, + isPublic: form.isPublic, + }); + if (!result.ok) { + if (result.issues) setErrors(result.issues); + toast.error(result.error); + return; + } + toast.success("Settings saved"); + if (result.data.slug !== initial.slug) { + router.refresh(); + } + }); + } + + return ( + + +
+ + setForm({ ...form, name: e.target.value })} + maxLength={60} + /> + {errors["name"] &&

{errors["name"][0]}

} +
+ +
+ +