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;
}