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,14 +45,21 @@ export async function deleteReminderAction(formData: FormData): Promise<void> {
redirect("/reminders" as any); redirect("/reminders" as any);
} }
const createReminderSchema = z.object({ const createReminderSchema = z
.object({
accountId: z.string().uuid(), 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(), text: z.string().nullable().optional(),
mediaId: z.string().uuid().nullable().optional(), mediaId: z.string().uuid().nullable().optional(),
caption: z.string().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), 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 = export type CreateReminderResult =
@ -105,9 +112,11 @@ export async function createReminderAction(
}) })
.returning({ id: reminders.id }); .returning({ id: reminders.id });
if (groupIds.length > 0) {
await tx.insert(reminderTargets).values( await tx.insert(reminderTargets).values(
groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })), groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })),
); );
}
if (text && !mediaId) { if (text && !mediaId) {
await tx.insert(reminderMessages).values({ await tx.insert(reminderMessages).values({

View File

@ -2,7 +2,6 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
UsersIcon, UsersIcon,
RefreshCwIcon,
Trash2Icon, Trash2Icon,
ArrowLeftIcon, ArrowLeftIcon,
SmartphoneIcon, SmartphoneIcon,
@ -33,7 +32,6 @@ import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries"; import { getAccount } from "@/lib/queries";
import { import {
syncGroupsAction,
unpairAccountAction, unpairAccountAction,
pairAccountAction, pairAccountAction,
deleteAccountAction, deleteAccountAction,
@ -110,7 +108,12 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
{/* Groups + Sync — visible when connected */} {/* Groups + Sync — visible when connected */}
{account.status === "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"> <CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted"> <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> <p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
</div> </div>
</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> </CardContent>
</Card> </Card>
</Link>
<Card> <Card>
<CardContent className="flex items-center justify-between gap-4 py-4"> <CardContent className="flex items-center justify-between gap-4 py-4">

View File

@ -1,5 +1,5 @@
import Link from "next/link"; 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 { Button } from "@/components/ui/button";
import { import {
Card, Card,
@ -7,19 +7,9 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AccountStatusBadge } from "@/components/account-status-badge"; import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listAccounts } from "@/lib/queries"; import { listAccounts } from "@/lib/queries";
import { pairAccountAction, deleteAccountAction } from "@/actions/accounts";
export default async function AccountsPage() { export default async function AccountsPage() {
const op = await getSeededOperator(); const op = await getSeededOperator();
@ -27,7 +17,6 @@ export default async function AccountsPage() {
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6"> <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"> <div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Accounts</h1> <h1 className="text-2xl font-semibold tracking-tight">Accounts</h1>
<Button asChild size="sm"> <Button asChild size="sm">
@ -39,26 +28,23 @@ export default async function AccountsPage() {
</Button> </Button>
</div> </div>
{/* Account cards */}
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{accounts.map((account) => ( {accounts.map((account) => (
<Card <Link
key={account.id} 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> <CardHeader>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug"> <CardTitle className="text-base leading-snug">{account.label}</CardTitle>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}` as any} className="hover:underline">
{account.label}
</Link>
</CardTitle>
<AccountStatusBadge status={account.status} /> <AccountStatusBadge status={account.status} />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-2">
{account.phoneNumber ? ( {account.phoneNumber ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" /> <SmartphoneIcon className="size-3.5 shrink-0" />
@ -81,54 +67,9 @@ export default async function AccountsPage() {
</span> </span>
</div> </div>
) : null} ) : 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> </CardContent>
</Card> </Card>
</Link>
))} ))}
</div> </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> <h1 className="text-2xl font-semibold tracking-tight">New Reminder</h1>
<Stepper current={step} /> <Stepper current={step} />
{step === 1 && <StepAccount />} {step === 1 && <StepAccount />}
{step === 2 && <StepGroups params={sp} />} {step === 2 && <StepCompose params={sp} />}
{step === 3 && <StepCompose params={sp} />} {step === 3 && <StepWhen params={sp} />}
{step === 4 && <StepWhen params={sp} />} {step === 4 && <StepGroups params={sp} />}
{step === 5 && <StepReview params={sp} />} {step === 5 && <StepReview params={sp} />}
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ArrowLeftIcon, UsersIcon, SearchIcon } from "lucide-react"; import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries"; import { listGroupsForAccount } from "@/lib/queries";
import { cn } from "@/lib/utils";
interface StepGroupsParams { interface StepGroupsParams {
step?: string; step?: string;
@ -23,9 +22,9 @@ interface StepGroupsProps {
} }
export async function StepGroups({ params }: 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any); redirect("/reminders/new" as any);
} }
@ -60,40 +59,12 @@ export async function StepGroups({ params }: StepGroupsProps) {
? [singleGroupId] ? [singleGroupId]
: []; : [];
const backHref = `/reminders/new?step=1` as const; const backParams = new URLSearchParams({ step: "3", accountId });
if (text) backParams.set("text", text);
// Build base URL params to carry forward if (mediaId) backParams.set("mediaId", mediaId);
const baseParams = new URLSearchParams({ if (params.caption) backParams.set("caption", params.caption);
step: "3", if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
accountId, const backHref = `/reminders/new?${backParams.toString()}`;
});
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>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -108,13 +79,13 @@ export async function StepGroups({ params }: StepGroupsProps) {
</div> </div>
<p className="text-sm text-muted-foreground"> <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> </p>
<StepGroupsForm <StepGroupsForm
groups={groups} groups={groups}
preSelected={preSelected} preSelected={preSelected}
baseParams={baseParams}
accountId={accountId} accountId={accountId}
passThroughParams={{ passThroughParams={{
text: params.text, text: params.text,
@ -140,13 +111,11 @@ interface PassThroughParams {
function StepGroupsForm({ function StepGroupsForm({
groups, groups,
preSelected, preSelected,
baseParams: _baseParams,
accountId, accountId,
passThroughParams, passThroughParams,
}: { }: {
groups: Array<{ id: string; name: string; participantCount: number; isArchived: boolean }>; groups: Array<{ id: string; name: string; participantCount: number; isArchived: boolean }>;
preSelected: string[]; preSelected: string[];
baseParams: URLSearchParams;
accountId: string; accountId: string;
passThroughParams: PassThroughParams; passThroughParams: PassThroughParams;
}) { }) {
@ -159,5 +128,3 @@ function StepGroupsForm({
/> />
); );
} }
export { cn };

View File

@ -53,7 +53,7 @@ function editLink(
export async function StepReview({ params }: StepReviewProps) { export async function StepReview({ params }: StepReviewProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params; 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any); redirect("/reminders/new" as any);
} }
@ -69,14 +69,16 @@ export async function StepReview({ params }: StepReviewProps) {
} }
// Fetch group names // Fetch group names
const groupIdsArray = groupIds.split(",").filter(Boolean); const groupIdsArray = groupIds ? groupIds.split(",").filter(Boolean) : [];
const groupsResult = await listGroupsForAccount(op.id, accountId); const groupsResult =
groupIdsArray.length > 0 ? await listGroupsForAccount(op.id, accountId) : null;
const selectedGroups = groupsResult const selectedGroups = groupsResult
? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id)) ? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id))
: []; : [];
const formattedDate = formatScheduledAt(scheduledAt, timezone); 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); const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt);
return ( return (
@ -108,42 +110,11 @@ export async function StepReview({ params }: StepReviewProps) {
)} )}
</ReviewRow> </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 */} {/* Message */}
<ReviewRow <ReviewRow
icon={<FileTextIcon className="size-4" />} icon={<FileTextIcon className="size-4" />}
label="Message" label="Message"
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt)} editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt)}
> >
{mediaId ? ( {mediaId ? (
<span className="text-sm text-muted-foreground"> <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> <span className="text-sm text-muted-foreground italic">No message</span>
)} )}
</ReviewRow> </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> </div>
<ReviewSubmitClient <ReviewSubmitClient

View File

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

View File

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

View File

@ -3,10 +3,10 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { ClockIcon, AlertCircleIcon } from "lucide-react"; import { CalendarIcon, ClockIcon, AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface PassThroughParams { interface PassThroughParams {
text?: string; text?: string;
@ -18,73 +18,37 @@ interface WhenFormClientProps {
accountId: string; accountId: string;
groupIds: string; groupIds: string;
timezone: string; timezone: string;
initialScheduledAt?: string; /** Pre-computed default ISO from the server — guarantees no hydration drift. */
initialDefaultIso: string;
passThroughParams: PassThroughParams; passThroughParams: PassThroughParams;
} }
/** Format a DateTime as "YYYY-MM-DDTHH:mm" for datetime-local input value */ function splitDateTime(iso: string, tz: string): { date: string; time: string } {
function toLocalInputValue(dt: DateTime): string { const dt = DateTime.fromISO(iso, { zone: tz });
return dt.toFormat("yyyy-MM-dd'T'HH:mm"); 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({ export function WhenFormClient({
accountId, accountId,
groupIds, groupIds,
timezone, timezone,
initialScheduledAt, initialDefaultIso,
passThroughParams, passThroughParams,
}: WhenFormClientProps) { }: WhenFormClientProps) {
const router = useRouter(); const router = useRouter();
const [localValue, setLocalValue] = useState(() => const initial = splitDateTime(initialDefaultIso, timezone);
getDefaultValue(timezone, initialScheduledAt)
); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function applyQuickPick(getDate: (tz: string) => DateTime) {
const dt = getDate(timezone);
setLocalValue(toLocalInputValue(dt));
setError(null);
}
function handleContinue() { function handleContinue() {
if (!localValue) { if (!date || !time) {
setError("Please select a date and time."); setError("Pick both a date and a time.");
return; return;
} }
// Parse the local value with the operator timezone and convert to ISO const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
const dt = DateTime.fromISO(localValue, { zone: timezone });
if (!dt.isValid) { if (!dt.isValid) {
setError("Invalid date or time."); setError("Invalid date or time.");
return; return;
@ -95,11 +59,11 @@ export function WhenFormClient({
} }
const scheduledAt = dt.toISO()!; const scheduledAt = dt.toISO()!;
const sp = new URLSearchParams({ const sp = new URLSearchParams({
step: "5", step: "4",
accountId, accountId,
groupIds,
scheduledAt, scheduledAt,
}); });
if (groupIds) sp.set("groupIds", groupIds);
if (passThroughParams.text) sp.set("text", passThroughParams.text); if (passThroughParams.text) sp.set("text", passThroughParams.text);
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId); if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption); if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
@ -109,50 +73,41 @@ export function WhenFormClient({
return ( return (
<div className="space-y-5"> <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"> <div className="space-y-1.5">
<Label htmlFor="scheduled-at" className="flex items-center gap-1.5"> <Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
<ClockIcon className="size-3.5" /> <CalendarIcon className="size-3.5" />
Date &amp; time Date
</Label> </Label>
<input <Input
id="scheduled-at" id="scheduled-date"
type="datetime-local" type="date"
value={localValue} value={date}
onChange={(e) => { onChange={(e) => {
setLocalValue(e.target.value); setDate(e.target.value);
setError(null); setError(null);
}} }}
className={cn( className="h-9"
"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"
)}
/> />
</div> </div>
{/* Quick picks */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs text-muted-foreground font-medium">Quick picks</p> <Label htmlFor="scheduled-time" className="flex items-center gap-1.5">
<div className="flex flex-wrap gap-2"> <ClockIcon className="size-3.5" />
{QUICK_PICKS.map(({ label, getDate }) => ( Time
<button </Label>
key={label} <Input
type="button" id="scheduled-time"
onClick={() => applyQuickPick(getDate)} type="time"
className={cn( value={time}
"inline-flex items-center rounded-full border border-border bg-muted/50 px-3 py-1 text-xs font-medium", onChange={(e) => {
"hover:border-primary/50 hover:bg-primary/5 hover:text-primary transition-colors" setTime(e.target.value);
)} setError(null);
> }}
{label} className="h-9"
</button> />
))}
</div> </div>
</div> </div>
{/* Error */}
{error && ( {error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <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" /> <AlertCircleIcon className="size-3.5 shrink-0" />
@ -160,12 +115,10 @@ export function WhenFormClient({
</div> </div>
)} )}
{/* Timezone reminder */}
<p className="text-xs text-muted-foreground"> <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> </p>
{/* Continue */}
<div className="flex justify-end pt-1"> <div className="flex justify-end pt-1">
<Button type="button" onClick={handleContinue}> <Button type="button" onClick={handleContinue}>
Continue Continue