refactor(quick-260610-jlp-01): extract InstructionSheet into shared component

- Move isIOS, IOS_STEPS, ANDROID_STEPS, InstructionSheetProps, InstructionSheet from PermissionDeniedBanner verbatim
- Export InstructionSheet as named export from InstructionSheet.tsx
- PermissionDeniedBanner now imports from ./InstructionSheet.js (behaviour identical)
This commit is contained in:
Lucas Berger
2026-06-10 14:10:58 -04:00
parent 1f3c672194
commit 74b5d44712
2 changed files with 191 additions and 180 deletions
@@ -0,0 +1,189 @@
/**
* InstructionSheet — shared OS-specific notification re-enable guidance bottom sheet.
*
* Extracted from PermissionDeniedBanner (Phase 5 UAT-05-T4 fix).
* Used by both PermissionDeniedBanner and SettingsSheet.
*
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
*/
import { X } from 'lucide-react'
// ── 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
}
export 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>
)
}
@@ -19,187 +19,9 @@
*/ */
import { useState } from 'react' import { useState } from 'react'
import { AlertCircle, X } from 'lucide-react' import { AlertCircle } from 'lucide-react'
import { readNotificationsEnabled } from '../hooks/usePushSubscription.js' import { readNotificationsEnabled } from '../hooks/usePushSubscription.js'
import { InstructionSheet } from './InstructionSheet.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 ──────────────────────────────────────────────── // ── PermissionDeniedBanner ────────────────────────────────────────────────