From 6a3f1f393687fdc928db614b6a016b6731443cbf Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 17:57:25 -0400 Subject: [PATCH] Onboarding step 3 shell (#92) Co-authored-by: dev_agent --- docs/ARCHITECTURE.md | 9 +- internal/household/settings.go | 11 + internal/household/settings_test.go | 34 +++ internal/server/settings.go | 3 + internal/server/settings_test.go | 15 + web/src/onboarding/NotificationsStep.test.tsx | 265 ++++++++++++++++++ web/src/onboarding/NotificationsStep.tsx | 129 +++++++++ web/src/onboarding/Onboarding.module.css | 23 +- web/src/onboarding/OnboardingPage.tsx | 11 +- web/src/settings/api.ts | 15 + 10 files changed, 503 insertions(+), 12 deletions(-) create mode 100644 web/src/onboarding/NotificationsStep.test.tsx create mode 100644 web/src/onboarding/NotificationsStep.tsx diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8faa66e..c33d566 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -489,7 +489,14 @@ First run: while `household_settings.onboarding_completed_at` (read from `/onboarding`. Onboarding is a full screen outside the app shell, in three steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`, mock 1h, adding and archiving through the dinners API optimistically) and -notifications (`/onboarding/notifications`). Offline on the saved pages the +notifications (`/onboarding/notifications`: what MealQuest notifies about, +the iOS install hint, "Turn on notifications" and "Maybe later"). Either +button sends `PATCH /api/v1/settings/household` with +`{"onboarding_completed": true}`; the server sets `onboarding_completed_at` +to the time of that update (keeping the first time if it is already set; +the field itself is read-only and `false` is refused) and the app goes Home. +Notification permission is never asked for on page load; until Feature 19 +wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved pages the settings cannot be read (the service worker never caches `/api/v1/settings`), so the saved pages open as they are. diff --git a/internal/household/settings.go b/internal/household/settings.go index 1e1df89..8e6342c 100644 --- a/internal/household/settings.go +++ b/internal/household/settings.go @@ -68,6 +68,11 @@ type SettingsPatch struct { SuggestionsTime *string ChatAutosendDow *int ChatAutosendTime *string + // OnboardingCompleted, when true, records that the household finished + // onboarding: onboarding_completed_at becomes the time of the update, + // unless it is already set, when the first time is kept. Onboarding is + // never undone, so false is invalid. + OnboardingCompleted *bool } // Problem is one invalid field of a SettingsPatch, named by its JSON key. @@ -208,6 +213,9 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat setString(&p.SuggestionsTime, patch.SuggestionsTime) setDow(&p.ChatAutosendDow, patch.ChatAutosendDow) setString(&p.ChatAutosendTime, patch.ChatAutosendTime) + if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid { + p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true} + } row, err = q.UpsertHouseholdSettings(ctx, p) if err != nil { return fmt.Errorf("household: update settings: %w", err) @@ -291,6 +299,9 @@ func (p SettingsPatch) validate() error { fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value)) } } + if p.OnboardingCompleted != nil && !*p.OnboardingCompleted { + fail("onboarding_completed", "onboarding cannot be undone (want true)") + } if len(problems) > 0 { return &ValidationError{Problems: problems} } diff --git a/internal/household/settings_test.go b/internal/household/settings_test.go index a01120c..9fca588 100644 --- a/internal/household/settings_test.go +++ b/internal/household/settings_test.go @@ -217,6 +217,39 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) { } } +func TestUpdateCompletesOnboardingOnce(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := seededService(t) + + got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam") + if err != nil { + t.Fatal(err) + } + if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != got.UpdatedAt { + t.Fatalf("onboarding_completed_at = %v, want the update time %s", got.OnboardingCompletedAt, got.UpdatedAt) + } + first := *got.OnboardingCompletedAt + + // Completing again keeps the first time. + got, err = svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "alex") + if err != nil { + t.Fatal(err) + } + if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first { + t.Errorf("second completion: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first) + } + + // Other changes leave it alone. + got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "sam") + if err != nil { + t.Fatal(err) + } + if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first { + t.Errorf("after another change: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first) + } +} + func TestUpdateRequiresMember(t *testing.T) { t.Parallel() ctx := context.Background() @@ -259,6 +292,7 @@ func TestUpdateValidation(t *testing.T) { {"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}}, {"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}}, {"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}}, + {"onboarding undone", SettingsPatch{OnboardingCompleted: ptr(false)}, []string{"onboarding_completed"}}, { "every problem is reported", SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)}, diff --git a/internal/server/settings.go b/internal/server/settings.go index a5f06b7..cce8413 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -37,6 +37,9 @@ type settingsPatch struct { SuggestionsTime *string `json:"suggestions_time"` ChatAutosendDow *int `json:"chat_autosend_dow"` ChatAutosendTime *string `json:"chat_autosend_time"` + // OnboardingCompleted true sets onboarding_completed_at, which is + // itself read-only. + OnboardingCompleted *bool `json:"onboarding_completed"` } func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 8491eb0..08b7736 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -115,6 +115,19 @@ func TestPatchHouseholdSettings(t *testing.T) { } } +func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) { + t.Parallel() + + rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPatch, settingsPath, + strings.NewReader(`{"onboarding_completed":true}`))) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + if at, ok := body["onboarding_completed_at"].(string); !ok || at != body["updated_at"] { + t.Errorf("onboarding_completed_at = %#v, want the update time %#v", body["onboarding_completed_at"], body["updated_at"]) + } +} + func TestPatchHouseholdSettingsRejected(t *testing.T) { t.Parallel() @@ -136,6 +149,8 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) { {"null value", `{"timezone":null}`, "invalid_request", "timezone"}, {"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"}, {"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"}, + {"read-only onboarding time", `{"onboarding_completed_at":"2026-10-06T12:00:00.000Z"}`, "invalid_request", "onboarding_completed_at"}, + {"onboarding undone", `{"onboarding_completed":false}`, "invalid_settings", "onboarding_completed"}, {"not an object", `["timezone"]`, "invalid_request", "JSON object"}, {"null body", `null`, "invalid_request", "JSON object"}, {"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"}, diff --git a/web/src/onboarding/NotificationsStep.test.tsx b/web/src/onboarding/NotificationsStep.test.tsx new file mode 100644 index 0000000..a34c56c --- /dev/null +++ b/web/src/onboarding/NotificationsStep.test.tsx @@ -0,0 +1,265 @@ +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> + +beforeEach(() => { + requestPermission = vi.fn<() => Promise>(() => 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((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((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( + + + , + ) + 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( + + + + + } + /> + Home} /> + + + + , + ) + 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( + () => + 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(() => 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('notificationsUnavailable', () => { + it('answers "unavailable" without asking for permission', async () => { + await expect(notificationsUnavailable()).resolves.toBe('unavailable') + expect(requestPermission).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/onboarding/NotificationsStep.tsx b/web/src/onboarding/NotificationsStep.tsx new file mode 100644 index 0000000..d8d5ed0 --- /dev/null +++ b/web/src/onboarding/NotificationsStep.tsx @@ -0,0 +1,129 @@ +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 { 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 + +// 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 { + 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. +export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) { + const navigate = useNavigate() + const complete = useCompleteOnboarding() + const [enabling, setEnabling] = useState(false) + const [problem, setProblem] = useState(null) + const busy = enabling || complete.isPending + + async function finish() { + try { + await complete.mutateAsync() + } catch (error) { + setProblem(`Could not finish setting up: ${(error as Error).message}`) + return + } + void navigate('/', { replace: true }) + } + + async function turnOn() { + setProblem(null) + 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() { + setProblem(null) + void finish() + } + + return ( + + + + + } + > +

MealQuest can let the household know when there's something to look at:

+
    + {topics.map(({ icon, name, meaning }) => ( +
  • + + + +

    + {name} + {meaning} +

    +
  • + ))} +
+
+ +
+ {problem && ( +

+ {problem} +

+ )} +
+ ) +} diff --git a/web/src/onboarding/Onboarding.module.css b/web/src/onboarding/Onboarding.module.css index 3ab1a7e..477e2da 100644 --- a/web/src/onboarding/Onboarding.module.css +++ b/web/src/onboarding/Onboarding.module.css @@ -73,7 +73,19 @@ font-size: 17px; } -/* Step 1: what MealQuest's words mean. */ +/* 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; @@ -221,6 +233,15 @@ 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; diff --git a/web/src/onboarding/OnboardingPage.tsx b/web/src/onboarding/OnboardingPage.tsx index d8f9853..48e3924 100644 --- a/web/src/onboarding/OnboardingPage.tsx +++ b/web/src/onboarding/OnboardingPage.tsx @@ -2,6 +2,7 @@ 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' @@ -65,13 +66,3 @@ function WelcomeStep() { ) } - -// TODO(Feature 12): onboarding step 3 — notifications and finishing -// onboarding — is a later task; until then the step only says so. -function NotificationsStep() { - return ( - -

Setting up notifications arrives in a later update.

-
- ) -} diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index 540f3ea..b32b127 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -389,6 +389,21 @@ export function useSaveHouseholdSettings() { }) } +// useCompleteOnboarding records that the household finished onboarding: the +// server sets onboarding_completed_at, which ends the first-run redirect. +export function useCompleteOnboarding() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async () => + parseHouseholdSettings( + `PATCH ${HOUSEHOLD_SETTINGS_PATH}`, + await apiFetch(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: { onboarding_completed: true } }), + ), + onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings), + }) +} + // HouseholdSettingsProblems is a refused household settings save: the reason // for each field the server named, and any reasons that name no field. export type HouseholdSettingsProblems = {