mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +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();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleVote() {
|
function handleVote() {
|
||||||
onVote(feedback.id, feedback.userHasVoted);
|
onVote(feedback.id, feedback.userHasVoted ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
<div class="feedback-card__vote">
|
<div class="feedback-card__vote">
|
||||||
<VoteButton
|
<VoteButton
|
||||||
count={feedback.voteCount}
|
count={feedback.voteCount}
|
||||||
hasVoted={feedback.userHasVoted}
|
hasVoted={feedback.userHasVoted ?? false}
|
||||||
onToggle={handleVote}
|
onToggle={handleVote}
|
||||||
disabled={votingDisabled || !feedback.isPublic}
|
disabled={votingDisabled || !feedback.isPublic}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
try {
|
try {
|
||||||
const [myResult, publicResult] = await Promise.all([
|
const [myResult, publicResult] = await Promise.all([
|
||||||
feedbackService.getMyFeedback(),
|
feedbackService.getMyFeedback(),
|
||||||
feedbackService.getPublicFeedback({ sort: 'votes' }),
|
feedbackService.getPublicFeedback({ sort: 'score' }),
|
||||||
]);
|
]);
|
||||||
myFeedback = myResult.items;
|
myFeedback = myResult.items;
|
||||||
publicFeedback = publicResult.items;
|
publicFeedback = publicResult.items;
|
||||||
|
|
@ -83,9 +83,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleVote(feedbackId: string, hasVoted: boolean) {
|
async function handleVote(feedbackId: string, _hasVoted: boolean) {
|
||||||
try {
|
try {
|
||||||
await feedbackService.toggleVote(feedbackId, hasVoted);
|
await feedbackService.toggleVote(feedbackId);
|
||||||
await loadFeedback();
|
await loadFeedback();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[FeedbackPage] Error voting:', 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 {
|
export interface CreateFeedbackInput {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -11,37 +17,66 @@ export interface CreateFeedbackInput {
|
||||||
/**
|
/**
|
||||||
* Whether the submission shows up in the public community list.
|
* Whether the submission shows up in the public community list.
|
||||||
* Defaults to `true` server-side. Set `false` for private intake
|
* 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;
|
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>;
|
deviceInfo?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackQueryParams {
|
export interface FeedbackQueryParams {
|
||||||
appId?: string;
|
appId?: string;
|
||||||
|
moduleContext?: string;
|
||||||
status?: FeedbackStatus;
|
status?: FeedbackStatus;
|
||||||
category?: FeedbackCategory;
|
category?: FeedbackCategory;
|
||||||
sort?: 'votes' | 'recent';
|
sort?: 'score' | 'recent';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackResponse {
|
export interface FeedbackResponse {
|
||||||
success: boolean;
|
success?: boolean;
|
||||||
feedback?: Feedback;
|
feedback?: Feedback;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackListResponse {
|
export interface FeedbackListResponse {
|
||||||
success: boolean;
|
success?: boolean;
|
||||||
items: Feedback[];
|
items: Feedback[];
|
||||||
total: number;
|
total?: number;
|
||||||
error?: string;
|
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 {
|
export interface VoteResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
newVoteCount: number;
|
newVoteCount?: number;
|
||||||
userHasVoted: boolean;
|
userHasVoted?: boolean;
|
||||||
error?: string;
|
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.
|
* One factory builds the auth-required service used by logged-in users
|
||||||
* Handles feedback submission, retrieval, and voting.
|
* (submit, react, manage own items). The companion `createPublicFeedbackService`
|
||||||
*
|
* (in createPublicFeedbackService.ts) is for SSR / unauthenticated reads.
|
||||||
* @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(),
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -22,28 +11,30 @@ import type {
|
||||||
FeedbackQueryParams,
|
FeedbackQueryParams,
|
||||||
FeedbackResponse,
|
FeedbackResponse,
|
||||||
FeedbackListResponse,
|
FeedbackListResponse,
|
||||||
|
PublicFeedListResponse,
|
||||||
|
PublicItemResponse,
|
||||||
|
ReactionResponse,
|
||||||
|
AdminPatchInput,
|
||||||
|
ReactInput,
|
||||||
VoteResponse,
|
VoteResponse,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type { FeedbackServiceConfig } from './types';
|
import type { FeedbackServiceConfig } from './types';
|
||||||
|
import type { PublicFeedbackItem, ReactionEmoji } from './feedback';
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a feedback service instance
|
|
||||||
*/
|
|
||||||
export function createFeedbackService(config: FeedbackServiceConfig) {
|
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(/\/$/, '');
|
const baseUrl = apiUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to make authenticated requests
|
|
||||||
*/
|
|
||||||
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
const token = await getAuthToken();
|
const token = await getAuthToken();
|
||||||
|
if (!token) throw new Error('Not authenticated');
|
||||||
if (!token) {
|
|
||||||
throw new Error('Not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}${endpoint}`, {
|
const response = await fetch(`${baseUrl}${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -63,9 +54,28 @@ export function createFeedbackService(config: FeedbackServiceConfig) {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function fetchPublic<T>(endpoint: string): Promise<T> {
|
||||||
* Submit new feedback
|
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> {
|
async function createFeedback(input: CreateFeedbackInput): Promise<FeedbackResponse> {
|
||||||
return fetchWithAuth<FeedbackResponse>(feedbackEndpoint, {
|
return fetchWithAuth<FeedbackResponse>(feedbackEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -73,74 +83,109 @@ export function createFeedbackService(config: FeedbackServiceConfig) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Reads (auth-required) ──────────────────────────────────────
|
||||||
* Get public community feedback
|
|
||||||
*/
|
|
||||||
async function getPublicFeedback(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
// Always filter by current app
|
/** Auth-enriched public feed: each item carries `myReactions[]`. */
|
||||||
params.set('appId', appId);
|
async function getPublicFeed(query?: FeedbackQueryParams): Promise<PublicFeedbackItem[]> {
|
||||||
|
const qs = feedQueryString(query);
|
||||||
if (query?.status) params.set('status', query.status);
|
const res = await fetchWithAuth<PublicFeedListResponse>(`${feedbackEndpoint}/public?${qs}`);
|
||||||
if (query?.category) params.set('category', query.category);
|
return res.items;
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user's own feedback
|
|
||||||
*/
|
|
||||||
async function getMyFeedback(): Promise<FeedbackListResponse> {
|
async function getMyFeedback(): Promise<FeedbackListResponse> {
|
||||||
const params = new URLSearchParams();
|
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/me`);
|
||||||
params.set('appId', appId);
|
|
||||||
|
|
||||||
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/my?${params}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function getReplies(feedbackId: string): Promise<PublicFeedbackItem[]> {
|
||||||
* Vote on a feedback item
|
return fetchWithAuth<PublicFeedbackItem[]>(`${feedbackEndpoint}/${feedbackId}/replies`);
|
||||||
*/
|
}
|
||||||
async function vote(feedbackId: string): Promise<VoteResponse> {
|
|
||||||
return fetchWithAuth<VoteResponse>(`${feedbackEndpoint}/${feedbackId}/vote`, {
|
// ── 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',
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ emoji } satisfies ReactInput),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Mutations ──────────────────────────────────────────────────
|
||||||
* Remove vote from a feedback item
|
|
||||||
*/
|
async function deleteFeedback(feedbackId: string): Promise<{ success: boolean }> {
|
||||||
async function unvote(feedbackId: string): Promise<VoteResponse> {
|
return fetchWithAuth<{ success: boolean }>(`${feedbackEndpoint}/${feedbackId}`, {
|
||||||
return fetchWithAuth<VoteResponse>(`${feedbackEndpoint}/${feedbackId}/vote`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Admin ──────────────────────────────────────────────────────
|
||||||
* Toggle vote on a feedback item
|
|
||||||
*/
|
async function adminListAll(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
|
||||||
async function toggleVote(feedbackId: string, currentlyVoted: boolean): Promise<VoteResponse> {
|
const qs = feedQueryString(query);
|
||||||
if (currentlyVoted) {
|
return fetchWithAuth<FeedbackListResponse>(`${feedbackEndpoint}/admin?${qs}`);
|
||||||
return unvote(feedbackId);
|
}
|
||||||
} else {
|
|
||||||
return vote(feedbackId);
|
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 {
|
return {
|
||||||
createFeedback,
|
createFeedback,
|
||||||
getPublicFeedback,
|
getPublicFeed,
|
||||||
|
getPublicFeedAnonymous,
|
||||||
|
getPublicItemAnonymous,
|
||||||
getMyFeedback,
|
getMyFeedback,
|
||||||
|
getReplies,
|
||||||
|
toggleReaction,
|
||||||
|
deleteFeedback,
|
||||||
|
adminListAll,
|
||||||
|
adminPatch,
|
||||||
|
// Legacy (deprecated):
|
||||||
|
getPublicFeedback,
|
||||||
vote,
|
vote,
|
||||||
unvote,
|
unvote,
|
||||||
toggleVote,
|
toggleVote,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Type for the feedback service instance
|
|
||||||
*/
|
|
||||||
export type FeedbackService = ReturnType<typeof createFeedbackService>;
|
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'
|
| 'completed'
|
||||||
| 'declined';
|
| '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 {
|
export interface Feedback {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -35,19 +79,19 @@ export interface Feedback {
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
adminResponse?: string;
|
adminResponse?: string;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
userHasVoted: boolean;
|
displayHash?: string;
|
||||||
|
displayName?: string;
|
||||||
|
moduleContext?: string;
|
||||||
|
parentId?: string;
|
||||||
|
reactions?: Partial<Record<string, number>>;
|
||||||
|
score?: number;
|
||||||
deviceInfo?: Record<string, unknown>;
|
deviceInfo?: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
}
|
// Legacy / derived for older UI surfaces:
|
||||||
|
userHasVoted?: boolean;
|
||||||
export interface FeedbackVote {
|
|
||||||
id: string;
|
|
||||||
feedbackId: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FEEDBACK_CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
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
|
* Single source of truth for all user-facing feedback in Mana:
|
||||||
* into a single package.
|
* - 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
|
// Types
|
||||||
|
|
@ -10,7 +13,10 @@ export {
|
||||||
type FeedbackCategory,
|
type FeedbackCategory,
|
||||||
type FeedbackStatus,
|
type FeedbackStatus,
|
||||||
type Feedback,
|
type Feedback,
|
||||||
type FeedbackVote,
|
type PublicFeedbackItem,
|
||||||
|
type ReactionEmoji,
|
||||||
|
REACTION_EMOJIS,
|
||||||
|
REACTION_LABELS,
|
||||||
FEEDBACK_CATEGORY_LABELS,
|
FEEDBACK_CATEGORY_LABELS,
|
||||||
FEEDBACK_STATUS_CONFIG,
|
FEEDBACK_STATUS_CONFIG,
|
||||||
} from './feedback';
|
} from './feedback';
|
||||||
|
|
@ -20,12 +26,21 @@ export {
|
||||||
type FeedbackQueryParams,
|
type FeedbackQueryParams,
|
||||||
type FeedbackResponse,
|
type FeedbackResponse,
|
||||||
type FeedbackListResponse,
|
type FeedbackListResponse,
|
||||||
|
type PublicFeedListResponse,
|
||||||
|
type PublicItemResponse,
|
||||||
|
type ReactionResponse,
|
||||||
|
type AdminPatchInput,
|
||||||
|
type ReactInput,
|
||||||
type VoteResponse,
|
type VoteResponse,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
// Service
|
// Services
|
||||||
export { createFeedbackService, type FeedbackService } from './createFeedbackService';
|
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
|
// UI Components
|
||||||
export { default as FeedbackPage } from './FeedbackPage.svelte';
|
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 FeedbackList } from './FeedbackList.svelte';
|
||||||
export { default as FeedbackCard } from './FeedbackCard.svelte';
|
export { default as FeedbackCard } from './FeedbackCard.svelte';
|
||||||
export { default as VoteButton } from './VoteButton.svelte';
|
export { default as VoteButton } from './VoteButton.svelte';
|
||||||
|
export { default as ReactionBar } from './ReactionBar.svelte';
|
||||||
export { default as StatusBadge } from './StatusBadge.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 {
|
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;
|
apiUrl: string;
|
||||||
/** App identifier for multi-app support */
|
/** App identifier used for filtering + per-app context. */
|
||||||
appId: string;
|
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>;
|
getAuthToken: () => Promise<string | null>;
|
||||||
/** Optional custom endpoint prefix (default: '/api/v1/feedback') */
|
/** Optional custom endpoint prefix (default: '/api/v1/feedback'). */
|
||||||
feedbackEndpoint?: string;
|
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