-
Notifications
You must be signed in to change notification settings - Fork 3
feat(whatsapp): 24h session countdown + agent compose box #869
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
92 changes: 92 additions & 0 deletions
92
turbo-repo/apps/app/src/features/whatsapp-inbox/components/compose-box.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
| import { HiPaperAirplane } from "react-icons/hi"; | ||
| import type { I18nRecord } from "@/features/i18n/i18n.service.types"; | ||
| import { tr } from "@/features/i18n/tr.service"; | ||
| import type { Conversation } from "../conversation.types"; | ||
| import { sendTextMessage } from "../inbox-data-service"; | ||
|
|
||
| /** | ||
| * Agent reply box at the foot of the thread. Free-text is only valid inside WhatsApp's 24h window, so | ||
| * when {@code windowOpen} is false it collapses to a hint (a template would be needed to reopen). The | ||
| * reply carries the thread's service code so it lands on the same service thread. On success it clears | ||
| * and asks the parent to refresh; a rejected send shows the modulith's reason inline. | ||
| */ | ||
| export default function ComposeBox({ | ||
| conversation, | ||
| windowOpen, | ||
| onSent, | ||
| dict, | ||
| }: { | ||
| readonly conversation: Conversation; | ||
| readonly windowOpen: boolean; | ||
| readonly onSent: () => void; | ||
| readonly dict: I18nRecord; | ||
| }) { | ||
| const [text, setText] = useState(""); | ||
| const [sending, setSending] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| const canSend = windowOpen && !sending && text.trim().length > 0; | ||
|
|
||
| async function handleSend() { | ||
| if (!canSend) { | ||
| return; | ||
| } | ||
| setSending(true); | ||
| setError(null); | ||
| try { | ||
| await sendTextMessage({ | ||
| to: conversation.phoneE164, | ||
| body: text.trim(), | ||
| serviceCode: conversation.contextServiceCode, | ||
| driverId: conversation.driverId, | ||
| }); | ||
| setText(""); | ||
| onSent(); | ||
| } catch (e) { | ||
| setError(e instanceof Error ? e.message : tr("sendError", dict)); | ||
| } finally { | ||
| setSending(false); | ||
| } | ||
| } | ||
|
|
||
| if (!windowOpen) { | ||
| return ( | ||
| <div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3 text-xs text-gray-500 dark:text-gray-400"> | ||
| {tr("windowClosedHint", dict)} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="border-t border-gray-200 dark:border-gray-700 p-3"> | ||
| {error && <p className="mb-1 text-xs text-red-600 dark:text-red-400">{error}</p>} | ||
| <div className="flex items-end gap-2"> | ||
| <textarea | ||
| value={text} | ||
| onChange={(e) => setText(e.target.value)} | ||
| onKeyDown={(e) => { | ||
| if (e.key === "Enter" && !e.shiftKey) { | ||
| e.preventDefault(); | ||
| void handleSend(); | ||
| } | ||
| }} | ||
| rows={1} | ||
| placeholder={tr("composePlaceholder", dict)} | ||
| className="flex-1 resize-none rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-green-500" | ||
| /> | ||
| <button | ||
| type="button" | ||
| onClick={() => void handleSend()} | ||
| disabled={!canSend} | ||
| aria-label={tr("send", dict)} | ||
| className="shrink-0 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-green-500 text-white hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-40" | ||
| > | ||
| <HiPaperAirplane className="h-4 w-4" aria-hidden /> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
43 changes: 43 additions & 0 deletions
43
turbo-repo/apps/app/src/features/whatsapp-inbox/components/session-countdown.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { HiOutlineClock } from "react-icons/hi"; | ||
| import type { I18nRecord } from "@/features/i18n/i18n.service.types"; | ||
| import { tr } from "@/features/i18n/tr.service"; | ||
| import { formatRemaining, type WindowState, type WindowStatus } from "../session-window"; | ||
|
|
||
| const STYLES: Record<WindowStatus, string> = { | ||
| open: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300", | ||
| closingSoon: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300", | ||
| closed: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400", | ||
| }; | ||
|
|
||
| const LABEL_KEY: Record<WindowStatus, string> = { | ||
| open: "windowOpen", | ||
| closingSoon: "windowClosingSoon", | ||
| closed: "windowClosed", | ||
| }; | ||
|
|
||
| /** | ||
| * Header pill for the 24h reply window: green while comfortably open (with a live `Hh Mm` countdown), | ||
| * amber in the last 2h, grey once closed. Purely presentational — {@link useSessionWindow} owns the | ||
| * clock. | ||
| */ | ||
| export default function SessionCountdown({ | ||
| state, | ||
| dict, | ||
| }: { | ||
| readonly state: WindowState; | ||
| readonly dict: I18nRecord; | ||
| }) { | ||
| const label = | ||
| state.status === "closed" | ||
| ? tr("windowClosed", dict) | ||
| : `${tr(LABEL_KEY[state.status], dict)} · ${formatRemaining(state.remainingMs)}`; | ||
| return ( | ||
| <span | ||
| className={`shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold ${STYLES[state.status]}`} | ||
| title={tr("windowHint", dict)} | ||
| > | ||
| <HiOutlineClock className="h-3 w-3" aria-hidden /> | ||
| {label} | ||
| </span> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
43 changes: 43 additions & 0 deletions
43
turbo-repo/apps/app/src/features/whatsapp-inbox/session-window.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { formatRemaining, windowState } from "./session-window"; | ||
|
|
||
| const NOW = Date.parse("2026-06-30T12:00:00Z"); | ||
| const hours = (h: number) => new Date(NOW + h * 60 * 60 * 1000).toISOString(); | ||
|
|
||
| describe("windowState", () => { | ||
| it("is closed when there is no session (never opened by an inbound)", () => { | ||
| expect(windowState(null, NOW).status).toBe("closed"); | ||
| }); | ||
|
|
||
| it("is closed when the expiry is in the past", () => { | ||
| expect(windowState(hours(-1), NOW).status).toBe("closed"); | ||
| }); | ||
|
|
||
| it("is closed for an unparseable timestamp", () => { | ||
| expect(windowState("not-a-date", NOW).status).toBe("closed"); | ||
| }); | ||
|
|
||
| it("is open with more than 2h remaining", () => { | ||
| const state = windowState(hours(5), NOW); | ||
| expect(state.status).toBe("open"); | ||
| expect(state.remainingMs).toBe(5 * 60 * 60 * 1000); | ||
| }); | ||
|
|
||
| it("is closingSoon within the last 2h", () => { | ||
| expect(windowState(hours(1), NOW).status).toBe("closingSoon"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("formatRemaining", () => { | ||
| it("shows hours and minutes when an hour or more is left", () => { | ||
| expect(formatRemaining(5 * 60 * 60 * 1000 + 12 * 60 * 1000)).toBe("5h 12m"); | ||
| }); | ||
|
|
||
| it("shows only minutes under an hour", () => { | ||
| expect(formatRemaining(45 * 60 * 1000)).toBe("45m"); | ||
| }); | ||
|
|
||
| it("is empty once closed", () => { | ||
| expect(formatRemaining(0)).toBe(""); | ||
| }); | ||
| }); |
43 changes: 43 additions & 0 deletions
43
turbo-repo/apps/app/src/features/whatsapp-inbox/session-window.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| /** | ||
| * WhatsApp's customer-service rule: free-form (non-template) messages are only allowed within 24h of | ||
| * the driver's last inbound message. The modulith stores that expiry as `sessionExpiresAt` (bumped | ||
| * on every inbound). These pure helpers turn it into a UI state so the compose box can enable/disable | ||
| * free-text and the header can show a live countdown. | ||
| */ | ||
|
|
||
| export type WindowStatus = "open" | "closingSoon" | "closed"; | ||
|
|
||
| /** Amber threshold — warn the operator when under 2h of the window remain. */ | ||
| const CLOSING_SOON_MS = 2 * 60 * 60 * 1000; | ||
|
|
||
| export interface WindowState { | ||
| readonly status: WindowStatus; | ||
| /** Milliseconds until the window closes; 0 once closed. */ | ||
| readonly remainingMs: number; | ||
| } | ||
|
|
||
| export function windowState(sessionExpiresAt: string | null, nowMs: number): WindowState { | ||
| if (!sessionExpiresAt) { | ||
| return { status: "closed", remainingMs: 0 }; | ||
| } | ||
| const expiry = new Date(sessionExpiresAt).getTime(); | ||
| if (Number.isNaN(expiry)) { | ||
| return { status: "closed", remainingMs: 0 }; | ||
| } | ||
| const remainingMs = expiry - nowMs; | ||
| if (remainingMs <= 0) { | ||
| return { status: "closed", remainingMs: 0 }; | ||
| } | ||
| return { status: remainingMs <= CLOSING_SOON_MS ? "closingSoon" : "open", remainingMs }; | ||
| } | ||
|
|
||
| /** Compact remaining label: `Hh Mm` when an hour or more is left, otherwise `Mm`. Empty when closed. */ | ||
| export function formatRemaining(remainingMs: number): string { | ||
| if (remainingMs <= 0) { | ||
| return ""; | ||
| } | ||
| const totalMinutes = Math.floor(remainingMs / 60_000); | ||
| const hours = Math.floor(totalMinutes / 60); | ||
| const minutes = totalMinutes % 60; | ||
| return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; | ||
| } |
23 changes: 23 additions & 0 deletions
23
turbo-repo/apps/app/src/features/whatsapp-inbox/use-session-window.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState } from "react"; | ||
| import { windowState, type WindowState } from "./session-window"; | ||
|
|
||
| const TICK_MS = 30_000; | ||
|
|
||
| /** | ||
| * Live 24h-session state, recomputed on mount and every 30s. Returns {@code null} until mounted so | ||
| * the server and first client render agree on this time-based value (no hydration mismatch); callers | ||
| * treat {@code null} as "not yet known". | ||
| */ | ||
| export function useSessionWindow(sessionExpiresAt: string | null): WindowState | null { | ||
| const [nowMs, setNowMs] = useState<number | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| setNowMs(Date.now()); | ||
| const id = setInterval(() => setNowMs(Date.now()), TICK_MS); | ||
| return () => clearInterval(id); | ||
| }, []); | ||
|
|
||
| return nowMs === null ? null : windowState(sessionExpiresAt, nowMs); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Guard against duplicate sends from Enter-key auto-repeat.
canSendis a render-time snapshot; rapid/held Enter presses can firehandleSendagain before thesendingstate update commits, bypassing the button'sdisabledguard and issuing a duplicate WhatsApp send. A synchronous ref check closes this gap.🔒 Proposed fix
(Add
useRefto thereactimport.)Also applies to: 70-75
🤖 Prompt for AI Agents