Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/components/feedback/FeedbackWidget.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
onSubmit?: (entry: FeedbackEntry) => void | Promise<void>;
}

const RATING_LABEL: Record<FeedbackRating, string> = {
love: '❤️ Loved it',
ok: '👍 OK',
dislike: '👎 Not for me',
};

export default function FeedbackWidget({
templateId,
recipientHash,
urlParams,
onSubmit,
}: FeedbackWidgetProps) {
const [rating, setRating] = useState<FeedbackRating | null>(null);
const [comment, setComment] = useState('');
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(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 (
<div role="status" aria-live="polite" className="feedback-widget feedback-widget--sent">
Thanks for the feedback!
</div>
);
}

return (
<section className="feedback-widget" aria-label="Email feedback">
<fieldset>
<legend>How was this email?</legend>
{(Object.keys(RATING_LABEL) as FeedbackRating[]).map((r) => (
<label key={r} className="feedback-widget__rating">
<input
type="radio"
name="rating"
value={r}
checked={rating === r}
onChange={() => setRating(r)}
/>
{RATING_LABEL[r]}
</label>
))}
</fieldset>
<label className="feedback-widget__comment">
<span>Comment (optional)</span>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={2000}
rows={3}
/>
</label>
{error ? (
<p role="alert" className="feedback-widget__error">
{error}
</p>
) : null}
<button
type="button"
disabled={!rating || status === 'sending'}
onClick={submit}
>
{status === 'sending' ? 'Sending…' : 'Send feedback'}
</button>
</section>
);
}
21 changes: 21 additions & 0 deletions src/schemas/feedback.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Feedback collection schema for Email Templates (#410).
*
* Captures a single piece of feedback tied to a specific email template
* instance (so the campaign owner can correlate feedback to delivery).
*/
import { z } from 'zod';

export const FeedbackRating = z.enum(['love', 'ok', 'dislike']);
export type FeedbackRating = z.infer<typeof FeedbackRating>;

export const FeedbackEntrySchema = z.object({
templateId: z.string().min(1),
recipientHash: z.string().min(8), // opaque, never raw email
rating: FeedbackRating,
comment: z.string().max(2000).optional(),
urlParams: z.record(z.string()).optional(),
submittedAt: z.string().datetime(),
});

export type FeedbackEntry = z.infer<typeof FeedbackEntrySchema>;
52 changes: 52 additions & 0 deletions src/utils/emailFeedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Server-side helpers for email-template feedback (#410).
*
* Kept dependency-free so they can run in edge / serverless contexts.
* Persistence is delegated to a caller-supplied sink function so the
* module works with any database or analytics pipeline.
*/
import {
FeedbackEntrySchema,
type FeedbackEntry,
} from '@/schemas/feedback.schema';

export type FeedbackSink = (entry: FeedbackEntry) => void | Promise<void>;

export interface FeedbackAggregate {
templateId: string;
total: number;
counts: { love: number; ok: number; dislike: number };
}

export async function ingestFeedback(
raw: unknown,
sink: FeedbackSink,
): Promise<{ ok: true; entry: FeedbackEntry } | { ok: false; reason: string }> {
const parsed = FeedbackEntrySchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false,
reason: parsed.error.issues[0]?.message ?? 'Invalid feedback payload',
};
}
await sink(parsed.data);
return { ok: true, entry: parsed.data };
}

export function aggregate(entries: FeedbackEntry[]): FeedbackAggregate[] {
const buckets = new Map<string, FeedbackAggregate>();
for (const e of entries) {
let bucket = buckets.get(e.templateId);
if (!bucket) {
bucket = {
templateId: e.templateId,
total: 0,
counts: { love: 0, ok: 0, dislike: 0 },
};
buckets.set(e.templateId, bucket);
}
bucket.total += 1;
bucket.counts[e.rating] += 1;
}
return Array.from(buckets.values());
}
Loading