Feature: Add customer search and selection to email page

- Create CustomerSelector component for searching and selecting customers
- Search by name or email with real-time filtering
- Select individual customers or select all with one click
- Shows count of selected customers
- Shows subscribed customer count in search results
- Updated email form to use selected customers
- Falls back to all subscribed if no customers selected
- Displays preview of recipient count before sending

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-19 10:25:04 +01:00
co-authored by Claude Haiku 4.5
parent 98b64ecd60
commit df4a95b3c8
3 changed files with 145 additions and 2 deletions
+13 -1
View File
@@ -31,7 +31,19 @@ export async function sendBulkEmail(formData: FormData) {
} }
: null; : null;
const recipients = await prisma.mailingListEntry.findMany({ where: { subscribed: true } }); // Get selected customer IDs from form
const selectedIds = formData.getAll('selectedCustomers') as string[];
let recipients;
if (selectedIds.length > 0) {
// Send to selected customers only
recipients = await prisma.mailingListEntry.findMany({
where: { id: { in: selectedIds } },
});
} else {
// Fall back to all subscribed if no selection made
recipients = await prisma.mailingListEntry.findMany({ where: { subscribed: true } });
}
let sent = 0; let sent = 0;
let failed = 0; let failed = 0;
+4 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav'; import AdminNav from '@/components/AdminNav';
import CustomerSelector from '@/components/CustomerSelector';
import { updateSubscribed, sendBulkEmail } from './actions'; import { updateSubscribed, sendBulkEmail } from './actions';
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
@@ -75,6 +76,8 @@ export default async function CustomersPage({
<h2 className="tag-label mt-10 mb-3 text-ink">Send an email</h2> <h2 className="tag-label mt-10 mb-3 text-ink">Send an email</h2>
<form action={sendBulkEmail} className="space-y-6"> <form action={sendBulkEmail} className="space-y-6">
<CustomerSelector entries={entries} sourceLabels={SOURCE_LABELS} />
<div> <div>
<label htmlFor="subject" className="tag-label mb-2 block"> <label htmlFor="subject" className="tag-label mb-2 block">
Subject Subject
@@ -107,7 +110,7 @@ export default async function CustomersPage({
<input id="attachment" name="attachment" type="file" className="w-full text-sm" /> <input id="attachment" name="attachment" type="file" className="w-full text-sm" />
</div> </div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"> <button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Send to {subscribedCount} subscribed recipient{subscribedCount === 1 ? '' : 's'} Send email
</button> </button>
</form> </form>
</div> </div>
+128
View File
@@ -0,0 +1,128 @@
'use client';
import { useState, useMemo } from 'react';
interface MailingListEntry {
id: string;
name: string | null;
email: string;
source: string;
subscribed: boolean;
}
interface Props {
entries: MailingListEntry[];
sourceLabels: Record<string, string>;
}
export default function CustomerSelector({ entries, sourceLabels }: Props) {
const [searchTerm, setSearchTerm] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const filtered = useMemo(() => {
if (!searchTerm) return entries.filter((e) => e.subscribed);
return entries.filter(
(e) =>
e.subscribed &&
(e.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
(e.name && e.name.toLowerCase().includes(searchTerm.toLowerCase())))
);
}, [searchTerm, entries]);
const toggleAll = () => {
if (selectedIds.size === filtered.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filtered.map((e) => e.id)));
}
};
const toggleCustomer = (id: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
};
return (
<div className="border border-line bg-surface p-6 rounded">
<h3 className="font-display text-lg mb-4">Select customers to email</h3>
<div className="mb-4">
<input
type="text"
placeholder="Search by name or email…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-2 text-xs text-muted">
Showing {filtered.length} subscribed customer{filtered.length !== 1 ? 's' : ''}
{searchTerm && ` matching "${searchTerm}"`}
</p>
</div>
{filtered.length === 0 ? (
<p className="text-sm text-muted py-4">No customers found.</p>
) : (
<div>
<div className="flex items-center gap-2 mb-3 pb-3 border-b border-line">
<input
type="checkbox"
id="select-all"
checked={selectedIds.size > 0 && selectedIds.size === filtered.length}
onChange={toggleAll}
className="h-4 w-4 accent-clay"
/>
<label htmlFor="select-all" className="text-sm font-medium cursor-pointer">
{selectedIds.size === 0 ? 'Select all' : `${selectedIds.size} selected`}
</label>
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{filtered.map((customer) => (
<div key={customer.id} className="flex items-center gap-2">
<input
type="checkbox"
id={`customer-${customer.id}`}
checked={selectedIds.has(customer.id)}
onChange={() => toggleCustomer(customer.id)}
className="h-4 w-4 accent-clay"
/>
<label
htmlFor={`customer-${customer.id}`}
className="flex-1 text-sm cursor-pointer py-1"
>
<span className="font-medium">{customer.name || customer.email}</span>
{customer.name && (
<>
{' · '}
<span className="text-muted text-xs">{customer.email}</span>
</>
)}
<span className="text-muted text-xs ml-2">{sourceLabels[customer.source]}</span>
</label>
</div>
))}
</div>
</div>
)}
{/* Hidden inputs for selected customer IDs */}
{Array.from(selectedIds).map((id) => (
<input key={id} type="hidden" name="selectedCustomers" value={id} />
))}
{selectedIds.size > 0 && (
<div className="mt-4 p-3 bg-splash-blue/10 border border-splash-blue/30 rounded">
<p className="text-sm text-splash-blue">
Will send to <strong>{selectedIds.size}</strong> customer{selectedIds.size !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
);
}