feat(web): split Add Account from Pair; add Unpair/Re-pair/Delete actions

Reshape the account lifecycle to match how operators actually want to
work the system:

- Add Account → creates a row with status='unpaired'. No QR yet; the
  operator lands on the detail page.
- Pair / Re-pair → transitions an unpaired account to status='pending'
  and opens the live QR flow. Works for first-time pair AND for re-pair
  of an account that was previously unpaired.
- Unpair → asks the bot to stop the live Baileys session and clean
  session files; sets status='unpaired' but KEEPS the row (and its
  reminders) so the operator can re-pair without retyping anything.
- Delete → permanently removes the account and cascades to its groups,
  reminders, run history.

Schema:
- whatsapp_groups.account_id and reminders.account_id now have
  ON DELETE CASCADE so deleting an account fans out cleanly.

UI:
- /accounts list shows everything except the transient 'pending' state.
- /accounts/[id] shows state-aware buttons: Pair (when unpaired/banned/
  disconnected), Sync + Unpair (when connected), Delete (always).
- /accounts/new is now an "Add Account" form (label only).

Other fixes:
- next.config.ts: allowedDevOrigins includes 192.168.0.253 +
  test/rexwa subdomains so Server Actions work across the LAN.
- packages/shared/src/rrule.ts: rrule@2.8.1 has no exports field and
  ships ESM that some bundlers can't resolve via default OR named
  import. Use createRequire to bridge — works under both NodeNext
  (bot runtime) and Turbopack (web SSR).
This commit is contained in:
yiekheng 2026-05-10 00:27:33 +08:00
parent e45bcb581a
commit 9437df74ee
24 changed files with 2723 additions and 117 deletions

View File

@ -1,7 +1,5 @@
import { eq } from "drizzle-orm";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../db.js";
import { env } from "../env.js";
import { sessionManager } from "../whatsapp/session-manager.js";
@ -9,27 +7,26 @@ import { writeAuditLog } from "../audit.js";
import { pgNotifyWeb } from "./notify.js";
import { logger } from "../logger.js";
/**
* Unpair handler: stop the live Baileys session and remove session files.
* The web's unpair action deletes the account row before notifying us,
* so we expect the row to be gone by the time we run. Audit log uses a
* null operator since the row is no longer queryable.
*/
export async function handleUnpair(accountId: string): Promise<void> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
});
if (!account) {
logger.warn({ accountId }, "unpair: account row missing");
return;
}
await sessionManager.stop(accountId);
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "logged_out", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId));
try {
await writeAuditLog(db, {
operatorId: account.operatorId,
operatorId: null,
source: "web",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: accountId,
payload: { label: account.label },
payload: {},
});
} catch (err) {
logger.warn({ err, accountId }, "unpair: audit log failed (non-fatal)");
}
await pgNotifyWeb({ type: "session.disconnected", accountId });
}

View File

@ -14,6 +14,9 @@ const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
outputFileTracingRoot: workspaceRoot,
// Allow Server Actions and dev HMR from the LAN host (phone testing).
// Tighten before exposing publicly via the reverse proxy.
allowedDevOrigins: ["192.168.0.253", "test.04080616.xyz", "rexwa.04080616.xyz"],
experimental: {
typedRoutes: true,
},

View File

