"use client"; import type { EntryKind, FeedbackEntry as FeedbackEntryData, FeedbackMetadata, } from "convex-feedback"; import { useEffect, useMemo, useState, type SyntheticEvent } from "react"; import { FeedbackBodyProvider, useFeedbackBody, } from "../shared/context/FeedbackBodyProvider.js"; import { FeedbackProvider, useFeedbackUi, } from "../shared/context/FeedbackProvider"; import { allowAuthenticatedAction, createEntryLabel, entryStatusChoices, } from "../shared/helpers.js"; import type { FeedbackScreenCommentBranchProps, FeedbackScreenContentProps, FeedbackScreenEntryCardProps, FeedbackScreenEntryDetailProps, FeedbackScreenEntryModalProps, FeedbackScreenReplyListProps, FeedbackScreenRootProps, } from "../shared/types/"; import { Comment, ChoiceChips, FeedbackBoard, FeedbackEntry, FeedbackForm, } from "./primitives.js"; import { FeedbackActionError, useFeedbackAction } from "./action.js"; import { collectWebMetadata } from "./metadata.js"; import { collectEntryMetadata, formatMetadataKey } from "../shared/metadata.js"; const entryKinds: readonly EntryKind[] = [ "feedback", "feature_request", "bug_report", ]; export interface FeedbackScreenProps extends FeedbackScreenRootProps { className?: string | undefined; } export function FeedbackScreen({ hooks, messages, theme, unstyled, entrySort = "top", commentSort = "top", enabledKinds = entryKinds, maxCommentDepth = 5, transformComments, renderActor, onUnauthenticated, debounceDuration = 300, collectMetadata, emptyState, loading, ...props }: FeedbackScreenProps) { return ( ); } interface FeedbackScreenInnerProps extends FeedbackScreenContentProps { className?: string | undefined; } function FeedbackScreenInner({ className, ...colors }: FeedbackScreenInnerProps) { const { hooks, entrySort, enabledKinds, showForm, setShowForm, setSelectedEntryId, selectedEntryId, statusFilter, setStatusFilter, query, setQuery, debouncedQuery, emptyState, loading: loadingIndicator, isAuthenticated, onUnauthenticated, } = useFeedbackBody(); const { messages } = useFeedbackUi(); const list = hooks.useEntries({ sort: entrySort, kinds: enabledKinds, statusFilter, }); const searchResults = hooks.useSearchEntries({ searchQuery: debouncedQuery, kinds: enabledKinds, statusFilter, }); if (selectedEntryId !== null) { return ( setSelectedEntryId(null)} /> ); } const normalizedQuery = query.trim(); const normalizedDebouncedQuery = debouncedQuery.trim(); const searching = normalizedQuery.length > 0; const entries = searching ? searchResults : list.results; const isLoading = searching ? normalizedQuery !== normalizedDebouncedQuery || searchResults === undefined : list.status === "LoadingFirstPage"; return (

{messages.board.subtitle}

{showForm && ( { setShowForm(false); setSelectedEntryId(entryId); }} /> )} {isLoading ? ( {loadingIndicator ?? ( {messages.board.loading} )} ) : entries !== undefined && entries.length === 0 ? ( {emptyState} {searching ? messages.board.noSearchResults : messages.board.noEntries} ) : ( {(entries ?? []).map((entry) => ( setSelectedEntryId(entry.id)} /> ))} )} {!searching && (list.status === "CanLoadMore" || list.status === "LoadingMore") && ( )}
); } function EntryCard({ entry, hooks, onOpen }: FeedbackScreenEntryCardProps) { const { isAuthenticated, onUnauthenticated } = useFeedbackBody(); const setUpvote = hooks.useSetEntryUpvote(); const action = useFeedbackAction(); const toggleUpvote = (desiredState: boolean) => { if ( !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) || action.pending ) { return; } void action.run(() => setUpvote({ entryId: entry.id, desiredState })); }; return ( <> { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpen(); } }} role="button" tabIndex={0} >
); } function CreateEntryForm({ onCreated }: FeedbackScreenEntryModalProps) { const { hooks, enabledKinds, collectMetadata, collectStandardMetadata, isAuthenticated, onUnauthenticated, } = useFeedbackBody(); const { messages } = useFeedbackUi(); const [kind, setKind] = useState(enabledKinds[0] ?? "feedback"); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const createEntry = hooks.useCreateEntry(); const action = useFeedbackAction(); const similar = hooks.useSimilarEntries({ title, body, kind }); const [confirmingDuplicate, setConfirmingDuplicate] = useState(false); const submit = (event: SyntheticEvent) => { event.preventDefault(); if ( similar === undefined || title.trim().length === 0 || body.trim().length === 0 ) { return; } if (suggestions.length > 0) { setConfirmingDuplicate(true); return; } void create(); }; const create = async () => { if (action.pending) return; if (!allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) return; await action.run(async () => { const metadata = await collectEntryMetadata( collectMetadata, kind, collectStandardMetadata, ); const entryId = await createEntry({ kind, title, body, ...(metadata === undefined ? {} : { metadata }), }); onCreated(entryId); }); }; const suggestions = useMemo(() => { if (similar === undefined) return []; return [...similar.exact, ...similar.similar]; }, [similar]); return ( {enabledKinds.length > 1 && ( )} {suggestions.length > 0 && ( )} {confirmingDuplicate && (
{similar?.exact.length ? messages.form.exactDuplicate : messages.form.possibleDuplicates}

{messages.form.duplicateWarning}

)}
); } function EntryDetail({ entryId, onBack }: FeedbackScreenEntryDetailProps) { const { hooks, commentSort, transformComments, isAuthenticated, onUnauthenticated, } = useFeedbackBody(); const { messages } = useFeedbackUi(); const entry = hooks.useEntry(entryId); const setUpvote = hooks.useSetEntryUpvote(); const comments = hooks.useComments({ entryId, sort: commentSort }); const createComment = hooks.useCreateComment(); const [body, setBody] = useState(""); const [showMetadata, setShowMetadata] = useState(false); const [editOpen, setEditOpen] = useState(false); const upvoteAction = useFeedbackAction(); const commentAction = useFeedbackAction(); const toggleUpvote = (desiredState: boolean) => { if ( !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) || upvoteAction.pending ) { return; } void upvoteAction.run(() => setUpvote({ entryId, desiredState })); }; const visibleComments = useMemo( () => transformComments?.(comments.results) ?? comments.results, [comments.results, transformComments], ); if (entry === undefined) return

