- 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';
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user