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 { 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)
|
await writeAuditLog(db, {
|
||||||
.set({ status: "logged_out", phoneNumber: null })
|
operatorId: null,
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
source: "web",
|
||||||
await writeAuditLog(db, {
|
action: "account.unpaired",
|
||||||
operatorId: account.operatorId,
|
targetType: "whatsapp_account",
|
||||||
source: "web",
|
targetId: accountId,
|
||||||
action: "account.unpaired",
|
payload: {},
|
||||||
targetType: "whatsapp_account",
|
});
|
||||||
targetId: accountId,
|
} catch (err) {
|
||||||
payload: { label: account.label },
|
logger.warn({ err, accountId }, "unpair: audit log failed (non-fatal)");
|
||||||
});
|
}
|
||||||
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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) {
|
* Trigger pair / re-pair for an existing account. Transitions the row to
|
||||||
const [created] = await db
|
* status='pending' and asks the bot to open a Baileys session. Operator
|
||||||
.insert(whatsappAccounts)
|
* lands on the live QR page.
|
||||||
.values({
|
*/
|
||||||
operatorId: op.id,
|
export async function pairAccountAction(formData: FormData): Promise<void> {
|
||||||
label: parsed.data.label,
|
await rateLimit("pair");
|
||||||
status: "pending",
|
const accountId = formData.get("accountId");
|
||||||
})
|
if (typeof accountId !== "string") return;
|
||||||
.returning({ id: whatsappAccounts.id });
|
const op = await getSeededOperator();
|
||||||
accountId = created!.id;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,50 +79,124 @@ 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 */}
|
||||||
<Card>
|
{account.status !== "connected" && (
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
<Card>
|
||||||
<div className="flex items-center gap-3">
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
<div className="flex items-center gap-3">
|
||||||
<UsersIcon className="size-4 text-muted-foreground" />
|
<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>
|
||||||
<div>
|
<form action={pairAccountAction}>
|
||||||
<p className="text-sm font-medium">Groups</p>
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
|
<Button type="submit" size="sm">
|
||||||
</div>
|
<PowerIcon />
|
||||||
</div>
|
{account.status === "unpaired" ? "Pair Now" : "Re-pair"}
|
||||||
<Button asChild variant="outline" size="sm">
|
</Button>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
</form>
|
||||||
<Link href={`/accounts/${account.id}/groups` as any}>View</Link>
|
</CardContent>
|
||||||
</Button>
|
</Card>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sync */}
|
{/* Groups + Sync — visible when connected */}
|
||||||
<Card>
|
{account.status === "connected" && (
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
<>
|
||||||
<div className="flex items-center gap-3">
|
<Card>
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
<RefreshCwIcon className="size-4 text-muted-foreground" />
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
||||||
<div>
|
<UsersIcon className="size-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium">Sync Groups Now</p>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div>
|
||||||
Fetch latest groups from WhatsApp
|
<p className="text-sm font-medium">Groups</p>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form action={syncGroupsAction}>
|
<Button asChild variant="outline" size="sm">
|
||||||
<input type="hidden" name="accountId" value={account.id} />
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Button type="submit" variant="outline" size="sm">
|
<Link href={`/accounts/${account.id}/groups` as any}>View</Link>
|
||||||
<RefreshCwIcon />
|
</Button>
|
||||||
Sync
|
</CardContent>
|
||||||
</Button>
|
</Card>
|
||||||
</form>
|
|
||||||
</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>
|
<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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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 { 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" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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) {
|
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)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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 }),
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user