Feature 12: Regular dinners and onboarding (#94)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #94.
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { AuthBootstrap } from '../AuthBootstrap.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { NotificationsStep, notificationsUnavailable, type EnableNotifications } from './NotificationsStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
const IPHONE_SAFARI =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
|
||||
|
||||
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
|
||||
|
||||
// requestPermission stands in for the browser's Notification API, which
|
||||
// jsdom lacks, so a test can prove nothing asks for permission.
|
||||
let requestPermission: Mock<() => Promise<NotificationPermission>>
|
||||
|
||||
beforeEach(() => {
|
||||
requestPermission = vi.fn<() => Promise<NotificationPermission>>(() => Promise.resolve('granted'))
|
||||
vi.stubGlobal('Notification', { permission: 'default', requestPermission })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(navigator, 'maxTouchPoints')
|
||||
Reflect.deleteProperty(navigator, 'standalone')
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// A PATCH the fake server holds until the test answers it.
|
||||
type Held = { body: unknown; answer: (response: Response) => void }
|
||||
|
||||
// stubFirstRunServer stands in for the server on first run: signed in,
|
||||
// onboarding not finished. Each PATCH of the household settings is held in
|
||||
// `held` until the test answers it.
|
||||
function stubFirstRunServer() {
|
||||
const held: Held[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/settings/household') {
|
||||
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
|
||||
}
|
||||
if (method === 'PATCH' && path === '/api/v1/settings/household') {
|
||||
return new Promise<Response>((resolve) => {
|
||||
held.push({ body: JSON.parse(String(init?.body)), answer: resolve })
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${method} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, held }
|
||||
}
|
||||
|
||||
function completed(request: Held) {
|
||||
request.answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: COMPLETED_AT }))
|
||||
}
|
||||
|
||||
// renderApp renders the whole app at step 3, so the first-run redirect is
|
||||
// what decides where finishing lands.
|
||||
async function renderApp() {
|
||||
mockViewport(390)
|
||||
const server = stubFirstRunServer()
|
||||
const api = createApiClient(
|
||||
fakeLocation('/onboarding/notifications'),
|
||||
createNetworkStatus(window),
|
||||
createVerifiedSession(localStorage),
|
||||
)
|
||||
render(
|
||||
<InDataRouter path="/onboarding/notifications">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
|
||||
return server
|
||||
}
|
||||
|
||||
// renderStep renders step 3 alone with the given enableNotifications, and a
|
||||
// Home that says it was reached. AuthBootstrap reads /api/v1/me for the CSRF
|
||||
// token every write needs.
|
||||
async function renderStep(enableNotifications?: EnableNotifications) {
|
||||
const server = stubFirstRunServer()
|
||||
render(
|
||||
<InDataRouter path="/onboarding/notifications">
|
||||
<ApiProviders>
|
||||
<AuthBootstrap>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/onboarding/notifications"
|
||||
element={<NotificationsStep enableNotifications={enableNotifications} />}
|
||||
/>
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
|
||||
return server
|
||||
}
|
||||
|
||||
function turnOn() {
|
||||
return screen.getByRole('button', { name: 'Turn on notifications' })
|
||||
}
|
||||
|
||||
function later() {
|
||||
return screen.getByRole('button', { name: 'Maybe later' })
|
||||
}
|
||||
|
||||
describe('onboarding step 3', () => {
|
||||
it('explains what MealQuest notifies about, with all three progress pills filled', async () => {
|
||||
await renderApp()
|
||||
|
||||
expect(screen.getByText('Step 3 of 3')).toBeInTheDocument()
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, true])
|
||||
const topics = within(screen.getByRole('list')).getAllByRole('listitem')
|
||||
expect(topics.map((topic) => topic.textContent)).toEqual([
|
||||
expect.stringMatching(/^Plan ready/),
|
||||
expect.stringMatching(/^Supply run ready/),
|
||||
expect.stringMatching(/^Tonight's dinner/),
|
||||
expect.stringMatching(/^New Side quests/),
|
||||
])
|
||||
expect(turnOn()).toBeEnabled()
|
||||
expect(later()).toBeEnabled()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('never asks for notification permission on render', async () => {
|
||||
await renderApp()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sizes both actions as tap targets and fixes them to the bottom', async () => {
|
||||
await renderApp()
|
||||
expectMinHitArea(turnOn())
|
||||
expectMinHitArea(later())
|
||||
expect(declaredStyle(turnOn()).height).toBe('52px')
|
||||
expect(declaredStyle(later()).height).toBe('52px')
|
||||
expect(declaredStyle(turnOn().parentElement!.parentElement!).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('shows no install hint outside iOS Safari', async () => {
|
||||
await renderApp()
|
||||
expect(screen.queryByText('Tap Share, then Add to Home Screen to get notifications')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the install hint in an iOS Safari tab', async () => {
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(IPHONE_SAFARI)
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList)
|
||||
await renderStep()
|
||||
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
|
||||
})
|
||||
|
||||
it('"Maybe later" finishes onboarding and goes Home, ending the first-run redirect', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(later())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
expect(turnOn()).toBeDisabled()
|
||||
expect(later()).toBeDisabled()
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" finishes onboarding and goes Home while notifications are unavailable', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(turnOn())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
|
||||
let answer: (result: 'enabled') => void = () => {}
|
||||
const enable = vi.fn<EnableNotifications>(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve
|
||||
}),
|
||||
)
|
||||
const server = await renderStep(enable)
|
||||
expect(enable).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(turnOn())
|
||||
expect(enable).toHaveBeenCalledOnce()
|
||||
expect(turnOn()).toBeDisabled()
|
||||
expect(later()).toBeDisabled()
|
||||
// Onboarding is not finished until enableNotifications has answered.
|
||||
await Promise.resolve()
|
||||
expect(server.held).toHaveLength(0)
|
||||
|
||||
answer('enabled')
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Maybe later" does not ask the injected enableNotifications', async () => {
|
||||
const enable = vi.fn<EnableNotifications>(() => Promise.resolve('enabled'))
|
||||
const server = await renderStep(enable)
|
||||
fireEvent.click(later())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(enable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says why and stays when enableNotifications fails', async () => {
|
||||
const server = await renderStep(() => Promise.reject(new Error('push service down')))
|
||||
fireEvent.click(turnOn())
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down')
|
||||
expect(server.held).toHaveLength(0)
|
||||
expect(turnOn()).toBeEnabled()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||
expect(later()).toBeEnabled()
|
||||
|
||||
fireEvent.click(later())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
completed(server.held[1]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding step 3 with too few Regulars', () => {
|
||||
it('says why and offers a way back to add Regulars', async () => {
|
||||
const server = await renderStep()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(
|
||||
jsonResponse(400, {
|
||||
error: {
|
||||
code: 'invalid_settings',
|
||||
message: 'invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Could not finish setting up: invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
||||
)
|
||||
const addRegulars = screen.getByRole('button', { name: 'Add Regulars' })
|
||||
expectMinHitArea(addRegulars)
|
||||
fireEvent.click(addRegulars)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Regulars step' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no way back for any other refusal', async () => {
|
||||
const server = await renderStep()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
|
||||
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('notificationsUnavailable', () => {
|
||||
it('answers "unavailable" without asking for permission', async () => {
|
||||
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react'
|
||||
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { InstallHint } from '../pwa/InstallHint.tsx'
|
||||
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
// NotificationsResult is how asking to turn on notifications ended.
|
||||
export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
|
||||
|
||||
export type EnableNotifications = () => Promise<NotificationsResult>
|
||||
|
||||
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
|
||||
// turned on, and nothing asks the browser for permission.
|
||||
export function notificationsUnavailable(): Promise<NotificationsResult> {
|
||||
return Promise.resolve('unavailable')
|
||||
}
|
||||
|
||||
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
|
||||
{
|
||||
icon: CalendarDays,
|
||||
name: 'Plan ready',
|
||||
meaning: "The week's Draft board is ready for the household to adjust.",
|
||||
},
|
||||
{
|
||||
icon: ShoppingBasket,
|
||||
name: 'Supply run ready',
|
||||
meaning: "The week's shopping list, grouped by dinner, is ready to shop.",
|
||||
},
|
||||
{
|
||||
icon: UtensilsCrossed,
|
||||
name: "Tonight's dinner",
|
||||
meaning: "A reminder before dinner of what's on the plan tonight.",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
name: 'New Side quests',
|
||||
meaning: 'Fresh dinners to Try it or Pass, picked from your Regulars.',
|
||||
},
|
||||
]
|
||||
|
||||
export type NotificationsStepProps = {
|
||||
// Asks to turn on notifications when the household taps "Turn on
|
||||
// notifications" — never on render.
|
||||
enableNotifications?: EnableNotifications
|
||||
}
|
||||
|
||||
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
||||
// iOS install hint, and finishing onboarding. Either button records that
|
||||
// onboarding is finished and goes Home. When the server refuses because the
|
||||
// household has fewer than three Regulars, the reason shows with a way back
|
||||
// to step 2 to add them.
|
||||
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
|
||||
const navigate = useNavigate()
|
||||
const complete = useCompleteOnboarding()
|
||||
const [enabling, setEnabling] = useState(false)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [fewRegulars, setFewRegulars] = useState(false)
|
||||
const busy = enabling || complete.isPending
|
||||
|
||||
async function finish() {
|
||||
try {
|
||||
await complete.mutateAsync()
|
||||
} catch (error) {
|
||||
setProblem(`Could not finish setting up: ${(error as Error).message}`)
|
||||
setFewRegulars(needsMoreRegulars(error as Error))
|
||||
return
|
||||
}
|
||||
void navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
function clearProblem() {
|
||||
setProblem(null)
|
||||
setFewRegulars(false)
|
||||
}
|
||||
|
||||
async function turnOn() {
|
||||
clearProblem()
|
||||
setEnabling(true)
|
||||
try {
|
||||
await enableNotifications()
|
||||
} catch (error) {
|
||||
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
|
||||
return
|
||||
} finally {
|
||||
setEnabling(false)
|
||||
}
|
||||
await finish()
|
||||
}
|
||||
|
||||
function later() {
|
||||
clearProblem()
|
||||
void finish()
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
step={3}
|
||||
title="Stay in the loop"
|
||||
action={
|
||||
<div className={styles.actions}>
|
||||
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
|
||||
Turn on notifications
|
||||
</Button>
|
||||
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
|
||||
Maybe later
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>MealQuest can let the household know when there's something to look at:</p>
|
||||
<ul className={styles.terms}>
|
||||
{topics.map(({ icon, name, meaning }) => (
|
||||
<li key={name} className={styles.term}>
|
||||
<span className={styles.termIcon}>
|
||||
<Icon icon={icon} size="nav" />
|
||||
</span>
|
||||
<p className={styles.termText}>
|
||||
<span className={styles.termName}>{name}</span>
|
||||
{meaning}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.hint}>
|
||||
<InstallHint />
|
||||
</div>
|
||||
{problem && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
{fewRegulars && (
|
||||
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
|
||||
Add Regulars
|
||||
</Button>
|
||||
)}
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/* Onboarding, handoff mock 1h: a full screen outside the app shell with
|
||||
the step's action fixed to the bottom. */
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
/* Room for the fixed 52 px action and its padding. */
|
||||
padding: var(--space-6) var(--space-4) calc(52px + 2 * var(--space-6));
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.column {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.filled {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 30px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 8px 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.helper {
|
||||
margin: 12px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: var(--space-4) var(--space-4) var(--space-6);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
height: 52px;
|
||||
margin: 0 auto;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* Step 3: two stacked actions. */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Room for the second fixed action and the gap above it. */
|
||||
.screen:has(.actions) {
|
||||
padding-bottom: calc(2 * 52px + var(--space-3) + 2 * var(--space-6));
|
||||
}
|
||||
|
||||
/* Step 1: what MealQuest's words mean; step 3: what it notifies about. */
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-6) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.term {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.termIcon {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-200);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.termText {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.termName {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Step 2: the add pill, with the "+" inside it. */
|
||||
.add {
|
||||
position: relative;
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
/* Beats the TextInput's own padding whatever order the sheets load in. */
|
||||
.add .addInput {
|
||||
width: 100%;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
/* 44 px hit box around the 36 px grape face, inset 2 px in the 48 px pill. */
|
||||
.addButton {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addButton:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.addFace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
transition: transform 100ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.addButton:disabled .addFace {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.addButton:active:not(:disabled) .addFace {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
margin: var(--space-6) 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border: 1.5px dashed var(--color-neutral-400);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pick:active {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.problem {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* Step 3: back to step 2 when the server wants more Regulars. */
|
||||
.recover {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Step 3: the iOS install hint, when it shows. */
|
||||
.hint {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.hint:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.addFace {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { LoadingScreen } from '../AuthBootstrap.tsx'
|
||||
import { useHouseholdSettings } from '../settings/api.ts'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import styles from '../pages/FullScreen.module.css'
|
||||
|
||||
export const ONBOARDING_PATH = '/onboarding'
|
||||
|
||||
function inOnboarding(pathname: string): boolean {
|
||||
return pathname === ONBOARDING_PATH || pathname.startsWith(`${ONBOARDING_PATH}/`)
|
||||
}
|
||||
|
||||
// OnboardingGate reads the household settings before any page: while
|
||||
// onboarding_completed_at is null, every route outside /onboarding
|
||||
// redirects there.
|
||||
//
|
||||
// The service worker never caches the settings, so offline on the saved
|
||||
// pages (see AuthBootstrap) they cannot be read: the saved pages open as they
|
||||
// are, since onboarding needs the server anyway, and the settings are read
|
||||
// again when the browser is back online.
|
||||
export function OnboardingGate() {
|
||||
const api = useApi()
|
||||
const { pathname } = useLocation()
|
||||
const settings = useHouseholdSettings()
|
||||
const offline = settings.isError && settings.error instanceof TypeError && api.session.verified()
|
||||
const { refetch } = settings
|
||||
|
||||
useEffect(() => {
|
||||
if (!offline) return
|
||||
return api.network.subscribe(() => {
|
||||
if (!api.network.offline()) void refetch()
|
||||
})
|
||||
}, [offline, api, refetch])
|
||||
|
||||
if (settings.data !== undefined) {
|
||||
if (settings.data.onboarding_completed_at === null && !inOnboarding(pathname)) {
|
||||
return <Navigate to={ONBOARDING_PATH} replace />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
if (offline) return <Outlet />
|
||||
if (settings.isError) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<ErrorBanner onRetry={() => void refetch()}>MealQuest could not load: {settings.error.message}</ErrorBanner>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return <LoadingScreen />
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea, hitArea } from '../test/hitArea.ts'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
type Dinner = DinnerBody
|
||||
|
||||
// A request the fake server holds until the test answers it.
|
||||
type Held = { method: string; path: string; body: unknown; answer: (response: Response) => void }
|
||||
|
||||
// stubDinnersServer stands in for the server a household meets on first
|
||||
// run: signed in, onboarding not finished and the given Regulars. GET answers at once;
|
||||
// each POST and DELETE is held in `held` until the test answers it, so the
|
||||
// test sees the optimistic list first. Once failReads is set, every GET of
|
||||
// the Regulars fails, so the test sees the list the client keeps by itself.
|
||||
function stubDinnersServer(regulars: Dinner[] = []) {
|
||||
const held: Held[] = []
|
||||
const reads = { fail: false }
|
||||
const fetchMock: Mock<typeof fetch> = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/settings/household') {
|
||||
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
|
||||
}
|
||||
if (method === 'GET' && path === '/api/v1/dinners?status=regular') {
|
||||
if (reads.fail) return Promise.resolve(errorResponse(500, 'internal'))
|
||||
return Promise.resolve(jsonResponse(200, { dinners: regulars }))
|
||||
}
|
||||
if ((method === 'POST' && path === '/api/v1/dinners') || (method === 'DELETE' && path.startsWith('/api/v1/dinners/'))) {
|
||||
return new Promise<Response>((resolve) => {
|
||||
held.push({ method, path, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), answer: resolve })
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${method} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, held, regulars, reads }
|
||||
}
|
||||
|
||||
// created answers a held POST /api/v1/dinners as the server does, adding the
|
||||
// Regular to the list later GETs read.
|
||||
function created(server: ReturnType<typeof stubDinnersServer>, request: Held, id: string) {
|
||||
const { name } = request.body as { name: string }
|
||||
server.regulars.push(dinnerBody(id, name))
|
||||
request.answer(jsonResponse(201, { ...dinnerBody(id, name, { grounding_status: 'pending' }), grounding_job_id: 'job-1' }))
|
||||
}
|
||||
|
||||
// renderAt renders the whole app, so /me supplies the CSRF token every
|
||||
// write needs and OnboardingGate reads the household settings.
|
||||
function renderAt(path: string) {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<InDataRouter path={path}>
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
async function regularsStep(regulars: Dinner[] = []) {
|
||||
const server = stubDinnersServer(regulars)
|
||||
renderAt('/onboarding/regulars')
|
||||
// Common picks show once the Regulars have loaded.
|
||||
await screen.findByRole('heading', { level: 2, name: 'Common picks' })
|
||||
expect(screen.getByRole('heading', { level: 2, name: `Your regulars · ${regulars.length}` })).toBeInTheDocument()
|
||||
return server
|
||||
}
|
||||
|
||||
function input() {
|
||||
return screen.getByRole('textbox', { name: 'Dinner name' })
|
||||
}
|
||||
|
||||
function regularsList() {
|
||||
return screen.getByRole('heading', { level: 2, name: /^Your regulars/ }).nextElementSibling as HTMLElement
|
||||
}
|
||||
|
||||
function regularNames() {
|
||||
return within(regularsList())
|
||||
.queryAllByRole('listitem')
|
||||
.map((item) => item.textContent)
|
||||
}
|
||||
|
||||
function next() {
|
||||
return screen.getByRole('button', { name: 'Next: notifications' })
|
||||
}
|
||||
|
||||
describe('onboarding step 1', () => {
|
||||
it('welcomes the household, explains the words and starts with step 2', async () => {
|
||||
stubDinnersServer()
|
||||
renderAt('/onboarding')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Step 1 of 3')).toBeInTheDocument()
|
||||
const terms = within(screen.getByRole('list')).getAllByRole('listitem')
|
||||
expect(terms.map((term) => term.textContent)).toEqual([
|
||||
expect.stringMatching(/^Regulars/),
|
||||
expect.stringMatching(/^Side quests/),
|
||||
expect.stringMatching(/^Quest log/),
|
||||
])
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: "Let's start" }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fills one progress pill of three', async () => {
|
||||
stubDinnersServer()
|
||||
renderAt('/onboarding')
|
||||
await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, false, false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding step 2 (mock 1h)', () => {
|
||||
it('shows the step as the mock does', async () => {
|
||||
await regularsStep([dinnerBody('d1', 'Beef tacos')])
|
||||
|
||||
expect(screen.getByText('Step 2 of 3')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Add the dinners your household makes on repeat. MealQuest builds suggestions from these.'),
|
||||
).toBeInTheDocument()
|
||||
expect(input()).toHaveAttribute('placeholder', 'e.g. Chicken enchiladas')
|
||||
expect(regularNames()).toEqual(['Beef tacos'])
|
||||
expect(screen.getByRole('button', { name: 'Remove Beef tacos' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Common picks' })).toBeInTheDocument()
|
||||
// A common pick the household already has is not offered again.
|
||||
expect(screen.queryByRole('button', { name: 'Add Beef tacos' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Chili' })).toHaveTextContent('+Chili')
|
||||
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, false])
|
||||
expect(declaredStyle(pills[0]!).height).toBe('6px')
|
||||
})
|
||||
|
||||
it('does not offer a common pick the server would refuse as a duplicate of a Regular', async () => {
|
||||
await regularsStep([dinnerBody('d1', 'Homemade chili'), dinnerBody('d2', 'Easy Chicken Stir Fry')])
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add Chicken stir-fry' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
|
||||
await regularsStep()
|
||||
expect(hitArea(input()).height).toBe(48)
|
||||
const add = screen.getByRole('button', { name: 'Add Regular' })
|
||||
expectMinHitArea(add)
|
||||
expect(declaredStyle(add.firstElementChild!).width).toBe('36px')
|
||||
expect(declaredStyle(add.firstElementChild!).height).toBe('36px')
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
expect(declaredStyle(next()).height).toBe('52px')
|
||||
expect(declaredStyle(next().parentElement!).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('adds a Regular on Enter, showing it before the server answers', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.change(input(), { target: { value: ' Chicken enchiladas ' } })
|
||||
// Enter in the input submits its form.
|
||||
fireEvent.submit(input())
|
||||
|
||||
expect(input()).toHaveValue('')
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chicken enchiladas']))
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
|
||||
// Not removable until the server has given it an id.
|
||||
expect(screen.queryByRole('button', { name: 'Remove Chicken enchiladas' })).not.toBeInTheDocument()
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chicken enchiladas' } })
|
||||
created(server, server.held[0]!, 'd1')
|
||||
expect(await screen.findByRole('button', { name: 'Remove Chicken enchiladas' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a Regular with the "+" button', async () => {
|
||||
const server = await regularsStep()
|
||||
const add = screen.getByRole('button', { name: 'Add Regular' })
|
||||
expect(add).toBeDisabled()
|
||||
fireEvent.change(input(), { target: { value: 'Shrimp scampi' } })
|
||||
fireEvent.click(add)
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Shrimp scampi']))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', body: { name: 'Shrimp scampi' } })
|
||||
})
|
||||
|
||||
it('adds a common pick on tap and stops offering it', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili']))
|
||||
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chili' } })
|
||||
})
|
||||
|
||||
it('takes an add back out and says why when the server refuses it', async () => {
|
||||
const server = await regularsStep([dinnerBody('d1', 'Chili')])
|
||||
fireEvent.change(input(), { target: { value: 'chili' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili', 'chili']))
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add chili: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
})
|
||||
|
||||
it('says why an add was refused while another add is still unanswered', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Burgers', 'Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
// The other add's answer does not hide the refusal.
|
||||
created(server, server.held[1]!, 'd1')
|
||||
await screen.findByRole('button', { name: 'Remove Chili' })
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
})
|
||||
|
||||
it('says why each of two overlapping adds was refused', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
|
||||
'Could not add Burgers: duplicate_dinner message',
|
||||
'Could not add Chili: internal message',
|
||||
]),
|
||||
)
|
||||
expect(regularNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('says why each of two overlapping adds of the same name was refused', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.change(input(), { target: { value: 'Lasagne' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.change(input(), { target: { value: 'Lasagne' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Lasagne', 'Lasagne'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Lasagne: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Lasagne'])
|
||||
|
||||
// The second add's refusal shows beside the first instead of replacing it.
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
|
||||
'Could not add Lasagne: duplicate_dinner message',
|
||||
'Could not add Lasagne: internal message',
|
||||
]),
|
||||
)
|
||||
expect(regularNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('removes a Regular at once and archives it on the server', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Pizza night']))
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
|
||||
// Removed, it is a common pick again.
|
||||
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/dinners/d1' })
|
||||
})
|
||||
|
||||
it('puts a removed Regular back in its place when the server refuses', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
|
||||
})
|
||||
|
||||
it('puts a refused removal back and says why while another removal is still unanswered', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Chili'])
|
||||
|
||||
// The other removal's answer does not hide the refusal.
|
||||
server.regulars.splice(1, 1)
|
||||
server.held[1]!.answer(new Response(null, { status: 204 }))
|
||||
await vi.waitFor(() => expect(server.fetchMock.mock.calls.filter(([path]) => String(path).includes('status=regular'))).toHaveLength(2))
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Beef tacos', 'Chili']))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
})
|
||||
|
||||
it('puts two refused removals back in their places without rereading the list', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
server.reads.fail = true
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
await screen.findByRole('alert')
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await screen.findByText('Could not remove Pizza night: internal message')
|
||||
// The reread after the last answer fails, so the order is the client's own.
|
||||
expect(await screen.findByText(/Your Regulars could not load/)).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /^Remove / }).map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'Remove Beef tacos',
|
||||
'Remove Pizza night',
|
||||
'Remove Chili',
|
||||
])
|
||||
})
|
||||
|
||||
it('enables "Next: notifications" once the server has saved 3 Regulars', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
expect(next()).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
|
||||
// The third chip shows at once, but Next waits for the server.
|
||||
expect(next()).toBeDisabled()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
created(server, server.held[0]!, 'd3')
|
||||
await screen.findByRole('button', { name: 'Remove Burgers' })
|
||||
expect(next()).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(next()).toBeDisabled())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() => expect(next()).toBeEnabled())
|
||||
|
||||
fireEvent.click(next())
|
||||
expect(await screen.findByText('Step 3 of 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps "Next: notifications" disabled when the third add is refused', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
|
||||
expect(next()).toBeDisabled()
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
|
||||
expect(next()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps "Next: notifications" disabled while a fourth add is unanswered', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
expect(next()).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 4' })
|
||||
expect(next()).toBeDisabled()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
await screen.findByRole('alert')
|
||||
expect(next()).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Repeat, ScrollText, Sparkles, type LucideIcon } from 'lucide-react'
|
||||
import { Route, Routes, useNavigate } from 'react-router'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { NotificationsStep } from './NotificationsStep.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
import { RegularsStep } from './RegularsStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
// OnboardingPage is /onboarding and its steps: the welcome, the Regulars
|
||||
// (/onboarding/regulars) and notifications (/onboarding/notifications).
|
||||
export function OnboardingPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<WelcomeStep />} />
|
||||
<Route path="regulars" element={<RegularsStep />} />
|
||||
<Route path="notifications" element={<NotificationsStep />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const terms: { icon: LucideIcon; name: string; meaning: string }[] = [
|
||||
{
|
||||
icon: Repeat,
|
||||
name: 'Regulars',
|
||||
meaning: 'The dinners our kitchen makes on repeat. You add a few next.',
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
name: 'Side quests',
|
||||
meaning: 'New dinners MealQuest suggests from your Regulars. Try it or Pass.',
|
||||
},
|
||||
{
|
||||
icon: ScrollText,
|
||||
name: 'Quest log',
|
||||
meaning: 'The Side quests you said Try it to, waiting for a night on the plan.',
|
||||
},
|
||||
]
|
||||
|
||||
function WelcomeStep() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<OnboardingStep
|
||||
step={1}
|
||||
title="Welcome to MealQuest"
|
||||
action={
|
||||
<Button className={styles.action} onClick={() => void navigate('/onboarding/regulars')}>
|
||||
Let's start
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>MealQuest plans the household's dinners around what you already love to cook.</p>
|
||||
<ul className={styles.terms}>
|
||||
{terms.map(({ icon, name, meaning }) => (
|
||||
<li key={name} className={styles.term}>
|
||||
<span className={styles.termIcon}>
|
||||
<Icon icon={icon} size="nav" />
|
||||
</span>
|
||||
<p className={styles.termText}>
|
||||
<span className={styles.termName}>{name}</span>
|
||||
{meaning}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
export const ONBOARDING_STEPS = 3
|
||||
|
||||
export type OnboardingStepProps = {
|
||||
// 1-based; the progress pills fill up to it.
|
||||
step: number
|
||||
title: string
|
||||
children: ReactNode
|
||||
// The step's primary action, fixed to the bottom of the screen.
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
// OnboardingStep is the frame every onboarding step shares: progress pills,
|
||||
// the "Step N of 3" kicker and the Bagel title.
|
||||
export function OnboardingStep({ step, title, children, action }: OnboardingStepProps) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.progress} aria-hidden="true">
|
||||
{Array.from({ length: ONBOARDING_STEPS }, (_, i) => (
|
||||
<span key={i} className={[styles.pill, i < step && styles.filled].filter(Boolean).join(' ')} />
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.kicker}>
|
||||
Step {step} of {ONBOARDING_STEPS}
|
||||
</p>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
{action && <div className={styles.footer}>{action}</div>}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
|
||||
import { similarNames } from '../dinners/names.ts'
|
||||
import { useRefusals } from '../dinners/useRefusals.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Chip } from '../ui/Chip.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
// MIN_REGULARS is how many Regulars the server has saved before the
|
||||
// household moves on.
|
||||
export const MIN_REGULARS = 3
|
||||
|
||||
// commonPicks are offered as one-tap Regulars until the household has them,
|
||||
// or a Regular the server would count as the same dinner.
|
||||
export const commonPicks = [
|
||||
'Beef tacos',
|
||||
'Spaghetti bolognese',
|
||||
'Chicken stir-fry',
|
||||
'Pizza night',
|
||||
'Chili',
|
||||
'Pot roast',
|
||||
'Burgers',
|
||||
'Fried rice',
|
||||
]
|
||||
|
||||
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
|
||||
// the dinners it makes on repeat. Each add and remove shows at once and is
|
||||
// undone if the server refuses it.
|
||||
export function RegularsStep() {
|
||||
const navigate = useNavigate()
|
||||
const regulars = useRegulars()
|
||||
const add = useAddRegular()
|
||||
const remove = useRemoveRegular()
|
||||
const [draft, setDraft] = useState('')
|
||||
const { refusals, track } = useRefusals()
|
||||
const list = regulars.data ?? []
|
||||
const picks = commonPicks.filter((pick) => !list.some((regular) => similarNames(regular.name, pick)))
|
||||
// Next waits for every add to be answered, so a refused add's error shows
|
||||
// here and an add the server takes back never counts towards the three.
|
||||
const adding = list.some((regular) => regular.pending)
|
||||
const saved = list.filter((regular) => !regular.pending).length
|
||||
|
||||
function addRegular(name: string) {
|
||||
track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync({ name }))
|
||||
}
|
||||
|
||||
function removeRegular(regular: Regular) {
|
||||
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
|
||||
}
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const name = draft.trim()
|
||||
if (name === '') return
|
||||
addRegular(name)
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
step={2}
|
||||
title="What's on your usual rotation?"
|
||||
action={
|
||||
<Button
|
||||
className={styles.action}
|
||||
disabled={adding || saved < MIN_REGULARS}
|
||||
onClick={() => void navigate('/onboarding/notifications')}
|
||||
>
|
||||
Next: notifications
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>
|
||||
Add the dinners your household makes on repeat. MealQuest builds suggestions from these.
|
||||
</p>
|
||||
<form className={styles.add} onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
label="Dinner name"
|
||||
labelHidden
|
||||
className={styles.addInput}
|
||||
placeholder="e.g. Chicken enchiladas"
|
||||
autoComplete="off"
|
||||
enterKeyHint="done"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<button type="submit" className={styles.addButton} aria-label="Add Regular" disabled={draft.trim() === ''}>
|
||||
<span className={styles.addFace}>
|
||||
<Icon icon={Plus} size="nav" />
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
{refusals.map((refusal) => (
|
||||
<p key={refusal.key} role="alert" className={styles.problem}>
|
||||
{refusal.text}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<h2 className={styles.groupLabel}>Your regulars · {list.length}</h2>
|
||||
{regulars.isPending && <Skeleton className={styles.status} />}
|
||||
{regulars.isError && (
|
||||
<div className={styles.status}>
|
||||
<ErrorBanner onRetry={() => void regulars.refetch()}>
|
||||
Your Regulars could not load: {regulars.error.message}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)}
|
||||
{list.length > 0 && (
|
||||
<ul className={styles.chips}>
|
||||
{list.map((regular: Regular) => (
|
||||
<li key={regular.id}>
|
||||
<Chip
|
||||
label={regular.name}
|
||||
className={regular.pending && styles.pending}
|
||||
aria-busy={regular.pending}
|
||||
onRemove={regular.pending ? undefined : () => removeRegular(regular)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{regulars.isSuccess && picks.length > 0 && (
|
||||
<>
|
||||
<h2 className={styles.groupLabel}>Common picks</h2>
|
||||
<ul className={styles.chips}>
|
||||
{picks.map((pick) => (
|
||||
<li key={pick}>
|
||||
<button type="button" className={styles.pick} aria-label={`Add ${pick}`} onClick={() => addRegular(pick)}>
|
||||
<span aria-hidden="true">+</span>
|
||||
{pick}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user