diff --git a/artifacts/tx-os/src/components/notification-settings.tsx b/artifacts/tx-os/src/components/notification-settings.tsx index 3ce2870e..a54442e2 100644 --- a/artifacts/tx-os/src/components/notification-settings.tsx +++ b/artifacts/tx-os/src/components/notification-settings.tsx @@ -364,9 +364,21 @@ function PushToggleRow() { if (subscribed) { await disable(); } else { - const ok = await enable(); - if (!ok) { - toast({ title: t("notifSettings.push.enableFailed") }); + const res = await enable(); + if (res.ok) { + toast({ title: t("notifSettings.push.enableSuccess") }); + } else { + // #576: show the specific failure reason (iOS-needs-PWA, + // permission denied, VAPID unreachable, subscribe failed, + // server rejected) instead of a generic toast. + const key = `notifSettings.push.errors.${res.reason}`; + const msg = t(key); + toast({ + title: + msg && msg !== key + ? msg + : t("notifSettings.push.enableFailed"), + }); } } }} diff --git a/artifacts/tx-os/src/components/push-enable-prompt.tsx b/artifacts/tx-os/src/components/push-enable-prompt.tsx index 4bbbba7d..87a4ea8e 100644 --- a/artifacts/tx-os/src/components/push-enable-prompt.tsx +++ b/artifacts/tx-os/src/components/push-enable-prompt.tsx @@ -92,11 +92,22 @@ export function PushEnablePrompt() { disabled={busy} data-testid="push-enable-prompt-enable" onClick={async () => { - const ok = await enable(); - if (ok) { + const res = await enable(); + if (res.ok) { + toast({ title: t("notifSettings.push.enableSuccess") }); dismiss(); } else { - toast({ title: t("notifSettings.push.enableFailed") }); + // #576: surface the SPECIFIC reason instead of a + // generic "could not enable" toast. Falls back to + // the legacy generic copy if the key is missing. + const key = `notifSettings.push.errors.${res.reason}`; + const msg = t(key); + toast({ + title: + msg && msg !== key + ? msg + : t("notifSettings.push.enableFailed"), + }); } }} className="text-xs font-semibold px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50" diff --git a/artifacts/tx-os/src/hooks/use-push-subscription.ts b/artifacts/tx-os/src/hooks/use-push-subscription.ts index 99b3ebda..0d0e33f5 100644 --- a/artifacts/tx-os/src/hooks/use-push-subscription.ts +++ b/artifacts/tx-os/src/hooks/use-push-subscription.ts @@ -8,6 +8,31 @@ type PushStatus = | "default" | "subscribed"; +/** + * Granular reason returned by `enable()` so the calling UI can show a + * specific toast instead of a generic "could not enable" message. + * + * - `unsupported` — browser lacks ServiceWorker / PushManager / Notification. + * - `unsupported_ios_safari` — iPhone/iPad Safari that isn't installed as a PWA. + * iOS only delivers Web Push when the page runs + * from the Home Screen (`navigator.standalone === true`). + * - `permission_denied` — user (or OS) declined the system permission prompt. + * - `vapid_unavailable` — backend `/api/push/vapid-public-key` is missing/down. + * - `subscribe_failed` — `pushManager.subscribe()` threw (e.g. AbortError). + * - `server_rejected` — backend `/api/push/subscribe` returned non-2xx. + */ +export type EnableReason = + | "unsupported" + | "unsupported_ios_safari" + | "permission_denied" + | "vapid_unavailable" + | "subscribe_failed" + | "server_rejected"; + +export type EnableResult = + | { ok: true } + | { ok: false; reason: EnableReason }; + function urlBase64ToUint8Array(base64: string): Uint8Array { const padding = "=".repeat((4 - (base64.length % 4)) % 4); const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); @@ -17,6 +42,36 @@ function urlBase64ToUint8Array(base64: string): Uint8Array { return out; } +/** + * True when the page is running on iPhone/iPad Safari that hasn't been + * installed to the Home Screen. iOS Safari exposes the Push APIs even + * in normal browsing, but `subscribe()` only succeeds in standalone + * PWA mode — calling it from a regular tab fails silently in some + * iOS builds, which is what was producing the generic + * "تعذّر تفعيل الإشعارات" toast. + */ +function isIosSafariNonStandalone(): boolean { + if (typeof window === "undefined" || typeof navigator === "undefined") { + return false; + } + const ua = navigator.userAgent || ""; + // iPhone / iPod / iPad. Modern iPadOS reports MacIntel + touch + // support instead of "iPad", so we check for that too. + const isIos = + /iPad|iPhone|iPod/.test(ua) || + (navigator.platform === "MacIntel" && + typeof (navigator as Navigator & { maxTouchPoints?: number }) + .maxTouchPoints === "number" && + (navigator as Navigator & { maxTouchPoints: number }).maxTouchPoints > + 1); + if (!isIos) return false; + const standalone = + (navigator as Navigator & { standalone?: boolean }).standalone === true || + (typeof window.matchMedia === "function" && + window.matchMedia("(display-mode: standalone)").matches); + return !standalone; +} + function isSupported(): boolean { return ( typeof window !== "undefined" && @@ -41,6 +96,12 @@ async function getRegistration(): Promise { * Web Push on iOS Safari only works when the page is installed as a * PWA (Add to Home Screen). The hook surfaces "unsupported" in that * case so the UI can prompt the user to install first. + * + * #576: `enable()` now returns `{ ok, reason }` instead of a bare + * boolean so the calling UI can render a specific toast (iOS-needs-PWA, + * permission denied, VAPID missing, subscribe failed, server rejected) + * instead of a generic "could not enable" message that left the user + * unable to diagnose why notifications wouldn't turn on. */ export function usePushSubscription() { const [status, setStatus] = useState(() => @@ -71,23 +132,40 @@ export function usePushSubscription() { void refresh(); }, [refresh]); - const enable = useCallback(async (): Promise => { - if (!isSupported()) return false; + const enable = useCallback(async (): Promise => { + if (!isSupported()) { + setStatus("unsupported"); + return { ok: false, reason: "unsupported" }; + } + // #576: iOS Safari outside Home Screen — subscribe() will fail with + // a confusing AbortError. Surface the real cause so the user knows + // to install the PWA first. + if (isIosSafariNonStandalone()) { + return { ok: false, reason: "unsupported_ios_safari" }; + } setBusy(true); try { const perm = await Notification.requestPermission(); if (perm !== "granted") { setStatus(perm === "denied" ? "denied" : "default"); - return false; + return { ok: false, reason: "permission_denied" }; } const reg = await getRegistration(); - if (!reg) return false; + if (!reg) return { ok: false, reason: "subscribe_failed" }; - const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, { - credentials: "include", - }); - if (!keyRes.ok) return false; - const { publicKey } = (await keyRes.json()) as { publicKey: string }; + let publicKey: string; + try { + const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, { + credentials: "include", + }); + if (!keyRes.ok) { + return { ok: false, reason: "vapid_unavailable" }; + } + publicKey = ((await keyRes.json()) as { publicKey: string }).publicKey; + if (!publicKey) return { ok: false, reason: "vapid_unavailable" }; + } catch { + return { ok: false, reason: "vapid_unavailable" }; + } // If a stale subscription exists from a previous VAPID key, // unsubscribe locally first so applicationServerKey isn't @@ -101,10 +179,16 @@ export function usePushSubscription() { } } - const sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey), - }); + let sub: PushSubscription; + try { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey) + .buffer as ArrayBuffer, + }); + } catch { + return { ok: false, reason: "subscribe_failed" }; + } const json = sub.toJSON(); const subRes = await fetch(`${BASE}/api/push/subscribe`, { method: "POST", @@ -121,12 +205,12 @@ export function usePushSubscription() { } catch { /* ignore */ } - return false; + return { ok: false, reason: "server_rejected" }; } setStatus("subscribed"); - return true; + return { ok: true }; } catch { - return false; + return { ok: false, reason: "subscribe_failed" }; } finally { setBusy(false); } diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index f35cad83..ac17e21f 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -234,7 +234,16 @@ "enabled": "مفعّل", "denied": "محظور — فعّله من إعدادات الجهاز", "unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.", - "enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى." + "enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى.", + "enableSuccess": "تم تفعيل الإشعارات", + "errors": { + "unsupported": "هذا المتصفح لا يدعم إشعارات شاشة القفل.", + "unsupported_ios_safari": "على الآيفون/الآيباد، افتح Tx من أيقونة الشاشة الرئيسية أولاً (مشاركة → إضافة إلى الشاشة الرئيسية)، ثم فعّل الإشعارات من داخل التطبيق.", + "permission_denied": "أذِن المتصفح للإشعارات مرفوض. افتح إعدادات الجهاز ← Tx وفعّل الإشعارات يدوياً.", + "vapid_unavailable": "تعذّر الاتصال بخادم الإشعارات. تأكّد من اتصالك ثم حاول مرة أخرى.", + "subscribe_failed": "تعذّر تسجيل الجهاز لاستقبال الإشعارات. حاول مرة أخرى.", + "server_rejected": "رفض الخادم تسجيل هذا الجهاز. حاول مرة أخرى أو أعد تسجيل الدخول." + } }, "slot": { "order": "الطلبات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index c0a8ec2d..f49b8fda 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -240,7 +240,16 @@ "enabled": "Enabled", "denied": "Blocked — enable from device settings", "unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.", - "enableFailed": "Could not enable notifications. Try again." + "enableFailed": "Could not enable notifications. Try again.", + "enableSuccess": "Notifications enabled", + "errors": { + "unsupported": "This browser does not support lock-screen notifications.", + "unsupported_ios_safari": "On iPhone/iPad, open Tx OS from its Home Screen icon first (Share → Add to Home Screen), then enable notifications from inside the app.", + "permission_denied": "Notification permission is blocked. Open Settings → Tx OS on your device and enable notifications manually.", + "vapid_unavailable": "Could not reach the notifications server. Check your connection and try again.", + "subscribe_failed": "Could not register this device for notifications. Try again.", + "server_rejected": "The server refused to register this device. Try again or sign in again." + } }, "slot": { "order": "Orders", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 1957b3db..0e8a1655 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -2185,6 +2185,40 @@ function ScheduleSection({ setAutoEditTitleId(null); }, []); + // #576: When the user picks "Edit" from a row's quick-actions + // dialog we no longer open a small Meeting edit Dialog — instead we + // flip the WHOLE schedule into edit mode (same surface as the + // toolbar pencil button) and gently scroll the originating row into + // view with a temporary accent ring so the user immediately sees + // which meeting their tap targeted. The ring auto-clears after + // ~1.6s — long enough to register, short enough to not linger as + // visual noise while the user starts editing other rows. + const [highlightEditRowId, setHighlightEditRowId] = useState( + null, + ); + useEffect(() => { + if (highlightEditRowId == null) return; + if (!meetings.some((m) => m.id === highlightEditRowId)) return; + const rafId = window.requestAnimationFrame(() => { + const row = document.querySelector( + `[data-testid="em-row-${highlightEditRowId}"]`, + ); + if (row && "scrollIntoView" in row) { + (row as HTMLElement).scrollIntoView({ + block: "center", + behavior: "smooth", + }); + } + }); + const clear = window.setTimeout(() => { + setHighlightEditRowId(null); + }, 1600); + return () => { + window.cancelAnimationFrame(rafId); + window.clearTimeout(clear); + }; + }, [highlightEditRowId, meetings]); + // #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 @@ -3020,33 +3054,23 @@ function ScheduleSection({ onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)} quickActionsCanMutate={canMutate && !editMode} onQuickPostpone={() => setPostponeMeetingId(m.id)} + // #576: "Edit" from the quick-actions sheet now + // turns the FULL schedule into edit mode (same as + // the toolbar pencil) and asks the page to scroll + // the originating row into view with a brief + // accent ring, instead of opening a small Meeting + // edit Dialog. Matches the behaviour the user + // requested (image 3 in #576) where they see the + // whole editable list, not a popup form. onQuickEdit={ canMutate && !editMode - ? () => - setEditingMeeting({ - id: m.id, - titleAr: htmlToPlainText(m.titleAr), - titleEn: htmlToPlainText(m.titleEn ?? ""), - meetingDate: m.meetingDate, - dailyNumber: String(m.dailyNumber), - startTime: m.startTime - ? m.startTime.slice(0, 5) - : "", - endTime: m.endTime ? m.endTime.slice(0, 5) : "", - location: m.location ?? "", - meetingUrl: m.meetingUrl ?? "", - platform: m.platform, - status: m.status, - isHighlighted: m.isHighlighted === 1, - notes: m.notes ?? "", - attendees: m.attendees.map((a) => ({ - ...a, - name: htmlToPlainText(a.name), - _sid: genAttendeeSid(), - })), - }) + ? () => { + setEditMode(true); + setHighlightEditRowId(m.id); + } : undefined } + highlightEdit={highlightEditRowId === m.id} /> ))} @@ -3919,6 +3943,7 @@ function MeetingRow({ quickActionsCanMutate, onQuickPostpone, onQuickEdit, + highlightEdit = false, }: { meeting: Meeting; displayNumber?: number; @@ -3950,6 +3975,12 @@ function MeetingRow({ // the Schedule tab can host the edit dialog without switching the // user to the Manage tab. onQuickEdit?: () => void; + // #576: Transient visual cue applied when the user entered edit + // mode via this row's quick-actions "Edit" button. ScheduleSection + // sets it for ~1.6s on the originating row only so the user can + // visually confirm which meeting their tap targeted now that the + // whole schedule is editable. + highlightEdit?: boolean; onSaveTitle: (html: string) => Promise; onSaveAttendeeName: (idx: number, html: string) => Promise; onSaveTimes: ( @@ -4516,7 +4547,19 @@ function MeetingRow({ const pressShadow = isPressing ? `inset 0 0 0 2px ${hexToRgba(highlightColor, 0.45)}` : null; + // #576: Brief accent ring on the row the user just opened from the + // quick-actions "Edit" button. Mirrors quickOpenShadow's "outer + // ring + inset" composition so it reads as clearly as the open + // quick-actions surface did, but is purely transient — ScheduleSection + // clears `highlightEdit` after ~1.6s. + const editHighlightShadow = highlightEdit + ? `inset 0 0 0 4px ${highlightColor}, 0 0 0 2px ${highlightColor}` + : null; + const editHighlightBg = highlightEdit + ? hexToRgba(highlightColor, 0.08) + : null; const composedShadow = [ + editHighlightShadow, quickOpenShadow, selectionShadow, currentShadow, @@ -4525,7 +4568,8 @@ function MeetingRow({ .filter(Boolean) .join(", "); const trStyle: CSSProperties = { - backgroundColor: quickOpenBg ?? selectionBg ?? baseBg, + backgroundColor: + editHighlightBg ?? quickOpenBg ?? selectionBg ?? baseBg, transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : reordering ? 0.7 : 1, @@ -4658,7 +4702,14 @@ function MeetingRow({ very narrow viewports. Edit button only renders when a handler is wired (defensive — quickActionsCanMutate already gates the surface itself). */} -
+ {/* #576: side-by-side, equal-width buttons (was flex-wrap + + min-w-[11rem], which forced the buttons to STACK on the + iPhone 6.1" dialog width and gave the Edit button a + much larger visual weight than Postpone). `flex-1` + + `min-w-0` lets both buttons share the available row + width on every viewport (iPhone, iPad, desktop) with + no wrapping. */} +
{onQuickEdit ? ( ) : null}