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:
+19
-1
@@ -15,21 +15,36 @@
|
|||||||
* navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to
|
* navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to
|
||||||
* /lists/* — the SW denylist only excludes /callback, /api/, and /health, so
|
* /lists/* — the SW denylist only excludes /callback, /api/, and /health, so
|
||||||
* /lists/* is served from cache correctly.
|
* /lists/* is served from cache correctly.
|
||||||
|
*
|
||||||
|
* Phase 5 additions:
|
||||||
|
* - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10)
|
||||||
|
* - SettingsSheet: avatar-triggered bottom sheet with master notifications toggle (D-09)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
||||||
import { CalendarShell } from './components/CalendarShell.js'
|
import { CalendarShell } from './components/CalendarShell.js'
|
||||||
import { ListsIndex } from './routes/ListsIndex.js'
|
import { ListsIndex } from './routes/ListsIndex.js'
|
||||||
import { ListDetail } from './routes/ListDetail.js'
|
import { ListDetail } from './routes/ListDetail.js'
|
||||||
import { BottomTabBar } from './components/BottomTabBar.js'
|
import { BottomTabBar } from './components/BottomTabBar.js'
|
||||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'
|
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'
|
||||||
|
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'
|
||||||
|
import { SettingsSheet } from './components/SettingsSheet.js'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
{/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
|
||||||
|
<PermissionDeniedBanner />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
||||||
<Route path="/calendar" element={<CalendarShell />} />
|
<Route
|
||||||
|
path="/calendar"
|
||||||
|
element={<CalendarShell onOpenSettings={() => setSettingsOpen(true)} />}
|
||||||
|
/>
|
||||||
<Route path="/lists" element={<ListsIndex />} />
|
<Route path="/lists" element={<ListsIndex />} />
|
||||||
<Route path="/lists/:listId" element={<ListDetail />} />
|
<Route path="/lists/:listId" element={<ListDetail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -37,6 +52,9 @@ export default function App() {
|
|||||||
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||||
and Notification.permission === 'default' and not dismissed */}
|
and Notification.permission === 'default' and not dismissed */}
|
||||||
<PushPermissionPrompt />
|
<PushPermissionPrompt />
|
||||||
|
|
||||||
|
{/* Settings sheet — master notifications toggle (D-09), opened from avatar */}
|
||||||
|
<SettingsSheet isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function isPhone(): boolean {
|
|||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CalendarShell() {
|
export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void } = {}) {
|
||||||
// Use per-field selectors so CalendarShell does NOT subscribe to openEventId.
|
// Use per-field selectors so CalendarShell does NOT subscribe to openEventId.
|
||||||
// Without selectors, any popover open/close triggers a full re-render here,
|
// 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).
|
// which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B).
|
||||||
@@ -358,6 +358,7 @@ export function CalendarShell() {
|
|||||||
members={members}
|
members={members}
|
||||||
currentUserColor={meQuery.data?.user.color}
|
currentUserColor={meQuery.data?.user.color}
|
||||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
{calendarContent}
|
{calendarContent}
|
||||||
@@ -421,6 +422,7 @@ export function CalendarShell() {
|
|||||||
members={members}
|
members={members}
|
||||||
currentUserColor={meQuery.data?.user.color}
|
currentUserColor={meQuery.data?.user.color}
|
||||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* 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)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user