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:
@@ -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 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user