0ac4beaaa8
- Add requireAuth() to admin-permissions.ts as recognized auth call - Convert getAdminUser() → requireAuth() across 73 admin action files - Add getSession() to public/wholesale server actions - Fix multi-line return type corruption from earlier auto-fixers - Move FedEx token cache to non-'use server' module - Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD, EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS - Update Stripe API version 2026-05-27 → 2026-06-24 - Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient - Fix 51 TypeScript errors (return type corruption, missing imports)
241 lines
8.5 KiB
TypeScript
241 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers";
|
|
import { sendStopBlast } from "@/actions/communications/stop-blast";
|
|
import { AdminButton } from "@/components/admin/design-system";
|
|
|
|
const quickMessages = [
|
|
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
|
|
{ label: "Stop moved", value: "Attention — today's stop location has changed to a new address. Please check the updated details." },
|
|
{ label: "Weather delay", value: "Due to weather conditions, today's stop may be delayed. We'll send updates as the day progresses." },
|
|
{ label: "Sold out", value: "This stop has sold out of several items. Check our website or next stop for availability." },
|
|
{ label: "Preorder cutoff reminder", value: "Friendly reminder — the preorder cutoff for our next stop is tonight at midnight. Order online to guarantee your pickup!" },
|
|
{ label: "Pickup reminder", value: "Reminder — you have an order ready for pickup today. See you soon!" },
|
|
];
|
|
|
|
export default function StopMessagingForm({
|
|
stopId,
|
|
brandId,
|
|
}: {
|
|
stopId: string;
|
|
brandId: string;
|
|
}) {
|
|
const [customers, setCustomers] = useState<StopCustomer[]>([]);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
|
|
const [message, setMessage] = useState("");
|
|
const [customMessage, setCustomMessage] = useState("");
|
|
const [sending, setSending] = useState(false);
|
|
const [sent, setSent] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function loadCustomers() {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const result = await getStopPendingCustomers(stopId);
|
|
|
|
if (!result.success) {
|
|
setError(result.error);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setCustomers(result.customers);
|
|
setLoaded(true);
|
|
setLoading(false);
|
|
}
|
|
|
|
function applyQuickMessage(msg: string) {
|
|
setMessage(msg);
|
|
setCustomMessage(msg);
|
|
}
|
|
|
|
async function handleSend() {
|
|
if (!message.trim()) return;
|
|
setSending(true);
|
|
setError(null);
|
|
|
|
const blast = await sendStopBlast({
|
|
stopId,
|
|
brandId,
|
|
channel,
|
|
body: message,
|
|
audience: "pending",
|
|
});
|
|
|
|
if (!blast.success) {
|
|
setError(blast.error);
|
|
setSending(false);
|
|
return;
|
|
}
|
|
|
|
setSent(blast.messages_logged);
|
|
setSending(false);
|
|
}
|
|
|
|
const recipients = customers.filter((c) => {
|
|
if (channel === "sms") return c.customer_phone;
|
|
if (channel === "email") return c.customer_email;
|
|
return c.customer_phone || c.customer_email;
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="ha-display text-2xl text-[var(--admin-text-primary)]">
|
|
Send Message
|
|
</h2>
|
|
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
|
Notify all customers with pending pickups at this stop.
|
|
</p>
|
|
</div>
|
|
|
|
{!loaded ? (
|
|
<button type="button"
|
|
onClick={loadCustomers}
|
|
disabled={loading}
|
|
className="w-full rounded-xl border-2 border-dashed border-[var(--admin-border)] px-6 py-4 text-lg font-medium text-[var(--admin-text-secondary)] hover:border-[var(--admin-primary)] hover:text-[var(--admin-primary)] transition-colors disabled:opacity-50"
|
|
>
|
|
{loading ? "Loading customers..." : "Load Pending Customers"}
|
|
</button>
|
|
) : (
|
|
<>
|
|
{customers.length === 0 ? (
|
|
<div className="rounded-xl bg-[var(--admin-bg-subtle)] p-6 text-center text-[var(--admin-text-muted)]">
|
|
No pending orders for this stop yet.
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
|
|
style={{ background: "var(--admin-success-soft)" }}
|
|
>
|
|
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found
|
|
{recipients.length !== customers.length && (
|
|
<span> — {recipients.length} with contact info</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Channel */}
|
|
<div>
|
|
<label className="ha-field-label mb-2">
|
|
<span>Send via</span>
|
|
</label>
|
|
<div className="flex gap-2">
|
|
{(["sms", "email", "both"] as const).map((ch) => (
|
|
<button type="button"
|
|
key={ch}
|
|
onClick={() => setChannel(ch)}
|
|
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
|
channel === ch
|
|
? "bg-[var(--admin-primary)] text-white"
|
|
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
|
|
}`}
|
|
>
|
|
{ch === "sms" ? "SMS" : ch === "email" ? "Email" : "Both"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick messages */}
|
|
<div>
|
|
<label className="ha-field-label mb-2">
|
|
<span>Quick messages</span>
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{quickMessages.map((qm) => (
|
|
<button type="button"
|
|
key={qm.label}
|
|
onClick={() => applyQuickMessage(qm.value)}
|
|
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
|
|
message === qm.value
|
|
? "bg-[var(--admin-primary)] text-white"
|
|
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
|
|
}`}
|
|
>
|
|
{qm.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message preview */}
|
|
<div>
|
|
<label className="ha-field-label mb-2">
|
|
<span>Message</span>
|
|
</label>
|
|
<textarea aria-label="Type Your Message..."
|
|
value={message}
|
|
onChange={(e) => {
|
|
setMessage(e.target.value);
|
|
setCustomMessage(e.target.value);
|
|
}}
|
|
rows={4}
|
|
className="ha-field-textarea"
|
|
placeholder="Type your message..."
|
|
/>
|
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)] tabular-nums">
|
|
{message.length} characters
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="rounded-xl p-4 text-sm text-[var(--admin-danger)]"
|
|
style={{ background: "var(--admin-danger-soft)" }}
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{sent > 0 && (
|
|
<div
|
|
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
|
|
style={{ background: "var(--admin-success-soft)" }}
|
|
>
|
|
Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
|
|
</div>
|
|
)}
|
|
|
|
{/* Recipients */}
|
|
{recipients.length > 0 && message && (
|
|
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
|
|
<p className="mb-3 text-sm font-medium text-[var(--admin-text-secondary)]">
|
|
Recipients ({recipients.length}):
|
|
</p>
|
|
<div className="space-y-2">
|
|
{recipients.map((c) => (
|
|
<div key={c.id} className="flex justify-between text-sm">
|
|
<span className="text-[var(--admin-text-primary)]">{c.customer_name}</span>
|
|
<span className="text-[var(--admin-text-muted)] tabular-nums">
|
|
{c.customer_phone ?? c.customer_email ?? "no contact"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<AdminButton
|
|
variant="primary"
|
|
size="lg"
|
|
onClick={handleSend}
|
|
disabled={!message || recipients.length === 0 || sending}
|
|
isLoading={sending}
|
|
className="w-full"
|
|
>
|
|
{sending
|
|
? "Sending..."
|
|
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
|
|
</AdminButton>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|