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:
co-authored by
Claude Haiku 4.5
parent
98b64ecd60
commit
df4a95b3c8
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user