Improve notification deep-linking and add Arabic meeting reminders
Enhance notification handling to preserve deep-link parameters, add relatedId and relatedType to notifications for better association, and implement Arabic pluralization for meeting reminders. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 9abb57f5-4f2c-4eed-9fad-ab278885083e Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fKZh36i Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -222,7 +222,7 @@ export async function broadcastExecutiveMeetingNotifications(
|
||||
type: "executive_meeting",
|
||||
relatedId: meetingId,
|
||||
tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`,
|
||||
url: "/meetings",
|
||||
url: meetingId ? `/meetings?meeting=${meetingId}` : "/meetings",
|
||||
});
|
||||
}
|
||||
io.emit("executive_meeting_notifications_changed", {
|
||||
|
||||
@@ -43,6 +43,16 @@ function stripHtmlToPlainText(input: string | null | undefined): string {
|
||||
return decoded.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Arabic minute pluralisation for the "starts after N minutes" reminder
|
||||
// body. Arabic uses three forms for small counts: 1 -> دقيقة, 2 -> دقيقتين
|
||||
// (dual), 3+ -> دقائق. The scheduler only fires for deltas of 1-5 min, but
|
||||
// the helper stays correct for the dual/plural boundary regardless.
|
||||
function minutesAfterAr(n: number): string {
|
||||
if (n <= 1) return "بعد دقيقة";
|
||||
if (n === 2) return "بعد دقيقتين";
|
||||
return `بعد ${n} دقائق`;
|
||||
}
|
||||
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
@@ -161,7 +171,7 @@ async function tickOnce(): Promise<void> {
|
||||
stripHtmlToPlainText(meeting.titleEn) ||
|
||||
stripHtmlToPlainText(meeting.titleAr) ||
|
||||
"Meeting";
|
||||
const bodyAr = `${subjectAr} يبدأ خلال ${deltaMin} دقيقة`;
|
||||
const bodyAr = `${subjectAr} يبدأ ${minutesAfterAr(deltaMin)}`;
|
||||
const bodyEn = `${subjectEn} starts in ${deltaMin} min`;
|
||||
|
||||
for (const { userId } of claimed) {
|
||||
@@ -175,7 +185,7 @@ async function tickOnce(): Promise<void> {
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
tag: `em-reminder-${meeting.id}`,
|
||||
url: "/meetings",
|
||||
url: `/meetings?meeting=${meeting.id}`,
|
||||
type: "executive_meeting",
|
||||
relatedId: meeting.id,
|
||||
},
|
||||
|
||||
@@ -1130,6 +1130,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
|
||||
bodyEn: `${senderName} sent you a note`,
|
||||
type: "note",
|
||||
relatedType: "note",
|
||||
relatedId: note.id,
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
|
||||
@@ -1143,7 +1145,7 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
type: "note",
|
||||
relatedId: note.id,
|
||||
tag: `note-${note.id}`,
|
||||
url: "/notes",
|
||||
url: `/notes?thread=${note.id}`,
|
||||
});
|
||||
await emitToUser(r.recipientUserId, "note_received", {
|
||||
noteId: note.id,
|
||||
@@ -1488,6 +1490,8 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
|
||||
bodyAr: `${replierNameAr} رد على ملاحظة`,
|
||||
bodyEn: `${replierName} replied on a note`,
|
||||
type: "note",
|
||||
relatedType: "note",
|
||||
relatedId: id.id,
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
|
||||
@@ -1501,7 +1505,7 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
|
||||
type: "note",
|
||||
relatedId: id.id,
|
||||
tag: `note-reply-${reply.id}`,
|
||||
url: "/notes",
|
||||
url: `/notes?thread=${id.id}`,
|
||||
});
|
||||
// Enrich the realtime payload so the recipient's client can render the
|
||||
// floating reply card without an extra round-trip. We truncate the body
|
||||
|
||||
@@ -117,9 +117,19 @@ async function notifyUser(
|
||||
bodyEn: string,
|
||||
orderId: number,
|
||||
actorId?: number,
|
||||
// Which order surface the deep-link should open: the recipient's own
|
||||
// "My orders" list (status/cancel updates) or the "Incoming orders"
|
||||
// queue (a brand-new order to claim). Drives both the stored
|
||||
// notification's relatedType and the Web Push deep-link URL so a tap
|
||||
// lands on the right page with the order highlighted.
|
||||
target: "mine" | "incoming" = "mine",
|
||||
) {
|
||||
// Never notify users about actions they themselves initiated.
|
||||
if (actorId !== undefined && actorId === userId) return;
|
||||
const relatedType = target === "incoming" ? "incoming_order" : "order";
|
||||
const deepLinkUrl = `${
|
||||
target === "incoming" ? "/orders/incoming" : "/my-orders"
|
||||
}?order=${orderId}`;
|
||||
const [notification] = await db
|
||||
.insert(notificationsTable)
|
||||
.values({
|
||||
@@ -130,7 +140,7 @@ async function notifyUser(
|
||||
bodyEn,
|
||||
type: "order",
|
||||
relatedId: orderId,
|
||||
relatedType: "order",
|
||||
relatedType,
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(userId, "notification_created", notification);
|
||||
@@ -154,7 +164,7 @@ async function notifyUser(
|
||||
type: "order",
|
||||
relatedId: orderId,
|
||||
tag: `order-${orderId}`,
|
||||
url: "/my-orders",
|
||||
url: deepLinkUrl,
|
||||
},
|
||||
{ ignoreConnected: true },
|
||||
);
|
||||
@@ -209,6 +219,7 @@ router.post("/orders", requireAuth, async (req, res): Promise<void> => {
|
||||
service.nameEn,
|
||||
order.id,
|
||||
userId,
|
||||
"incoming",
|
||||
);
|
||||
}
|
||||
await broadcastIncomingChanged(receivers);
|
||||
|
||||
@@ -18,7 +18,12 @@ function scopedUrl(relative) {
|
||||
// Strip a leading slash from `relative` so URL() treats it as a
|
||||
// path under scope rather than origin-relative.
|
||||
const cleaned = String(relative || "").replace(/^\/+/, "");
|
||||
return new URL(cleaned, self.registration.scope).pathname;
|
||||
const resolved = new URL(cleaned, self.registration.scope);
|
||||
// Preserve the query string and hash so notification deep-links
|
||||
// (e.g. "/meetings?meeting=12" or "/notes?thread=7") survive the click
|
||||
// handler — taking only .pathname would drop them and land the user on
|
||||
// the generic page instead of the specific item.
|
||||
return resolved.pathname + resolved.search + resolved.hash;
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -2450,6 +2450,84 @@ function ScheduleSection({
|
||||
};
|
||||
}, [highlightEditRowId, meetings]);
|
||||
|
||||
// --- Deep-link: /meetings?meeting=<id> (optionally &date=YYYY-MM-DD) ---
|
||||
// Opened from a notification (in-app list or system Web Push). We switch
|
||||
// the schedule to the meeting's day, then scroll + flash its row reusing
|
||||
// the same accent-ring as the row "Edit" affordance. When no date is
|
||||
// supplied we resolve it from the meeting id via the API so the same link
|
||||
// works regardless of which day is currently shown.
|
||||
const deepLinkSearch = useSearch();
|
||||
const deepLinkHandledRef = useRef<string | null>(null);
|
||||
const [pendingDeepLinkMeetingId, setPendingDeepLinkMeetingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(deepLinkSearch);
|
||||
const meetingParam = params.get("meeting");
|
||||
if (!meetingParam) return;
|
||||
if (deepLinkHandledRef.current === meetingParam) return;
|
||||
const id = Number(meetingParam);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
deepLinkHandledRef.current = meetingParam;
|
||||
setPendingDeepLinkMeetingId(id);
|
||||
const dateParam = params.get("date");
|
||||
if (dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
|
||||
if (dateParam !== date) onDateChange(dateParam);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void apiJson<{ meetingDate?: string }>(`/api/executive-meetings/${id}`)
|
||||
.then((m) => {
|
||||
if (cancelled) return;
|
||||
if (m?.meetingDate && m.meetingDate !== date) onDateChange(m.meetingDate);
|
||||
})
|
||||
.catch(() => {
|
||||
// Meeting may have been deleted/moved — leave the user on the
|
||||
// current day rather than surfacing an error for a stale link.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [deepLinkSearch, date, onDateChange]);
|
||||
useEffect(() => {
|
||||
if (pendingDeepLinkMeetingId == null) return;
|
||||
if (!meetings.some((m) => m.id === pendingDeepLinkMeetingId)) return;
|
||||
setHighlightEditRowId(pendingDeepLinkMeetingId);
|
||||
setPendingDeepLinkMeetingId(null);
|
||||
deepLinkHandledRef.current = null;
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("meeting");
|
||||
params.delete("date");
|
||||
const qs = params.toString();
|
||||
const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
}, [pendingDeepLinkMeetingId, meetings]);
|
||||
useEffect(() => {
|
||||
if (pendingDeepLinkMeetingId == null) return;
|
||||
// Safety net for stale links: if the target meeting never materializes
|
||||
// (deleted, moved to another day we can't resolve, or simply not in the
|
||||
// loaded set), clear the pending state and strip the param after a grace
|
||||
// period so the URL doesn't linger and a later valid link still works.
|
||||
const stale = window.setTimeout(() => {
|
||||
setPendingDeepLinkMeetingId(null);
|
||||
deepLinkHandledRef.current = null;
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("meeting");
|
||||
params.delete("date");
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
}, 8000);
|
||||
return () => window.clearTimeout(stale);
|
||||
}, [pendingDeepLinkMeetingId]);
|
||||
|
||||
// #489: Drag-from-anywhere on the row rotates meeting CONTENT through
|
||||
// the day's fixed time slots. The (startTime, endTime, dailyNumber)
|
||||
// tuple of each chronological slot stays anchored to its physical
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useListMyServiceOrders,
|
||||
@@ -159,10 +159,12 @@ function OrderCard({
|
||||
order,
|
||||
onDelete,
|
||||
onReorder,
|
||||
highlighted = false,
|
||||
}: {
|
||||
order: ServiceOrder;
|
||||
onDelete: (id: number) => void;
|
||||
onReorder: (order: ServiceOrder) => void;
|
||||
highlighted?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -236,7 +238,13 @@ function OrderCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel rounded-xl p-4 flex flex-col gap-3">
|
||||
<div
|
||||
data-order-id={order.id}
|
||||
className={cn(
|
||||
"glass-panel rounded-xl p-4 flex flex-col gap-3 transition-shadow",
|
||||
highlighted && "ring-2 ring-primary ring-offset-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<OrderImage src={img} alt={name} />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -433,6 +441,53 @@ export default function MyOrdersPage() {
|
||||
)
|
||||
: [];
|
||||
|
||||
// Deep-link from a notification: /my-orders?order=<id> scrolls to and
|
||||
// flashes the matching order card, then strips the param so re-renders
|
||||
// don't re-trigger the jump.
|
||||
const orderSearch = useSearch();
|
||||
const [highlightOrderId, setHighlightOrderId] = useState<number | null>(null);
|
||||
const highlightHandledRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(orderSearch);
|
||||
const orderParam = params.get("order");
|
||||
if (!orderParam) return;
|
||||
if (highlightHandledRef.current === orderParam) return;
|
||||
const id = Number(orderParam);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
highlightHandledRef.current = orderParam;
|
||||
setHighlightOrderId(id);
|
||||
if (typeof window !== "undefined") {
|
||||
params.delete("order");
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
}, [orderSearch]);
|
||||
useEffect(() => {
|
||||
if (highlightOrderId == null) return;
|
||||
if (!sortedOrders.some((o) => o.id === highlightOrderId)) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-order-id="${highlightOrderId}"]`);
|
||||
if (el && "scrollIntoView" in el) {
|
||||
(el as HTMLElement).scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
const clear = window.setTimeout(() => {
|
||||
setHighlightOrderId(null);
|
||||
highlightHandledRef.current = null;
|
||||
}, 2000);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.clearTimeout(clear);
|
||||
};
|
||||
}, [highlightOrderId, sortedOrders]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex flex-col">
|
||||
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
|
||||
@@ -488,6 +543,7 @@ export default function MyOrdersPage() {
|
||||
<OrderCard
|
||||
key={o.id}
|
||||
order={o}
|
||||
highlighted={o.id === highlightOrderId}
|
||||
onDelete={(id) => scheduleDelete([id])}
|
||||
onReorder={(order) =>
|
||||
setReorderTarget({
|
||||
|
||||
@@ -14,6 +14,34 @@ import { AppDock } from "@/components/app-dock";
|
||||
import { formatDateTime } from "@/lib/i18n-format";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
// Map a notification to the in-app deep-link it should open when tapped.
|
||||
// Mirrors the Web Push `url` built on the server so the in-app list and a
|
||||
// system push notification land on the exact same item: a note opens its
|
||||
// thread, a meeting opens that meeting on its day, and an order opens the
|
||||
// right order surface ("Incoming orders" for a fresh order to claim,
|
||||
// otherwise the recipient's "My orders"). Returns null when there is no
|
||||
// specific destination (the tap then only marks the item read).
|
||||
function notificationDestination(n: {
|
||||
type: string;
|
||||
relatedType?: string | null;
|
||||
relatedId?: number | null;
|
||||
}): string | null {
|
||||
const id = n.relatedId;
|
||||
switch (n.type) {
|
||||
case "note":
|
||||
return id ? `/notes?thread=${id}` : "/notes";
|
||||
case "executive_meeting":
|
||||
return id ? `/meetings?meeting=${id}` : "/meetings";
|
||||
case "order": {
|
||||
const base =
|
||||
n.relatedType === "incoming_order" ? "/orders/incoming" : "/my-orders";
|
||||
return id ? `${base}?order=${id}` : base;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -53,10 +81,15 @@ export default function NotificationsPage() {
|
||||
const handleNotificationClick = (n: {
|
||||
id: number;
|
||||
isRead: boolean;
|
||||
type: string;
|
||||
relatedType?: string | null;
|
||||
relatedId?: number | null;
|
||||
}) => {
|
||||
if (!n.isRead) {
|
||||
handleMarkOne(n.id);
|
||||
}
|
||||
const dest = notificationDestination(n);
|
||||
if (dest) setLocation(dest);
|
||||
};
|
||||
|
||||
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useListIncomingServiceOrders,
|
||||
@@ -105,6 +105,7 @@ function IncomingOrderCard({
|
||||
selected,
|
||||
onToggleSelected,
|
||||
onRequestDelete,
|
||||
highlighted = false,
|
||||
}: {
|
||||
order: ServiceOrder;
|
||||
meId: number;
|
||||
@@ -112,6 +113,7 @@ function IncomingOrderCard({
|
||||
selected: boolean;
|
||||
onToggleSelected: (id: number) => void;
|
||||
onRequestDelete: (id: number) => void;
|
||||
highlighted?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -279,6 +281,7 @@ function IncomingOrderCard({
|
||||
className={cn(
|
||||
"glass-panel rounded-xl p-3 flex flex-col gap-3 transition-colors",
|
||||
selected && "ring-2 ring-amber-400 bg-amber-50/40",
|
||||
!selected && highlighted && "ring-2 ring-primary ring-offset-2",
|
||||
)}
|
||||
data-testid={`incoming-order-card-${order.id}`}
|
||||
>
|
||||
@@ -406,6 +409,55 @@ export default function OrdersIncomingPage() {
|
||||
[mineIds, unclaimedIds],
|
||||
);
|
||||
|
||||
// Deep-link from a notification: /orders/incoming?order=<id> scrolls to
|
||||
// and flashes the matching order card, then strips the param so
|
||||
// re-renders don't re-trigger the jump.
|
||||
const orderSearch = useSearch();
|
||||
const [highlightOrderId, setHighlightOrderId] = useState<number | null>(null);
|
||||
const highlightHandledRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(orderSearch);
|
||||
const orderParam = params.get("order");
|
||||
if (!orderParam) return;
|
||||
if (highlightHandledRef.current === orderParam) return;
|
||||
const id = Number(orderParam);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
highlightHandledRef.current = orderParam;
|
||||
setHighlightOrderId(id);
|
||||
if (typeof window !== "undefined") {
|
||||
params.delete("order");
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
}, [orderSearch]);
|
||||
useEffect(() => {
|
||||
if (highlightOrderId == null) return;
|
||||
if (![...mine, ...unclaimed].some((o) => o.id === highlightOrderId)) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-testid="incoming-order-card-${highlightOrderId}"]`,
|
||||
);
|
||||
if (el && "scrollIntoView" in el) {
|
||||
(el as HTMLElement).scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
const clear = window.setTimeout(() => {
|
||||
setHighlightOrderId(null);
|
||||
highlightHandledRef.current = null;
|
||||
}, 2000);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.clearTimeout(clear);
|
||||
};
|
||||
}, [highlightOrderId, mine, unclaimed]);
|
||||
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
// confirmIds === null → no dialog; otherwise the ids that confirming will delete.
|
||||
const [confirmIds, setConfirmIds] = useState<number[] | null>(null);
|
||||
@@ -610,6 +662,7 @@ export default function OrdersIncomingPage() {
|
||||
key={o.id}
|
||||
order={o}
|
||||
meId={meId}
|
||||
highlighted={o.id === highlightOrderId}
|
||||
onActionDone={refetch}
|
||||
selected={validSelected.has(o.id)}
|
||||
onToggleSelected={toggleOne}
|
||||
@@ -649,6 +702,7 @@ export default function OrdersIncomingPage() {
|
||||
key={o.id}
|
||||
order={o}
|
||||
meId={meId}
|
||||
highlighted={o.id === highlightOrderId}
|
||||
onActionDone={refetch}
|
||||
selected={validSelected.has(o.id)}
|
||||
onToggleSelected={toggleOne}
|
||||
|
||||
Reference in New Issue
Block a user