feat(05-08): PermissionDeniedBanner + App mount + CalendarShell onOpenSettings wiring

- Create PermissionDeniedBanner.tsx: role=alert banner shown only when
  permission=denied AND notificationsEnabled=1 (OS-revoked case, D-10)
  with AlertCircle icon, 'Notifications blocked' heading, inline 'How to enable'
  button that opens OS-specific instruction sheet (iOS 4-step / Android 4-step)
- Mount PermissionDeniedBanner and SettingsSheet in App.tsx; wire onOpenSettings
  state from avatar tap through CalendarShell → AppNav → PhoneNav/DesktopNav
- CalendarShell accepts optional onOpenSettings prop, threads to both AppNav usages
- playwright-cli verified: banner renders with exact UI-SPEC copy when
  permission=denied+was-enabled; banner absent when permission=granted;
  'How to enable' opens instruction sheet with correct Android steps;
  SettingsSheet opens from avatar click with toggle + permission-denied hint
This commit is contained in:
Lucas Berger
2026-06-09 21:50:48 -04:00
parent 1de4aa5a3e
commit 010a69c047
3 changed files with 302 additions and 2 deletions
+3 -1
View File
@@ -72,7 +72,7 @@ function isPhone(): boolean {
// ── Component ──────────────────────────────────────────────────────────────
export function CalendarShell() {
export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void } = {}) {
// Use per-field selectors so CalendarShell does NOT subscribe to openEventId.
// Without selectors, any popover open/close triggers a full re-render here,
// which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B).
@@ -358,6 +358,7 @@ export function CalendarShell() {
members={members}
currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined}
onOpenSettings={onOpenSettings}
/>
<InstallPrompt />
{calendarContent}
@@ -421,6 +422,7 @@ export function CalendarShell() {
members={members}
currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined}
onOpenSettings={onOpenSettings}
/>
{/* Main content area */}
@@ -0,0 +1,280 @@
/**
* PermissionDeniedBanner — persistent OS-revoked notification banner (D-10).
*
* Shown ONLY when:
* - Notification.permission === 'denied' (OS revoked), AND
* - localStorage.notificationsEnabled was previously '1' (user had it on)
*
* Silent re-subscribe (D-10) covers the expired-subscription case. This banner
* is strictly for the OS-revoked case — the one case the app cannot silently fix.
*
* UI-SPEC §Surface 3:
* - role="alert" (assertive live region — permission loss is high-priority)
* - AlertCircle (--color-destructive) + "Notifications blocked" heading
* - "Re-enable in your browser settings." + "How to enable" inline button
* - No dismiss — persists until OS permission is restored
* - "How to enable" opens an OS-specific instruction sheet (iOS 4-step / Android 4-step)
*
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
*/
import { useState } from 'react'
import { AlertCircle, X } from 'lucide-react'
import { readNotificationsEnabled } from '../hooks/usePushSubscription.js'
// ── OS detection ──────────────────────────────────────────────────────────
function isIOS(): boolean {
return /iPad|iPhone|iPod/.test(navigator.userAgent) &&
!(window as unknown as { MSStream?: unknown }).MSStream
}
// ── Instruction steps ────────────────────────────────────────────────────
const IOS_STEPS = [
'Open Settings on your iPhone',
'Scroll down and tap Safari',
'Tap Notifications',
'Allow notifications for FamilySync',
]
const ANDROID_STEPS = [
'Open Chrome on your phone',
'Tap the three-dot menu → Settings',
'Tap Site Settings → Notifications',
'Find FamilySync and tap Allow',
]
// ── Instruction sheet ────────────────────────────────────────────────────
interface InstructionSheetProps {
onClose: () => void
}
function InstructionSheet({ onClose }: InstructionSheetProps) {
const steps = isIOS() ? IOS_STEPS : ANDROID_STEPS
const platform = isIOS() ? 'iOS' : 'Android'
return (
<div
role="dialog"
aria-modal="true"
aria-label={`Re-enable notifications on ${platform}`}
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
zIndex: 1000,
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
style={{
background: 'var(--color-surface, #ffffff)',
borderRadius: '12px 12px 0 0',
padding: 'var(--space-6, 24px)',
maxHeight: '90dvh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-4, 16px)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<h2
style={{
margin: 0,
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
}}
>
How to enable notifications
</h2>
<button
onClick={onClose}
aria-label="Close instructions"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-secondary, #5c6472)',
borderRadius: 'var(--space-1, 4px)',
}}
>
<X size={20} aria-hidden="true" />
</button>
</div>
{/* Steps */}
<ol
style={{
margin: 0,
padding: 0,
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-3, 12px)',
}}
>
{steps.map((step, i) => (
<li
key={i}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--space-3, 12px)',
}}
>
<span
style={{
flexShrink: 0,
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'var(--color-destructive, #DC2626)',
color: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 600,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
{i + 1}
</span>
<span
style={{
fontSize: 'var(--text-body-size, 15px)',
lineHeight: 'var(--text-body-line-height, 1.5)',
color: 'var(--color-text-primary, #111318)',
paddingTop: '4px',
}}
>
{step}
</span>
</li>
))}
</ol>
{/* Done button */}
<button
onClick={onClose}
style={{
background: 'var(--color-text-primary, #111318)',
color: '#ffffff',
border: 'none',
borderRadius: 'var(--space-1, 4px)',
minHeight: '44px',
padding: '0 var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
alignSelf: 'stretch',
}}
>
Done
</button>
</div>
</div>
)
}
// ── PermissionDeniedBanner ────────────────────────────────────────────────
export function PermissionDeniedBanner() {
const [instructionsOpen, setInstructionsOpen] = useState(false)
// Only show when OS permission is 'denied' AND the user previously had notifications on.
// This is the OS-revoked case (D-10). Silent re-subscribe handles the expired-sub case.
const permissionDenied =
typeof Notification !== 'undefined' && Notification.permission === 'denied'
const wasEnabled = readNotificationsEnabled()
if (!permissionDenied || !wasEnabled) return null
return (
<>
<div
role="alert"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
background: 'var(--color-surface-raised, #ffffff)',
borderBottom: '1px solid var(--color-border, #e2e4e9)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
<AlertCircle
size={24}
aria-hidden="true"
style={{ color: 'var(--color-destructive, #DC2626)', flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
lineHeight: 1.4,
}}
>
Notifications blocked
</div>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-text-secondary, #5c6472)',
lineHeight: 1.4,
}}
>
Re-enable in your browser settings.{' '}
<button
onClick={() => setInstructionsOpen(true)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-focus-ring, #4A90D9)',
textDecoration: 'underline',
fontFamily: 'inherit',
}}
>
How to enable
</button>
</div>
</div>
</div>
{instructionsOpen && (
<InstructionSheet onClose={() => setInstructionsOpen(false)} />
)}
</>
)
}