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);
|
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 =
|
||||||
| { ok: true; reminderId: string }
|
| { ok: true; reminderId: string }
|
||||||
@ -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({
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 };
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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 & 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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user