yiekheng e38b9ac7b6 feat(web): RunEtaPill at the wizard review step
Renders an advisory ETA badge above the Schedule button:
* Green "Fits before deadline" when the projected finish lands
  before the chosen deadline hour.
* Amber "Likely to pause" with a "Push the deadline later or split
  into smaller runs" hint when it doesn't.

Pill is purely informational — the operator can still schedule a
run that's likely to pause; the pause/resume flow (Phase 3) covers
that case. The pill just removes the surprise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:11:53 +08:00

126 lines
3.7 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createReminderAction, updateReminderAction } from "@/actions/reminders";
import { cn } from "@/lib/utils";
import type { MessagePart } from "@/lib/reminder-messages";
import { RunEtaPill } from "./run-eta-pill";
import { windowEndAt } from "@cmbot/shared";
interface ReviewSubmitClientProps {
accountId: string;
groupIds?: string;
name?: string;
messages: MessagePart[];
scheduledAt: string;
rrule?: string;
editReminderId?: string;
timezone: string;
deliveryEndHour?: number;
}
export function ReviewSubmitClient({
accountId,
groupIds,
name,
messages,
scheduledAt,
rrule,
editReminderId,
timezone,
deliveryEndHour,
}: ReviewSubmitClientProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSchedule() {
const trimmedName = name?.trim();
if (!trimmedName) {
// The wizard's compose step now blocks Continue when the name is
// blank, so the only way to land here without one is a stale
// bookmarked URL. Bounce the operator back to step 2 with a
// clear error rather than letting the server reject it.
setError("Give the reminder a name (back on the Message step).");
return;
}
setSubmitting(true);
setError(null);
try {
const payload = {
accountId,
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
name: trimmedName,
messages,
scheduledAtIso: scheduledAt,
rrule: rrule ?? null,
timezone,
deliveryWindowEndHour: deliveryEndHour,
};
const result = editReminderId
? await updateReminderAction({ ...payload, reminderId: editReminderId })
: await createReminderAction(payload);
if (result.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/${result.reminderId}` as any);
} else {
setError(result.error);
setSubmitting(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : "An unexpected error occurred.");
setSubmitting(false);
}
}
const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0;
const fireAt = new Date(scheduledAt);
const endHour = deliveryEndHour ?? 18;
const wEnd = windowEndAt(timezone, endHour, fireAt);
return (
<div className="space-y-3 pt-2">
<RunEtaPill
targetCount={groupCount}
fireAt={fireAt}
windowEndAt={wEnd}
timezone={timezone}
/>
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
<AlertCircleIcon className="size-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
size="lg"
onClick={handleSchedule}
disabled={submitting}
className={cn("gap-2", submitting && "cursor-wait")}
>
{submitting ? (
<>
<Loader2Icon className="size-4 animate-spin" />
{editReminderId ? "Saving…" : "Scheduling…"}
</>
) : (
<>
<CalendarCheckIcon className="size-4" />
{editReminderId ? "Save changes" : "Schedule Reminder"}
</>
)}
</Button>
</div>
</div>
);
}