1555 lines
49 KiB
Markdown
1555 lines
49 KiB
Markdown
# Changelog, Roadmap & Feedback Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a public `/changelog` page with What's New / What's Next / Give Feedback tabs, auth-gated roadmap upvoting, a reusable feedback form + modal, a standalone `/feedback` page, and an admin view for feedback submissions.
|
|
|
|
**Architecture:** Sanity CMS provides changelog and roadmap content (two new document types: `changelogEntry`, `roadmapItem`). The Next.js RSC page fetches both via `sanityFetch()`, passes data as props to a `"use client"` `ChangelogTabs` component. Votes and feedback are stored in Spring Boot (separate repo — endpoints are prerequisites). Next.js `app/api/` routes proxy all Spring Boot calls using the project's established manual `cookies()` / `fetch()` pattern — **not** `lib/springProxy.ts`, which is not used by any existing route in this project.
|
|
|
|
**Tech Stack:** Next.js 15 App Router, TypeScript, Tailwind CSS, Sanity CMS (`lib/sanityFetch.ts` for queries), `cookies()` from `next/headers` for auth forwarding, `@heroicons/react/20/solid`, `lucide-react` (admin nav), `PortableTextRenderer` (existing component at `components/PortableTextRenderer.tsx`)
|
|
|
|
**Spring Boot prerequisites (out of scope — must exist before votes/feedback work end-to-end):**
|
|
- `POST /api/v1/feedback` — body: `{ category, message, email?, ipAddress, userAgent }`
|
|
- `GET /api/v1/roadmap/votes?ids=...` — returns `{ [sanityId]: { count: number, voted: boolean } }`, unauthenticated returns `voted: false` for all
|
|
- `POST /api/v1/roadmap/{sanityId}/vote` — auth required, toggles vote, returns `{ count: number, voted: boolean }`
|
|
- `GET /api/v1/admin/feedback` — returns array of feedback submissions
|
|
- `PATCH /api/v1/admin/feedback/{id}` — body: `{ reviewed: boolean }`
|
|
|
|
Until those endpoints exist, the UI degrades gracefully (votes show 0/unvoted, feedback form shows error).
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| File | Action | Responsibility |
|
|
|---|---|---|
|
|
| `types/changelog.ts` | Create | Shared TypeScript types for changelog + roadmap data |
|
|
| `lib/sanity.ts` | Modify | Add `changelogEntriesQuery` and `roadmapItemsQuery` |
|
|
| `middleware.ts` | Modify | Whitelist `/changelog` and `/feedback` in `isPublicPath()` |
|
|
| `app/(app)/changelog/page.tsx` | Create | RSC — fetch Sanity data, render page header + `ChangelogTabs` |
|
|
| `components/ChangelogTabs.tsx` | Create | `"use client"` — hash tab state, renders all three panels, fetches + manages vote state |
|
|
| `components/RoadmapItem.tsx` | Create | `"use client"` — single roadmap item with optimistic upvote button |
|
|
| `components/FeedbackForm.tsx` | Create | `"use client"` — category + message + email form, posts to `/api/feedback` |
|
|
| `components/FeedbackModal.tsx` | Create | `"use client"` — backdrop + dialog wrapping `FeedbackForm` |
|
|
| `app/(app)/feedback/page.tsx` | Create | RSC — standalone feedback page rendering `FeedbackForm` |
|
|
| `app/api/feedback/route.ts` | Create | Proxy `POST /api/v1/feedback`, captures IP + user-agent server-side |
|
|
| `app/api/roadmap/votes/route.ts` | Create | Proxy `GET /api/v1/roadmap/votes`, auth-optional |
|
|
| `app/api/roadmap/[sanityId]/vote/route.ts` | Create | Proxy `POST /api/v1/roadmap/:id/vote`, auth-required |
|
|
| `app/api/admin/feedback/route.ts` | Create | Proxy `GET /api/v1/admin/feedback`, admin-authed |
|
|
| `app/api/admin/feedback/[id]/route.ts` | Create | Proxy `PATCH /api/v1/admin/feedback/:id`, admin-authed |
|
|
| `app/admin/feedback/page.tsx` | Create | `"use client"` — admin table of submissions |
|
|
| `components/TopNav.tsx` | Modify | Add "Send Feedback" ghost button + `FeedbackModal` local state |
|
|
| `app/page.tsx` | Modify | Add "Send Feedback" link to inline footer |
|
|
| `app/admin/layout.tsx` | Modify | Add Feedback nav item to "Growth & Comms" group |
|
|
|
|
---
|
|
|
|
## Task 1: Shared types + Sanity queries + middleware whitelist
|
|
|
|
**Files:**
|
|
- Create: `types/changelog.ts`
|
|
- Modify: `lib/sanity.ts`
|
|
- Modify: `middleware.ts`
|
|
|
|
- [ ] **Step 1: Create `types/changelog.ts`**
|
|
|
|
```ts
|
|
export type ChangelogEntry = {
|
|
_id: string
|
|
title: string
|
|
version: string
|
|
publishedAt: string
|
|
tags: string[]
|
|
body: unknown[]
|
|
}
|
|
|
|
export type RoadmapItemData = {
|
|
_id: string
|
|
title: string
|
|
description: string
|
|
status: 'planned' | 'in-progress'
|
|
order: number
|
|
}
|
|
|
|
export type Tab = 'whats-new' | 'whats-next' | 'give-feedback'
|
|
|
|
export type VoteState = {
|
|
count: number
|
|
voted: boolean
|
|
loading: boolean
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add GROQ queries to `lib/sanity.ts`**
|
|
|
|
Append after the existing `postSlugsQuery` export:
|
|
|
|
```ts
|
|
/** All changelog entries — published, newest first */
|
|
export const changelogEntriesQuery = `
|
|
*[_type == "changelogEntry" && publishedAt <= now()]
|
|
| order(publishedAt desc) {
|
|
_id, title, version, publishedAt, tags, body
|
|
}
|
|
`
|
|
|
|
/** Roadmap items — excludes shipped, sorted by manual order */
|
|
export const roadmapItemsQuery = `
|
|
*[_type == "roadmapItem" && status != "shipped"]
|
|
| order(order asc) {
|
|
_id, title, description, status, order
|
|
}
|
|
`
|
|
```
|
|
|
|
- [ ] **Step 3: Whitelist `/changelog` and `/feedback` in `middleware.ts`**
|
|
|
|
In `isPublicPath()`, add after the guides block (around line 25):
|
|
|
|
```ts
|
|
// Changelog and feedback — always public
|
|
if (pathname === '/changelog') return true
|
|
if (pathname === '/feedback') return true
|
|
```
|
|
|
|
- [ ] **Step 4: Type-check**
|
|
|
|
```bash
|
|
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no new errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add types/changelog.ts lib/sanity.ts middleware.ts
|
|
git commit -m "feat: add changelog/roadmap types, Sanity queries, and public route whitelist"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: `/changelog` RSC page + `ChangelogTabs` (What's New tab)
|
|
|
|
**Files:**
|
|
- Create: `app/(app)/changelog/page.tsx`
|
|
- Create: `components/ChangelogTabs.tsx`
|
|
|
|
- [ ] **Step 1: Create `app/(app)/changelog/page.tsx`**
|
|
|
|
```tsx
|
|
import type { Metadata } from 'next'
|
|
import { changelogEntriesQuery, roadmapItemsQuery } from '@/lib/sanity'
|
|
import { sanityFetch } from '@/lib/sanityFetch'
|
|
import { ChangelogTabs } from '@/components/ChangelogTabs'
|
|
import type { ChangelogEntry, RoadmapItemData } from '@/types/changelog'
|
|
|
|
export const revalidate = 60
|
|
|
|
export const metadata: Metadata = {
|
|
title: "What's New — Battl Builders",
|
|
description:
|
|
'Release notes, upcoming features, and product updates from Battl Builders.',
|
|
}
|
|
|
|
export default async function ChangelogPage() {
|
|
let entries: ChangelogEntry[] = []
|
|
let roadmapItems: RoadmapItemData[] = []
|
|
|
|
try {
|
|
entries = await sanityFetch<ChangelogEntry[]>(changelogEntriesQuery)
|
|
} catch (err) {
|
|
console.error('[Changelog] entries fetch failed:', err)
|
|
}
|
|
|
|
try {
|
|
roadmapItems = await sanityFetch<RoadmapItemData[]>(roadmapItemsQuery)
|
|
} catch (err) {
|
|
console.error('[Changelog] roadmap fetch failed:', err)
|
|
}
|
|
|
|
return (
|
|
<main className="mx-auto max-w-4xl px-4 py-10">
|
|
<header className="mb-10">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
|
|
Battl Builders
|
|
</p>
|
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Updates</h1>
|
|
<p className="mt-2 text-sm text-zinc-400">
|
|
What we've shipped and what's coming next.
|
|
</p>
|
|
</header>
|
|
<ChangelogTabs entries={entries} roadmapItems={roadmapItems} />
|
|
</main>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `components/ChangelogTabs.tsx`**
|
|
|
|
What's New tab is fully implemented. Roadmap and Give Feedback tabs are stubs — filled in by Tasks 3 and 6.
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
|
|
import type { ChangelogEntry, RoadmapItemData, Tab, VoteState } from '@/types/changelog'
|
|
|
|
const TABS: { id: Tab; label: string }[] = [
|
|
{ id: 'whats-new', label: "What's New" },
|
|
{ id: 'whats-next', label: "What's Next" },
|
|
{ id: 'give-feedback', label: 'Give Feedback' },
|
|
]
|
|
|
|
const TAG_STYLES: Record<string, string> = {
|
|
feature: 'bg-amber-500/10 text-amber-400 border border-amber-500/25',
|
|
fix: 'bg-green-500/10 text-green-400 border border-green-500/20',
|
|
improvement: 'bg-blue-400/10 text-blue-400 border border-blue-400/20',
|
|
}
|
|
|
|
function formatDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
|
|
export function ChangelogTabs({
|
|
entries,
|
|
roadmapItems,
|
|
}: {
|
|
entries: ChangelogEntry[]
|
|
roadmapItems: RoadmapItemData[]
|
|
}) {
|
|
const [activeTab, setActiveTab] = useState<Tab>('whats-new')
|
|
|
|
useEffect(() => {
|
|
const hash = window.location.hash.replace('#', '') as Tab
|
|
if (TABS.some((t) => t.id === hash)) setActiveTab(hash)
|
|
}, [])
|
|
|
|
function handleTabChange(tab: Tab) {
|
|
setActiveTab(tab)
|
|
window.history.replaceState(null, '', `#${tab}`)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Tab bar */}
|
|
<div className="mb-10 flex border-b border-zinc-800">
|
|
{TABS.map(({ id, label }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => handleTabChange(id)}
|
|
className={[
|
|
'-mb-px border-b-2 px-5 py-2.5 text-sm font-medium transition-colors',
|
|
activeTab === id
|
|
? 'border-amber-400 text-amber-400'
|
|
: 'border-transparent text-zinc-500 hover:text-zinc-300',
|
|
].join(' ')}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── What's New ── */}
|
|
{activeTab === 'whats-new' && (
|
|
<div>
|
|
{entries.length === 0 ? (
|
|
<p className="text-sm text-zinc-500">
|
|
No releases yet — check back soon.
|
|
</p>
|
|
) : (
|
|
<div className="divide-y divide-zinc-900">
|
|
{entries.map((entry) => (
|
|
<div
|
|
key={entry._id}
|
|
className="grid grid-cols-[120px_1fr] gap-8 py-8"
|
|
>
|
|
<div className="pt-1">
|
|
<p className="font-mono text-xs font-bold text-amber-400">
|
|
{entry.version}
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-zinc-600">
|
|
{formatDate(entry.publishedAt)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
{entry.tags?.length > 0 && (
|
|
<div className="mb-2.5 flex flex-wrap gap-1.5">
|
|
{entry.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
|
TAG_STYLES[tag] ??
|
|
'border border-zinc-700 bg-zinc-800 text-zinc-400'
|
|
}`}
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<h3 className="mb-3 text-[17px] font-semibold text-white">
|
|
{entry.title}
|
|
</h3>
|
|
{entry.body && (
|
|
<div className="text-sm text-zinc-400">
|
|
<PortableTextRenderer value={entry.body} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── What's Next — stub, filled in Task 3 ── */}
|
|
{activeTab === 'whats-next' && (
|
|
<p className="text-sm text-zinc-500">Roadmap coming soon.</p>
|
|
)}
|
|
|
|
{/* ── Give Feedback — stub, filled in Task 6 ── */}
|
|
{activeTab === 'give-feedback' && (
|
|
<p className="text-sm text-zinc-500">Feedback form coming soon.</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify build**
|
|
|
|
```bash
|
|
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 4: Smoke test in browser**
|
|
|
|
Run `npm run dev`, open `http://localhost:3000/changelog`. Confirm:
|
|
- Page renders with header "Updates"
|
|
- Three tabs visible; amber underline on active tab
|
|
- URL hash updates on tab click
|
|
- What's New shows "No releases yet" (Sanity schema not yet deployed — expected)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add "app/(app)/changelog/page.tsx" components/ChangelogTabs.tsx
|
|
git commit -m "feat: add /changelog page with What's New tab"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `RoadmapItem` component + What's Next tab (static display)
|
|
|
|
**Files:**
|
|
- Create: `components/RoadmapItem.tsx`
|
|
- Modify: `components/ChangelogTabs.tsx`
|
|
|
|
- [ ] **Step 1: Create `components/RoadmapItem.tsx`**
|
|
|
|
Vote handler is a callback prop — actual vote logic lives in `ChangelogTabs` and is wired in Task 5.
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { ChevronUpIcon } from '@heroicons/react/20/solid'
|
|
import type { VoteState } from '@/types/changelog'
|
|
|
|
export function RoadmapItem({
|
|
id,
|
|
title,
|
|
description,
|
|
status,
|
|
vote,
|
|
onVote,
|
|
}: {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
status: 'planned' | 'in-progress'
|
|
vote: VoteState
|
|
onVote: (id: string) => void
|
|
}) {
|
|
const statusBadgeClass =
|
|
status === 'in-progress'
|
|
? 'border-amber-500/25 bg-amber-500/10 text-amber-400'
|
|
: 'border-zinc-700 bg-zinc-800/40 text-zinc-500'
|
|
|
|
return (
|
|
<div className="grid grid-cols-[52px_1fr_auto] items-center gap-4 rounded-xl border border-zinc-800/60 bg-zinc-950/60 p-5">
|
|
{/* Upvote button */}
|
|
<button
|
|
onClick={() => onVote(id)}
|
|
disabled={vote.loading}
|
|
title={vote.voted ? 'Remove vote' : 'Upvote'}
|
|
className={[
|
|
'flex h-[52px] w-11 flex-col items-center justify-center gap-0.5 rounded-lg border text-xs font-bold transition-colors disabled:opacity-50',
|
|
vote.voted
|
|
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
|
|
: 'border-zinc-700 text-zinc-500 hover:border-amber-500/40 hover:text-amber-400',
|
|
].join(' ')}
|
|
>
|
|
<ChevronUpIcon className="h-4 w-4" />
|
|
<span>{vote.count}</span>
|
|
</button>
|
|
|
|
{/* Content */}
|
|
<div>
|
|
<p className="text-sm font-semibold text-zinc-200">{title}</p>
|
|
<p className="mt-1 text-xs leading-relaxed text-zinc-500">{description}</p>
|
|
</div>
|
|
|
|
{/* Status badge */}
|
|
<span
|
|
className={`rounded-full border px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${statusBadgeClass}`}
|
|
>
|
|
{status === 'in-progress' ? 'In Progress' : 'Planned'}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Replace the What's Next stub in `components/ChangelogTabs.tsx`**
|
|
|
|
Add import at the top (after existing imports):
|
|
|
|
```tsx
|
|
import { RoadmapItem } from '@/components/RoadmapItem'
|
|
import type { VoteState } from '@/types/changelog'
|
|
```
|
|
|
|
Add `voteState` state inside the component (after the `activeTab` state):
|
|
|
|
```tsx
|
|
const [voteState, setVoteState] = useState<Record<string, VoteState>>(
|
|
() =>
|
|
Object.fromEntries(
|
|
roadmapItems.map((item) => [
|
|
item._id,
|
|
{ count: 0, voted: false, loading: false },
|
|
])
|
|
)
|
|
)
|
|
|
|
// Filled in Task 5 — no-op for now
|
|
function handleVote(_id: string) {}
|
|
```
|
|
|
|
Replace the `{activeTab === 'whats-next' && ...}` stub block with:
|
|
|
|
```tsx
|
|
{/* ── What's Next ── */}
|
|
{activeTab === 'whats-next' && (
|
|
<div>
|
|
{roadmapItems.length === 0 ? (
|
|
<p className="text-sm text-zinc-500">
|
|
No roadmap items yet — check back soon.
|
|
</p>
|
|
) : (
|
|
<>
|
|
{roadmapItems.filter((i) => i.status === 'in-progress').length > 0 && (
|
|
<div className="mb-8">
|
|
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
|
|
In Progress
|
|
</p>
|
|
<div className="flex flex-col gap-2.5">
|
|
{roadmapItems
|
|
.filter((i) => i.status === 'in-progress')
|
|
.map((item) => (
|
|
<RoadmapItem
|
|
key={item._id}
|
|
id={item._id}
|
|
title={item.title}
|
|
description={item.description}
|
|
status={item.status}
|
|
vote={
|
|
voteState[item._id] ?? {
|
|
count: 0,
|
|
voted: false,
|
|
loading: false,
|
|
}
|
|
}
|
|
onVote={handleVote}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{roadmapItems.filter((i) => i.status === 'planned').length > 0 && (
|
|
<div>
|
|
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
|
|
Planned
|
|
</p>
|
|
<div className="flex flex-col gap-2.5">
|
|
{roadmapItems
|
|
.filter((i) => i.status === 'planned')
|
|
.map((item) => (
|
|
<RoadmapItem
|
|
key={item._id}
|
|
id={item._id}
|
|
title={item.title}
|
|
description={item.description}
|
|
status={item.status}
|
|
vote={
|
|
voteState[item._id] ?? {
|
|
count: 0,
|
|
voted: false,
|
|
loading: false,
|
|
}
|
|
}
|
|
onVote={handleVote}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="mt-8 text-xs text-zinc-600">
|
|
Don't see what you need?{' '}
|
|
<button
|
|
onClick={() => handleTabChange('give-feedback')}
|
|
className="underline underline-offset-2 hover:text-zinc-300"
|
|
>
|
|
Give us feedback →
|
|
</button>
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify build**
|
|
|
|
```bash
|
|
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
|
|
```
|
|
|
|
- [ ] **Step 4: Smoke test**
|
|
|
|
Open `http://localhost:3000/changelog#whats-next`. Confirm roadmap tab renders (empty state with message, or items if Sanity has data). Vote buttons render; clicking them does nothing yet (no-op).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add components/RoadmapItem.tsx components/ChangelogTabs.tsx
|
|
git commit -m "feat: add roadmap tab with static RoadmapItem component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: API proxy routes
|
|
|
|
**Files:**
|
|
- Create: `app/api/feedback/route.ts`
|
|
- Create: `app/api/roadmap/votes/route.ts`
|
|
- Create: `app/api/roadmap/[sanityId]/vote/route.ts`
|
|
- Create: `app/api/admin/feedback/route.ts`
|
|
- Create: `app/api/admin/feedback/[id]/route.ts`
|
|
|
|
All routes use the project's established manual `cookies()` + `fetch()` pattern — the same pattern used by `app/api/builds/route.ts` and all other routes in this project.
|
|
|
|
- [ ] **Step 1: Create `app/api/feedback/route.ts`**
|
|
|
|
No auth required. Captures IP and user-agent server-side and adds them to the JSON body before forwarding to Spring Boot.
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
|
|
const BACKEND_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const body = await req.json()
|
|
|
|
const ip =
|
|
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
req.headers.get('x-real-ip') ??
|
|
'unknown'
|
|
const userAgent = req.headers.get('user-agent') ?? 'unknown'
|
|
|
|
const res = await fetch(`${BACKEND_URL}/api/v1/feedback`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...body, ipAddress: ip, userAgent }),
|
|
cache: 'no-store',
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '')
|
|
console.error('[/api/feedback] Spring Boot error:', res.status, text)
|
|
return NextResponse.json({ error: 'Failed to submit' }, { status: res.status })
|
|
}
|
|
|
|
return NextResponse.json({ ok: true })
|
|
} catch (e) {
|
|
console.error('[/api/feedback] proxy error:', e)
|
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `app/api/roadmap/votes/route.ts`**
|
|
|
|
Auth is optional. If a session token is present, it is forwarded so Spring Boot can return the current user's voted state.
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { cookies } from 'next/headers'
|
|
|
|
const BACKEND_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const cookieStore = await cookies()
|
|
const token = cookieStore.get('session_token')?.value
|
|
|
|
const headers: Record<string, string> = { Accept: 'application/json' }
|
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
|
|
const ids = req.nextUrl.searchParams.get('ids') ?? ''
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${BACKEND_URL}/api/v1/roadmap/votes?ids=${encodeURIComponent(ids)}`,
|
|
{ headers, cache: 'no-store' }
|
|
)
|
|
if (!res.ok) {
|
|
// Degrade gracefully — return empty map so UI shows 0 votes
|
|
return NextResponse.json({})
|
|
}
|
|
return NextResponse.json(await res.json())
|
|
} catch {
|
|
return NextResponse.json({})
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `app/api/roadmap/[sanityId]/vote/route.ts`**
|
|
|
|
Auth required — returns 401 if no session token.
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { cookies } from 'next/headers'
|
|
|
|
const BACKEND_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
|
|
export async function POST(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ sanityId: string }> }
|
|
) {
|
|
const cookieStore = await cookies()
|
|
const token = cookieStore.get('session_token')?.value
|
|
if (!token) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const { sanityId } = await params
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${BACKEND_URL}/api/v1/roadmap/${encodeURIComponent(sanityId)}/vote`,
|
|
{
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
cache: 'no-store',
|
|
}
|
|
)
|
|
if (!res.ok) {
|
|
return NextResponse.json({ error: await res.text() }, { status: res.status })
|
|
}
|
|
return NextResponse.json(await res.json())
|
|
} catch (e) {
|
|
return NextResponse.json({ error: 'Request failed' }, { status: 500 })
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create `app/api/admin/feedback/route.ts`**
|
|
|
|
Auth required. Session token is forwarded; Spring Boot enforces admin role.
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { cookies } from 'next/headers'
|
|
|
|
const BACKEND_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const cookieStore = await cookies()
|
|
const token = cookieStore.get('session_token')?.value
|
|
if (!token) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const params = req.nextUrl.searchParams.toString()
|
|
try {
|
|
const res = await fetch(
|
|
`${BACKEND_URL}/api/v1/admin/feedback${params ? `?${params}` : ''}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
cache: 'no-store',
|
|
}
|
|
)
|
|
if (!res.ok) {
|
|
return NextResponse.json({ error: await res.text() }, { status: res.status })
|
|
}
|
|
return NextResponse.json(await res.json())
|
|
} catch (e) {
|
|
return NextResponse.json({ error: 'Request failed' }, { status: 500 })
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create `app/api/admin/feedback/[id]/route.ts`**
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { cookies } from 'next/headers'
|
|
|
|
const BACKEND_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
|
|
export async function PATCH(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const cookieStore = await cookies()
|
|
const token = cookieStore.get('session_token')?.value
|
|
if (!token) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const { id } = await params
|
|
const body = await req.json()
|
|
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}/api/v1/admin/feedback/${id}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
cache: 'no-store',
|
|
})
|
|
if (!res.ok) {
|
|
return NextResponse.json({ error: await res.text() }, { status: res.status })
|
|
}
|
|
return res.status === 204
|
|
? new NextResponse(null, { status: 204 })
|
|
: NextResponse.json(await res.json())
|
|
} catch (e) {
|
|
return NextResponse.json({ error: 'Request failed' }, { status: 500 })
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Verify build**
|
|
|
|
```bash
|
|
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add app/api/feedback/route.ts app/api/roadmap/votes/route.ts "app/api/roadmap/[sanityId]/vote/route.ts" app/api/admin/feedback/route.ts "app/api/admin/feedback/[id]/route.ts"
|
|
git commit -m "feat: add API proxy routes for feedback and roadmap votes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Wire up upvote functionality
|
|
|
|
**Files:**
|
|
- Modify: `components/ChangelogTabs.tsx`
|
|
|
|
Replaces the no-op `handleVote` with real vote toggle logic. Unauthenticated users see an inline sign-in prompt (not a redirect) — keeps the user on the page.
|
|
|
|
- [ ] **Step 1: Update `components/ChangelogTabs.tsx`**
|
|
|
|
Add this import at the top (after existing imports):
|
|
|
|
```tsx
|
|
import { useAuth } from '@/context/AuthContext'
|
|
import Link from 'next/link'
|
|
```
|
|
|
|
Inside the component, add these after the `voteState` state declaration:
|
|
|
|
```tsx
|
|
const { user } = useAuth()
|
|
const [showSignInPrompt, setShowSignInPrompt] = useState(false)
|
|
const [votesFetched, setVotesFetched] = useState(false)
|
|
|
|
// Fetch vote state when the What's Next tab is first viewed
|
|
useEffect(() => {
|
|
if (activeTab !== 'whats-next' || votesFetched || roadmapItems.length === 0) return
|
|
setVotesFetched(true)
|
|
|
|
const ids = roadmapItems.map((i) => i._id).join(',')
|
|
fetch(`/api/roadmap/votes?ids=${encodeURIComponent(ids)}`, {
|
|
credentials: 'include',
|
|
})
|
|
.then((r) => (r.ok ? r.json() : {}))
|
|
.then((data: Record<string, { count: number; voted: boolean }>) => {
|
|
setVoteState((prev) => {
|
|
const next = { ...prev }
|
|
for (const [id, v] of Object.entries(data)) {
|
|
next[id] = { count: v.count, voted: v.voted, loading: false }
|
|
}
|
|
return next
|
|
})
|
|
})
|
|
.catch(() => {
|
|
// Spring Boot not yet available — silently keep count=0
|
|
})
|
|
}, [activeTab, votesFetched, roadmapItems])
|
|
```
|
|
|
|
Replace the no-op `handleVote` with:
|
|
|
|
```tsx
|
|
async function handleVote(id: string) {
|
|
if (!user) {
|
|
setShowSignInPrompt(true)
|
|
setTimeout(() => setShowSignInPrompt(false), 4000)
|
|
return
|
|
}
|
|
|
|
const current = voteState[id] ?? { count: 0, voted: false, loading: false }
|
|
|
|
// Optimistic update
|
|
setVoteState((prev) => ({
|
|
...prev,
|
|
[id]: {
|
|
count: current.voted ? current.count - 1 : current.count + 1,
|
|
voted: !current.voted,
|
|
loading: true,
|
|
},
|
|
}))
|
|
|
|
try {
|
|
const res = await fetch(`/api/roadmap/${encodeURIComponent(id)}/vote`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
})
|
|
if (res.ok) {
|
|
const data: { count: number; voted: boolean } = await res.json()
|
|
setVoteState((prev) => ({
|
|
...prev,
|
|
[id]: { count: data.count, voted: data.voted, loading: false },
|
|
}))
|
|
} else {
|
|
setVoteState((prev) => ({ ...prev, [id]: { ...current, loading: false } }))
|
|
}
|
|
} catch {
|
|
setVoteState((prev) => ({ ...prev, [id]: { ...current, loading: false } }))
|
|
}
|
|
}
|
|
```
|
|
|
|
Add the sign-in prompt below the roadmap items (inside the What's Next block, after the "Don't see what you need?" nudge):
|
|
|
|
```tsx
|
|
{showSignInPrompt && (
|
|
<p className="mt-3 text-xs text-amber-400">
|
|
<Link href="/login" className="underline underline-offset-2 hover:text-amber-300">
|
|
Sign in
|
|
</Link>{' '}
|
|
to upvote roadmap items.
|
|
</p>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
```bash
|
|
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
|
|
```
|
|
|
|
- [ ] **Step 3: Smoke test**
|
|
|
|
Open `http://localhost:3000/changelog#whats-next`:
|
|
- Logged out: click upvote → "Sign in to upvote roadmap items." prompt appears below the list, fades after 4 seconds
|
|
- Logged in: click upvote → optimistic count update, request fires to `/api/roadmap/{id}/vote` (rolls back cleanly until Spring Boot is ready)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add components/ChangelogTabs.tsx
|
|
git commit -m "feat: wire up roadmap upvote with optimistic UI and inline auth prompt"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: `FeedbackForm` + `FeedbackModal` + Give Feedback tab
|
|
|
|
**Files:**
|
|
- Create: `components/FeedbackForm.tsx`
|
|
- Create: `components/FeedbackModal.tsx`
|
|
- Modify: `components/ChangelogTabs.tsx` (replace Give Feedback stub)
|
|
|
|
- [ ] **Step 1: Create `components/FeedbackForm.tsx`**
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { useState } from 'react'
|
|
|
|
type Status = 'idle' | 'loading' | 'done' | 'error'
|
|
|
|
const CATEGORIES = [
|
|
{ value: 'BUG', label: 'Bug report' },
|
|
{ value: 'FEATURE_REQUEST', label: 'Feature request' },
|
|
{ value: 'GENERAL', label: 'General feedback' },
|
|
]
|
|
|
|
export function FeedbackForm({ onSuccess }: { onSuccess?: () => void }) {
|
|
const [category, setCategory] = useState('BUG')
|
|
const [message, setMessage] = useState('')
|
|
const [email, setEmail] = useState('')
|
|
const [status, setStatus] = useState<Status>('idle')
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!message.trim()) return
|
|
setStatus('loading')
|
|
try {
|
|
const res = await fetch('/api/feedback', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
category,
|
|
message: message.trim(),
|
|
email: email.trim() || undefined,
|
|
}),
|
|
})
|
|
if (res.ok) {
|
|
setStatus('done')
|
|
onSuccess?.()
|
|
} else {
|
|
setStatus('error')
|
|
}
|
|
} catch {
|
|
setStatus('error')
|
|
}
|
|
}
|
|
|
|
if (status === 'done') {
|
|
return (
|
|
<div className="py-6 text-center">
|
|
<p className="text-sm font-semibold text-amber-400">Thanks — we got it.</p>
|
|
<p className="mt-1 text-xs text-zinc-500">
|
|
We read everything. If you left an email we'll follow up.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
|
<div>
|
|
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
Category
|
|
</label>
|
|
<select
|
|
value={category}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
disabled={status === 'loading'}
|
|
className="w-full appearance-none rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-200 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
|
|
>
|
|
{CATEGORIES.map((c) => (
|
|
<option key={c.value} value={c.value}>
|
|
{c.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
Email{' '}
|
|
<span className="font-normal normal-case tracking-normal text-zinc-600">
|
|
(optional — if you want a reply)
|
|
</span>
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="you@email.com"
|
|
disabled={status === 'loading'}
|
|
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-200 placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
Message
|
|
</label>
|
|
<textarea
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value.slice(0, 1000))}
|
|
placeholder="What's on your mind?"
|
|
rows={5}
|
|
required
|
|
disabled={status === 'loading'}
|
|
className="w-full resize-y rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm leading-relaxed text-zinc-200 placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
|
|
/>
|
|
<p className="mt-1.5 text-right text-[11px] text-zinc-600">
|
|
{message.length} / 1000
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="text-[11px] text-zinc-600">
|
|
We'll only use your email to follow up on your feedback.
|
|
</p>
|
|
<button
|
|
type="submit"
|
|
disabled={status === 'loading' || !message.trim()}
|
|
className="shrink-0 rounded-lg bg-amber-500/90 px-6 py-2.5 text-sm font-bold text-black hover:bg-amber-400 disabled:opacity-50"
|
|
>
|
|
{status === 'loading' ? 'Sending…' : 'Send Feedback →'}
|
|
</button>
|
|
</div>
|
|
|
|
{status === 'error' && (
|
|
<p className="text-xs text-red-400">Something went wrong. Try again.</p>
|
|
)}
|
|
</form>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `components/FeedbackModal.tsx`**
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { useEffect } from 'react'
|
|
import { XMarkIcon } from '@heroicons/react/20/solid'
|
|
import { FeedbackForm } from '@/components/FeedbackForm'
|
|
|
|
export function FeedbackModal({
|
|
open,
|
|
onClose,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
}) {
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [open, onClose])
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div
|
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="relative z-10 w-full max-w-lg rounded-2xl border border-zinc-800 bg-zinc-950 p-7 shadow-2xl">
|
|
<div className="mb-5 flex items-center justify-between">
|
|
<h2 className="text-base font-bold text-white">Send Feedback</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-zinc-500 hover:text-zinc-300"
|
|
aria-label="Close"
|
|
>
|
|
<XMarkIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
<FeedbackForm onSuccess={onClose} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Replace the Give Feedback stub in `components/ChangelogTabs.tsx`**
|
|
|
|
Add import at the top:
|
|
|
|
```tsx
|
|
import { FeedbackForm } from '@/components/FeedbackForm'
|
|
```
|
|
|
|
Replace the `{activeTab === 'give-feedback' && ...}` stub:
|
|
|
|
```tsx
|
|
{/* ── Give Feedback ── */}
|
|
{activeTab === 'give-feedback' && (
|
|
<div>
|
|
<div className="mb-8">
|
|
<h2 className="text-xl font-bold text-white">Tell us what you think.</h2>
|
|
<p className="mt-1.5 text-sm text-zinc-500">
|
|
Bug, feature idea, or general thought — we read everything.
|
|
</p>
|
|
</div>
|
|
<FeedbackForm />
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify build**
|
|
|
|
```bash
|
|
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
|
|
```
|
|
|
|
- [ ] **Step 5: Smoke test**
|
|
|
|
Open `http://localhost:3000/changelog#give-feedback`. Confirm form renders. Submit — expect error until Spring Boot is ready; success state renders cleanly when it is.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add components/FeedbackForm.tsx components/FeedbackModal.tsx components/ChangelogTabs.tsx
|
|
git commit -m "feat: add FeedbackForm, FeedbackModal, and Give Feedback tab"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Standalone `/feedback` page + TopNav + homepage footer
|
|
|
|
**Files:**
|
|
- Create: `app/(app)/feedback/page.tsx`
|
|
- Modify: `components/TopNav.tsx`
|
|
- Modify: `app/page.tsx`
|
|
|
|
- [ ] **Step 1: Create `app/(app)/feedback/page.tsx`**
|
|
|
|
```tsx
|
|
import type { Metadata } from 'next'
|
|
import { FeedbackForm } from '@/components/FeedbackForm'
|
|
|
|
export const metadata: Metadata = {
|
|
title: 'Send Feedback — Battl Builders',
|
|
description:
|
|
'Report a bug, suggest a feature, or share a thought with the Battl Builders team.',
|
|
}
|
|
|
|
export default function FeedbackPage() {
|
|
return (
|
|
<main className="mx-auto max-w-4xl px-4 py-10">
|
|
<header className="mb-10">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
|
|
Battl Builders
|
|
</p>
|
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">
|
|
Send Feedback
|
|
</h1>
|
|
<p className="mt-2 text-sm text-zinc-400">
|
|
Bug, feature idea, or general thought — we read everything.
|
|
</p>
|
|
</header>
|
|
<FeedbackForm />
|
|
</main>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add "Send Feedback" button to `components/TopNav.tsx`**
|
|
|
|
The file already has `"use client"` and uses `useAuth`. Add the `useState` import and `FeedbackModal` import by adding a new line near the top of the existing imports:
|
|
|
|
```tsx
|
|
import { useState } from 'react'
|
|
import { FeedbackModal } from '@/components/FeedbackModal'
|
|
```
|
|
|
|
Inside the `TopNav` component, add state before `return`:
|
|
|
|
```tsx
|
|
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
|
```
|
|
|
|
In the JSX right-side actions `<div>` (the `<div className="flex items-center justify-end gap-3 sm:gap-4">` div), add the button as the first child, before the loading/auth conditional:
|
|
|
|
```tsx
|
|
<button
|
|
type="button"
|
|
onClick={() => setFeedbackOpen(true)}
|
|
className="hidden text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors sm:inline"
|
|
>
|
|
Send Feedback
|
|
</button>
|
|
```
|
|
|
|
After the closing `</header>` tag (outside the header element), add the modal:
|
|
|
|
```tsx
|
|
<FeedbackModal open={feedbackOpen} onClose={() => setFeedbackOpen(false)} />
|
|
```
|
|
|
|
- [ ] **Step 3: Add "Send Feedback" to homepage footer in `app/page.tsx`**
|
|
|
|
In the footer bottom bar, add a "Send Feedback" `<Link>` before the Privacy link:
|
|
|
|
```tsx
|
|
<Link href="/feedback" className="hover:text-zinc-500">
|
|
Send Feedback
|
|
</Link>
|
|
```
|
|
|
|
The full bottom bar should look like:
|
|
|
|
```tsx
|
|
<div className="mx-auto flex max-w-5xl items-center justify-between border-t border-zinc-900/60 px-4 py-4 text-[11px] text-zinc-700">
|
|
<span>© {new Date().getFullYear()} BATTL BUILDERS™</span>
|
|
<div className="flex gap-4">
|
|
<Link href="/feedback" className="hover:text-zinc-500">
|
|
Send Feedback
|
|
</Link>
|
|
<Link href="/privacy" className="hover:text-zinc-500">
|
|
Privacy
|
|
</Link>
|
|
<Link href="/tos" className="hover:text-zinc-500">
|
|
Terms
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 4: Verify build**
|
|
|
|
```bash
|
|
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
|
|
```
|
|
|
|
- [ ] **Step 5: Smoke test**
|
|
|
|
- `http://localhost:3000/feedback` — standalone page renders with full-width form
|
|
- TopNav "Send Feedback" button appears on all `(app)` layout pages; clicking opens the modal; Escape closes it
|
|
- Homepage footer has "Send Feedback" link
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add "app/(app)/feedback/page.tsx" components/TopNav.tsx app/page.tsx
|
|
git commit -m "feat: add /feedback page, FeedbackModal to TopNav, and footer link"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Admin feedback page + nav link
|
|
|
|
**Files:**
|
|
- Create: `app/admin/feedback/page.tsx`
|
|
- Modify: `app/admin/layout.tsx`
|
|
|
|
- [ ] **Step 1: Create `app/admin/feedback/page.tsx`**
|
|
|
|
Follows the project's admin page pattern: `"use client"` + `useEffect` + `useState` + `useAuth()`.
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useAuth } from '@/context/AuthContext'
|
|
|
|
type FeedbackSubmission = {
|
|
id: string
|
|
category: 'BUG' | 'FEATURE_REQUEST' | 'GENERAL'
|
|
message: string
|
|
email: string | null
|
|
ipAddress: string
|
|
reviewed: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
BUG: 'Bug',
|
|
FEATURE_REQUEST: 'Feature Request',
|
|
GENERAL: 'General',
|
|
}
|
|
|
|
const CATEGORY_STYLES: Record<string, string> = {
|
|
BUG: 'bg-red-500/10 text-red-400 border border-red-500/20',
|
|
FEATURE_REQUEST: 'bg-amber-500/10 text-amber-400 border border-amber-500/20',
|
|
GENERAL: 'bg-zinc-800 text-zinc-400 border border-zinc-700',
|
|
}
|
|
|
|
function formatDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
|
|
export default function AdminFeedbackPage() {
|
|
const { getAuthHeaders } = useAuth()
|
|
const [items, setItems] = useState<FeedbackSubmission[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [categoryFilter, setCategoryFilter] = useState('ALL')
|
|
const [reviewedFilter, setReviewedFilter] = useState('ALL')
|
|
|
|
async function load() {
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
const res = await fetch('/api/admin/feedback', {
|
|
headers: getAuthHeaders(),
|
|
credentials: 'include',
|
|
})
|
|
if (!res.ok) throw new Error(`Failed to load feedback (${res.status})`)
|
|
setItems(await res.json())
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'Failed to load')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
load()
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
async function toggleReviewed(item: FeedbackSubmission) {
|
|
const prev = items
|
|
setItems((cur) =>
|
|
cur.map((i) => (i.id === item.id ? { ...i, reviewed: !i.reviewed } : i))
|
|
)
|
|
try {
|
|
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ reviewed: !item.reviewed }),
|
|
})
|
|
if (!res.ok) setItems(prev)
|
|
} catch {
|
|
setItems(prev)
|
|
}
|
|
}
|
|
|
|
const filtered = items.filter((i) => {
|
|
if (categoryFilter !== 'ALL' && i.category !== categoryFilter) return false
|
|
if (reviewedFilter === 'REVIEWED' && !i.reviewed) return false
|
|
if (reviewedFilter === 'UNREVIEWED' && i.reviewed) return false
|
|
return true
|
|
})
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold tracking-tight">Feedback</h1>
|
|
<p className="mt-1 text-sm text-zinc-400">
|
|
User-submitted feedback. Mark items reviewed to track triage.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={load}
|
|
className="rounded-md border border-zinc-800 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mb-6 flex gap-3">
|
|
<select
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300"
|
|
>
|
|
<option value="ALL">All categories</option>
|
|
<option value="BUG">Bug</option>
|
|
<option value="FEATURE_REQUEST">Feature Request</option>
|
|
<option value="GENERAL">General</option>
|
|
</select>
|
|
<select
|
|
value={reviewedFilter}
|
|
onChange={(e) => setReviewedFilter(e.target.value)}
|
|
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300"
|
|
>
|
|
<option value="ALL">All statuses</option>
|
|
<option value="UNREVIEWED">Unreviewed</option>
|
|
<option value="REVIEWED">Reviewed</option>
|
|
</select>
|
|
<span className="ml-auto self-center text-xs text-zinc-600">
|
|
{filtered.length} of {items.length} submissions
|
|
</span>
|
|
</div>
|
|
|
|
{loading && <p className="text-sm text-zinc-500">Loading…</p>}
|
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
|
|
{!loading && !error && filtered.length === 0 && (
|
|
<p className="text-sm text-zinc-500">No submissions match your filters.</p>
|
|
)}
|
|
|
|
{!loading && !error && filtered.length > 0 && (
|
|
<div className="overflow-hidden rounded-xl border border-zinc-800">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-zinc-800 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
<th className="px-4 py-3">Date</th>
|
|
<th className="px-4 py-3">Category</th>
|
|
<th className="px-4 py-3">Message</th>
|
|
<th className="px-4 py-3">Email</th>
|
|
<th className="px-4 py-3">Reviewed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-900">
|
|
{filtered.map((item) => (
|
|
<tr key={item.id} className="hover:bg-zinc-950/60">
|
|
<td className="whitespace-nowrap px-4 py-3 text-xs text-zinc-500">
|
|
{formatDate(item.createdAt)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
|
CATEGORY_STYLES[item.category]
|
|
}`}
|
|
>
|
|
{CATEGORY_LABELS[item.category]}
|
|
</span>
|
|
</td>
|
|
<td className="max-w-sm px-4 py-3 text-xs text-zinc-300">
|
|
{item.message.length > 120
|
|
? `${item.message.slice(0, 120)}…`
|
|
: item.message}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-zinc-500">
|
|
{item.email ?? '—'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<button
|
|
onClick={() => toggleReviewed(item)}
|
|
className={`rounded px-2 py-0.5 text-[10px] font-semibold transition-colors ${
|
|
item.reviewed
|
|
? 'bg-green-500/10 text-green-400 hover:bg-red-500/10 hover:text-red-400'
|
|
: 'bg-zinc-800 text-zinc-500 hover:bg-green-500/10 hover:text-green-400'
|
|
}`}
|
|
>
|
|
{item.reviewed ? 'Reviewed' : 'Mark reviewed'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add Feedback nav item to `app/admin/layout.tsx`**
|
|
|
|
The admin nav uses `lucide-react` icons. Add `MessageSquare` to the existing import:
|
|
|
|
```tsx
|
|
import {
|
|
LayoutDashboard,
|
|
Download,
|
|
Layers,
|
|
Boxes,
|
|
Store,
|
|
Users,
|
|
Settings,
|
|
Mail,
|
|
Wand2,
|
|
FolderKanban,
|
|
Shield,
|
|
MessageSquare, // add this
|
|
} from "lucide-react";
|
|
```
|
|
|
|
In `navGroups`, find the `"Growth & Comms"` group and add the Feedback item:
|
|
|
|
```tsx
|
|
{
|
|
label: "Growth & Comms",
|
|
icon: <Mail className="h-4 w-4" />,
|
|
items: [
|
|
{
|
|
label: "Beta Invites",
|
|
href: "/admin/beta-invites",
|
|
icon: <Mail className="h-4 w-4" />,
|
|
},
|
|
{
|
|
label: "List Emails",
|
|
href: "/admin/email",
|
|
icon: <Mail className="h-4 w-4" />,
|
|
},
|
|
{
|
|
label: "Send an Email",
|
|
href: "/admin/email/send",
|
|
icon: <Mail className="h-4 w-4" />,
|
|
},
|
|
// Add this item:
|
|
{
|
|
label: "Feedback",
|
|
href: "/admin/feedback",
|
|
icon: <MessageSquare className="h-4 w-4" />,
|
|
},
|
|
],
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 3: Verify build**
|
|
|
|
```bash
|
|
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
|
|
```
|
|
|
|
- [ ] **Step 4: Smoke test**
|
|
|
|
Open `http://localhost:3000/admin` (must be logged in as admin). Confirm "Feedback" appears in the "Growth & Comms" nav section. Click it — page renders with filters and empty table (data loads once Spring Boot is ready).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/admin/feedback/page.tsx app/admin/layout.tsx
|
|
git commit -m "feat: add admin feedback review page and nav link"
|
|
```
|
|
|
|
---
|
|
|
|
## Done
|
|
|
|
All 8 tasks complete. The Next.js side is fully implemented. Remaining work to complete the feature end-to-end:
|
|
|
|
**Spring Boot (`ballistic-builder-spring` repo) — required for votes and feedback to function:**
|
|
- `POST /api/v1/feedback` — persist feedback, read `ipAddress` and `userAgent` from request body
|
|
- `GET /api/v1/roadmap/votes?ids=...` — return vote counts + current user voted state (200 for unauthenticated, `voted: false`)
|
|
- `POST /api/v1/roadmap/{sanityId}/vote` — toggle vote, return `{ count, voted }`
|
|
- `GET /api/v1/admin/feedback` — list submissions, admin-only
|
|
- `PATCH /api/v1/admin/feedback/{id}` — update `reviewed` field
|
|
|
|
**Sanity Studio — required for content to appear:**
|
|
- Define and deploy `changelogEntry` schema (fields: `title`, `version`, `publishedAt`, `tags`, `body`)
|
|
- Define and deploy `roadmapItem` schema (fields: `title`, `description`, `status`, `order`)
|
|
- Publish at least one entry of each type to verify the UI end-to-end
|