@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { whatsappAccounts } from "@cmbot/db";
import { whatsappAccounts, whatsappGroups } from "@cmbot/db";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { pgNotifyBot } from "@/lib/notify";
@ -21,7 +21,7 @@ async function rateLimit(key: string) {
if (r.limited) throw new Error("Too many requests");
}
const pairSchema = z.object({
const addAccountSchema = z.object({
label: z
.string()
.trim()
@ -29,72 +29,119 @@ const pairSchema = z.object({
.max(60, "Label too long (max 60)"),
});
export type PairResult =
export type AddAccountResult =
| { ok: true; accountId: string }
| { ok: false; error: string };
export async function pairAccountAction(
/**
* Step 1 of the lifecycle: create an account row with status='unpaired'.
* No QR scan yet. Caller redirects to /accounts/[id] where the operator
* sees the account detail with a "Pair Now" button.
*/
export async function addAccountAction(
_prev: unknown,
formData: FormData,
): Promise<PairResult> {
await rateLimit("pair");
const parsed = pairSchema.safeParse({ label: formData.get("label") });
): Promise<AddAccountResult> {
await rateLimit("add-account");
const parsed = addAccountSchema.safeParse({ label: formData.get("label") });
if (!parsed.success) {
return {
ok: false,
error: parsed.error.issues[0]?.message ?? "Invalid label",
};
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid label" };
}
const op = await getSeededOperator();
const existing = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)),
where: (a, { eq, and }) => and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)),
});
if (existing && existing.status === "connected") {
if (existing) {
return {
ok: false,
error: `"${parsed.data.label}" is already connected. Unpair first.`,
error: `An account labelled "${parsed.data.label}" already exists.`,
};
}
let accountId = existing?.id;
if (!accountId) {
const [created] = await db
.insert(whatsappAccounts)
.values({
operatorId: op.id,
label: parsed.data.label,
status: "pending",
})
.values({ operatorId: op.id, label: parsed.data.label, status: "unpaired" })
.returning({ id: whatsappAccounts.id });
accountId = created!.id;
revalidatePath("/accounts");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${created!.id}` as any);
}
/**
* Trigger pair / re-pair for an existing account. Transitions the row to
* status='pending' and asks the bot to open a Baileys session. Operator
* lands on the live QR page.
*/
export async function pairAccountAction(formData: FormData): Promise<void> {
await rateLimit("pair");
const accountId = formData.get("accountId");
if (typeof accountId !== "string") return;
const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) return;
if (account.status === "connected") {
// Already connected — bounce to detail
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}` as any);
}
await db
.update(whatsappAccounts)
.set({ status: "pending", lastQrAt: new Date() })
.where(eq(whatsappAccounts.id, accountId));
await pgNotifyBot({ type: "account.start_pairing", accountId });
revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}/pairing` as any);
}
/**
* Unpair: stop the Baileys session and clear session files via the bot,
* but KEEP the account row (status -> 'unpaired') so the operator can
* re-pair without retyping the label or losing any references.
*/
export async function unpairAccountAction(formData: FormData): Promise<void> {
await rateLimit("unpair");
const accountId = formData.get("accountId");
if (typeof accountId !== "string") return;
const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.id, accountId), eq(a.operatorId, op.id)),
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) return;
await pgNotifyBot({ type: "account.unpair", accountId });
// Optimistic UI — bot will overwrite status via the same column
await db
.update(whatsappAccounts)
.set({ status: "logged_out", phoneNumber: null })
.set({ status: "unpaired", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId));
// Wipe synced groups too — they belong to a different WA login now.
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}` as any);
}
/**
* Permanently delete an account, its groups, reminders and run history
* via the cascade FKs added in migration 0003.
*/
export async function deleteAccountAction(formData: FormData): Promise<void> {
await rateLimit("delete-account");
const accountId = formData.get("accountId");
if (typeof accountId !== "string") return;
const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) return;
// Stop any live session / clean session files first.
await pgNotifyBot({ type: "account.unpair", accountId });
// Cascade FKs handle groups, reminders, runs, run_targets, messages.
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
revalidatePath("/accounts");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/accounts" as any);
}

View File

