Skip to content

Commit e8a6156

Browse files
Merge pull request #686 from Icahbod/fix/issue-410-email-feedback-collection
feat: Email Templates Feedback Collection (#410)
2 parents fcfa1a9 + 3d7fb1a commit e8a6156

3 files changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Feedback Widget for Email Templates (#410).
3+
*
4+
* A self-contained widget that can be embedded in an email template's
5+
* landing page or an in-app "How was this email?" prompt. Submissions are
6+
* forwarded to a consumer-provided onSubmit handler (validation handled via
7+
* FeedbackEntrySchema in src/schemas/feedback.schema.ts).
8+
*/
9+
'use client';
10+
11+
import { useCallback, useState } from 'react';
12+
import {
13+
FeedbackEntrySchema,
14+
type FeedbackEntry,
15+
type FeedbackRating,
16+
} from '@/schemas/feedback.schema';
17+
18+
export interface FeedbackWidgetProps {
19+
templateId: string;
20+
recipientHash: string;
21+
urlParams?: Record<string, string>;
22+
onSubmit?: (entry: FeedbackEntry) => void | Promise<void>;
23+
}
24+
25+
const RATING_LABEL: Record<FeedbackRating, string> = {
26+
love: '❤️ Loved it',
27+
ok: '👍 OK',
28+
dislike: '👎 Not for me',
29+
};
30+
31+
export default function FeedbackWidget({
32+
templateId,
33+
recipientHash,
34+
urlParams,
35+
onSubmit,
36+
}: FeedbackWidgetProps) {
37+
const [rating, setRating] = useState<FeedbackRating | null>(null);
38+
const [comment, setComment] = useState('');
39+
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
40+
const [error, setError] = useState<string | null>(null);
41+
42+
const submit = useCallback(async () => {
43+
if (!rating) return;
44+
const parsed = FeedbackEntrySchema.safeParse({
45+
templateId,
46+
recipientHash,
47+
rating,
48+
comment: comment.trim() || undefined,
49+
urlParams,
50+
submittedAt: new Date().toISOString(),
51+
});
52+
if (!parsed.success) {
53+
setError(parsed.error.issues[0]?.message ?? 'Invalid feedback');
54+
setStatus('error');
55+
return;
56+
}
57+
setStatus('sending');
58+
setError(null);
59+
try {
60+
await onSubmit?.(parsed.data);
61+
setStatus('sent');
62+
} catch (e) {
63+
setError(e instanceof Error ? e.message : 'Submission failed');
64+
setStatus('error');
65+
}
66+
}, [rating, comment, templateId, recipientHash, urlParams, onSubmit]);
67+
68+
if (status === 'sent') {
69+
return (
70+
<div role="status" aria-live="polite" className="feedback-widget feedback-widget--sent">
71+
Thanks for the feedback!
72+
</div>
73+
);
74+
}
75+
76+
return (
77+
<section className="feedback-widget" aria-label="Email feedback">
78+
<fieldset>
79+
<legend>How was this email?</legend>
80+
{(Object.keys(RATING_LABEL) as FeedbackRating[]).map((r) => (
81+
<label key={r} className="feedback-widget__rating">
82+
<input
83+
type="radio"
84+
name="rating"
85+
value={r}
86+
checked={rating === r}
87+
onChange={() => setRating(r)}
88+
/>
89+
{RATING_LABEL[r]}
90+
</label>
91+
))}
92+
</fieldset>
93+
<label className="feedback-widget__comment">
94+
<span>Comment (optional)</span>
95+
<textarea
96+
value={comment}
97+
onChange={(e) => setComment(e.target.value)}
98+
maxLength={2000}
99+
rows={3}
100+
/>
101+
</label>
102+
{error ? (
103+
<p role="alert" className="feedback-widget__error">
104+
{error}
105+
</p>
106+
) : null}
107+
<button
108+
type="button"
109+
disabled={!rating || status === 'sending'}
110+
onClick={submit}
111+
>
112+
{status === 'sending' ? 'Sending…' : 'Send feedback'}
113+
</button>
114+
</section>
115+
);
116+
}

src/schemas/feedback.schema.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Feedback collection schema for Email Templates (#410).
3+
*
4+
* Captures a single piece of feedback tied to a specific email template
5+
* instance (so the campaign owner can correlate feedback to delivery).
6+
*/
7+
import { z } from 'zod';
8+
9+
export const FeedbackRating = z.enum(['love', 'ok', 'dislike']);
10+
export type FeedbackRating = z.infer<typeof FeedbackRating>;
11+
12+
export const FeedbackEntrySchema = z.object({
13+
templateId: z.string().min(1),
14+
recipientHash: z.string().min(8), // opaque, never raw email
15+
rating: FeedbackRating,
16+
comment: z.string().max(2000).optional(),
17+
urlParams: z.record(z.string()).optional(),
18+
submittedAt: z.string().datetime(),
19+
});
20+
21+
export type FeedbackEntry = z.infer<typeof FeedbackEntrySchema>;

src/utils/emailFeedback.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Server-side helpers for email-template feedback (#410).
3+
*
4+
* Kept dependency-free so they can run in edge / serverless contexts.
5+
* Persistence is delegated to a caller-supplied sink function so the
6+
* module works with any database or analytics pipeline.
7+
*/
8+
import {
9+
FeedbackEntrySchema,
10+
type FeedbackEntry,
11+
} from '@/schemas/feedback.schema';
12+
13+
export type FeedbackSink = (entry: FeedbackEntry) => void | Promise<void>;
14+
15+
export interface FeedbackAggregate {
16+
templateId: string;
17+
total: number;
18+
counts: { love: number; ok: number; dislike: number };
19+
}
20+
21+
export async function ingestFeedback(
22+
raw: unknown,
23+
sink: FeedbackSink,
24+
): Promise<{ ok: true; entry: FeedbackEntry } | { ok: false; reason: string }> {
25+
const parsed = FeedbackEntrySchema.safeParse(raw);
26+
if (!parsed.success) {
27+
return {
28+
ok: false,
29+
reason: parsed.error.issues[0]?.message ?? 'Invalid feedback payload',
30+
};
31+
}
32+
await sink(parsed.data);
33+
return { ok: true, entry: parsed.data };
34+
}
35+
36+
export function aggregate(entries: FeedbackEntry[]): FeedbackAggregate[] {
37+
const buckets = new Map<string, FeedbackAggregate>();
38+
for (const e of entries) {
39+
let bucket = buckets.get(e.templateId);
40+
if (!bucket) {
41+
bucket = {
42+
templateId: e.templateId,
43+
total: 0,
44+
counts: { love: 0, ok: 0, dislike: 0 },
45+
};
46+
buckets.set(e.templateId, bucket);
47+
}
48+
bucket.total += 1;
49+
bucket.counts[e.rating] += 1;
50+
}
51+
return Array.from(buckets.values());
52+
}

0 commit comments

Comments
 (0)