mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-30 00:15:48 +00:00
feat: add bulk mailbox warmup controls
This commit is contained in:
@@ -9,7 +9,9 @@ import useFeatureStatus from "@/lib/api/hooks/app/subscription/useFeatureStatus"
|
||||
import warmupLifecycle from "@/lib/api/client/app/emails/warmupLifecycle";
|
||||
import removeEmail from "@/lib/api/client/app/emails/removeEmail";
|
||||
import { useUserProfile } from "@/hooks/context/user";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import InboxDetails from "@/components/app/emails/InboxDetails";
|
||||
import BulkWarmupDialog from "@/components/app/emails/BulkWarmupDialog";
|
||||
import type Tag from "@/lib/api/models/app/Tag";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus";
|
||||
@@ -68,6 +70,7 @@ function healthTone(status?: AccountStatus): { dot: string; text: string; label:
|
||||
|
||||
export default function AddressesPage() {
|
||||
const p = useUserProfile();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [query, setQuery] = React.useState<string>("");
|
||||
const [tag, setTag] = React.useState<string>("");
|
||||
@@ -76,6 +79,7 @@ export default function AddressesPage() {
|
||||
const [view, setView] = React.useState<string>("");
|
||||
const [viewTab, setViewTab] = React.useState<string>("overview");
|
||||
const [removing, setRemoving] = React.useState(false);
|
||||
const [bulkStart, setBulkStart] = React.useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Warmup is a paid/trial feature; gate the start controls when the org
|
||||
@@ -87,19 +91,25 @@ export default function AddressesPage() {
|
||||
// One query feeds live health for every row; the realtime layer already
|
||||
// invalidates ["analytics","accounts",…] on warmup/account events.
|
||||
const statuses = useAccountStatuses();
|
||||
// Coerce to an array defensively: a wrong-shape (non-array) response must
|
||||
// never reach a `for…of`, which would throw "{} is not iterable".
|
||||
const accountStatuses = useMemo(
|
||||
() => (Array.isArray(statuses.data) ? statuses.data : []),
|
||||
[statuses.data],
|
||||
);
|
||||
const statusById = useMemo(() => {
|
||||
const m = new Map<string, AccountStatus>();
|
||||
for (const s of statuses.data ?? []) m.set(s.id, s);
|
||||
for (const s of accountStatuses) m.set(s.id, s);
|
||||
return m;
|
||||
}, [statuses.data]);
|
||||
}, [accountStatuses]);
|
||||
|
||||
// Proactively notify the user when a mailbox's health drops.
|
||||
const prevHealth = useRef<Map<string, string>>(new Map());
|
||||
useEffect(() => {
|
||||
if (!statuses.data) return;
|
||||
if (accountStatuses.length === 0) return;
|
||||
const prev = prevHealth.current;
|
||||
const next = new Map<string, string>();
|
||||
for (const s of statuses.data) {
|
||||
for (const s of accountStatuses) {
|
||||
const cur = s.health?.status ?? "healthy";
|
||||
next.set(s.id, cur);
|
||||
const before = prev.get(s.id);
|
||||
@@ -109,20 +119,24 @@ export default function AddressesPage() {
|
||||
}
|
||||
}
|
||||
prevHealth.current = next;
|
||||
}, [statuses.data]);
|
||||
}, [accountStatuses]);
|
||||
|
||||
const removeSelected = async () => {
|
||||
const removeSelected = () => {
|
||||
if (selected.length === 0 || removing) return;
|
||||
const n = selected.length;
|
||||
if (!window.confirm(`Remove ${n} mailbox${n > 1 ? "es" : ""}? This disconnects ${n > 1 ? "them" : "it"} from Warmbly.`)) return;
|
||||
setRemoving(true);
|
||||
const results = await Promise.allSettled(selected.map((id) => removeEmail(id)));
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
await queryClient.invalidateQueries({ queryKey: ["emails"] });
|
||||
setSelected([]);
|
||||
setRemoving(false);
|
||||
if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be removed`);
|
||||
else toast.success(`Removed ${n} mailbox${n > 1 ? "es" : ""}`);
|
||||
confirm.show(
|
||||
`Remove ${n} mailbox${n > 1 ? "es" : ""}? This disconnects ${n > 1 ? "them" : "it"} from Warmbly.`,
|
||||
async () => {
|
||||
setRemoving(true);
|
||||
const results = await Promise.allSettled(selected.map((id) => removeEmail(id)));
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
await queryClient.invalidateQueries({ queryKey: ["emails"] });
|
||||
setSelected([]);
|
||||
setRemoving(false);
|
||||
if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be removed`);
|
||||
else toast.success(`Removed ${n} mailbox${n > 1 ? "es" : ""}`);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const bulkWarmup = async (action: "start" | "pause") => {
|
||||
@@ -319,7 +333,7 @@ export default function AddressesPage() {
|
||||
{canWarmup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bulkWarmup("start")}
|
||||
onClick={() => setBulkStart(true)}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded text-[12px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
|
||||
>
|
||||
<PlayIcon className="w-3.5 h-3.5" />
|
||||
@@ -357,6 +371,13 @@ export default function AddressesPage() {
|
||||
</PageBody>
|
||||
|
||||
<InboxDetails emails={emailsData.emails} view={view} setView={setView} initialTab={viewTab} canWarmup={canWarmup} />
|
||||
|
||||
<BulkWarmupDialog
|
||||
open={bulkStart}
|
||||
ids={selected}
|
||||
onClose={() => setBulkStart(false)}
|
||||
onComplete={() => setSelected([])}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -379,6 +400,7 @@ function MailboxRow({
|
||||
onOpen: (id: string, tab?: string) => void;
|
||||
}) {
|
||||
const life = useWarmupLifecycle(box.id);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const off = !box.warmup;
|
||||
const paused = !!box.warmup && !!box.warmup_paused_at;
|
||||
@@ -412,27 +434,37 @@ function MailboxRow({
|
||||
};
|
||||
|
||||
const stopReset = () => {
|
||||
if (!window.confirm(`Stop warmup for ${box.email}? This resets ramp progress — restarting begins from the base volume. Use Pause to keep progress.`)) return;
|
||||
life.mutate("stop", {
|
||||
onSuccess: () => toast.success(`Warmup stopped for ${box.email}`),
|
||||
onError: () => toast.error("Couldn't update warmup"),
|
||||
});
|
||||
confirm.show(
|
||||
`Stop warmup for ${box.email}? This resets ramp progress — restarting begins from the base volume. Use Pause to keep progress.`,
|
||||
async () => {
|
||||
try {
|
||||
await life.mutateAsync("stop");
|
||||
toast.success(`Warmup stopped for ${box.email}`);
|
||||
} catch {
|
||||
toast.error("Couldn't update warmup");
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const upsell = () => toast("Warmup is available on paid plans", { icon: "✨" });
|
||||
|
||||
return (
|
||||
<tr className="border-b border-slate-200/60 hover:bg-slate-50/80 transition-colors group h-11">
|
||||
<tr
|
||||
onClick={() => onOpen(box.id)}
|
||||
className="border-b border-slate-200/60 hover:bg-slate-50/80 transition-colors group h-11 cursor-pointer"
|
||||
>
|
||||
<td className="pl-5 pr-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-3.5 h-3.5 rounded accent-sky-600"
|
||||
checked={checked}
|
||||
onChange={onToggleSelect}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<button type="button" onClick={() => onOpen(box.id)} className="flex items-center gap-2.5 text-left">
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); onOpen(box.id); }} className="flex items-center gap-2.5 text-left">
|
||||
<div className="w-6 h-6 rounded-full bg-sky-100 flex items-center justify-center shrink-0">
|
||||
<span className="text-[9.5px] font-semibold text-sky-700">
|
||||
{box.email.slice(0, 2).toUpperCase()}
|
||||
@@ -450,7 +482,7 @@ function MailboxRow({
|
||||
<td className="px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(box.id, "overview")}
|
||||
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "overview"); }}
|
||||
className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${tone.text}`}
|
||||
title="View mailbox health"
|
||||
>
|
||||
@@ -505,8 +537,8 @@ function MailboxRow({
|
||||
<button
|
||||
type="button"
|
||||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors cursor-pointer"
|
||||
onClick={() => onOpen(box.id)}
|
||||
aria-label="Account details"
|
||||
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "settings"); }}
|
||||
aria-label="Mailbox settings"
|
||||
>
|
||||
<RiMoreLine className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { FlameIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Label, NumberInput } from "@/components/ui/field";
|
||||
import TimeSelect from "@/components/ui/TimeSelect";
|
||||
import WeekdayBitmask from "@/components/app/campaigns/schedule/WeekdayBitmask";
|
||||
import { Loading } from "@/components/loader";
|
||||
import updateEmail from "@/lib/api/client/app/emails/updateEmail";
|
||||
import warmupLifecycle from "@/lib/api/client/app/emails/warmupLifecycle";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
|
||||
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
|
||||
// Bulk warmup-start dialog. Lets the user optionally apply one set of warmup
|
||||
// settings to every selected mailbox before starting — useful when onboarding a
|
||||
// batch of new inboxes that should all ramp the same way.
|
||||
export default function BulkWarmupDialog({
|
||||
open,
|
||||
ids,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: {
|
||||
open: boolean;
|
||||
ids: string[];
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [customize, setCustomize] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [base, setBase] = useState(10);
|
||||
const [increase, setIncrease] = useState(1);
|
||||
const [max, setMax] = useState(40);
|
||||
const [replyRate, setReplyRate] = useState(30);
|
||||
const [startTime, setStartTime] = useState("08:00");
|
||||
const [endTime, setEndTime] = useState("20:00");
|
||||
const [days, setDays] = useState(0);
|
||||
|
||||
const n = ids.length;
|
||||
const baseOverMax = base > max;
|
||||
|
||||
const start = async () => {
|
||||
if (busy || n === 0) return;
|
||||
if (customize && baseOverMax) {
|
||||
toast.error("Starting volume can't exceed the maximum.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const patch: Partial<Inbox> = {
|
||||
warmup_base: base,
|
||||
warmup_increase: increase,
|
||||
warmup_max: max,
|
||||
warmup_reply_rate: replyRate,
|
||||
warmup_start_time: startTime,
|
||||
warmup_end_time: endTime,
|
||||
warmup_days: days,
|
||||
};
|
||||
// Apply settings (if customizing) then start, per mailbox. allSettled so
|
||||
// one failure doesn't abort the rest.
|
||||
const results = await Promise.allSettled(
|
||||
ids.map(async (id) => {
|
||||
if (customize) await updateEmail(id, patch);
|
||||
await warmupLifecycle(id, "start");
|
||||
}),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
await queryClient.invalidateQueries({ queryKey: ["emails"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["analytics", "accounts"] });
|
||||
setBusy(false);
|
||||
if (failed > 0) {
|
||||
toast.error(`${failed} of ${n} mailbox${n > 1 ? "es" : ""} couldn't start`);
|
||||
} else {
|
||||
toast.success(`Warmup started for ${n} mailbox${n > 1 ? "es" : ""}`);
|
||||
}
|
||||
onComplete();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed inset-0 z-50 bg-slate-900/40"
|
||||
onClick={busy ? undefined : onClose}
|
||||
/>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="pointer-events-auto w-full max-w-[440px] max-h-[88vh] overflow-y-auto rounded-lg border border-slate-200 bg-white shadow-[0_24px_60px_-12px_rgba(15,23,42,0.35)]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-5 h-14 flex items-center gap-3 border-b border-slate-200">
|
||||
<div className="w-8 h-8 rounded-lg bg-orange-50 text-orange-600 flex items-center justify-center shrink-0">
|
||||
<FlameIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-medium text-slate-900">Start warmup</div>
|
||||
<div className="text-[11px] text-slate-400">
|
||||
{n} mailbox{n > 1 ? "es" : ""} selected
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label="Close"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<Toggle on={customize} onChange={setCustomize} />
|
||||
<span className="text-[12.5px] text-slate-700">
|
||||
Apply these settings to all selected mailboxes
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className={customize ? "space-y-4" : "space-y-4 opacity-40 pointer-events-none"}>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>Start</Label>
|
||||
<NumberInput value={base} min={1} max={500} onChange={setBase} suffix="/day" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>+ / day</Label>
|
||||
<NumberInput value={increase} min={0} max={100} onChange={setIncrease} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Max</Label>
|
||||
<NumberInput value={max} min={1} max={500} onChange={setMax} suffix="/day" />
|
||||
</div>
|
||||
</div>
|
||||
{baseOverMax && (
|
||||
<p className="text-[11px] text-rose-500 -mt-1.5">
|
||||
Starting volume can't exceed the maximum.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<Label>Reply rate</Label>
|
||||
<NumberInput value={replyRate} min={0} max={100} onChange={setReplyRate} suffix="%" className="w-32" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sending window</Label>
|
||||
<div className="grid grid-cols-2 gap-3 max-w-[320px]">
|
||||
<TimeSelect value={startTime} onChange={setStartTime} />
|
||||
<TimeSelect value={endTime} onChange={setEndTime} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sending days</Label>
|
||||
<WeekdayBitmask weekdays={WEEKDAYS} value={days} setValue={setDays} />
|
||||
<p className="text-[11px] text-slate-400 mt-1.5">
|
||||
Leave all unselected to send every day.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 h-14 flex items-center justify-end gap-2 border-t border-slate-200 bg-slate-50/60">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="h-8 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] text-slate-700 hover:text-slate-900 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={busy || n === 0}
|
||||
className="h-8 px-3.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loading className="!w-3.5 h-3.5 text-white" />}
|
||||
Start warmup
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
onClick={() => onChange(!on)}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${on ? "bg-sky-600" : "bg-slate-200"}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${on ? "translate-x-4" : ""}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -51,10 +51,11 @@ import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import EmailEditor from "../EmailEditor";
|
||||
import TagSelector from "../popup/select/TagSelector";
|
||||
import TimeSelector from "../popup/select/TimeSelector";
|
||||
import TimeSelect from "@/components/ui/TimeSelect";
|
||||
import WeekdayBitmask from "../campaigns/schedule/WeekdayBitmask";
|
||||
import { Loading } from "@/components/loader";
|
||||
import { NumberInput, TextInput } from "@/components/ui/field";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/* ── small themed primitives ─────────────────────── */
|
||||
@@ -660,6 +661,7 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form:
|
||||
// read live state off the mailbox prop, which the lifecycle mutation patches
|
||||
// back into cache on success.
|
||||
const life = useWarmupLifecycle(mailbox.id);
|
||||
const confirm = useConfirm();
|
||||
const off = !mailbox.warmup;
|
||||
const paused = !!mailbox.warmup && !!mailbox.warmup_paused_at;
|
||||
const active = !!mailbox.warmup && !mailbox.warmup_paused_at;
|
||||
@@ -671,8 +673,17 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form:
|
||||
});
|
||||
|
||||
const stopReset = () => {
|
||||
if (!window.confirm("Stop warmup and reset ramp progress? Restarting begins from the base volume. Use Pause to keep progress.")) return;
|
||||
run("stop", "stopped");
|
||||
confirm.show(
|
||||
"Stop warmup and reset ramp progress? Restarting begins from the base volume. Use Pause to keep progress.",
|
||||
async () => {
|
||||
try {
|
||||
await life.mutateAsync("stop");
|
||||
toast.success("Warmup stopped");
|
||||
} catch (e) {
|
||||
toast.error(buildError(e as unknown as AppError));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const baseOverMax = form.warmup_base > form.warmup_max;
|
||||
@@ -825,14 +836,14 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form:
|
||||
<Eyebrow>Sending window</Eyebrow>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FieldShell label="Start time">
|
||||
<TimeSelector value={form.warmup_start_time || "08:00"} onChange={(v) => update({ warmup_start_time: v })} />
|
||||
<TimeSelect value={form.warmup_start_time || "08:00"} onChange={(v) => update({ warmup_start_time: v })} />
|
||||
</FieldShell>
|
||||
<FieldShell label="End time">
|
||||
<TimeSelector value={form.warmup_end_time || "20:00"} onChange={(v) => update({ warmup_end_time: v })} />
|
||||
<TimeSelect value={form.warmup_end_time || "20:00"} onChange={(v) => update({ warmup_end_time: v })} />
|
||||
</FieldShell>
|
||||
</div>
|
||||
<FieldShell label="Sending days" hint="Days warmup mail goes out. Leave all unchecked to send every day.">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 mt-1">
|
||||
<FieldShell label="Sending days" hint="Days warmup mail goes out. Leave all unselected to send every day.">
|
||||
<div className="mt-1">
|
||||
<WeekdayBitmask
|
||||
weekdays={WEEKDAYS}
|
||||
value={form.warmup_days ?? 0}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ClockIcon } from "lucide-react";
|
||||
import {
|
||||
PopoverMenu,
|
||||
PopoverMenuContent,
|
||||
PopoverMenuItem,
|
||||
PopoverMenuTrigger,
|
||||
SelectButton,
|
||||
} from "@/components/ui/popover-menu";
|
||||
import { timeOptions, to12Hour } from "@/lib/core/time";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Themed 30-minute time picker. Portal-based (PopoverMenu) so it renders above
|
||||
// drawers/modals. Shared by the campaign schedule and the warmup settings.
|
||||
export default function TimeSelect({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<PopoverMenu>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<SelectButton
|
||||
icon={<ClockIcon className="w-3.5 h-3.5" />}
|
||||
label={to12Hour(value)}
|
||||
className={cn("w-full justify-between", className)}
|
||||
/>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={160} className="max-h-64 overflow-y-auto">
|
||||
{timeOptions.map((t) => (
|
||||
<PopoverMenuItem
|
||||
key={t.value}
|
||||
selected={t.value === value}
|
||||
onSelect={() => onChange(t.value)}
|
||||
>
|
||||
{t.name}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user