- 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:
119
apps/web/src/app/config/page.tsx
Normal file
119
apps/web/src/app/config/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
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';
|
import type { Tip } from '@oo/shared-types';
|
||||||
|
|
||||||
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
|
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
|
||||||
|
|
||||||
// Fade wrapper — children fade in when `visible`, fade out when not
|
|
||||||
function Fade({ visible, children, style }: {
|
function Fade({ visible, children, style }: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -30,9 +29,7 @@ export default function TipPage() {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [pressed, setPressed] = useState(false);
|
const [pressed, setPressed] = useState(false);
|
||||||
const [pushState, setPushState] = useState<'idle' | 'subscribed' | 'denied'>('idle');
|
|
||||||
|
|
||||||
// Fade in after state change settles
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state === 'loading' || state === 'done') {
|
if (state === 'loading' || state === 'done') {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
@@ -61,42 +58,12 @@ export default function TipPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadTip(); }, [loadTip]);
|
useEffect(() => { loadTip(); }, [loadTip]);
|
||||||
|
|
||||||
// Check existing push permission on mount
|
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
|
||||||
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') => {
|
|
||||||
if (!tip) return;
|
if (!tip) return;
|
||||||
const isNavigating = ['done', 'dismiss', 'snooze'].includes(action);
|
|
||||||
if (isNavigating) {
|
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
setState('done');
|
setState('done');
|
||||||
} else {
|
|
||||||
setState('tip');
|
|
||||||
}
|
|
||||||
await sendFeedback(tip.id, { action });
|
await sendFeedback(tip.id, { action });
|
||||||
if (isNavigating) setTimeout(() => loadTip(), 700);
|
setTimeout(() => loadTip(), 700);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerDown = () => {
|
const onPointerDown = () => {
|
||||||
@@ -119,7 +86,6 @@ export default function TipPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes breathe {
|
@keyframes breathe {
|
||||||
0%, 100% { opacity: 0.3; }
|
0%, 100% { opacity: 0.3; }
|
||||||
@@ -144,7 +110,7 @@ export default function TipPage() {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Ambient glow — breathes while loading */}
|
{/* Ambient glow */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -192,24 +158,6 @@ export default function TipPage() {
|
|||||||
}}>
|
}}>
|
||||||
hold to act
|
hold to act
|
||||||
</p>
|
</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>
|
</Fade>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -242,12 +190,7 @@ export default function TipPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
onClick={() => { setState('tip'); }}
|
onClick={() => { setState('tip'); }}
|
||||||
style={{
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)' }}
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0,0,0,0.5)',
|
|
||||||
animation: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -260,8 +203,6 @@ export default function TipPage() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
transform: 'translateY(0)',
|
|
||||||
transition: 'transform 0.3s ease',
|
|
||||||
}}>
|
}}>
|
||||||
{tip && (
|
{tip && (
|
||||||
<p style={{
|
<p style={{
|
||||||
@@ -274,8 +215,6 @@ export default function TipPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
|
<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="Snooze" onClick={() => react('snooze')} />
|
||||||
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
|
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
|
||||||
<button
|
<button
|
||||||
@@ -295,6 +234,27 @@ export default function TipPage() {
|
|||||||
</div>
|
</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>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ vi.mock('@/lib/api', () => ({
|
|||||||
import { getRecommendation, sendFeedback } from '@/lib/api';
|
import { getRecommendation, sendFeedback } from '@/lib/api';
|
||||||
import TipPage from '@/app/tip/page';
|
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 mockGetRec = getRecommendation as ReturnType<typeof vi.fn>;
|
||||||
const mockSendFeedback = sendFeedback 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' });
|
expect(mockSendFeedback).toHaveBeenCalledWith('tip:dis', { action: 'dismiss' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicking "Helpful" calls sendFeedback with action=helpful (non-navigating)', async () => {
|
it('action sheet has exactly Done, Snooze, Dismiss — no Helpful/Not helpful', async () => {
|
||||||
await renderTipAndHold('tip:help', 'Helpful tip');
|
await renderTipAndHold('tip:actions', 'Check actions');
|
||||||
await act(async () => { fireEvent.click(screen.getByText('Helpful')); });
|
expect(screen.getByText('Done ✓')).toBeInTheDocument();
|
||||||
expect(mockSendFeedback).toHaveBeenCalledWith('tip:help', { action: 'helpful' });
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user