feat(feedback): public feed types + ReactionBar + service split

@mana/feedback wird zur Pflege-SSOT für Public-Community-Hub.

- PublicFeedbackItem-Typ: anonymisiertes Item, das nur display_name +
  reactions + status führt — kein userId, displayHash, deviceInfo.
- ReactionEmoji ('👍' '❤️' '🚀' '🤔' '🎉') + REACTION_LABELS mit DE-Labels.
- CreateFeedbackInput erweitert um moduleContext + parentId. Reactions
  + score auf Feedback-Type optional gemacht.
- Service-Split:
  createFeedbackService    — auth-required Submit/React/Manage,
                            getPublicFeed (auth-enriched mit myReactions)
  createPublicFeedbackService — anonymous, SSR-only, getFeed/getItem.
  toggleReaction(emoji) statt vote/unvote (legacy-Shims bleiben für
  back-compat zu vote → '👍'-Toggle).
- ReactionBar.svelte: Slack-Style emoji-row mit Active-Highlighting für
  myReactions, ReadOnly-Mode für Public-SSR. Auto-disabled-Tooltip.
- index.ts re-exportiert die neuen Typen + ReactionBar; FeedbackVote
  rausgeschmissen (durch FeedbackReactions im Server-Schema ersetzt).

FeedbackCard + FeedbackPage minimal angepasst, damit svelte-check
clean bleibt — die Legacy-Komponenten bleiben funktional, werden aber
in Phase 3 zu @mana/feedback's neuen Modul-Views ausgemistet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 00:01:06 +02:00
parent 8b0a943e71
commit c9b122076a
9 changed files with 460 additions and 108 deletions

View file

@ -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 @@
<div class="feedback-card__vote">
<VoteButton
count={feedback.voteCount}
hasVoted={feedback.userHasVoted}
hasVoted={feedback.userHasVoted ?? false}
onToggle={handleVote}
disabled={votingDisabled || !feedback.isPublic}
/>

View file

@ -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);

View file

