fix(web): reminder wizard date/time picker, reorder, optional groups

- Fix "Invalid datetime" error: createReminderAction's Zod schema rejected
  offset-suffixed ISO strings (luxon's `toISO()` produces +08:00 form).
  Switched to `.datetime({ offset: true })`.

- Replace the single datetime-local input with separate native date + time
  inputs (proper UI pickers on both desktop and mobile). Default value is
  now computed server-side ("now + 1h") and passed in as a prop, so first
  render is fully populated and there's no SSR/client hydration mismatch
  from `Date.now()` inside the client component. Removed the quick-pick
  shortcuts.

- Reorder wizard steps: Account → Compose → When → Groups → Review.
  Groups is now the last and optional step (Continue button reads
  "Skip groups" when empty); the action accepts an empty array and
  inserts no reminder_targets in that case.

- Account list: card is the link target. Removed inline Pair / Open /
  Delete quick-action buttons; lifecycle actions stay on the detail page.

- Account detail: removed the "Sync Groups Now" card. The bot already
  auto-syncs on `groups.upsert` / `groups.update` events. The Groups card
  itself is now a clickable link instead of carrying an inline View
  button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 00:45:19 +08:00
parent 2ef64c9192
commit 86f2fe0124
14 changed files with 208 additions and 349 deletions

View File

@ -45,15 +45,22 @@ export async function deleteReminderAction(formData: FormData): Promise<void> {
redirect("/reminders" as any);
}
const createReminderSchema = z.object({
const createReminderSchema = z
.object({
accountId: z.string().uuid(),
groupIds: z.array(z.string().uuid()).min(1, "Pick at least one group"),
groupIds: z.array(z.string().uuid()),
text: z.string().nullable().optional(),
mediaId: z.string().uuid().nullable().optional(),
caption: z.string().nullable().optional(),
scheduledAtIso: z.string().datetime(),
// `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets
// like `+08:00` (luxon's `toISO()` produces the offset form).
scheduledAtIso: z.string().datetime({ offset: true }),
timezone: z.string().default(DEFAULT_TIMEZONE),
});
})
.refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), {
message: "Add a message or attach a file",
path: ["text"],
});
export type CreateReminderResult =
| { ok: true; reminderId: string }
@ -105,9 +112,11 @@ export async function createReminderAction(
})
.returning({ id: reminders.id });
if (groupIds.length > 0) {
await tx.insert(reminderTargets).values(
groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })),
);
}
if (text && !mediaId) {
await tx.insert(reminderMessages).values({

View File

@ -2,7 +2,6 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import {
UsersIcon,
RefreshCwIcon,
Trash2Icon,
ArrowLeftIcon,
SmartphoneIcon,
@ -33,7 +32,6 @@ import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
import {
syncGroupsAction,
unpairAccountAction,
pairAccountAction,
deleteAccountAction,
@ -110,7 +108,12 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
{/* Groups + Sync — visible when connected */}
{account.status === "connected" && (
<>
<Card>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}/groups` as any}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
>
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
@ -121,35 +124,9 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}/groups` as any}>View</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<RefreshCwIcon className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Sync Groups Now</p>
<p className="text-xs text-muted-foreground">
Fetch latest groups from WhatsApp
</p>
</div>
</div>
<form action={syncGroupsAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="outline" size="sm">
<RefreshCwIcon />
Sync
</Button>
</form>
</CardContent>
</Card>
</Link>
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">

View File

@ -1,5 +1,5 @@
import Link from "next/link";
import { PlusIcon, SmartphoneIcon, CalendarIcon, PowerIcon, Trash2Icon } from "lucide-react";
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
@ -7,19 +7,9 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator";
import { listAccounts } from "@/lib/queries";
import { pairAccountAction, deleteAccountAction } from "@/actions/accounts";
export default async function AccountsPage() {
const op = await getSeededOperator();
@ -27,7 +17,6 @@ export default async function AccountsPage() {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Accounts</h1>
<Button asChild size="sm">
@ -39,26 +28,23 @@ export default async function AccountsPage() {
</Button>
</div>
{/* Account cards */}
{accounts.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{accounts.map((account) => (
<Card
<Link
key={account.id}
className="h-full transition-shadow hover:shadow-md hover:ring-foreground/20"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}` as any}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
>
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}` as any} className="hover:underline">
{account.label}
</Link>
</CardTitle>
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
<AccountStatusBadge status={account.status} />
</div>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-2">
{account.phoneNumber ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
@ -81,54 +67,9 @@ export default async function AccountsPage() {
</span>
</div>
) : null}
{/* Quick actions */}
<div className="flex items-center gap-2 pt-1">
{account.status !== "connected" && (
<form action={pairAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" size="sm" variant="default">
<PowerIcon />
{account.status === "unpaired" ? "Pair" : "Re-pair"}
</Button>
</form>
)}
<Button asChild size="sm" variant="outline">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}` as any}>Open</Link>
</Button>
<Dialog>
<DialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive ml-auto"
>
<Trash2Icon />
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> and all its reminders, groups,
and history will be permanently removed. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={deleteAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (

View File

@ -29,9 +29,9 @@ export default async function NewReminderPage({ searchParams }: PageProps) {
<h1 className="text-2xl font-semibold tracking-tight">New Reminder</h1>
<Stepper current={step} />
{step === 1 && <StepAccount />}
{step === 2 && <StepGroups params={sp} />}
{step === 3 && <StepCompose params={sp} />}
{step === 4 && <StepWhen params={sp} />}
{step === 2 && <StepCompose params={sp} />}
{step === 3 && <StepWhen params={sp} />}
{step === 4 && <StepGroups params={sp} />}
{step === 5 && <StepReview params={sp} />}
</div>
);

View File

@ -110,10 +110,10 @@ export function ComposeFormClient({
return;
}
const sp = new URLSearchParams({
step: "4",
step: "3",
accountId,
groupIds,
});
if (groupIds) sp.set("groupIds", groupIds);
if (text.trim()) sp.set("text", text.trim());
if (mediaId) sp.set("mediaId", mediaId);
if (caption.trim()) sp.set("caption", caption.trim());

View File

@ -59,15 +59,13 @@ export function GroupsFormClient({
}
function handleContinue() {
if (selected.size === 0) {
setError("Select at least one group.");
return;
}
const sp = new URLSearchParams({
step: "3",
step: "5",
accountId,
groupIds: Array.from(selected).join(","),
});
if (selected.size > 0) {
sp.set("groupIds", Array.from(selected).join(","));
}
if (passThroughParams.text) sp.set("text", passThroughParams.text);
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
@ -175,7 +173,7 @@ export function GroupsFormClient({
{/* Continue */}
<div className="flex justify-end pt-1">
<Button type="button" onClick={handleContinue}>
Continue
{selected.size === 0 ? "Skip groups" : "Continue"}
</Button>
</div>
</div>

View File

@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
interface ReviewSubmitClientProps {
accountId: string;
groupIds: string;
groupIds?: string;
text?: string;
mediaId?: string;
caption?: string;
@ -37,7 +37,7 @@ export function ReviewSubmitClient({
try {
const result = await createReminderAction({
accountId,
groupIds: groupIds.split(",").filter(Boolean),
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
text: text ?? null,
mediaId: mediaId ?? null,
caption: caption ?? null,

View File

@ -46,6 +46,7 @@ export async function StepAccount() {
key={account.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/new?step=2&accountId=${account.id}` as any}
// step 2 is now "Compose"; "Groups" moved to last (optional) step
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card

View File

@ -21,12 +21,12 @@ interface StepComposeProps {
export function StepCompose({ params }: StepComposeProps) {
const { accountId, groupIds, text, mediaId, caption } = params;
if (!accountId || !groupIds) {
if (!accountId) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
const backHref = `/reminders/new?step=2&accountId=${accountId}&groupIds=${groupIds}` as const;
const backHref = `/reminders/new?step=1` as const;
return (
<div className="space-y-4">
@ -46,7 +46,7 @@ export function StepCompose({ params }: StepComposeProps) {
<ComposeFormClient
accountId={accountId}
groupIds={groupIds}
groupIds={groupIds ?? ""}
initialText={text ?? ""}
initialMediaId={mediaId}
initialCaption={caption}

View File

@ -1,11 +1,10 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeftIcon, UsersIcon, SearchIcon } from "lucide-react";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries";
import { cn } from "@/lib/utils";
interface StepGroupsParams {
step?: string;
@ -23,9 +22,9 @@ interface StepGroupsProps {
}
export async function StepGroups({ params }: StepGroupsProps) {
const { accountId, groupIds: groupIdsParam, groupId: singleGroupId } = params;
const { accountId, groupIds: groupIdsParam, groupId: singleGroupId, scheduledAt, text, mediaId } = params;
if (!accountId) {
if (!accountId || !scheduledAt || (!text && !mediaId)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
@ -60,40 +59,12 @@ export async function StepGroups({ params }: StepGroupsProps) {
? [singleGroupId]
: [];
const backHref = `/reminders/new?step=1` as const;
// Build base URL params to carry forward
const baseParams = new URLSearchParams({
step: "3",
accountId,
});
if (groups.length === 0) {
return (
<div className="space-y-4">
<div>
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={backHref as any}>
<ArrowLeftIcon />
Back
</Link>
</Button>
</div>
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<UsersIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">No groups found.</p>
<p className="text-xs text-muted-foreground">
Sync groups from this account first.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
const backParams = new URLSearchParams({ step: "3", accountId });
if (text) backParams.set("text", text);
if (mediaId) backParams.set("mediaId", mediaId);
if (params.caption) backParams.set("caption", params.caption);
if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
const backHref = `/reminders/new?${backParams.toString()}`;
return (
<div className="space-y-4">
@ -108,13 +79,13 @@ export async function StepGroups({ params }: StepGroupsProps) {
</div>
<p className="text-sm text-muted-foreground">
Select one or more groups to send this reminder to.
Pick which groups to send this reminder to. You can also leave it empty
and add targets later.
</p>
<StepGroupsForm
groups={groups}
preSelected={preSelected}
baseParams={baseParams}
accountId={accountId}
passThroughParams={{
text: params.text,
@ -140,13 +111,11 @@ interface PassThroughParams {
function StepGroupsForm({
groups,
preSelected,
baseParams: _baseParams,
accountId,
passThroughParams,
}: {
groups: Array<{ id: string; name: string; participantCount: number; isArchived: boolean }>;
preSelected: string[];
baseParams: URLSearchParams;
accountId: string;
passThroughParams: PassThroughParams;
}) {
@ -159,5 +128,3 @@ function StepGroupsForm({
/>
);
}
export { cn };

View File

@ -53,7 +53,7 @@ function editLink(
export async function StepReview({ params }: StepReviewProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params;
if (!accountId || !groupIds || !scheduledAt) {
if (!accountId || !scheduledAt || (!text && !mediaId)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
@ -69,14 +69,16 @@ export async function StepReview({ params }: StepReviewProps) {
}
// Fetch group names
const groupIdsArray = groupIds.split(",").filter(Boolean);
const groupsResult = await listGroupsForAccount(op.id, accountId);
const groupIdsArray = groupIds ? groupIds.split(",").filter(Boolean) : [];
const groupsResult =
groupIdsArray.length > 0 ? await listGroupsForAccount(op.id, accountId) : null;
const selectedGroups = groupsResult
? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id))
: [];
const formattedDate = formatScheduledAt(scheduledAt, timezone);
// Back goes to step 4 (Groups, the previous step in the new order)
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt);
return (
@ -108,42 +110,11 @@ export async function StepReview({ params }: StepReviewProps) {
)}
</ReviewRow>
{/* Groups */}
<ReviewRow
icon={<UsersIcon className="size-4" />}
label="Groups"
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt)}
>
{selectedGroups.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedGroups.map((g) => (
<span
key={g.id}
className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{g.name}
</span>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">{groupIds}</span>
)}
</ReviewRow>
{/* When */}
<ReviewRow
icon={<CalendarIcon className="size-4" />}
label="When"
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt)}
>
<span className="text-sm font-medium">{formattedDate}</span>
</ReviewRow>
{/* Message */}
<ReviewRow
icon={<FileTextIcon className="size-4" />}
label="Message"
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt)}
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt)}
>
{mediaId ? (
<span className="text-sm text-muted-foreground">
@ -161,6 +132,39 @@ export async function StepReview({ params }: StepReviewProps) {
<span className="text-sm text-muted-foreground italic">No message</span>
)}
</ReviewRow>
{/* When */}
<ReviewRow
icon={<CalendarIcon className="size-4" />}
label="When"
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt)}
>
<span className="text-sm font-medium">{formattedDate}</span>
</ReviewRow>
{/* Groups */}
<ReviewRow
icon={<UsersIcon className="size-4" />}
label="Groups"
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt)}
>
{selectedGroups.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedGroups.map((g) => (
<span
key={g.id}
className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{g.name}
</span>
))}
</div>
) : (
<span className="text-sm text-muted-foreground italic">
No groups reminder will be saved without targets
</span>
)}
</ReviewRow>
</div>
<ReviewSubmitClient

View File

@ -1,6 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { DateTime } from "luxon";
import { Button } from "@/components/ui/button";
import { getSeededOperator } from "@/lib/operator";
import { WhenFormClient } from "./when-form-client";
@ -22,7 +23,7 @@ interface StepWhenProps {
export async function StepWhen({ params }: StepWhenProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params;
if (!accountId || !groupIds) {
if (!accountId || (!text && !mediaId)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
@ -30,7 +31,12 @@ export async function StepWhen({ params }: StepWhenProps) {
const op = await getSeededOperator();
const timezone = op.defaultTimezone ?? "UTC";
const backHref = `/reminders/new?step=3&accountId=${accountId}&groupIds=${groupIds}${text ? `&text=${encodeURIComponent(text)}` : ""}${mediaId ? `&mediaId=${mediaId}` : ""}${caption ? `&caption=${encodeURIComponent(caption)}` : ""}` as const;
const backParams = new URLSearchParams({ step: "2", accountId });
if (groupIds) backParams.set("groupIds", groupIds);
if (text) backParams.set("text", text);
if (mediaId) backParams.set("mediaId", mediaId);
if (caption) backParams.set("caption", caption);
const backHref = `/reminders/new?${backParams.toString()}`;
return (
<div className="space-y-4">
@ -51,9 +57,12 @@ export async function StepWhen({ params }: StepWhenProps) {
<WhenFormClient
accountId={accountId}
groupIds={groupIds}
groupIds={groupIds ?? ""}
timezone={timezone}
initialScheduledAt={scheduledAt}
initialDefaultIso={
scheduledAt ??
DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()!
}
passThroughParams={{ text, mediaId, caption }}
/>
</div>

View File

@ -2,9 +2,9 @@ import { cn } from "@/lib/utils";
const STEPS = [
{ n: 1, label: "Account" },
{ n: 2, label: "Groups" },
{ n: 3, label: "Compose" },
{ n: 4, label: "When" },
{ n: 2, label: "Compose" },
{ n: 3, label: "When" },
{ n: 4, label: "Groups" },
{ n: 5, label: "Review" },
];

View File

@ -3,10 +3,10 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { DateTime } from "luxon";
import { ClockIcon, AlertCircleIcon } from "lucide-react";
import { CalendarIcon, ClockIcon, AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface PassThroughParams {
text?: string;
@ -18,73 +18,37 @@ interface WhenFormClientProps {
accountId: string;
groupIds: string;
timezone: string;
initialScheduledAt?: string;
/** Pre-computed default ISO from the server — guarantees no hydration drift. */
initialDefaultIso: string;
passThroughParams: PassThroughParams;
}
/** Format a DateTime as "YYYY-MM-DDTHH:mm" for datetime-local input value */
function toLocalInputValue(dt: DateTime): string {
return dt.toFormat("yyyy-MM-dd'T'HH:mm");
function splitDateTime(iso: string, tz: string): { date: string; time: string } {
const dt = DateTime.fromISO(iso, { zone: tz });
if (!dt.isValid) return { date: "", time: "" };
return { date: dt.toFormat("yyyy-MM-dd"), time: dt.toFormat("HH:mm") };
}
/** Get the default value: now + 1 hour in the operator timezone */
function getDefaultValue(timezone: string, initialScheduledAt?: string): string {
if (initialScheduledAt) {
try {
const dt = DateTime.fromISO(initialScheduledAt, { zone: timezone });
if (dt.isValid) return toLocalInputValue(dt);
} catch {
// fall through to default
}
}
const dt = DateTime.now().setZone(timezone).plus({ hours: 1 });
return toLocalInputValue(dt);
}
const QUICK_PICKS = [
{ label: "Now", getDate: (tz: string) => DateTime.now().setZone(tz).plus({ minutes: 5 }) },
{
label: "Tomorrow 9 AM",
getDate: (tz: string) =>
DateTime.now().setZone(tz).plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0 }),
},
{
label: "Next Mon 9 AM",
getDate: (tz: string) => {
const now = DateTime.now().setZone(tz);
// 1 = Monday in Luxon ISO weekday
const daysUntilMonday = ((8 - now.weekday) % 7) || 7;
return now.plus({ days: daysUntilMonday }).set({ hour: 9, minute: 0, second: 0 });
},
},
];
export function WhenFormClient({
accountId,
groupIds,
timezone,
initialScheduledAt,
initialDefaultIso,
passThroughParams,
}: WhenFormClientProps) {
const router = useRouter();
const [localValue, setLocalValue] = useState(() =>
getDefaultValue(timezone, initialScheduledAt)
);
const initial = splitDateTime(initialDefaultIso, timezone);
const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time);
const [error, setError] = useState<string | null>(null);
function applyQuickPick(getDate: (tz: string) => DateTime) {
const dt = getDate(timezone);
setLocalValue(toLocalInputValue(dt));
setError(null);
}
function handleContinue() {
if (!localValue) {
setError("Please select a date and time.");
if (!date || !time) {
setError("Pick both a date and a time.");
return;
}
// Parse the local value with the operator timezone and convert to ISO
const dt = DateTime.fromISO(localValue, { zone: timezone });
const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
if (!dt.isValid) {
setError("Invalid date or time.");
return;
@ -95,11 +59,11 @@ export function WhenFormClient({
}
const scheduledAt = dt.toISO()!;
const sp = new URLSearchParams({
step: "5",
step: "4",
accountId,
groupIds,
scheduledAt,
});
if (groupIds) sp.set("groupIds", groupIds);
if (passThroughParams.text) sp.set("text", passThroughParams.text);
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
@ -109,50 +73,41 @@ export function WhenFormClient({
return (
<div className="space-y-5">
{/* Date time input */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="scheduled-at" className="flex items-center gap-1.5">
<ClockIcon className="size-3.5" />
Date &amp; time
<Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
<CalendarIcon className="size-3.5" />
Date
</Label>
<input
id="scheduled-at"
type="datetime-local"
value={localValue}
<Input
id="scheduled-date"
type="date"
value={date}
onChange={(e) => {
setLocalValue(e.target.value);
setDate(e.target.value);
setError(null);
}}
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50",
"md:text-sm dark:bg-input/30"
)}
className="h-9"
/>
</div>
{/* Quick picks */}
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground font-medium">Quick picks</p>
<div className="flex flex-wrap gap-2">
{QUICK_PICKS.map(({ label, getDate }) => (
<button
key={label}
type="button"
onClick={() => applyQuickPick(getDate)}
className={cn(
"inline-flex items-center rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-medium",
"hover:border-primary/50 hover:bg-primary/5 hover:text-primary transition-colors"
)}
>
{label}
</button>
))}
<Label htmlFor="scheduled-time" className="flex items-center gap-1.5">
<ClockIcon className="size-3.5" />
Time
</Label>
<Input
id="scheduled-time"
type="time"
value={time}
onChange={(e) => {
setTime(e.target.value);
setError(null);
}}
className="h-9"
/>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" />
@ -160,12 +115,10 @@ export function WhenFormClient({
</div>
)}
{/* Timezone reminder */}
<p className="text-xs text-muted-foreground">
All times are interpreted as <span className="font-medium">{timezone}</span>.
Times are in <span className="font-medium">{timezone}</span>.
</p>
{/* Continue */}
<div className="flex justify-end pt-1">
<Button type="button" onClick={handleContinue}>
Continue