@ -9,6 +9,8 @@ import {
CalendarIcon,
TagIcon,
DatabaseIcon,
PowerIcon,
PowerOffIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@ -30,7 +32,12 @@ import {
import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
import { syncGroupsAction, unpairAccountAction } from "@/actions/accounts";
import {
syncGroupsAction,
unpairAccountAction,
pairAccountAction,
deleteAccountAction,
} from "@/actions/accounts";
interface AccountDetailPageProps {
params: Promise<{ id: string }>;
@ -72,7 +79,37 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
{/* Actions */}
<div className="flex flex-col gap-3">
{/* Groups */}
{/* Pair / Re-pair — visible when not currently connected */}
{account.status !== "connected" && (
<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-emerald-500/10">
<PowerIcon className="size-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium">
{account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"}
</p>
<p className="text-xs text-muted-foreground">
Show a QR code so this account can connect to WhatsApp
</p>
</div>
</div>
<form action={pairAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" size="sm">
<PowerIcon />
{account.status === "unpaired" ? "Pair Now" : "Re-pair"}
</Button>
</form>
</CardContent>
</Card>
)}
{/* Groups + Sync — visible when connected */}
{account.status === "connected" && (
<>
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
@ -91,7 +128,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</CardContent>
</Card>
{/* Sync */}
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
@ -115,7 +151,52 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</CardContent>
</Card>
{/* Unpair */}
<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-amber-500/10">
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-medium">Unpair</p>
<p className="text-xs text-muted-foreground">
Disconnect from WhatsApp; keep the account so you can re-pair later
</p>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<PowerOffIcon />
Unpair
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will disconnect from WhatsApp and
scheduled reminders using it will stop firing until you re-pair.
The account itself is kept; reminders and other data are not deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={unpairAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="default" size="sm">
<PowerOffIcon />
Yes, unpair
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</>
)}
{/* Delete — always available */}
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
@ -123,33 +204,34 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">Unpair Account</p>
<p className="text-sm font-medium text-destructive">Delete Account</p>
<p className="text-xs text-muted-foreground">
Disconnect and remove this account
Remove the account and all its reminders, groups, and history
</p>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
Unpair
<Trash2Icon />
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair account?</DialogTitle>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
This will disconnect <strong>{account.label}</strong> from cm WhatsApp Bot.
Any scheduled reminders using this account will stop firing. This action
cannot be undone.
<strong>{account.label}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={unpairAccountAction}>
<form action={deleteAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, unpair
Yes, delete
</Button>
</form>
</DialogFooter>

View File

@ -28,9 +28,9 @@ export default function NewAccountPage() {
<SmartphoneIcon className="size-5 text-muted-foreground" />
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Pair New Account</h1>
<h1 className="text-2xl font-semibold tracking-tight">Add Account</h1>
<p className="text-sm text-muted-foreground">
Link a WhatsApp number to start scheduling reminders.
Create a new account slot. You'll pair the WhatsApp number on the next screen.
</p>
</div>
</div>

View File

@ -0,0 +1,38 @@
import { notFound } from "next/navigation";
import { Stepper } from "@/components/reminder-wizard/stepper";
import { StepAccount } from "@/components/reminder-wizard/step-account";
import { StepGroups } from "@/components/reminder-wizard/step-groups";
import { StepCompose } from "@/components/reminder-wizard/step-compose";
import { StepWhen } from "@/components/reminder-wizard/step-when";
import { StepReview } from "@/components/reminder-wizard/step-review";
interface PageProps {
searchParams: Promise<{
step?: string;
accountId?: string;
groupIds?: string;
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
groupId?: string;
}>;
}
export default async function NewReminderPage({ searchParams }: PageProps) {
const sp = await searchParams;
const step = Number(sp.step ?? "1");
if (![1, 2, 3, 4, 5].includes(step)) notFound();
return (
<div className="container mx-auto max-w-2xl space-y-6 p-4 sm:p-6">
<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 === 5 && <StepReview params={sp} />}
</div>
);
}

View File

@ -6,12 +6,12 @@ import { ArrowRightIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { pairAccountAction, type PairResult } from "@/actions/accounts";
import { addAccountAction, type AddAccountResult } from "@/actions/accounts";
const initialState: PairResult = { ok: true, accountId: "" };
const initialState: AddAccountResult = { ok: true, accountId: "" };
export function PairForm() {
const [state, action, isPending] = useActionState(pairAccountAction, initialState);
const [state, action, isPending] = useActionState(addAccountAction, initialState);
return (
<form action={action} className="space-y-5">
@ -56,11 +56,11 @@ export function PairForm() {
{isPending ? (
<>
<Loader2Icon className="size-3.5 animate-spin" />
Starting
Adding
</>
) : (
<>
Start Pairing
Add Account
<ArrowRightIcon className="size-3.5" />
</>
)}

View File

@ -0,0 +1,252 @@
"use client";
import { useState, useRef, useActionState } from "react";
import { useRouter } from "next/navigation";
import {
PaperclipIcon,
ImageIcon,
FileIcon,
XIcon,
AlertCircleIcon,
UploadIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { uploadMediaAction } from "@/actions/media";
import { cn } from "@/lib/utils";
interface PassThroughParams {
scheduledAt?: string;
}
interface ComposeFormClientProps {
accountId: string;
groupIds: string;
initialText: string;
initialMediaId?: string;
initialCaption?: string;
passThroughParams: PassThroughParams;
}
export function ComposeFormClient({
accountId,
groupIds,
initialText,
initialMediaId,
initialCaption,
passThroughParams,
}: ComposeFormClientProps) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState(initialText);
const [caption, setCaption] = useState(initialCaption ?? "");
const [mediaId, setMediaId] = useState<string | undefined>(initialMediaId);
const [mediaFilename, setMediaFilename] = useState<string | undefined>(undefined);
const [mediaMimeType, setMediaMimeType] = useState<string | undefined>(undefined);
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined);
const [dragOver, setDragOver] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadState, uploadAction] = useActionState(uploadMediaAction, null);
// Show server-side upload errors
const uploadError =
uploadState && !uploadState.ok ? uploadState.error : null;
async function handleFile(file: File) {
setLocalError(null);
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const result = await uploadMediaAction(null, fd);
setUploading(false);
if (result.ok) {
setMediaId(result.mediaId);
setMediaFilename(result.filename);
setMediaMimeType(result.mimeType);
if (result.mimeType.startsWith("image/")) {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
} else {
setPreviewUrl(undefined);
}
} else {
setLocalError(result.error);
}
}
function handleFileInput(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
}
function removeMedia() {
setMediaId(undefined);
setMediaFilename(undefined);
setMediaMimeType(undefined);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(undefined);
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
function handleContinue() {
if (!text.trim() && !mediaId) {
setLocalError("Add a message or attach a file.");
return;
}
const sp = new URLSearchParams({
step: "4",
accountId,
groupIds,
});
if (text.trim()) sp.set("text", text.trim());
if (mediaId) sp.set("mediaId", mediaId);
if (caption.trim()) sp.set("caption", caption.trim());
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any);
}
const isImage = mediaMimeType?.startsWith("image/");
return (
<div className="space-y-5">
{/* Body text */}
<div className="space-y-1.5">
<Label htmlFor="body-text">Message</Label>
<Textarea
id="body-text"
placeholder="Type your reminder message here…"
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
{/* Media section */}
{mediaId ? (
/* Media preview */
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="flex items-start gap-3">
{isImage && previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={mediaFilename ?? "Uploaded image"}
className="size-16 rounded-lg object-cover shrink-0 border border-border"
/>
) : (
<div className="flex size-16 shrink-0 items-center justify-center rounded-lg bg-muted border border-border">
<FileIcon className="size-6 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<p className="text-sm font-medium truncate">{mediaFilename ?? "Uploaded file"}</p>
<p className="text-xs text-muted-foreground">{mediaMimeType}</p>
{/* Caption for media */}
<Input
placeholder="Caption (optional)"
value={caption}
onChange={(e) => setCaption(e.target.value)}
className="mt-1"
/>
</div>
<button
type="button"
onClick={removeMedia}
aria-label="Remove attachment"
className="shrink-0 flex size-6 items-center justify-center rounded-full bg-muted hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<XIcon className="size-3" />
</button>
</div>
</div>
) : (
/* Upload drop zone */
<div className="space-y-1.5">
<Label>Attachment (optional)</Label>
<div
role="button"
tabIndex={0}
aria-label="Click or drag a file to upload"
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
}}
className={cn(
"flex flex-col items-center gap-2 rounded-xl border-2 border-dashed px-6 py-8 text-center cursor-pointer transition-colors",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/30",
uploading && "pointer-events-none opacity-60"
)}
>
{uploading ? (
<>
<UploadIcon className="size-6 text-muted-foreground animate-pulse" />
<p className="text-sm text-muted-foreground">Uploading</p>
</>
) : (
<>
<div className="flex items-center gap-2 text-muted-foreground">
<ImageIcon className="size-5" />
<PaperclipIcon className="size-4" />
</div>
<div>
<p className="text-sm font-medium">Click to upload or drag & drop</p>
<p className="text-xs text-muted-foreground mt-0.5">
Images, documents, audio up to 50 MB
</p>
</div>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
className="sr-only"
onChange={handleFileInput}
/>
</div>
)}
{/* Errors */}
{(localError ?? uploadError) && (
<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" />
{localError ?? uploadError}
</div>
)}
{/* Action */}
<div className="flex justify-end pt-1">
<Button type="button" onClick={handleContinue} disabled={uploading}>
Continue
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,183 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { SearchIcon, UsersIcon, AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface Group {
id: string;
name: string;
participantCount: number;
isArchived: boolean;
}
interface PassThroughParams {
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
}
interface GroupsFormClientProps {
groups: Group[];
preSelected: string[];
accountId: string;
passThroughParams: PassThroughParams;
}
export function GroupsFormClient({
groups,
preSelected,
accountId,
passThroughParams,
}: GroupsFormClientProps) {
const router = useRouter();
const [selected, setSelected] = useState<Set<string>>(() => new Set(preSelected));
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
if (!q) return groups;
return groups.filter((g) => g.name.toLowerCase().includes(q));
}, [groups, search]);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
setError(null);
}
function handleContinue() {
if (selected.size === 0) {
setError("Select at least one group.");
return;
}
const sp = new URLSearchParams({
step: "3",
accountId,
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);
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any);
}
return (
<div className="space-y-3">
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input
type="search"
placeholder="Search groups…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
aria-label="Search groups"
/>
</div>
{/* Selection summary */}
{selected.size > 0 && (
<p className="text-xs text-muted-foreground">
{selected.size} group{selected.size !== 1 ? "s" : ""} selected
</p>
)}
{/* 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" />
{error}
</div>
)}
{/* Group list */}
{filtered.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<UsersIcon className="size-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">No groups match your search.</p>
</div>
) : (
<div className="flex flex-col gap-1.5 max-h-[420px] overflow-y-auto rounded-xl border border-border bg-card p-1">
{filtered.map((group) => {
const isChecked = selected.has(group.id);
return (
<button
key={group.id}
type="button"
onClick={() => toggle(group.id)}
aria-pressed={isChecked}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
isChecked
? "bg-primary/10 text-foreground"
: "hover:bg-muted text-foreground",
group.isArchived && "opacity-60"
)}
>
{/* Checkbox indicator */}
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
isChecked
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background"
)}
aria-hidden="true"
>
{isChecked && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="size-2.5"
>
<path
fillRule="evenodd"
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
clipRule="evenodd"
/>
</svg>
)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate leading-snug">
{group.name}
{group.isArchived && (
<span className="ml-1.5 text-xs text-muted-foreground">(archived)</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{group.participantCount} participant{group.participantCount !== 1 ? "s" : ""}
</p>
</div>
</button>
);
})}
</div>
)}
{/* Continue */}
<div className="flex justify-end pt-1">
<Button type="button" onClick={handleContinue}>
Continue
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createReminderAction } from "@/actions/reminders";
import { cn } from "@/lib/utils";
interface ReviewSubmitClientProps {
accountId: string;
groupIds: string;
text?: string;
mediaId?: string;
caption?: string;
scheduledAt: string;
timezone: string;
}
export function ReviewSubmitClient({
accountId,
groupIds,
text,
mediaId,
caption,
scheduledAt,
timezone,
}: ReviewSubmitClientProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSchedule() {
setSubmitting(true);
setError(null);
try {
const result = await createReminderAction({
accountId,
groupIds: groupIds.split(",").filter(Boolean),
text: text ?? null,
mediaId: mediaId ?? null,
caption: caption ?? null,
scheduledAtIso: scheduledAt,
timezone,
});
if (result.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/${result.reminderId}` as any);
} else {
setError(result.error);
setSubmitting(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : "An unexpected error occurred.");
setSubmitting(false);
}
}
return (
<div className="space-y-3 pt-2">
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
<AlertCircleIcon className="size-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
size="lg"
onClick={handleSchedule}
disabled={submitting}
className={cn("gap-2", submitting && "cursor-wait")}
>
{submitting ? (
<>
<Loader2Icon className="size-4 animate-spin" />
Scheduling
</>
) : (
<>
<CalendarCheckIcon className="size-4" />
Schedule Reminder
</>
)}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
import Link from "next/link";
import { SmartphoneIcon, WifiIcon, WifiOffIcon, PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { listAccounts } from "@/lib/queries";
import { cn } from "@/lib/utils";
export async function StepAccount() {
const op = await getSeededOperator();
const accounts = await listAccounts(op.id);
if (accounts.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<SmartphoneIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">No accounts paired yet.</p>
<p className="text-xs text-muted-foreground">
You need to pair a WhatsApp account before scheduling a reminder.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any}>
<PlusIcon />
Pair an account first
</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Choose which WhatsApp account to send from.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{accounts.map((account) => {
const isConnected = account.status === "connected";
return (
<Link
key={account.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/new?step=2&accountId=${account.id}` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card
className={cn(
"transition-all hover:shadow-md cursor-pointer",
isConnected
? "hover:ring-primary/40"
: "opacity-60 hover:ring-destructive/30"
)}
>
<CardContent className="flex items-center gap-3 py-3 px-4">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
isConnected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}
>
<SmartphoneIcon className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-snug truncate">{account.label}</p>
<p className="text-xs text-muted-foreground truncate">
{account.phoneNumber ?? "No phone number"}
</p>
</div>
<div className="shrink-0">
{isConnected ? (
<WifiIcon className="size-4 text-emerald-500" />
) : (
<WifiOffIcon className="size-4 text-muted-foreground/50" />
)}
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ComposeFormClient } from "./compose-form-client";
interface StepComposeParams {
step?: string;
accountId?: string;
groupIds?: string;
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
}
interface StepComposeProps {
params: StepComposeParams;
}
export function StepCompose({ params }: StepComposeProps) {
const { accountId, groupIds, text, mediaId, caption } = params;
if (!accountId || !groupIds) {
// 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;
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>
<p className="text-sm text-muted-foreground">
Write your reminder message. You can also attach an image or document.
</p>
<ComposeFormClient
accountId={accountId}
groupIds={groupIds}
initialText={text ?? ""}
initialMediaId={mediaId}
initialCaption={caption}
passThroughParams={{
scheduledAt: params.scheduledAt,
}}
/>
</div>
);
}

View File

@ -0,0 +1,163 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeftIcon, UsersIcon, SearchIcon } 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;
accountId?: string;
groupIds?: string;
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
groupId?: string;
}
interface StepGroupsProps {
params: StepGroupsParams;
}
export async function StepGroups({ params }: StepGroupsProps) {
const { accountId, groupIds: groupIdsParam, groupId: singleGroupId } = params;
if (!accountId) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
const op = await getSeededOperator();
const result = await listGroupsForAccount(op.id, accountId);
if (!result) {
return (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">Account not found.</p>
<Button asChild variant="outline" size="sm" className="mt-4">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>
<ArrowLeftIcon />
Back
</Link>
</Button>
</CardContent>
</Card>
);
}
const { groups } = result;
// Determine pre-selected group IDs
// Priority: groupIds param > single groupId param
const preSelected: string[] = groupIdsParam
? groupIdsParam.split(",").filter(Boolean)
: singleGroupId
? [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>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<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>
<p className="text-sm text-muted-foreground">
Select one or more groups to send this reminder to.
</p>
<StepGroupsForm
groups={groups}
preSelected={preSelected}
baseParams={baseParams}
accountId={accountId}
passThroughParams={{
text: params.text,
mediaId: params.mediaId,
caption: params.caption,
scheduledAt: params.scheduledAt,
}}
/>
</div>
);
}
// Client component for the interactive form
import { GroupsFormClient } from "./groups-form-client";
interface PassThroughParams {
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
}
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;
}) {
return (
<GroupsFormClient
groups={groups}
preSelected={preSelected}
accountId={accountId}
passThroughParams={passThroughParams}
/>
);
}
export { cn };

View File

@ -0,0 +1,216 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeftIcon, PencilIcon, CalendarIcon, UsersIcon, FileTextIcon, SmartphoneIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { getAccount, listGroupsForAccount } from "@/lib/queries";
import { ReviewSubmitClient } from "./review-submit-client";
import { DateTime } from "luxon";
interface StepReviewParams {
step?: string;
accountId?: string;
groupIds?: string;
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
}
interface StepReviewProps {
params: StepReviewParams;
}
function formatScheduledAt(iso: string, timezone: string): string {
try {
const dt = DateTime.fromISO(iso, { zone: timezone });
if (!dt.isValid) return iso;
return dt.toLocaleString(DateTime.DATETIME_FULL);
} catch {
return iso;
}
}
function editLink(
step: number,
accountId: string,
groupIds?: string,
text?: string,
mediaId?: string,
caption?: string,
scheduledAt?: string
): string {
const sp = new URLSearchParams({ step: String(step), accountId });
if (groupIds) sp.set("groupIds", groupIds);
if (text) sp.set("text", text);
if (mediaId) sp.set("mediaId", mediaId);
if (caption) sp.set("caption", caption);
if (scheduledAt) sp.set("scheduledAt", scheduledAt);
return `/reminders/new?${sp.toString()}`;
}
export async function StepReview({ params }: StepReviewProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params;
if (!accountId || !groupIds || !scheduledAt) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
const op = await getSeededOperator();
const timezone = op.defaultTimezone ?? "UTC";
// Fetch account details
const account = await getAccount(op.id, accountId);
if (!account) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
// Fetch group names
const groupIdsArray = groupIds.split(",").filter(Boolean);
const groupsResult = await listGroupsForAccount(op.id, accountId);
const selectedGroups = groupsResult
? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id))
: [];
const formattedDate = formatScheduledAt(scheduledAt, timezone);
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt);
return (
<div className="space-y-5">
<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>
<p className="text-sm text-muted-foreground">
Review your reminder before scheduling.
</p>
<div className="space-y-3">
{/* Account */}
<ReviewRow
icon={<SmartphoneIcon className="size-4" />}
label="Account"
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt)}
>
<span className="text-sm font-medium">{account.label}</span>
{account.phoneNumber && (
<span className="text-xs text-muted-foreground ml-1.5">{account.phoneNumber}</span>
)}
</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)}
>
{mediaId ? (
<span className="text-sm text-muted-foreground">
Media file
{caption && (
<> with caption: <span className="text-foreground">{caption}</span></>
)}
{text && (
<> · <span className="text-foreground">{text}</span></>
)}
</span>
) : text ? (
<p className="text-sm whitespace-pre-wrap break-words">{text}</p>
) : (
<span className="text-sm text-muted-foreground italic">No message</span>
)}
</ReviewRow>
</div>
<ReviewSubmitClient
accountId={accountId}
groupIds={groupIds}
text={text}
mediaId={mediaId}
caption={caption}
scheduledAt={scheduledAt}
timezone={timezone}
/>
</div>
);
}
function ReviewRow({
icon,
label,
editHref,
children,
}: {
icon: React.ReactNode;
label: string;
editHref: string;
children: React.ReactNode;
}) {
return (
<Card>
<CardContent className="py-3 px-4">
<div className="flex items-start gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground mt-0.5">
{icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
{label}
</p>
<div>{children}</div>
</div>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={editHref as any}
className="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1.5 py-1 hover:bg-muted"
aria-label={`Edit ${label}`}
>
<PencilIcon className="size-3" />
Edit
</Link>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,61 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getSeededOperator } from "@/lib/operator";
import { WhenFormClient } from "./when-form-client";
interface StepWhenParams {
step?: string;
accountId?: string;
groupIds?: string;
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
}
interface StepWhenProps {
params: StepWhenParams;
}
export async function StepWhen({ params }: StepWhenProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params;
if (!accountId || !groupIds) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
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;
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>
<p className="text-sm text-muted-foreground">
Choose when to send this reminder. Times are in{" "}
<span className="font-medium text-foreground">{timezone}</span>.
</p>
<WhenFormClient
accountId={accountId}
groupIds={groupIds}
timezone={timezone}
initialScheduledAt={scheduledAt}
passThroughParams={{ text, mediaId, caption }}
/>
</div>
);
}

View File

@ -0,0 +1,112 @@
import { cn } from "@/lib/utils";
const STEPS = [
{ n: 1, label: "Account" },
{ n: 2, label: "Groups" },
{ n: 3, label: "Compose" },
{ n: 4, label: "When" },
{ n: 5, label: "Review" },
];
interface StepperProps {
current: number;
}
export function Stepper({ current }: StepperProps) {
return (
<nav aria-label="Wizard steps" className="w-full">
{/* Desktop: horizontal row */}
<ol className="hidden sm:flex items-center w-full">
{STEPS.map((step, idx) => {
const done = current > step.n;
const active = current === step.n;
const isLast = idx === STEPS.length - 1;
return (
<li
key={step.n}
className={cn(
"flex items-center",
!isLast && "flex-1"
)}
>
{/* Circle + label */}
<div className="flex flex-col items-center gap-1.5 shrink-0">
<span
className={cn(
"flex size-7 items-center justify-center rounded-full text-xs font-semibold ring-2 transition-colors",
done && "bg-primary ring-primary text-primary-foreground",
active && "bg-background ring-primary text-primary",
!done && !active && "bg-background ring-border text-muted-foreground"
)}
aria-current={active ? "step" : undefined}
>
{done ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="size-3.5"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
clipRule="evenodd"
/>
</svg>
) : (
step.n
)}
</span>
<span
className={cn(
"text-xs font-medium whitespace-nowrap",
active ? "text-foreground" : "text-muted-foreground"
)}
>
{step.label}
</span>
</div>
{/* Connector line */}
{!isLast && (
<div
className={cn(
"mx-2 h-px flex-1 transition-colors",
done ? "bg-primary" : "bg-border"
)}
aria-hidden="true"
/>
)}
</li>
);
})}
</ol>
{/* Mobile: compact pill bar */}
<div className="flex sm:hidden flex-col gap-2">
<div className="flex gap-1 w-full">
{STEPS.map((step) => (
<div
key={step.n}
className={cn(
"flex-1 h-1 rounded-full transition-colors",
current > step.n && "bg-primary",
current === step.n && "bg-primary/60",
current < step.n && "bg-border"
)}
aria-hidden="true"
/>
))}
</div>
<p className="text-xs text-muted-foreground">
Step {current} of {STEPS.length} {" "}
<span className="font-medium text-foreground">
{STEPS.find((s) => s.n === current)?.label}
</span>
</p>
</div>
</nav>
);
}