@ -0,0 +1,147 @@
<!--
ReactionBar — Slack-style emoji-row for a single feedback item.
Renders one chip per whitelisted emoji with its current count plus a
highlighted state if `myReactions` includes that emoji. Click toggles
the user's own reaction for that emoji.
Disabled mode: when `readOnly={true}` (e.g. on the public SSR mirror
for non-logged-in visitors) the buttons render as static badges.
-->
<script lang="ts">
import { REACTION_EMOJIS, REACTION_LABELS, type ReactionEmoji } from './feedback';
interface Props {
reactions: Partial<Record<string, number>>;
myReactions?: string[];
readOnly?: boolean;
readOnlyTooltip?: string;
onToggle?: (emoji: ReactionEmoji) => void | Promise<void>;
size?: 'sm' | 'md';
}
let {
reactions,
myReactions = [],
readOnly = false,
readOnlyTooltip = 'Login zum Reagieren',
onToggle,
size = 'md',
}: Props = $props();
let pendingEmoji = $state<string | null>(null);
async function handleClick(emoji: ReactionEmoji) {
if (readOnly || !onToggle) return;
pendingEmoji = emoji;
try {
await onToggle(emoji);
} finally {
pendingEmoji = null;
}
}
function countFor(emoji: string): number {
return reactions[emoji] ?? 0;
}
</script>
<div class="reaction-bar" class:sm={size === 'sm'}>
{#each REACTION_EMOJIS as emoji (emoji)}
{@const count = countFor(emoji)}
{@const mine = myReactions.includes(emoji)}
{@const isPending = pendingEmoji === emoji}
<button
type="button"
class="chip"
class:active={mine}
class:zero={count === 0}
class:read-only={readOnly}
class:pending={isPending}
disabled={readOnly || isPending}
title={readOnly ? readOnlyTooltip : `${REACTION_LABELS[emoji]} (${count})`}
aria-pressed={mine}
aria-label={`${REACTION_LABELS[emoji]}: ${count}`}
onclick={() => handleClick(emoji)}
>
<span class="emoji">{emoji}</span>
{#if count > 0}<span class="count">{count}</span>{/if}
</button>
{/each}
</div>
<style>
.reaction-bar {
display: inline-flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
border-radius: 999px;
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.15s ease,
opacity 0.15s ease;
}
.reaction-bar.sm .chip {
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
}
.chip:hover:not(:disabled) {
background: hsl(var(--color-surface-hover, var(--color-muted)));
border-color: hsl(var(--color-primary) / 0.4);
transform: translateY(-1px);
}
.chip.active {
background: hsl(var(--color-primary) / 0.12);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.chip.zero {
opacity: 0.55;
}
.chip.zero:hover:not(:disabled) {
opacity: 1;
}
.chip.read-only {
cursor: not-allowed;
}
.chip.read-only:hover {
transform: none;
background: hsl(var(--color-card));
border-color: hsl(var(--color-border));
}
.chip.pending {
opacity: 0.6;
}
.emoji {
font-size: 1em;
}
.count {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
</style>

View file

@ -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<string, unknown>;
}
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<Record<string, number>>;
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;
}

View file

@ -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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
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<T>(endpoint: string): Promise<T> {
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<FeedbackResponse> {
return fetchWithAuth<FeedbackResponse>(feedbackEndpoint, {
method: 'POST',
@ -73,74 +83,109 @@ export function createFeedbackService(config: FeedbackServiceConfig) {
});
}
/**
* Get public community feedback
*/
async function getPublicFeedback(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
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<FeedbackListResponse>(`${feedbackEndpoint}/public?${params}`);
/** Auth-enriched public feed: each item carries `myReactions[]`. */
async function getPublicFeed(query?: FeedbackQueryParams): Promise<PublicFeedbackItem[]> {
const qs = feedQueryString(query);
const res = await fetchWithAuth<PublicFeedListResponse>(`${feedbackEndpoint}/public?${qs}`);
return res.items;
}
/**
* Get user's own feedback
*/
async function getMyFeedback(): Promise<FeedbackListResponse> {
const params = new URLSearchParams();
params.set('appId', appId);
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/my?${params}`);
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/me`);
}
/**
* Vote on a feedback item
*/
async function vote(feedbackId: string): Promise<VoteResponse> {
return fetchWithAuth<VoteResponse>(`${feedbackEndpoint}/${feedbackId}/vote`, {
async function getReplies(feedbackId: string): Promise<PublicFeedbackItem[]> {
return fetchWithAuth<PublicFeedbackItem[]>(`${feedbackEndpoint}/${feedbackId}/replies`);
}
// ── Reads (anonymous, no auth) ─────────────────────────────────
async function getPublicFeedAnonymous(
query?: FeedbackQueryParams
): Promise<PublicFeedbackItem[]> {
const qs = feedQueryString(query);
const res = await fetchPublic<PublicFeedListResponse>(`${publicEndpoint}/feed?${qs}`);
return res.items;
}
async function getPublicItemAnonymous(id: string): Promise<PublicItemResponse> {
return fetchPublic<PublicItemResponse>(`${publicEndpoint}/${id}`);
}
// ── Reactions ──────────────────────────────────────────────────
async function toggleReaction(
feedbackId: string,
emoji: ReactionEmoji
): Promise<ReactionResponse> {
return fetchWithAuth<ReactionResponse>(`${feedbackEndpoint}/${feedbackId}/react`, {
method: 'POST',
body: JSON.stringify({ emoji } satisfies ReactInput),
});
}
/**
* Remove vote from a feedback item
*/
async function unvote(feedbackId: string): Promise<VoteResponse> {
return fetchWithAuth<VoteResponse>(`${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<VoteResponse> {
if (currentlyVoted) {
return unvote(feedbackId);
} else {
return vote(feedbackId);
}
// ── Admin ──────────────────────────────────────────────────────
async function adminListAll(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
const qs = feedQueryString(query);
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/admin?${qs}`);
}
async function adminPatch(feedbackId: string, patch: AdminPatchInput): Promise<FeedbackResponse> {
return fetchWithAuth<FeedbackResponse>(`${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<VoteResponse> {
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<VoteResponse> {
return vote(feedbackId);
}
async function getPublicFeedback(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
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<typeof createFeedbackService>;

View file

@ -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<T>(endpoint: string): Promise<T> {
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<PublicFeedbackItem[]> {
const qs = feedQueryString(query);
const res = await fetchPublic<PublicFeedListResponse>(`${publicEndpoint}/feed?${qs}`);
return res.items;
}
async function getItem(id: string): Promise<PublicItemResponse> {
return fetchPublic<PublicItemResponse>(`${publicEndpoint}/${id}`);
}
return { getFeed, getItem };
}
export type PublicFeedbackService = ReturnType<typeof createPublicFeedbackService>;

View file

@ -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<ReactionEmoji, string> = {
'👍': '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<Record<string, number>>;
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<Record<string, number>>;
score?: number;
deviceInfo?: Record<string, unknown>;
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<FeedbackCategory, string> = {

View file

@ -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';

View file

@ -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<string | null>;
/** 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;
}