From 3d7fb1af2c6c9d0e499f722500aead0ecf22d0ba Mon Sep 17 00:00:00 2001 From: Icahbod Date: Thu, 25 Jun 2026 12:02:09 +0000 Subject: [PATCH] feat(emails): add Feedback Collection for Email Templates (#410) - Define FeedbackEntry Zod schema for per-template feedback - Provide Reusable FeedbackWidget React component - Add server-side ingest + aggregate helpers (dependency-free) --- src/components/feedback/FeedbackWidget.tsx | 116 +++++++++++++++++++++ src/schemas/feedback.schema.ts | 21 ++++ src/utils/emailFeedback.ts | 52 +++++++++ 3 files changed, 189 insertions(+) create mode 100644 src/components/feedback/FeedbackWidget.tsx create mode 100644 src/schemas/feedback.schema.ts create mode 100644 src/utils/emailFeedback.ts diff --git a/src/components/feedback/FeedbackWidget.tsx b/src/components/feedback/FeedbackWidget.tsx new file mode 100644 index 00000000..a3f426ce --- /dev/null +++ b/src/components/feedback/FeedbackWidget.tsx @@ -0,0 +1,116 @@ +/** + * Feedback Widget for Email Templates (#410). + * + * A self-contained widget that can be embedded in an email template's + * landing page or an in-app "How was this email?" prompt. Submissions are + * forwarded to a consumer-provided onSubmit handler (validation handled via + * FeedbackEntrySchema in src/schemas/feedback.schema.ts). + */ +'use client'; + +import { useCallback, useState } from 'react'; +import { + FeedbackEntrySchema, + type FeedbackEntry, + type FeedbackRating, +} from '@/schemas/feedback.schema'; + +export interface FeedbackWidgetProps { + templateId: string; + recipientHash: string; + urlParams?: Record; + onSubmit?: (entry: FeedbackEntry) => void | Promise; +} + +const RATING_LABEL: Record = { + love: '❤️ Loved it', + ok: '👍 OK', + dislike: '👎 Not for me', +}; + +export default function FeedbackWidget({ + templateId, + recipientHash, + urlParams, + onSubmit, +}: FeedbackWidgetProps) { + const [rating, setRating] = useState(null); + const [comment, setComment] = useState(''); + const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + if (!rating) return; + const parsed = FeedbackEntrySchema.safeParse({ + templateId, + recipientHash, + rating, + comment: comment.trim() || undefined, + urlParams, + submittedAt: new Date().toISOString(), + }); + if (!parsed.success) { + setError(parsed.error.issues[0]?.message ?? 'Invalid feedback'); + setStatus('error'); + return; + } + setStatus('sending'); + setError(null); + try { + await onSubmit?.(parsed.data); + setStatus('sent'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Submission failed'); + setStatus('error'); + } + }, [rating, comment, templateId, recipientHash, urlParams, onSubmit]); + + if (status === 'sent') { + return ( +
+ Thanks for the feedback! +
+ ); + } + + return ( +
+
+ How was this email? + {(Object.keys(RATING_LABEL) as FeedbackRating[]).map((r) => ( + + ))} +
+