View File

@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { DateTime } from "luxon";
import { ClockIcon, AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface PassThroughParams {
text?: string;
mediaId?: string;
caption?: string;
}
interface WhenFormClientProps {
accountId: string;
groupIds: string;
timezone: string;
initialScheduledAt?: 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");
}
/** 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,
passThroughParams,
}: WhenFormClientProps) {
const router = useRouter();
const [localValue, setLocalValue] = useState(() =>
getDefaultValue(timezone, initialScheduledAt)
);
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.");
return;
}
// Parse the local value with the operator timezone and convert to ISO
const dt = DateTime.fromISO(localValue, { zone: timezone });
if (!dt.isValid) {
setError("Invalid date or time.");
return;
}
if (dt.toMillis() <= Date.now()) {
setError("The selected time is in the past. Choose a future time.");
return;
}
const scheduledAt = dt.toISO()!;
const sp = new URLSearchParams({
step: "5",
accountId,
groupIds,
scheduledAt,
});
if (passThroughParams.text) sp.set("text", passThroughParams.text);
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any);
}
return (
<div className="space-y-5">
{/* Date time input */}
<div className="space-y-1.5">
<Label htmlFor="scheduled-at" className="flex items-center gap-1.5">
<ClockIcon className="size-3.5" />
Date &amp; time
</Label>
<input
id="scheduled-at"
type="datetime-local"
value={localValue}
onChange={(e) => {
setLocalValue(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"
)}
/>
</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>
))}
</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" />
{error}
</div>
)}
{/* Timezone reminder */}
<p className="text-xs text-muted-foreground">
All times are interpreted as <span className="font-medium">{timezone}</span>.
</p>
{/* Continue */}
<div className="flex justify-end pt-1">
<Button type="button" onClick={handleContinue}>
Continue
</Button>
</div>
</div>
);
}

