mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
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:
parent
8b0a943e71
commit
c9b122076a
9 changed files with 460 additions and 108 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
147
packages/feedback/src/ReactionBar.svelte
Normal file
147
packages/feedback/src/ReactionBar.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
48
packages/feedback/src/createPublicFeedbackService.ts
Normal file
48
packages/feedback/src/createPublicFeedbackService.ts
Normal 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>;
|
||||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue