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 { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../db.js"; import { db } from "../db.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { sessionManager } from "../whatsapp/session-manager.js"; import { sessionManager } from "../whatsapp/session-manager.js";
@ -9,27 +7,26 @@ import { writeAuditLog } from "../audit.js";
import { pgNotifyWeb } from "./notify.js"; import { pgNotifyWeb } from "./notify.js";
import { logger } from "../logger.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> { 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 sessionManager.stop(accountId);
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true }); await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
await db try {
.update(whatsappAccounts)
.set({ status: "logged_out", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId));
await writeAuditLog(db, { await writeAuditLog(db, {
operatorId: account.operatorId, operatorId: null,
source: "web", source: "web",
action: "account.unpaired", action: "account.unpaired",
targetType: "whatsapp_account", targetType: "whatsapp_account",
targetId: accountId, 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 }); await pgNotifyWeb({ type: "session.disconnected", accountId });
} }

View File

@ -14,6 +14,9 @@ const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: "standalone", output: "standalone",
outputFileTracingRoot: workspaceRoot, 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: { experimental: {
typedRoutes: true, typedRoutes: true,
}, },

View File

@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { z } from "zod"; import { z } from "zod";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { whatsappAccounts } from "@cmbot/db"; import { whatsappAccounts, whatsappGroups } from "@cmbot/db";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { pgNotifyBot } from "@/lib/notify"; import { pgNotifyBot } from "@/lib/notify";
@ -21,7 +21,7 @@ async function rateLimit(key: string) {
if (r.limited) throw new Error("Too many requests"); if (r.limited) throw new Error("Too many requests");
} }
const pairSchema = z.object({ const addAccountSchema = z.object({
label: z label: z
.string() .string()
.trim() .trim()
@ -29,72 +29,119 @@ const pairSchema = z.object({
.max(60, "Label too long (max 60)"), .max(60, "Label too long (max 60)"),
}); });
export type PairResult = export type AddAccountResult =
| { ok: true; accountId: string } | { ok: true; accountId: string }
| { ok: false; error: 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, _prev: unknown,
formData: FormData, formData: FormData,
): Promise<PairResult> { ): Promise<AddAccountResult> {
await rateLimit("pair"); await rateLimit("add-account");
const parsed = pairSchema.safeParse({ label: formData.get("label") }); const parsed = addAccountSchema.safeParse({ label: formData.get("label") });
if (!parsed.success) { if (!parsed.success) {
return { return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid label" };
ok: false,
error: parsed.error.issues[0]?.message ?? "Invalid label",
};
} }
const op = await getSeededOperator(); const op = await getSeededOperator();
const existing = await db.query.whatsappAccounts.findFirst({ const existing = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => where: (a, { eq, and }) => and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)),
and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)),
}); });
if (existing && existing.status === "connected") { if (existing) {
return { return {
ok: false, 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 const [created] = await db
.insert(whatsappAccounts) .insert(whatsappAccounts)
.values({ .values({ operatorId: op.id, label: parsed.data.label, status: "unpaired" })
operatorId: op.id,
label: parsed.data.label,
status: "pending",
})
.returning({ id: whatsappAccounts.id }); .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 }); await pgNotifyBot({ type: "account.start_pairing", accountId });
revalidatePath("/accounts"); revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}/pairing` as 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> { export async function unpairAccountAction(formData: FormData): Promise<void> {
await rateLimit("unpair"); await rateLimit("unpair");
const accountId = formData.get("accountId"); const accountId = formData.get("accountId");
if (typeof accountId !== "string") return; if (typeof accountId !== "string") return;
const op = await getSeededOperator(); const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({ const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
and(eq(a.id, accountId), eq(a.operatorId, op.id)),
}); });
if (!account) return; if (!account) return;
await pgNotifyBot({ type: "account.unpair", accountId }); await pgNotifyBot({ type: "account.unpair", accountId });
// Optimistic UI — bot will overwrite status via the same column
await db await db
.update(whatsappAccounts) .update(whatsappAccounts)
.set({ status: "logged_out", phoneNumber: null }) .set({ status: "unpaired", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId)); .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");
revalidatePath(`/accounts/${accountId}`); revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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); redirect("/accounts" as any);
} }

View File

@ -9,6 +9,8 @@ import {
CalendarIcon, CalendarIcon,
TagIcon, TagIcon,
DatabaseIcon, DatabaseIcon,
PowerIcon,
PowerOffIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -30,7 +32,12 @@ import {
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 { getAccount } from "@/lib/queries"; import { getAccount } from "@/lib/queries";
import { syncGroupsAction, unpairAccountAction } from "@/actions/accounts"; import {
syncGroupsAction,
unpairAccountAction,
pairAccountAction,
deleteAccountAction,
} from "@/actions/accounts";
interface AccountDetailPageProps { interface AccountDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -72,7 +79,37 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
{/* Actions */} {/* Actions */}
<div className="flex flex-col gap-3"> <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> <Card>
<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">
@ -91,7 +128,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</CardContent> </CardContent>
</Card> </Card>
{/* Sync */}
<Card> <Card>
<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">
@ -115,7 +151,52 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</CardContent> </CardContent>
</Card> </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> <Card>
<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">
@ -123,33 +204,34 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
<Trash2Icon className="size-4 text-destructive" /> <Trash2Icon className="size-4 text-destructive" />
</div> </div>
<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"> <p className="text-xs text-muted-foreground">
Disconnect and remove this account Remove the account and all its reminders, groups, and history
</p> </p>
</div> </div>
</div> </div>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="destructive" size="sm"> <Button variant="destructive" size="sm">
Unpair <Trash2Icon />
Delete
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Unpair account?</DialogTitle> <DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription> <DialogDescription>
This will disconnect <strong>{account.label}</strong> from cm WhatsApp Bot. <strong>{account.label}</strong> will be removed along with its
Any scheduled reminders using this account will stop firing. This action synced groups, scheduled reminders, and all run history. This cannot be
cannot be undone. undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter showCloseButton> <DialogFooter showCloseButton>
<form action={unpairAccountAction}> <form action={deleteAccountAction}>
<input type="hidden" name="accountId" value={account.id} /> <input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm"> <Button type="submit" variant="destructive" size="sm">
<Trash2Icon /> <Trash2Icon />
Yes, unpair Yes, delete
</Button> </Button>
</form> </form>
</DialogFooter> </DialogFooter>

View File

@ -28,9 +28,9 @@ export default function NewAccountPage() {
<SmartphoneIcon className="size-5 text-muted-foreground" /> <SmartphoneIcon className="size-5 text-muted-foreground" />
</div> </div>
<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"> <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> </p>
</div> </div>
</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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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() { export function PairForm() {
const [state, action, isPending] = useActionState(pairAccountAction, initialState); const [state, action, isPending] = useActionState(addAccountAction, initialState);
return ( return (
<form action={action} className="space-y-5"> <form action={action} className="space-y-5">
@ -56,11 +56,11 @@ export function PairForm() {
{isPending ? ( {isPending ? (
<> <>
<Loader2Icon className="size-3.5 animate-spin" /> <Loader2Icon className="size-3.5 animate-spin" />
Starting Adding
</> </>
) : ( ) : (
<> <>
Start Pairing Add Account
<ArrowRightIcon className="size-3.5" /> <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) { 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({ 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)], 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, "when": 1778338808600,
"tag": "0002_left_jimmy_woo", "tag": "0002_left_jimmy_woo",
"breakpoints": true "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", "whatsapp_groups",
{ {
id: uuid("id").primaryKey().defaultRandom(), 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(), waGroupJid: text("wa_group_jid").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
participantCount: integer("participant_count").notNull().default(0), participantCount: integer("participant_count").notNull().default(0),
@ -73,7 +73,7 @@ export const mediaFiles = pgTable("media_files", {
export const reminders = pgTable("reminders", { export const reminders = pgTable("reminders", {
id: uuid("id").primaryKey().defaultRandom(), 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(), name: text("name").notNull(),
scheduleKind: text("schedule_kind").notNull(), scheduleKind: text("schedule_kind").notNull(),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }), 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 // rrule@2.8.1 has no "exports" field and ships ESM that some bundlers can't
// runtime with NodeNext resolution. Use the default import and destructure. // resolve via either default OR named imports. Use createRequire to bridge
import rrulePkg from "rrule"; // 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"; import type { RRule as RRuleType } from "rrule";
const { RRule, rrulestr } = rrulePkg as unknown as typeof import("rrule");
import { DateTime } from "luxon"; 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 const MIN_INTERVAL_MS = 5 * 60 * 1000;
export function parseRRule(rule: string): RRuleType { export function parseRRule(rule: string): RRuleType {

1
session Normal file
View File

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