View File

@ -32,8 +32,13 @@ export async function getDashboardStats(operatorId: string) {
}
export async function listAccounts(operatorId: string) {
// Show all accounts except those in the transient `pending` state
// (active QR scan in progress — they're surfaced via the pairing page,
// and abandoned ones are swept by the bot). Operators see unpaired,
// connected, disconnected, and banned accounts so they can manage them.
return db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId),
where: (a, { eq, and, ne }) =>
and(eq(a.operatorId, operatorId), ne(a.status, "pending")),
orderBy: (a, { asc }) => [asc(a.label)],
});
}

View File

@ -0,0 +1,15 @@
ALTER TABLE "reminders" DROP CONSTRAINT "reminders_account_id_whatsapp_accounts_id_fk";
--> statement-breakpoint
ALTER TABLE "whatsapp_groups" DROP CONSTRAINT "whatsapp_groups_account_id_whatsapp_accounts_id_fk";
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_account_id_whatsapp_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."whatsapp_accounts"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "whatsapp_groups" ADD CONSTRAINT "whatsapp_groups_account_id_whatsapp_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."whatsapp_accounts"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,13 @@
"when": 1778338808600,
"tag": "0002_left_jimmy_woo",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778343712901,
"tag": "0003_messy_bruce_banner",
"breakpoints": true
}
]
}

