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:
parent
2ef64c9192
commit
86f2fe0124
@ -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({
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@ -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 & 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user