diff --git a/packages/feedback/src/FeedbackCard.svelte b/packages/feedback/src/FeedbackCard.svelte index 1fae6faf6..ee3d75c78 100644 --- a/packages/feedback/src/FeedbackCard.svelte +++ b/packages/feedback/src/FeedbackCard.svelte @@ -20,7 +20,7 @@ }: Props = $props(); function handleVote() { - onVote(feedback.id, feedback.userHasVoted); + onVote(feedback.id, feedback.userHasVoted ?? false); } function formatDate(dateString: string): string { @@ -37,7 +37,7 @@
diff --git a/packages/feedback/src/FeedbackPage.svelte b/packages/feedback/src/FeedbackPage.svelte index 10825389a..676719248 100644 --- a/packages/feedback/src/FeedbackPage.svelte +++ b/packages/feedback/src/FeedbackPage.svelte @@ -57,7 +57,7 @@ try { const [myResult, publicResult] = await Promise.all([ feedbackService.getMyFeedback(), - feedbackService.getPublicFeedback({ sort: 'votes' }), + feedbackService.getPublicFeedback({ sort: 'score' }), ]); myFeedback = myResult.items; publicFeedback = publicResult.items; @@ -83,9 +83,9 @@ } } - async function handleVote(feedbackId: string, hasVoted: boolean) { + async function handleVote(feedbackId: string, _hasVoted: boolean) { try { - await feedbackService.toggleVote(feedbackId, hasVoted); + await feedbackService.toggleVote(feedbackId); await loadFeedback(); } catch (error) { console.error('[FeedbackPage] Error voting:', error); diff --git a/packages/feedback/src/ReactionBar.svelte b/packages/feedback/src/ReactionBar.svelte new file mode 100644 index 000000000..936ae2071 --- /dev/null +++ b/packages/feedback/src/ReactionBar.svelte @@ -0,0 +1,147 @@ + + + +
+ {#each REACTION_EMOJIS as emoji (emoji)} + {@const count = countFor(emoji)} + {@const mine = myReactions.includes(emoji)} + {@const isPending = pendingEmoji === emoji} + + {/each} +
+ + diff --git a/packages/feedback/src/api.ts b/packages/feedback/src/api.ts index 7467d9026..1691edf39 100644 --- a/packages/feedback/src/api.ts +++ b/packages/feedback/src/api.ts @@ -1,8 +1,14 @@ /** - * API request/response types for feedback + * API request/response types for feedback. */ -import type { Feedback, FeedbackCategory, FeedbackStatus } from './feedback'; +import type { + Feedback, + FeedbackCategory, + FeedbackStatus, + PublicFeedbackItem, + ReactionEmoji, +} from './feedback'; export interface CreateFeedbackInput { title?: string; @@ -11,37 +17,66 @@ export interface CreateFeedbackInput { /** * Whether the submission shows up in the public community list. * Defaults to `true` server-side. Set `false` for private intake - * categories like `onboarding-wish` or `churn-feedback`. + * categories like `onboarding-wish` (private mode) or `churn-feedback`. */ isPublic?: boolean; + /** Module that triggered the inline FeedbackHook submission. */ + moduleContext?: string; + /** If set, submission is a reply to that feedback item. */ + parentId?: string; deviceInfo?: Record; } export interface FeedbackQueryParams { appId?: string; + moduleContext?: string; status?: FeedbackStatus; category?: FeedbackCategory; - sort?: 'votes' | 'recent'; + sort?: 'score' | 'recent'; limit?: number; offset?: number; } export interface FeedbackResponse { - success: boolean; + success?: boolean; feedback?: Feedback; error?: string; + [key: string]: unknown; } export interface FeedbackListResponse { - success: boolean; + success?: boolean; items: Feedback[]; - total: number; + total?: number; error?: string; } +export interface PublicFeedListResponse { + items: PublicFeedbackItem[]; +} + +export interface PublicItemResponse { + item: PublicFeedbackItem; + replies: PublicFeedbackItem[]; +} + +export interface ReactionResponse { + reactions: Partial>; + score: number; + userHasReacted: boolean; +} + +export interface AdminPatchInput { + status?: FeedbackStatus; + adminResponse?: string; + isPublic?: boolean; +} + +export type ReactInput = { emoji: ReactionEmoji }; + export interface VoteResponse { success: boolean; - newVoteCount: number; - userHasVoted: boolean; + newVoteCount?: number; + userHasVoted?: boolean; error?: string; } diff --git a/packages/feedback/src/createFeedbackService.ts b/packages/feedback/src/createFeedbackService.ts index 2c6123bc1..bf18356a2 100644 --- a/packages/feedback/src/createFeedbackService.ts +++ b/packages/feedback/src/createFeedbackService.ts @@ -1,20 +1,9 @@ /** - * Feedback Service Factory + * Feedback Service Factory — Public-Community Hub client. * - * Creates a feedback service instance configured for a specific app. - * Handles feedback submission, retrieval, and voting. - * - * @example - * ```ts - * import { createFeedbackService } from '@mana/feedback'; - * import { authStore } from '$lib/stores/auth.svelte'; - * - * export const feedbackService = createFeedbackService({ - * apiUrl: 'https://auth.mana.how', - * appId: 'chat', - * getAuthToken: async () => authStore.getToken(), - * }); - * ``` + * One factory builds the auth-required service used by logged-in users + * (submit, react, manage own items). The companion `createPublicFeedbackService` + * (in createPublicFeedbackService.ts) is for SSR / unauthenticated reads. */ import type { @@ -22,28 +11,30 @@ import type { FeedbackQueryParams, FeedbackResponse, FeedbackListResponse, + PublicFeedListResponse, + PublicItemResponse, + ReactionResponse, + AdminPatchInput, + ReactInput, VoteResponse, } from './api'; import type { FeedbackServiceConfig } from './types'; +import type { PublicFeedbackItem, ReactionEmoji } from './feedback'; -/** - * Create a feedback service instance - */ export function createFeedbackService(config: FeedbackServiceConfig) { - const { apiUrl, appId, getAuthToken, feedbackEndpoint = '/api/v1/feedback' } = config; + const { + apiUrl, + appId, + getAuthToken, + feedbackEndpoint = '/api/v1/feedback', + publicEndpoint = '/api/v1/public/feedback', + } = config; - // Normalize API URL (remove trailing slash) const baseUrl = apiUrl.replace(/\/$/, ''); - /** - * Helper to make authenticated requests - */ async function fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise { const token = await getAuthToken(); - - if (!token) { - throw new Error('Not authenticated'); - } + if (!token) throw new Error('Not authenticated'); const response = await fetch(`${baseUrl}${endpoint}`, { ...options, @@ -63,9 +54,28 @@ export function createFeedbackService(config: FeedbackServiceConfig) { return response.json(); } - /** - * Submit new feedback - */ + async function fetchPublic(endpoint: string): Promise { + const response = await fetch(`${baseUrl}${endpoint}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + } + + function feedQueryString(query?: FeedbackQueryParams): string { + const params = new URLSearchParams(); + params.set('appId', query?.appId ?? appId); + if (query?.moduleContext) params.set('moduleContext', query.moduleContext); + if (query?.status) params.set('status', query.status); + if (query?.category) params.set('category', query.category); + if (query?.sort) params.set('sort', query.sort); + if (query?.limit) params.set('limit', String(query.limit)); + if (query?.offset) params.set('offset', String(query.offset)); + return params.toString(); + } + + // ── Submission ────────────────────────────────────────────────── + async function createFeedback(input: CreateFeedbackInput): Promise { return fetchWithAuth(feedbackEndpoint, { method: 'POST', @@ -73,74 +83,109 @@ export function createFeedbackService(config: FeedbackServiceConfig) { }); } - /** - * Get public community feedback - */ - async function getPublicFeedback(query?: FeedbackQueryParams): Promise { - const params = new URLSearchParams(); + // ── Reads (auth-required) ────────────────────────────────────── - // Always filter by current app - params.set('appId', appId); - - if (query?.status) params.set('status', query.status); - if (query?.category) params.set('category', query.category); - if (query?.sort) params.set('sort', query.sort); - if (query?.limit) params.set('limit', String(query.limit)); - if (query?.offset) params.set('offset', String(query.offset)); - - return fetchWithAuth(`${feedbackEndpoint}/public?${params}`); + /** Auth-enriched public feed: each item carries `myReactions[]`. */ + async function getPublicFeed(query?: FeedbackQueryParams): Promise { + const qs = feedQueryString(query); + const res = await fetchWithAuth(`${feedbackEndpoint}/public?${qs}`); + return res.items; } - /** - * Get user's own feedback - */ async function getMyFeedback(): Promise { - const params = new URLSearchParams(); - params.set('appId', appId); - - return fetchWithAuth(`${feedbackEndpoint}/my?${params}`); + return fetchWithAuth(`${feedbackEndpoint}/me`); } - /** - * Vote on a feedback item - */ - async function vote(feedbackId: string): Promise { - return fetchWithAuth(`${feedbackEndpoint}/${feedbackId}/vote`, { + async function getReplies(feedbackId: string): Promise { + return fetchWithAuth(`${feedbackEndpoint}/${feedbackId}/replies`); + } + + // ── Reads (anonymous, no auth) ───────────────────────────────── + + async function getPublicFeedAnonymous( + query?: FeedbackQueryParams + ): Promise { + const qs = feedQueryString(query); + const res = await fetchPublic(`${publicEndpoint}/feed?${qs}`); + return res.items; + } + + async function getPublicItemAnonymous(id: string): Promise { + return fetchPublic(`${publicEndpoint}/${id}`); + } + + // ── Reactions ────────────────────────────────────────────────── + + async function toggleReaction( + feedbackId: string, + emoji: ReactionEmoji + ): Promise { + return fetchWithAuth(`${feedbackEndpoint}/${feedbackId}/react`, { method: 'POST', + body: JSON.stringify({ emoji } satisfies ReactInput), }); } - /** - * Remove vote from a feedback item - */ - async function unvote(feedbackId: string): Promise { - return fetchWithAuth(`${feedbackEndpoint}/${feedbackId}/vote`, { + // ── Mutations ────────────────────────────────────────────────── + + async function deleteFeedback(feedbackId: string): Promise<{ success: boolean }> { + return fetchWithAuth<{ success: boolean }>(`${feedbackEndpoint}/${feedbackId}`, { method: 'DELETE', }); } - /** - * Toggle vote on a feedback item - */ - async function toggleVote(feedbackId: string, currentlyVoted: boolean): Promise { - if (currentlyVoted) { - return unvote(feedbackId); - } else { - return vote(feedbackId); - } + // ── Admin ────────────────────────────────────────────────────── + + async function adminListAll(query?: FeedbackQueryParams): Promise { + const qs = feedQueryString(query); + return fetchWithAuth(`${feedbackEndpoint}/admin?${qs}`); + } + + async function adminPatch(feedbackId: string, patch: AdminPatchInput): Promise { + return fetchWithAuth(`${feedbackEndpoint}/admin/${feedbackId}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); + } + + // ── Legacy (back-compat shims) ───────────────────────────────── + // Older callers still use vote/unvote — translate to 👍-reaction toggles. + + async function vote(feedbackId: string): Promise { + const res = await toggleReaction(feedbackId, '👍'); + return { + success: true, + newVoteCount: res.reactions['👍'] ?? 0, + userHasVoted: res.userHasReacted, + }; + } + const unvote = vote; // toggle, semantically idempotent for legacy callers + async function toggleVote(feedbackId: string): Promise { + return vote(feedbackId); + } + + async function getPublicFeedback(query?: FeedbackQueryParams): Promise { + const items = await getPublicFeed(query); + return { items: items as unknown as FeedbackListResponse['items'] }; } return { createFeedback, - getPublicFeedback, + getPublicFeed, + getPublicFeedAnonymous, + getPublicItemAnonymous, getMyFeedback, + getReplies, + toggleReaction, + deleteFeedback, + adminListAll, + adminPatch, + // Legacy (deprecated): + getPublicFeedback, vote, unvote, toggleVote, }; } -/** - * Type for the feedback service instance - */ export type FeedbackService = ReturnType; diff --git a/packages/feedback/src/createPublicFeedbackService.ts b/packages/feedback/src/createPublicFeedbackService.ts new file mode 100644 index 000000000..e3fa896ab --- /dev/null +++ b/packages/feedback/src/createPublicFeedbackService.ts @@ -0,0 +1,48 @@ +/** + * Public, anonymous feedback service — for SSR / unauthenticated reads + * of the community feed (e.g. /community route, embeddable widget). + * + * No auth, no submit, no react. If you need write access, instantiate + * `createFeedbackService()` instead and pass a getAuthToken callback. + */ + +import type { PublicFeedListResponse, PublicItemResponse, FeedbackQueryParams } from './api'; +import type { PublicFeedbackItem } from './feedback'; +import type { PublicFeedbackServiceConfig } from './types'; + +export function createPublicFeedbackService(config: PublicFeedbackServiceConfig) { + const { apiUrl, appId, publicEndpoint = '/api/v1/public/feedback' } = config; + const baseUrl = apiUrl.replace(/\/$/, ''); + + async function fetchPublic(endpoint: string): Promise { + const res = await fetch(`${baseUrl}${endpoint}`); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + return res.json(); + } + + function feedQueryString(query?: FeedbackQueryParams): string { + const params = new URLSearchParams(); + const effectiveAppId = query?.appId ?? appId; + if (effectiveAppId) params.set('appId', effectiveAppId); + if (query?.moduleContext) params.set('moduleContext', query.moduleContext); + if (query?.status) params.set('status', query.status); + if (query?.category) params.set('category', query.category); + if (query?.limit) params.set('limit', String(query.limit)); + if (query?.offset) params.set('offset', String(query.offset)); + return params.toString(); + } + + async function getFeed(query?: FeedbackQueryParams): Promise { + const qs = feedQueryString(query); + const res = await fetchPublic(`${publicEndpoint}/feed?${qs}`); + return res.items; + } + + async function getItem(id: string): Promise { + return fetchPublic(`${publicEndpoint}/${id}`); + } + + return { getFeed, getItem }; +} + +export type PublicFeedbackService = ReturnType; diff --git a/packages/feedback/src/feedback.ts b/packages/feedback/src/feedback.ts index 29a9f861f..fee300e72 100644 --- a/packages/feedback/src/feedback.ts +++ b/packages/feedback/src/feedback.ts @@ -24,6 +24,50 @@ export type FeedbackStatus = | 'completed' | 'declined'; +/** + * Whitelisted reaction emojis — must mirror REACTION_WEIGHTS in + * services/mana-analytics/src/services/feedback.ts. Server rejects + * any emoji not in this list. + */ +export const REACTION_EMOJIS = ['👍', '❤️', '🚀', '🤔', '🎉'] as const; +export type ReactionEmoji = (typeof REACTION_EMOJIS)[number]; + +export const REACTION_LABELS: Record = { + '👍': 'Will ich auch', + '❤️': 'Liebe ich', + '🚀': 'Ship it', + '🤔': 'Macht mich nachdenklich', + '🎉': 'Feier', +}; + +/** + * Anonymized feedback item as it appears in the public community feed. + * Never contains userId or other identifying fields — only the + * persistent display_name pseudonym ("Wachsame Eule #4528"). + */ +export interface PublicFeedbackItem { + id: string; + appId: string; + title: string | null; + feedbackText: string; + category: FeedbackCategory; + status: FeedbackStatus; + moduleContext: string | null; + parentId: string | null; + displayName: string; + reactions: Partial>; + score: number; + adminResponse: string | null; + createdAt: string; + updatedAt: string; + /** Auth-only: which emojis the requesting user has reacted with. */ + myReactions?: string[]; +} + +/** + * Authenticated, full feedback record (own submissions / admin views). + * Includes the user-private fields the public feed redacts. + */ export interface Feedback { id: string; userId: string; @@ -35,19 +79,19 @@ export interface Feedback { isPublic: boolean; adminResponse?: string; voteCount: number; - userHasVoted: boolean; + displayHash?: string; + displayName?: string; + moduleContext?: string; + parentId?: string; + reactions?: Partial>; + score?: number; deviceInfo?: Record; createdAt: string; updatedAt: string; publishedAt?: string; completedAt?: string; -} - -export interface FeedbackVote { - id: string; - feedbackId: string; - userId: string; - createdAt: string; + // Legacy / derived for older UI surfaces: + userHasVoted?: boolean; } export const FEEDBACK_CATEGORY_LABELS: Record = { diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 791a0bc46..a81685d1c 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -1,8 +1,11 @@ /** - * @mana/feedback — Unified feedback package + * @mana/feedback — Public-Community Feedback Hub * - * Consolidates shared-feedback-types + shared-feedback-service + shared-feedback-ui - * into a single package. + * Single source of truth for all user-facing feedback in Mana: + * - Anonymized by display-name pseudonym ("Wachsame Eule #4528") + * - Slack-style emoji reactions (👍 ❤️ 🚀 🤔 🎉) instead of binary votes + * - Public read endpoints under /api/v1/public/feedback (no auth) + * - Authenticated submit + react under /api/v1/feedback (Bearer) */ // Types @@ -10,7 +13,10 @@ export { type FeedbackCategory, type FeedbackStatus, type Feedback, - type FeedbackVote, + type PublicFeedbackItem, + type ReactionEmoji, + REACTION_EMOJIS, + REACTION_LABELS, FEEDBACK_CATEGORY_LABELS, FEEDBACK_STATUS_CONFIG, } from './feedback'; @@ -20,12 +26,21 @@ export { type FeedbackQueryParams, type FeedbackResponse, type FeedbackListResponse, + type PublicFeedListResponse, + type PublicItemResponse, + type ReactionResponse, + type AdminPatchInput, + type ReactInput, type VoteResponse, } from './api'; -// Service +// Services export { createFeedbackService, type FeedbackService } from './createFeedbackService'; -export type { FeedbackServiceConfig } from './types'; +export { + createPublicFeedbackService, + type PublicFeedbackService, +} from './createPublicFeedbackService'; +export type { FeedbackServiceConfig, PublicFeedbackServiceConfig } from './types'; // UI Components export { default as FeedbackPage } from './FeedbackPage.svelte'; @@ -33,4 +48,5 @@ export { default as FeedbackForm } from './FeedbackForm.svelte'; export { default as FeedbackList } from './FeedbackList.svelte'; export { default as FeedbackCard } from './FeedbackCard.svelte'; export { default as VoteButton } from './VoteButton.svelte'; +export { default as ReactionBar } from './ReactionBar.svelte'; export { default as StatusBadge } from './StatusBadge.svelte'; diff --git a/packages/feedback/src/types.ts b/packages/feedback/src/types.ts index 0dfaaf478..e824cf9e2 100644 --- a/packages/feedback/src/types.ts +++ b/packages/feedback/src/types.ts @@ -1,13 +1,30 @@ /** - * Configuration for creating a feedback service instance + * Configuration for creating a feedback service instance. */ export interface FeedbackServiceConfig { - /** Base API URL for the feedback endpoints */ + /** Base API URL for the feedback endpoints (e.g. https://analytics.mana.how). */ apiUrl: string; - /** App identifier for multi-app support */ + /** App identifier used for filtering + per-app context. */ appId: string; - /** Function to get the current auth token */ + /** + * Function returning the current Bearer token. Required for write + * paths (createFeedback, react). For read-only public consumption + * use `createPublicFeedbackService()` instead. + */ getAuthToken: () => Promise; - /** Optional custom endpoint prefix (default: '/api/v1/feedback') */ + /** Optional custom endpoint prefix (default: '/api/v1/feedback'). */ feedbackEndpoint?: string; + /** Optional public-endpoint prefix (default: '/api/v1/public/feedback'). */ + publicEndpoint?: string; +} + +/** + * Configuration for the read-only, anonymous public feedback service — + * used by SSR pages that don't have a logged-in user and just need to + * render the community feed. + */ +export interface PublicFeedbackServiceConfig { + apiUrl: string; + appId?: string; + publicEndpoint?: string; }