{messages.board.loading}

; if (entry === null) return ( ); return (
{entry.viewerIsAuthor === true && ( )}
{editOpen && } {entry.metadata !== undefined && (
)} {showMetadata && entry.metadata !== undefined && ( setShowMetadata(false)} /> )}

{messages.comments.title}

{ event.preventDefault(); if (body.trim().length === 0) return; if ( !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) || commentAction.pending ) { return; } void commentAction.run(async () => { await createComment({ entryId, body }); setBody(""); }); }} > setBody(event.currentTarget.value)} placeholder={messages.comments.placeholder} rows={3} /> {comments.status === "LoadingFirstPage" && (

{messages.board.loading}

)} {comments.status !== "LoadingFirstPage" && visibleComments.length === 0 && (

{messages.comments.noComments}

)}
{visibleComments.map((comment) => ( ))}
{(comments.status === "CanLoadMore" || comments.status === "LoadingMore") && ( )}
); } function EditEntryDialog({ entry, onOpenChange, }: { entry: FeedbackEntryData; onOpenChange: (open: boolean) => void; }) { const { hooks, isAuthenticated, onUnauthenticated } = useFeedbackBody(); const { messages } = useFeedbackUi(); const updateEntry = hooks.useUpdateEntry(); const action = useFeedbackAction(); const [title, setTitle] = useState(entry.title); const [body, setBody] = useState(entry.body); useEffect(() => { setTitle(entry.title); setBody(entry.body); }, [entry.body, entry.id, entry.title]); const save = async () => { if ( action.pending || title.trim().length === 0 || body.trim().length === 0 || !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ) { return; } const result = await action.run(() => updateEntry({ entryId: entry.id, title: title.trim(), body: body.trim(), }), ); if (result !== undefined) onOpenChange(false); }; return (
{ if (event.target === event.currentTarget && !action.pending) { onOpenChange(false); } }} >
event.stopPropagation()} >

{messages.form.editTitle}

{messages.form.editDescription}

{ event.preventDefault(); void save(); }} >
{messages.form.kind} {messages.kinds[entry.kind]}
{messages.form.saveChanges}
); } function MetadataDialog({ metadata, onClose, }: { metadata: FeedbackMetadata; onClose: () => void; }) { const { messages } = useFeedbackUi(); const sections = [ [messages.metadata.standard, metadata.standard], [messages.metadata.additional, metadata.additional], ] as const; const hasValues = sections.some( ([, values]) => values !== undefined && Object.keys(values).length > 0, ); return (
event.stopPropagation()} >

{messages.metadata.title}

{hasValues ? ( sections.map(([label, values]) => values === undefined || Object.keys(values).length === 0 ? null : (

{label}

{Object.entries(values).map(([key, value]) => (
{formatMetadataKey(key)}
{String(value)}
))}
), ) ) : (

{messages.metadata.empty}

)}
); } function CommentBranch({ comment, entryId }: FeedbackScreenCommentBranchProps) { const { hooks, maxCommentDepth, renderActor, isAuthenticated, onUnauthenticated, } = useFeedbackBody(); const { messages } = useFeedbackUi(); const [expanded, setExpanded] = useState(false); const [replying, setReplying] = useState(false); const [replyBody, setReplyBody] = useState(""); const setLike = hooks.useSetCommentLike(); const createComment = hooks.useCreateComment(); const likeAction = useFeedbackAction(); const replyAction = useFeedbackAction(); const toggleLike = (desiredState: boolean) => { if ( !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) || likeAction.pending ) { return; } void likeAction.run(() => setLike({ commentId: comment.id, desiredState })); }; return ( {renderActor === undefined ? null : (
{renderActor(comment.actorId)}
)}
{comment.depth < maxCommentDepth && ( { if ( allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ) { setReplying((value) => !value); } }} /> )}
{replying && ( { event.preventDefault(); if (replyBody.trim().length === 0) return; if ( !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) || replyAction.pending ) { return; } void replyAction.run(async () => { await createComment({ entryId, parentCommentId: comment.id, body: replyBody, }); setReplyBody(""); setReplying(false); setExpanded(true); }); }} > setReplyBody(event.currentTarget.value)} placeholder={messages.comments.placeholder} rows={2} />
)} {expanded && }
); } function ReplyList({ entryId, parentCommentId }: FeedbackScreenReplyListProps) { const { hooks, commentSort, transformComments } = useFeedbackBody(); const { messages } = useFeedbackUi(); const replies = hooks.useComments({ entryId, parentCommentId, sort: commentSort, }); const visibleReplies = useMemo( () => transformComments?.(replies.results) ?? replies.results, [replies.results, transformComments], ); return ( {replies.status === "LoadingFirstPage" && (

{messages.board.loading}

)} {visibleReplies.map((reply) => ( ))} {(replies.status === "CanLoadMore" || replies.status === "LoadingMore") && ( )}
); }