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:
parent
e45bcb581a
commit
9437df74ee
@ -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));
|
||||
await writeAuditLog(db, {
|
||||
operatorId: account.operatorId,
|
||||
source: "web",
|
||||
action: "account.unpaired",
|
||||
targetType: "whatsapp_account",
|
||||
targetId: accountId,
|
||||
payload: { label: account.label },
|
||||
});
|
||||
try {
|
||||
await writeAuditLog(db, {
|
||||
operatorId: null,
|
||||
source: "web",
|
||||
action: "account.unpaired",
|
||||
targetType: "whatsapp_account",
|
||||
targetId: accountId,
|
||||
payload: {},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, accountId }, "unpair: audit log failed (non-fatal)");
|
||||
}
|
||||
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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.`,
|
||||
};
|
||||
}
|
||||
const [created] = await db
|
||||
.insert(whatsappAccounts)
|
||||
.values({ operatorId: op.id, label: parsed.data.label, status: "unpaired" })
|
||||
.returning({ id: whatsappAccounts.id });
|
||||
revalidatePath("/accounts");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
redirect(`/accounts/${created!.id}` as any);
|
||||
}
|
||||
|
||||
let accountId = existing?.id;
|
||||
if (!accountId) {
|
||||
const [created] = await db
|
||||
.insert(whatsappAccounts)
|
||||
.values({
|
||||
operatorId: op.id,
|
||||
label: parsed.data.label,
|
||||
status: "pending",
|
||||
})
|
||||
.returning({ id: whatsappAccounts.id });
|
||||
accountId = created!.id;
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,50 +79,124 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Groups */}
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<UsersIcon className="size-4 text-muted-foreground" />
|
||||
{/* 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>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Groups</p>
|
||||
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={`/accounts/${account.id}/groups` as any}>View</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Sync */}
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<RefreshCwIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Sync Groups Now</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fetch latest groups from WhatsApp
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={syncGroupsAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
<RefreshCwIcon />
|
||||
Sync
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 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">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||
<UsersIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Groups</p>
|
||||
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={`/accounts/${account.id}/groups` as any}>View</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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-muted">
|
||||
<RefreshCwIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Sync Groups Now</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fetch latest groups from WhatsApp
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={syncGroupsAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
<RefreshCwIcon />
|
||||
Sync
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
|
||||
38
apps/web/src/app/reminders/new/page.tsx
Normal file
38
apps/web/src/app/reminders/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
252
apps/web/src/components/reminder-wizard/compose-form-client.tsx
Normal file
252
apps/web/src/components/reminder-wizard/compose-form-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
apps/web/src/components/reminder-wizard/groups-form-client.tsx
Normal file
183
apps/web/src/components/reminder-wizard/groups-form-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
91
apps/web/src/components/reminder-wizard/step-account.tsx
Normal file
91
apps/web/src/components/reminder-wizard/step-account.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/web/src/components/reminder-wizard/step-compose.tsx
Normal file
59
apps/web/src/components/reminder-wizard/step-compose.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/components/reminder-wizard/step-groups.tsx
Normal file
163
apps/web/src/components/reminder-wizard/step-groups.tsx
Normal 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 };
|
||||
216
apps/web/src/components/reminder-wizard/step-review.tsx
Normal file
216
apps/web/src/components/reminder-wizard/step-review.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/components/reminder-wizard/step-when.tsx
Normal file
61
apps/web/src/components/reminder-wizard/step-when.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
apps/web/src/components/reminder-wizard/stepper.tsx
Normal file
112
apps/web/src/components/reminder-wizard/stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
apps/web/src/components/reminder-wizard/when-form-client.tsx
Normal file
176
apps/web/src/components/reminder-wizard/when-form-client.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@ -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)],
|
||||
});
|
||||
}
|
||||
|
||||
15
packages/db/migrations/0003_messy_bruce_banner.sql
Normal file
15
packages/db/migrations/0003_messy_bruce_banner.sql
Normal 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 $$;
|
||||
1001
packages/db/migrations/meta/0003_snapshot.json
Normal file
1001
packages/db/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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 }),
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user