feat(web): action sheet cleanup + settings page (#100 #101 #102)

- Remove "Helpful"/"Not helpful" from action sheet — reward is inferred
  from done/snooze/dismiss + dwell time; explicit sentiment buttons were
  redundant and cluttered the UI (#100)
- Move "notify me" push subscription button to new /config page (#101)
- Add settings gear icon (bottom-right, fixed) on tip page linking to /config (#102)
- New /config page: push notification toggle + link to /connect integrations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 13:52:45 +00:00
parent 4267e6ac68
commit 9bd60a9835
3 changed files with 164 additions and 72 deletions

View File

@@ -0,0 +1,119 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { getVapidPublicKey, subscribePush } from '@/lib/api';
type PushState = 'idle' | 'subscribed' | 'denied';
export default function ConfigPage() {
const [pushState, setPushState] = useState<PushState>('idle');
useEffect(() => {
if (typeof Notification !== 'undefined') {
if (Notification.permission === 'granted') setPushState('subscribed');
else if (Notification.permission === 'denied') setPushState('denied');
}
}, []);
const requestPush = useCallback(async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') { setPushState('denied'); return; }
try {
const reg = await navigator.serviceWorker.register('/sw.js');
const vapidKey = await getVapidPublicKey();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidKey,
});
await subscribePush(sub.toJSON());
setPushState('subscribed');
} catch { setPushState('denied'); }
}, []);
return (
<main style={{ minHeight: '100vh', padding: '4rem 2rem', maxWidth: '480px', margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '3rem' }}>
<a
href="/tip"
style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.85rem', textDecoration: 'none' }}
>
back
</a>
<h2 style={{ fontSize: '1.5rem', fontWeight: 300, margin: 0, letterSpacing: '-0.02em' }}>
Settings
</h2>
</div>
{/* Notifications */}
<section style={{ marginBottom: '2.5rem' }}>
<h3 style={{ fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', marginBottom: '1rem', fontWeight: 400 }}>
Notifications
</h3>
<div style={{
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div>
<div style={{ fontWeight: 400, fontSize: '0.9rem' }}>Push notifications</div>
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.75rem', marginTop: '0.2rem' }}>
{pushState === 'subscribed' ? 'Enabled' : pushState === 'denied' ? 'Blocked by browser' : 'Get notified when a tip is ready'}
</div>
</div>
{pushState === 'idle' && (
<button
onClick={requestPush}
style={{
background: 'var(--white)',
color: 'var(--black)',
border: 'none',
borderRadius: '0.375rem',
padding: '0.375rem 0.875rem',
fontSize: '0.8rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Enable
</button>
)}
{pushState === 'subscribed' && (
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}></span>
)}
</div>
</section>
{/* Integrations */}
<section>
<h3 style={{ fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)', marginBottom: '1rem', fontWeight: 400 }}>
Integrations
</h3>
<a
href="/connect"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '0.75rem',
padding: '1.25rem 1.5rem',
textDecoration: 'none',
color: 'var(--white)',
}}
>
<div>
<div style={{ fontWeight: 400, fontSize: '0.9rem' }}>Connected apps</div>
<div style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.75rem', marginTop: '0.2rem' }}>
Manage Todoist and other sources
</div>
</div>
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.85rem' }}></span>
</a>
</section>
</main>
);
}

View File

@@ -1,12 +1,11 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { getRecommendation, sendFeedback, getVapidPublicKey, subscribePush } from '@/lib/api';
import { getRecommendation, sendFeedback } from '@/lib/api';
import type { Tip } from '@oo/shared-types';
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
// Fade wrapper — children fade in when `visible`, fade out when not
function Fade({ visible, children, style }: {
visible: boolean;
children: React.ReactNode;
@@ -30,9 +29,7 @@ export default function TipPage() {
const [visible, setVisible] = useState(false);
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [pressed, setPressed] = useState(false);
const [pushState, setPushState] = useState<'idle' | 'subscribed' | 'denied'>('idle');
// Fade in after state change settles
useEffect(() => {
if (state === 'loading' || state === 'done') {
setVisible(false);
@@ -61,42 +58,12 @@ export default function TipPage() {
useEffect(() => { loadTip(); }, [loadTip]);
// Check existing push permission on mount
useEffect(() => {
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
setPushState('subscribed');
} else if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {
setPushState('denied');
}
}, []);
const requestPush = useCallback(async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') { setPushState('denied'); return; }
try {
const reg = await navigator.serviceWorker.register('/sw.js');
const vapidKey = await getVapidPublicKey();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidKey,
});
await subscribePush(sub.toJSON());
setPushState('subscribed');
} catch { setPushState('denied'); }
}, []);
const react = async (action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful') => {
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
if (!tip) return;
const isNavigating = ['done', 'dismiss', 'snooze'].includes(action);
if (isNavigating) {
setVisible(false);
setState('done');
} else {
setState('tip');
}
setVisible(false);
setState('done');
await sendFeedback(tip.id, { action });
if (isNavigating) setTimeout(() => loadTip(), 700);
setTimeout(() => loadTip(), 700);
};
const onPointerDown = () => {
@@ -119,7 +86,6 @@ export default function TipPage() {
return (
<>
<style>{`
@keyframes breathe {
0%, 100% { opacity: 0.3; }
@@ -144,7 +110,7 @@ export default function TipPage() {
overflow: 'hidden',
}}
>
{/* Ambient glow — breathes while loading */}
{/* Ambient glow */}
<div style={{
position: 'absolute',
inset: 0,
@@ -192,24 +158,6 @@ export default function TipPage() {
}}>
hold to act
</p>
{pushState === 'idle' && (
<button
onClick={(e) => { e.stopPropagation(); requestPush(); }}
style={{
marginTop: '2.5rem',
background: 'transparent',
border: 'none',
color: 'rgba(255,255,255,0.18)',
fontSize: '0.65rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
cursor: 'pointer',
padding: 0,
}}
>
notify me
</button>
)}
</Fade>
)}
@@ -242,12 +190,7 @@ export default function TipPage() {
<>
<div
onClick={() => { setState('tip'); }}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
animation: 'none',
}}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)' }}
/>
<div style={{
position: 'fixed',
@@ -260,8 +203,6 @@ export default function TipPage() {
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
transform: 'translateY(0)',
transition: 'transform 0.3s ease',
}}>
{tip && (
<p style={{
@@ -274,8 +215,6 @@ export default function TipPage() {
</p>
)}
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
<ActionButton label="Helpful" onClick={() => react('helpful')} />
<ActionButton label="Not helpful" onClick={() => react('not_helpful')} />
<ActionButton label="Snooze" onClick={() => react('snooze')} />
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
<button
@@ -295,6 +234,27 @@ export default function TipPage() {
</div>
</>
)}
{/* Settings gear — bottom right */}
<a
href="/config"
onClick={(e) => e.stopPropagation()}
aria-label="Settings"
style={{
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
color: 'rgba(255,255,255,0.15)',
fontSize: '1.1rem',
lineHeight: 1,
textDecoration: 'none',
padding: '0.5rem',
pointerEvents: 'auto',
zIndex: 10,
}}
>
</a>
</main>
</>
);

View File

@@ -13,6 +13,8 @@ vi.mock('@/lib/api', () => ({
import { getRecommendation, sendFeedback } from '@/lib/api';
import TipPage from '@/app/tip/page';
// jsdom doesn't support full anchor navigation — just verify the link exists
const mockGetRec = getRecommendation as ReturnType<typeof vi.fn>;
const mockSendFeedback = sendFeedback as ReturnType<typeof vi.fn>;
@@ -123,9 +125,20 @@ describe('TipPage — action sheet', () => {
expect(mockSendFeedback).toHaveBeenCalledWith('tip:dis', { action: 'dismiss' });
});
it('clicking "Helpful" calls sendFeedback with action=helpful (non-navigating)', async () => {
await renderTipAndHold('tip:help', 'Helpful tip');
await act(async () => { fireEvent.click(screen.getByText('Helpful')); });
expect(mockSendFeedback).toHaveBeenCalledWith('tip:help', { action: 'helpful' });
it('action sheet has exactly Done, Snooze, Dismiss — no Helpful/Not helpful', async () => {
await renderTipAndHold('tip:actions', 'Check actions');
expect(screen.getByText('Done ✓')).toBeInTheDocument();
expect(screen.getByText('Snooze')).toBeInTheDocument();
expect(screen.getByText('Dismiss')).toBeInTheDocument();
expect(screen.queryByText('Helpful')).not.toBeInTheDocument();
expect(screen.queryByText('Not helpful')).not.toBeInTheDocument();
});
it('settings gear link is present on tip page', async () => {
mockGetRec.mockResolvedValue({ tip: { id: 'tip:g', content: 'Gear test', source: 'todoist', createdAt: '' } });
render(<TipPage />);
await screen.findByText('Gear test');
const link = screen.getByRole('link', { name: /settings/i });
expect(link).toHaveAttribute('href', '/config');
});
});