View File

@ -48,7 +48,7 @@ export const whatsappGroups = pgTable(
"whatsapp_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id),
accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id, { onDelete: "cascade" }),
waGroupJid: text("wa_group_jid").notNull(),
name: text("name").notNull(),
participantCount: integer("participant_count").notNull().default(0),
@ -73,7 +73,7 @@ export const mediaFiles = pgTable("media_files", {
export const reminders = pgTable("reminders", {
id: uuid("id").primaryKey().defaultRandom(),
accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id),
accountId: uuid("account_id").notNull().references(() => whatsappAccounts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
scheduleKind: text("schedule_kind").notNull(),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),

View File

@ -1,10 +1,14 @@
// rrule@2.8.1 lacks a proper "exports" field, so named ESM imports fail at
// runtime with NodeNext resolution. Use the default import and destructure.
import rrulePkg from "rrule";
// rrule@2.8.1 has no "exports" field and ships ESM that some bundlers can't
// resolve via either default OR named imports. Use createRequire to bridge
// to the CJS entry — works under NodeNext at runtime and Turbopack at build.
import { createRequire } from "node:module";
import type { RRule as RRuleType } from "rrule";
const { RRule, rrulestr } = rrulePkg as unknown as typeof import("rrule");
import { DateTime } from "luxon";
const require = createRequire(import.meta.url);
const rrulePkg = require("rrule") as typeof import("rrule");
const { RRule, rrulestr } = rrulePkg;
export const MIN_INTERVAL_MS = 5 * 60 * 1000;
export function parseRRule(rule: string): RRuleType {

1
session Normal file
View File

@ -0,0 +1 @@
claude --resume 61b410fc-4f7d-4df6-a888-b5a7b914b6a0