User feedback system with element picker, screenshots, and issue tracker integration
npm install @nextdevx/feedbackComprehensive user feedback system for Next.js applications with element picker, screenshot capture, voting, and issue tracker integration.
- Feedback Dialog - Beautiful modal for collecting user feedback
- Element Picker - Let users click on specific UI elements to reference
- Screenshot Capture - Automatic screenshot capture for visual context
- Public Suggestions - Allow users to submit and vote on feature suggestions
- Issue Tracker Integration - Automatically create issues in Linear, Jira, or GitHub
- Admin Panel - Manage feedback, review submissions, update status
- Multi-Tenancy - Organization-based data isolation
- Dark Mode - Full light/dark theme support
- i18n Ready - Optional next-intl integration
``bash`
npm install @nextdevx/feedbackor
pnpm add @nextdevx/feedbackor
yarn add @nextdevx/feedback
`bashFor screenshot capture
npm install modern-screenshot
Quick Start
$3
Copy the schema from
node_modules/@nextdevx/feedback/prisma/schema.prisma to your project's prisma/schema.prisma:`prisma
model Feedback {
id String @id @default(cuid())
message String @db.Text
pageUrl String
elementXPath String? @db.VarChar(2000)
elementSelector String? @db.VarChar(500)
elementTagName String? @db.VarChar(50)
elementFriendlyName String? @db.VarChar(200)
screenshotUrl String?
userId String
userEmail String
userName String?
organizationId String?
externalIssueId String?
externalIssueUrl String?
issueProvider String?
issueCreatedAt DateTime?
issueCreationError String?
isPublicSuggestion Boolean @default(false)
voteScore Int @default(0)
status String @default("pending")
adminNotes String? @db.Text
reviewedBy String?
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
votes FeedbackVote[] @@index([userId])
@@index([organizationId])
@@index([status])
@@index([isPublicSuggestion, voteScore(sort: Desc)])
}
model FeedbackVote {
id String @id @default(cuid())
feedbackId String
userId String
voteType String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
feedback Feedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
@@unique([feedbackId, userId])
}
model IssueTrackerConfig {
id String @id @default(cuid())
organizationId String? @unique
provider String?
isEnabled Boolean @default(false)
// Linear fields
linearApiKeyEncrypted String?
linearTeamId String?
linearDefaultLabels String[] @default(["user-feedback"])
// Jira fields
jiraHost String?
jiraEmail String?
jiraApiTokenEncrypted String?
jiraProjectKey String?
jiraIssueType String? @default("Task")
jiraDefaultLabels String[] @default([])
// GitHub fields
githubTokenEncrypted String?
githubRepo String?
githubDefaultLabels String[] @default(["feedback"])
encryptionIv String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
`Then run migrations:
`bash
npx prisma migrate dev --name add-feedback
`$3
`tsx
// app/layout.tsx or app/providers.tsx
'use client'import { FeedbackProvider } from '@nextdevx/feedback'
export function Providers({ children }: { children: React.ReactNode }) {
return (
config={{
enableElementPicker: true,
enableScreenshots: true,
maxMessageLength: 2000,
apiEndpoint: '/api/feedback',
uploadEndpoint: '/api/feedback/upload',
}}
>
{children}
)
}
`$3
`tsx
// components/Header.tsx
import { FeedbackButton } from '@nextdevx/feedback'export function Header() {
return (
)
}
`$3
`typescript
// app/api/feedback/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createFeedbackService } from '@nextdevx/feedback'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'export async function POST(request: NextRequest) {
const service = createFeedbackService(prisma, auth)
const body = await request.json()
try {
const feedback = await service.submitFeedback(body)
return NextResponse.json(feedback)
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to submit' },
{ status: 400 }
)
}
}
``typescript
// app/api/feedback/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile } from 'fs/promises'
import { join } from 'path'export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Save to your storage solution (S3, local, etc.)
const filename =
screenshot-${Date.now()}.jpg
const path = join(process.cwd(), 'public/uploads', filename)
await writeFile(path, buffer) return NextResponse.json({ url:
/uploads/${filename} })
}
`API Reference
$3
#### FeedbackProvider
Root provider for the feedback system.
`tsx
import { FeedbackProvider } from '@nextdevx/feedback' config={{
enableElementPicker: true,
enableScreenshots: true,
maxMessageLength: 2000,
screenshotQuality: 0.8,
apiEndpoint: '/api/feedback',
uploadEndpoint: '/api/feedback/upload',
}}
>
{children}
`##### Config Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
|
enableElementPicker | boolean | true | Enable element selection |
| enableScreenshots | boolean | true | Enable screenshot capture |
| maxMessageLength | number | 2000 | Maximum feedback message length |
| screenshotQuality | number | 0.8 | Screenshot JPEG quality (0-1) |
| apiEndpoint | string | '/api/feedback' | Feedback submission endpoint |
| uploadEndpoint | string | '/api/feedback/upload' | Screenshot upload endpoint |#### FeedbackButton
Trigger button that opens the feedback dialog.
`tsx
import { FeedbackButton } from '@nextdevx/feedback' className="custom-class"
onOpen={() => console.log('Dialog opened')}
/>
`#### FeedbackDialog
The main feedback dialog (controlled via FeedbackProvider).
`tsx
import { FeedbackDialog } from '@nextdevx/feedback'// Usually rendered automatically by FeedbackProvider
// Can be used standalone for custom implementations
`#### ElementPicker
Standalone element picker component.
`tsx
import { ElementPicker } from '@nextdevx/feedback' isOpen={isPickerOpen}
onSelect={(elementInfo) => {
console.log(elementInfo.friendlyName)
console.log(elementInfo.cssSelector)
setIsPickerOpen(false)
}}
onCancel={() => setIsPickerOpen(false)}
/>
`#### FeedbackAdmin
Admin panel for managing feedback.
`tsx
import { FeedbackAdmin } from '@nextdevx/feedback' apiEndpoint="/api/admin/feedback"
onStatusChange={(id, status) => console.log('Status updated')}
/>
`$3
#### useFeedback
Hook for programmatic feedback submission.
`typescript
import { useFeedback } from '@nextdevx/feedback'function MyComponent() {
const { submitFeedback, isSubmitting, error, clearError } = useFeedback()
const handleSubmit = async () => {
const success = await submitFeedback({
message: 'Great feature!',
pageUrl: window.location.href,
element: selectedElement, // optional
screenshot: screenshotBlob, // optional
})
if (success) {
console.log('Feedback submitted!')
}
}
return (
{error && {error}
}
)
}
`#### useSuggestions
Hook for fetching public suggestions with pagination.
`typescript
import { useSuggestions } from '@nextdevx/feedback'function SuggestionsList() {
const {
suggestions,
isLoading,
error,
page,
totalPages,
setPage,
vote,
refetch,
} = useSuggestions({
sortBy: 'votes', // or 'newest'
limit: 10,
})
return (
{suggestions.map((suggestion) => (
{suggestion.message}
Score: {suggestion.voteScore}
))}
Page {page} of {totalPages}
)
}
`$3
#### createFeedbackService
Factory function to create a backend feedback service.
`typescript
import { createFeedbackService } from '@nextdevx/feedback'const service = createFeedbackService(prisma, auth, {
multiTenancy: {
enabled: true,
getOrganizationId: async () => user?.organizationId ?? null,
},
})
// Available methods:
await service.submitFeedback(input)
await service.getMyFeedback()
await service.getSuggestions({ page: 1, limit: 10, sortBy: 'votes' })
await service.vote(feedbackId, 'up') // or 'down' or null to remove
await service.getFeedbackById(id)
`$3
Create issues automatically in Linear, Jira, or GitHub.
`typescript
import {
createLinearClient,
createJiraClient,
createGitHubClient,
createIssue,
testConnection,
} from '@nextdevx/feedback'// Linear
const linearClient = createLinearClient({
apiKey: process.env.LINEAR_API_KEY!,
teamId: 'team-id',
defaultLabels: ['user-feedback'],
})
// Jira
const jiraClient = createJiraClient({
host: 'your-company.atlassian.net',
email: 'your-email@company.com',
apiToken: process.env.JIRA_API_TOKEN!,
projectKey: 'PROJ',
issueType: 'Bug',
})
// GitHub
const githubClient = createGitHubClient({
token: process.env.GITHUB_TOKEN!,
repo: 'owner/repo',
defaultLabels: ['feedback', 'user-reported'],
})
// Test connection
const result = await testConnection(linearClient)
if (result.success) {
console.log('Connected!', result.metadata)
}
// Create issue from feedback
const feedback = await service.getFeedbackById(id)
const issueResult = await createIssue(linearClient, {
title:
Feedback: ${feedback.message.slice(0, 50)},
description: feedback.message,
pageUrl: feedback.pageUrl,
userEmail: feedback.userEmail,
screenshotUrl: feedback.screenshotUrl,
elementInfo: feedback.elementFriendlyName,
})if (issueResult.success) {
console.log('Issue created:', issueResult.issueUrl)
}
`$3
`typescript
import {
captureScreenshot,
isScreenshotAvailable,
formatFileSize,
} from '@nextdevx/feedback'// Check if screenshot capture is supported
if (isScreenshotAvailable()) {
// Capture screenshot
const blob = await captureScreenshot({
quality: 0.8,
type: 'image/jpeg',
ignoreElements: (element) => {
// Exclude certain elements from screenshot
return element.classList.contains('no-screenshot')
},
})
console.log('Screenshot size:', formatFileSize(blob.size))
}
`$3
`typescript
import {
getElementInfo,
generateCssSelector,
generateXPath,
generateFriendlyName,
findElementBySelector,
shouldExcludeElement,
getElementBounds,
isTailwindClass,
filterTailwindClasses,
getNameGenerationMetrics,
resetNameGenerationMetrics,
clearNameCaches,
} from '@nextdevx/feedback'// Get complete element information
const info = getElementInfo(element)
console.log(info.friendlyName) // "Submit Button"
console.log(info.cssSelector) // "button.btn-primary"
console.log(info.xpath) // "//button[@class='btn-primary']"
console.log(info.tagName) // "button"
// Individual generators
const selector = generateCssSelector(element)
const xpath = generateXPath(element)
const name = generateFriendlyName(element)
// Find element by selector
const foundElement = findElementBySelector('button.btn-primary')
// Performance metrics
const metrics = getNameGenerationMetrics()
console.log(
Cache hits: ${metrics.cacheHits}, misses: ${metrics.cacheMisses})
resetNameGenerationMetrics()
clearNameCaches()
`Types
`typescript
import type {
Feedback,
FeedbackInput,
FeedbackVote,
FeedbackStatus,
FeedbackConfig,
ElementInfo,
IssueTrackerConfig,
IssueProvider,
IssueTrackerClient,
CreateIssueResult,
TestConnectionResult,
LinearConfig,
JiraConfig,
GitHubConfig,
CaptureOptions,
FeedbackService,
FeedbackAdminProps,
FeedbackQueryParams,
} from '@nextdevx/feedback'
`$3
`typescript
interface FeedbackInput {
message: string
pageUrl: string
element?: ElementInfo
screenshot?: Blob
}interface Feedback {
id: string
message: string
pageUrl: string
elementFriendlyName: string | null
elementCssSelector: string | null
elementXpath: string | null
elementTagName: string | null
screenshotUrl: string | null
userId: string
userEmail: string
userName: string | null
organizationId: string | null
isPublicSuggestion: boolean
voteScore: number
status: FeedbackStatus
adminNotes: string | null
reviewedBy: string | null
reviewedAt: Date | null
externalIssueId: string | null
externalIssueUrl: string | null
issueProvider: IssueProvider | null
createdAt: Date
updatedAt: Date
currentUserVote?: 'up' | 'down' | null
}
type FeedbackStatus = 'pending' | 'reviewed' | 'resolved' | 'rejected'
type IssueProvider = 'linear' | 'jira' | 'github'
interface ElementInfo {
friendlyName: string
cssSelector: string
xpath: string
tagName: string
}
`i18n Integration
$3
`tsx
import { useTranslations } from 'next-intl'
import { FeedbackProvider, feedbackMessages } from '@nextdevx/feedback'// Merge messages
const messages = {
feedback: feedbackMessages.en,
// ... other messages
}
function App() {
const t = useTranslations('feedback')
return (
{children}
)
}
`$3
`tsx
translations={{
title: 'Send Feedback',
placeholder: 'Describe your feedback...',
submit: 'Submit',
cancel: 'Cancel',
// ... other translations
}}
>
`Peer Dependencies
| Package | Version | Required |
|---------|---------|----------|
|
react | >=18.0.0 | Yes |
| react-dom | >=18.0.0 | Yes |
| next | >=14.0.0 | Yes |
| @prisma/client | >=5.0.0 | Yes |
| lucide-react | >=0.300.0 | Yes |
| modern-screenshot | >=4.0.0 | Optional |
| next-intl` | >=3.0.0 | Optional |MIT