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
|
||||
* /lists/* — the SW denylist only excludes /callback, /api/, and /health, so
|
||||
* /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 { CalendarShell } from './components/CalendarShell.js'
|
||||
import { ListsIndex } from './routes/ListsIndex.js'
|
||||
import { ListDetail } from './routes/ListDetail.js'
|
||||
import { BottomTabBar } from './components/BottomTabBar.js'
|
||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'
|
||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'
|
||||
import { SettingsSheet } from './components/SettingsSheet.js'
|
||||
|
||||
export default function App() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
|
||||
<PermissionDeniedBanner />
|
||||
|
||||
<Routes>
|
||||
<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/:listId" element={<ListDetail />} />
|
||||
</Routes>
|
||||
@@ -37,6 +52,9 @@ export default function App() {
|
||||
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||
and Notification.permission === 'default' and not dismissed */}
|
||||
<PushPermissionPrompt />
|
||||
|
||||
{/* Settings sheet — master notifications toggle (D-09), opened from avatar */}
|
||||
<SettingsSheet isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user