diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index 551de0c..1cd852c 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -45,15 +45,22 @@ export async function deleteReminderAction(formData: FormData): Promise { redirect("/reminders" as any); } -const createReminderSchema = z.object({ - accountId: z.string().uuid(), - groupIds: z.array(z.string().uuid()).min(1, "Pick at least one group"), - text: z.string().nullable().optional(), - mediaId: z.string().uuid().nullable().optional(), - caption: z.string().nullable().optional(), - scheduledAtIso: z.string().datetime(), - timezone: z.string().default(DEFAULT_TIMEZONE), -}); +const createReminderSchema = z + .object({ + accountId: z.string().uuid(), + groupIds: z.array(z.string().uuid()), + text: z.string().nullable().optional(), + mediaId: z.string().uuid().nullable().optional(), + caption: z.string().nullable().optional(), + // `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets + // like `+08:00` (luxon's `toISO()` produces the offset form). + scheduledAtIso: z.string().datetime({ offset: true }), + timezone: z.string().default(DEFAULT_TIMEZONE), + }) + .refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), { + message: "Add a message or attach a file", + path: ["text"], + }); export type CreateReminderResult = | { ok: true; reminderId: string } @@ -105,9 +112,11 @@ export async function createReminderAction( }) .returning({ id: reminders.id }); - await tx.insert(reminderTargets).values( - groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })), - ); + if (groupIds.length > 0) { + await tx.insert(reminderTargets).values( + groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })), + ); + } if (text && !mediaId) { await tx.insert(reminderMessages).values({ diff --git a/apps/web/src/app/accounts/[id]/page.tsx b/apps/web/src/app/accounts/[id]/page.tsx index 04618a9..cdca5c9 100644 --- a/apps/web/src/app/accounts/[id]/page.tsx +++ b/apps/web/src/app/accounts/[id]/page.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { UsersIcon, - RefreshCwIcon, Trash2Icon, ArrowLeftIcon, SmartphoneIcon, @@ -33,7 +32,6 @@ import { AccountStatusBadge } from "@/components/account-status-badge"; import { getSeededOperator } from "@/lib/operator"; import { getAccount } from "@/lib/queries"; import { - syncGroupsAction, unpairAccountAction, pairAccountAction, deleteAccountAction, @@ -110,46 +108,25 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro {/* Groups + Sync — visible when connected */} {account.status === "connected" && ( <> - - -
-
- + + + +
+
+ +
+
+

Groups

+

View synced WhatsApp groups

+
-
-

Groups

-

View synced WhatsApp groups

-
-
- - - - - - -
-
- -
-
-

Sync Groups Now

-

- Fetch latest groups from WhatsApp -

-
-
-
- - -
-
-
+ + + diff --git a/apps/web/src/app/accounts/page.tsx b/apps/web/src/app/accounts/page.tsx index d7e0405..dd9ef76 100644 --- a/apps/web/src/app/accounts/page.tsx +++ b/apps/web/src/app/accounts/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { PlusIcon, SmartphoneIcon, CalendarIcon, PowerIcon, Trash2Icon } from "lucide-react"; +import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, @@ -7,19 +7,9 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; import { AccountStatusBadge } from "@/components/account-status-badge"; import { getSeededOperator } from "@/lib/operator"; import { listAccounts } from "@/lib/queries"; -import { pairAccountAction, deleteAccountAction } from "@/actions/accounts"; export default async function AccountsPage() { const op = await getSeededOperator(); @@ -27,7 +17,6 @@ export default async function AccountsPage() { return (
- {/* Header */}

Accounts

- {/* Account cards */} {accounts.length > 0 ? (
{accounts.map((account) => ( - - -
- - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {account.label} - - - -
-
- - {account.phoneNumber ? ( -
- - {account.phoneNumber} + + +
+ {account.label} +
- ) : ( -

Not paired yet

- )} - {account.lastConnectedAt ? ( -
- - - Last connected{" "} - {account.lastConnectedAt.toLocaleDateString("en-MY", { - timeZone: "Asia/Kuala_Lumpur", - year: "numeric", - month: "short", - day: "numeric", - })} - -
- ) : null} - {/* Quick actions */} -
- {account.status !== "connected" && ( -
- - -
+ + + {account.phoneNumber ? ( +
+ + {account.phoneNumber} +
+ ) : ( +

Not paired yet

)} - - - - - - - - Delete this account? - - {account.label} and all its reminders, groups, - and history will be permanently removed. This cannot be undone. - - - -
- - -
-
-
-
-
- -
+ {account.lastConnectedAt ? ( +
+ + + Last connected{" "} + {account.lastConnectedAt.toLocaleDateString("en-MY", { + timeZone: "Asia/Kuala_Lumpur", + year: "numeric", + month: "short", + day: "numeric", + })} + +
+ ) : null} + + + ))}
) : ( diff --git a/apps/web/src/app/reminders/new/page.tsx b/apps/web/src/app/reminders/new/page.tsx index 7d66dcf..6d5e434 100644 --- a/apps/web/src/app/reminders/new/page.tsx +++ b/apps/web/src/app/reminders/new/page.tsx @@ -29,9 +29,9 @@ export default async function NewReminderPage({ searchParams }: PageProps) {

New Reminder

{step === 1 && } - {step === 2 && } - {step === 3 && } - {step === 4 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } {step === 5 && }
); diff --git a/apps/web/src/components/reminder-wizard/compose-form-client.tsx b/apps/web/src/components/reminder-wizard/compose-form-client.tsx index f2c8c29..810377c 100644 --- a/apps/web/src/components/reminder-wizard/compose-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/compose-form-client.tsx @@ -110,10 +110,10 @@ export function ComposeFormClient({ return; } const sp = new URLSearchParams({ - step: "4", + step: "3", accountId, - groupIds, }); + if (groupIds) sp.set("groupIds", groupIds); if (text.trim()) sp.set("text", text.trim()); if (mediaId) sp.set("mediaId", mediaId); if (caption.trim()) sp.set("caption", caption.trim()); diff --git a/apps/web/src/components/reminder-wizard/groups-form-client.tsx b/apps/web/src/components/reminder-wizard/groups-form-client.tsx index 870a9a4..c743c15 100644 --- a/apps/web/src/components/reminder-wizard/groups-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/groups-form-client.tsx @@ -59,15 +59,13 @@ export function GroupsFormClient({ } function handleContinue() { - if (selected.size === 0) { - setError("Select at least one group."); - return; - } const sp = new URLSearchParams({ - step: "3", + step: "5", accountId, - groupIds: Array.from(selected).join(","), }); + if (selected.size > 0) { + sp.set("groupIds", Array.from(selected).join(",")); + } if (passThroughParams.text) sp.set("text", passThroughParams.text); if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId); if (passThroughParams.caption) sp.set("caption", passThroughParams.caption); @@ -175,7 +173,7 @@ export function GroupsFormClient({ {/* Continue */}
diff --git a/apps/web/src/components/reminder-wizard/review-submit-client.tsx b/apps/web/src/components/reminder-wizard/review-submit-client.tsx index 9c4066d..28a25de 100644 --- a/apps/web/src/components/reminder-wizard/review-submit-client.tsx +++ b/apps/web/src/components/reminder-wizard/review-submit-client.tsx @@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"; interface ReviewSubmitClientProps { accountId: string; - groupIds: string; + groupIds?: string; text?: string; mediaId?: string; caption?: string; @@ -37,7 +37,7 @@ export function ReviewSubmitClient({ try { const result = await createReminderAction({ accountId, - groupIds: groupIds.split(",").filter(Boolean), + groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [], text: text ?? null, mediaId: mediaId ?? null, caption: caption ?? null, diff --git a/apps/web/src/components/reminder-wizard/step-account.tsx b/apps/web/src/components/reminder-wizard/step-account.tsx index 9885a8e..f0327e6 100644 --- a/apps/web/src/components/reminder-wizard/step-account.tsx +++ b/apps/web/src/components/reminder-wizard/step-account.tsx @@ -46,6 +46,7 @@ export async function StepAccount() { key={account.id} // eslint-disable-next-line @typescript-eslint/no-explicit-any href={`/reminders/new?step=2&accountId=${account.id}` as any} + // step 2 is now "Compose"; "Groups" moved to last (optional) step className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > @@ -46,7 +46,7 @@ export function StepCompose({ params }: StepComposeProps) { -
- -
- - - -
-

No groups found.

-

- Sync groups from this account first. -

-
-
-
-
- ); - } + const backParams = new URLSearchParams({ step: "3", accountId }); + if (text) backParams.set("text", text); + if (mediaId) backParams.set("mediaId", mediaId); + if (params.caption) backParams.set("caption", params.caption); + if (scheduledAt) backParams.set("scheduledAt", scheduledAt); + const backHref = `/reminders/new?${backParams.toString()}`; return (
@@ -108,13 +79,13 @@ export async function StepGroups({ params }: StepGroupsProps) {

- Select one or more groups to send this reminder to. + Pick which groups to send this reminder to. You can also leave it empty + and add targets later.

; preSelected: string[]; - baseParams: URLSearchParams; accountId: string; passThroughParams: PassThroughParams; }) { @@ -159,5 +128,3 @@ function StepGroupsForm({ /> ); } - -export { cn }; diff --git a/apps/web/src/components/reminder-wizard/step-review.tsx b/apps/web/src/components/reminder-wizard/step-review.tsx index 8e133f9..994a289 100644 --- a/apps/web/src/components/reminder-wizard/step-review.tsx +++ b/apps/web/src/components/reminder-wizard/step-review.tsx @@ -53,7 +53,7 @@ function editLink( export async function StepReview({ params }: StepReviewProps) { const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params; - if (!accountId || !groupIds || !scheduledAt) { + if (!accountId || !scheduledAt || (!text && !mediaId)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect("/reminders/new" as any); } @@ -69,14 +69,16 @@ export async function StepReview({ params }: StepReviewProps) { } // Fetch group names - const groupIdsArray = groupIds.split(",").filter(Boolean); - const groupsResult = await listGroupsForAccount(op.id, accountId); + const groupIdsArray = groupIds ? groupIds.split(",").filter(Boolean) : []; + const groupsResult = + groupIdsArray.length > 0 ? await listGroupsForAccount(op.id, accountId) : null; const selectedGroups = groupsResult ? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id)) : []; const formattedDate = formatScheduledAt(scheduledAt, timezone); + // Back goes to step 4 (Groups, the previous step in the new order) const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt); return ( @@ -108,42 +110,11 @@ export async function StepReview({ params }: StepReviewProps) { )} - {/* Groups */} - } - label="Groups" - editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt)} - > - {selectedGroups.length > 0 ? ( -
- {selectedGroups.map((g) => ( - - {g.name} - - ))} -
- ) : ( - {groupIds} - )} -
- - {/* When */} - } - label="When" - editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt)} - > - {formattedDate} - - {/* Message */} } label="Message" - editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt)} + editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt)} > {mediaId ? ( @@ -161,6 +132,39 @@ export async function StepReview({ params }: StepReviewProps) { No message )} + + {/* When */} + } + label="When" + editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt)} + > + {formattedDate} + + + {/* Groups */} + } + label="Groups" + editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt)} + > + {selectedGroups.length > 0 ? ( +
+ {selectedGroups.map((g) => ( + + {g.name} + + ))} +
+ ) : ( + + No groups — reminder will be saved without targets + + )} +
@@ -51,9 +57,12 @@ export async function StepWhen({ params }: StepWhenProps) { diff --git a/apps/web/src/components/reminder-wizard/stepper.tsx b/apps/web/src/components/reminder-wizard/stepper.tsx index 50526f3..13e69cf 100644 --- a/apps/web/src/components/reminder-wizard/stepper.tsx +++ b/apps/web/src/components/reminder-wizard/stepper.tsx @@ -2,9 +2,9 @@ import { cn } from "@/lib/utils"; const STEPS = [ { n: 1, label: "Account" }, - { n: 2, label: "Groups" }, - { n: 3, label: "Compose" }, - { n: 4, label: "When" }, + { n: 2, label: "Compose" }, + { n: 3, label: "When" }, + { n: 4, label: "Groups" }, { n: 5, label: "Review" }, ]; diff --git a/apps/web/src/components/reminder-wizard/when-form-client.tsx b/apps/web/src/components/reminder-wizard/when-form-client.tsx index 26cbb5b..450fa43 100644 --- a/apps/web/src/components/reminder-wizard/when-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/when-form-client.tsx @@ -3,10 +3,10 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { DateTime } from "luxon"; -import { ClockIcon, AlertCircleIcon } from "lucide-react"; +import { CalendarIcon, ClockIcon, AlertCircleIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { cn } from "@/lib/utils"; interface PassThroughParams { text?: string; @@ -18,73 +18,37 @@ interface WhenFormClientProps { accountId: string; groupIds: string; timezone: string; - initialScheduledAt?: string; + /** Pre-computed default ISO from the server — guarantees no hydration drift. */ + initialDefaultIso: 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"); +function splitDateTime(iso: string, tz: string): { date: string; time: string } { + const dt = DateTime.fromISO(iso, { zone: tz }); + if (!dt.isValid) return { date: "", time: "" }; + return { date: dt.toFormat("yyyy-MM-dd"), time: dt.toFormat("HH:mm") }; } -/** Get the default value: now + 1 hour in the operator timezone */ -function getDefaultValue(timezone: string, initialScheduledAt?: string): string { - if (initialScheduledAt) { - try { - const dt = DateTime.fromISO(initialScheduledAt, { zone: timezone }); - if (dt.isValid) return toLocalInputValue(dt); - } catch { - // fall through to default - } - } - const dt = DateTime.now().setZone(timezone).plus({ hours: 1 }); - return toLocalInputValue(dt); -} - -const QUICK_PICKS = [ - { label: "Now", getDate: (tz: string) => DateTime.now().setZone(tz).plus({ minutes: 5 }) }, - { - label: "Tomorrow 9 AM", - getDate: (tz: string) => - DateTime.now().setZone(tz).plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0 }), - }, - { - label: "Next Mon 9 AM", - getDate: (tz: string) => { - const now = DateTime.now().setZone(tz); - // 1 = Monday in Luxon ISO weekday - const daysUntilMonday = ((8 - now.weekday) % 7) || 7; - return now.plus({ days: daysUntilMonday }).set({ hour: 9, minute: 0, second: 0 }); - }, - }, -]; - export function WhenFormClient({ accountId, groupIds, timezone, - initialScheduledAt, + initialDefaultIso, passThroughParams, }: WhenFormClientProps) { const router = useRouter(); - const [localValue, setLocalValue] = useState(() => - getDefaultValue(timezone, initialScheduledAt) - ); + const initial = splitDateTime(initialDefaultIso, timezone); + + const [date, setDate] = useState(initial.date); + const [time, setTime] = useState(initial.time); const [error, setError] = useState(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."); + if (!date || !time) { + setError("Pick both a date and a time."); return; } - // Parse the local value with the operator timezone and convert to ISO - const dt = DateTime.fromISO(localValue, { zone: timezone }); + const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone }); if (!dt.isValid) { setError("Invalid date or time."); return; @@ -95,11 +59,11 @@ export function WhenFormClient({ } const scheduledAt = dt.toISO()!; const sp = new URLSearchParams({ - step: "5", + step: "4", accountId, - groupIds, scheduledAt, }); + if (groupIds) sp.set("groupIds", groupIds); if (passThroughParams.text) sp.set("text", passThroughParams.text); if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId); if (passThroughParams.caption) sp.set("caption", passThroughParams.caption); @@ -109,50 +73,41 @@ export function WhenFormClient({ return (
- {/* Date time input */} -
- - { - 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" - )} - /> -
- - {/* Quick picks */} -
-

Quick picks

-
- {QUICK_PICKS.map(({ label, getDate }) => ( - - ))} +
+
+ + { + setDate(e.target.value); + setError(null); + }} + className="h-9" + /> +
+
+ + { + setTime(e.target.value); + setError(null); + }} + className="h-9" + />
- {/* Error */} {error && (
@@ -160,12 +115,10 @@ export function WhenFormClient({
)} - {/* Timezone reminder */}

- All times are interpreted as {timezone}. + Times are in {timezone}.

- {/* Continue */}