feat: Phase 0 walking skeleton — monorepo, API, web, ML stub
Sets up the full Phase 0 foundation: - pnpm workspaces + turbo build graph; native module build approval - packages/shared-types: HTTP contracts (Tip, Auth, Integrations, User) - services/api: Express modular monolith with better-sqlite3/drizzle - auth: Google OAuth2 + PKCE via openid-client v6, cookie sessions - integrations: Todoist OAuth2 connect/disconnect, token vault - recommender: RandomPolicy over Todoist tasks, feedback sink - user: profile, consent capture, full account deletion (GDPR) - apps/web: Next.js 15, three pages (sign-in → connect → tip) - tip page: black canvas, hold-to-act gesture, action sheet - PWA manifest + theme - ml/serving: FastAPI stub implementing the POST /score contract - infra: docker-compose (core/full profiles), Dockerfiles, CI skeleton - .env.example with all required vars documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
218
apps/web/src/app/tip/page.tsx
Normal file
218
apps/web/src/app/tip/page.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getSession, getRecommendation, sendFeedback } from '@/lib/api';
|
||||
import type { Tip } from '@oo/shared-types';
|
||||
|
||||
type State = 'loading' | 'tip' | 'empty' | 'actions' | 'done';
|
||||
|
||||
export default function TipPage() {
|
||||
const router = useRouter();
|
||||
const [tip, setTip] = useState<Tip | null>(null);
|
||||
const [state, setState] = useState<State>('loading');
|
||||
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [pressed, setPressed] = useState(false);
|
||||
|
||||
const loadTip = useCallback(async () => {
|
||||
setState('loading');
|
||||
const { user } = await getSession();
|
||||
if (!user) { router.replace('/sign-in'); return; }
|
||||
|
||||
const rec = await getRecommendation();
|
||||
if (!rec) {
|
||||
setState('empty');
|
||||
return;
|
||||
}
|
||||
setTip(rec.tip);
|
||||
setState('tip');
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => { loadTip(); }, [loadTip]);
|
||||
|
||||
const react = async (action: 'done' | 'dismiss' | 'snooze') => {
|
||||
if (!tip) return;
|
||||
setState('done');
|
||||
await sendFeedback(tip.id, { action });
|
||||
setTimeout(() => loadTip(), 600);
|
||||
};
|
||||
|
||||
const onPointerDown = () => {
|
||||
if (state !== 'tip') return;
|
||||
setPressed(true);
|
||||
holdTimer.current = setTimeout(() => {
|
||||
setState('actions');
|
||||
setPressed(false);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
setPressed(false);
|
||||
if (holdTimer.current) {
|
||||
clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '3rem 2rem',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
cursor: state === 'tip' ? 'default' : 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Radial glow when pressed */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'radial-gradient(ellipse at center, rgba(255,255,255,0.04) 0%, transparent 70%)',
|
||||
opacity: pressed ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{state === 'loading' && (
|
||||
<div style={{ color: 'rgba(255,255,255,0.2)', fontSize: '0.75rem', letterSpacing: '0.15em' }}>
|
||||
···
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'tip' && tip && (
|
||||
<div style={{ textAlign: 'center', maxWidth: '420px' }}>
|
||||
<p style={{
|
||||
fontSize: 'clamp(1.25rem, 4vw, 1.75rem)',
|
||||
fontWeight: 300,
|
||||
lineHeight: 1.45,
|
||||
letterSpacing: '-0.01em',
|
||||
color: 'var(--white)',
|
||||
transition: 'opacity 0.2s ease',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
}}>
|
||||
{tip.content}
|
||||
</p>
|
||||
<p style={{
|
||||
marginTop: '2rem',
|
||||
color: 'rgba(255,255,255,0.2)',
|
||||
fontSize: '0.65rem',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
hold to act
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'empty' && (
|
||||
<div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>
|
||||
<p style={{ fontSize: '1.1rem', fontWeight: 300 }}>All clear.</p>
|
||||
<button
|
||||
onClick={loadTip}
|
||||
style={{
|
||||
marginTop: '2rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
Check again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'done' && (
|
||||
<div style={{ color: 'rgba(255,255,255,0.15)', fontSize: '0.75rem', letterSpacing: '0.15em' }}>
|
||||
···
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action sheet */}
|
||||
{state === 'actions' && (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setState('tip')}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: '#111',
|
||||
borderRadius: '1rem 1rem 0 0',
|
||||
padding: '1.5rem 1.5rem 2.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}>
|
||||
{tip && (
|
||||
<p style={{
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
fontSize: '0.8rem',
|
||||
marginBottom: '0.5rem',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{tip.content}
|
||||
</p>
|
||||
)}
|
||||
<ActionButton label="Done ✓" onClick={() => react('done')} primary />
|
||||
<ActionButton label="Snooze" onClick={() => react('snooze')} />
|
||||
<ActionButton label="Dismiss" onClick={() => react('dismiss')} />
|
||||
<button
|
||||
onClick={() => setState('tip')}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'rgba(255,255,255,0.25)',
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ label, onClick, primary }: { label: string; onClick: () => void; primary?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: primary ? 'var(--white)' : 'rgba(255,255,255,0.06)',
|
||||
color: primary ? 'var(--black)' : 'var(--white)',
|
||||
border: 'none',
|
||||
borderRadius: '0.625rem',
|
||||
padding: '1rem',
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: primary ? 500 : 400,
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user