feat: add onboarding tutorial for new users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s

- Introduced a modular tutorial layer to guide new parents through the app setup process.
- Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation).
- Added just-in-time contextual hints for various features as users encounter them.
- Persisted user progress on the backend with new fields in the User model.
- Created a new tutorial controller and step registry in the frontend for managing tutorial states.
- Added Help button for easy access to tutorial tips and a restart option in the user profile.
- Ensured accessibility and mobile responsiveness for the tutorial overlay.
- Included tests for backend and frontend functionalities related to the tutorial.
This commit is contained in:
2026-06-19 17:27:35 -04:00
parent d147bd6f27
commit e2bb9cd6b9
24 changed files with 1764 additions and 627 deletions
+338 -277
View File
@@ -1,40 +1,97 @@
<template>
<div class="view">
<EntityEditForm
entityLabel="User Profile"
:fields="fields"
:initialData="initialData"
:isEdit="true"
:loading="loading"
:error="errorMsg"
:title="'User Profile'"
:fieldErrors="{ push_enabled: pushError }"
@submit="handleSubmit"
@cancel="router.back"
@add-image="onAddImage"
>
<template #custom-field-email="{ modelValue }">
<div class="email-actions">
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
<h2>Profile</h2>
<div v-if="loading" class="loading-message">Loading profile...</div>
<div v-else class="profile-card">
<div v-if="errorMsg" class="error-banner" aria-live="polite">{{ errorMsg }}</div>
<ProfileSection title="User" :defaultOpen="true">
<div class="name-fields" @focusout="handleNameFocusOut">
<div class="field-group">
<label for="first-name">First Name</label>
<input
id="first-name"
v-model="firstName"
type="text"
maxlength="64"
:disabled="saving"
/>
</div>
<div class="field-group">
<label for="last-name">Last Name</label>
<input
id="last-name"
v-model="lastName"
type="text"
maxlength="64"
:disabled="saving"
/>
</div>
</div>
<div class="field-group">
<label>Image</label>
<ImagePicker
:modelValue="imageId"
@update:modelValue="onImageChange"
@add-image="onAddImage"
:image-type="1"
/>
</div>
</ProfileSection>
<ProfileSection title="Account">
<div class="field-group">
<label for="email">Email Address</label>
<input id="email" type="email" :value="email" disabled class="readonly-input" />
</div>
<div class="action-links">
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
Change Parent PIN
</button>
<button
type="button"
class="btn-link btn-link-space"
@click="resetPassword"
:disabled="resetting"
>
<button type="button" class="btn-link btn-link-space" @click="resetPassword" :disabled="resetting">
Change Password
</button>
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
Delete My Account
</button>
</div>
</template>
</EntityEditForm>
</ProfileSection>
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
<ProfileSection title="Notifications">
<div class="toggle-stack">
<ToggleField
label="Daily Digest"
:modelValue="emailDigestEnabled"
@update:modelValue="onToggleDigest"
:disabled="saving"
description="Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links."
/>
<ToggleField
label="Push Notifications"
:modelValue="pushEnabled"
@update:modelValue="onTogglePush"
:disabled="saving || pushPermissionDenied"
description="Receive instant push notifications when a chore or reward needs your approval."
:error="pushError"
/>
</div>
</ProfileSection>
<ProfileSection title="Help">
<ToggleField
label="Show tutorial tips"
:modelValue="tutorialEnabled"
@update:modelValue="onToggleTutorial"
description="Show helpful tips as I use the app."
/>
<button type="button" class="btn-link btn-link-space" @click="openRestartConfirm">
Restart tutorial
</button>
</ProfileSection>
</div>
<!-- Password reset modal -->
<ModalDialog
v-if="showModal"
:title="modalTitle"
@@ -96,27 +153,6 @@
</div>
</ModalDialog>
<!-- Help / Tutorial section -->
<section class="help-section" aria-labelledby="help-heading">
<h3 id="help-heading" class="help-heading">Help</h3>
<label class="help-row">
<span class="help-row-text">
<span class="help-row-label">Show tutorial tips</span>
<span class="help-row-desc">Show helpful tips as I use the app.</span>
</span>
<input
type="checkbox"
class="help-toggle"
:checked="tutorialEnabled"
@change="onToggleTutorial"
aria-label="Show tutorial tips"
/>
</label>
<button type="button" class="btn-link btn-link-space" @click="openRestartConfirm">
Restart tutorial
</button>
</section>
<!-- Restart confirmation -->
<ModalDialog
v-if="showRestartConfirm"
@@ -133,11 +169,7 @@
</ModalDialog>
<!-- Restart success -->
<ModalDialog
v-if="showRestartSuccess"
title="Tour reset"
@close="showRestartSuccess = false"
>
<ModalDialog v-if="showRestartSuccess" title="Tour reset" @close="showRestartSuccess = false">
<div class="modal-message">Tour reset — here we go!</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
@@ -147,10 +179,12 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import ProfileSection from './ProfileSection.vue'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import ToggleField from '@/components/shared/ToggleField.vue'
import ModalDialog from '@/components/shared/ModalDialog.vue'
import {
isSubscribedToPush,
subscribeToPushWithResult,
@@ -161,24 +195,34 @@ import {
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { ALREADY_MARKED } from '@/common/errorCodes'
import { logoutUser, suppressForceLogout } from '@/stores/auth'
import {
tutorialEnabled,
setTutorialEnabled,
resetAllProgress,
} from '@/tutorial/controller'
import { tutorialEnabled, setTutorialEnabled, resetAllProgress } from '@/tutorial/controller'
import '@/assets/styles.css'
const router = useRouter()
const loading = ref(false)
// Profile data
const loading = ref(true)
const saving = ref(false)
const errorMsg = ref('')
const resetting = ref(false)
const firstName = ref('')
const lastName = ref('')
const imageId = ref<string | null>(null)
const email = ref('')
const emailDigestEnabled = ref(true)
const pushEnabled = ref(false)
const pushPermissionDenied = ref(false)
const pushError = ref('')
const localImageFile = ref<File | null>(null)
// Modal state
const resetting = ref(false)
const showModal = ref(false)
const modalTitle = ref('')
const modalSubtitle = ref('')
const modalMessage = ref('')
// Delete account modal state
const showDeleteWarning = ref(false)
const confirmEmail = ref('')
const deletingAccount = ref(false)
@@ -186,65 +230,10 @@ const showDeleteSuccess = ref(false)
const showDeleteError = ref(false)
const deleteErrorMessage = ref('')
const pushError = ref('')
const pushPermissionDenied = ref(false)
// Tutorial controls
const showRestartConfirm = ref(false)
const showRestartSuccess = ref(false)
async function onToggleTutorial(e: Event) {
const target = e.target as HTMLInputElement
await setTutorialEnabled(target.checked)
}
function openRestartConfirm() {
showRestartConfirm.value = true
}
async function confirmRestartTutorial() {
showRestartConfirm.value = false
await resetAllProgress()
showRestartSuccess.value = true
}
const initialData = ref<{
image_id: string | null
first_name: string
last_name: string
email: string
email_digest_enabled: boolean
push_enabled: boolean
}>({
image_id: null,
first_name: '',
last_name: '',
email: '',
email_digest_enabled: true,
push_enabled: false,
})
const fields = computed(() => [
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 1 },
{ name: 'first_name', label: 'First Name', type: 'text' as const, required: true, maxlength: 64 },
{ name: 'last_name', label: 'Last Name', type: 'text' as const, required: true, maxlength: 64 },
{ name: 'email', label: 'Email Address', type: 'custom' as const },
{
name: 'email_digest_enabled',
label: 'Daily Digest',
type: 'toggle' as const,
description:
'Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links.',
},
{
name: 'push_enabled',
label: 'Push Notifications',
type: 'toggle' as const,
description: 'Receive instant push notifications when a chore or reward needs your approval.',
disabled: pushPermissionDenied.value,
},
])
// Load profile
onMounted(async () => {
loading.value = true
try {
@@ -253,14 +242,12 @@ onMounted(async () => {
const data = await res.json()
pushPermissionDenied.value = getPushPermissionState() === 'denied'
const pushSubscribed = await isSubscribedToPush()
initialData.value = {
image_id: data.image_id || null,
first_name: data.first_name || '',
last_name: data.last_name || '',
email: data.email || '',
email_digest_enabled: data.email_digest_enabled !== false,
push_enabled: data.push_notifications_enabled !== false && pushSubscribed,
}
firstName.value = data.first_name || ''
lastName.value = data.last_name || ''
imageId.value = data.image_id || null
email.value = data.email || ''
emailDigestEnabled.value = data.email_digest_enabled !== false
pushEnabled.value = data.push_notifications_enabled !== false && pushSubscribed
} catch {
errorMsg.value = 'Could not load user profile.'
} finally {
@@ -268,102 +255,139 @@ onMounted(async () => {
}
})
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
} else {
localImageFile.value = null
initialData.value.image_id = id
function handleNameFocusOut(event: FocusEvent) {
const wrapper = event.currentTarget as HTMLElement
const relatedTarget = event.relatedTarget as HTMLElement | null
if (relatedTarget && wrapper.contains(relatedTarget)) {
return
}
saveNames()
}
// ─── Auto-save: names ───
async function saveNames() {
if (saving.value) return
saving.value = true
errorMsg.value = ''
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: firstName.value,
last_name: lastName.value,
}),
})
if (!res.ok) throw new Error('Failed to update profile')
} catch {
errorMsg.value = 'Failed to update profile.'
} finally {
saving.value = false
}
}
function handleSubmit(form: {
image_id: string | null
first_name: string
last_name: string
email: string
email_digest_enabled?: boolean
push_enabled?: boolean
}) {
errorMsg.value = ''
loading.value = true
// ─── Auto-save: image ───
function onImageChange(id: string | null) {
if (id === 'local-upload') {
// localImageFile is set by onAddImage which fires first
uploadLocalImage()
} else {
localImageFile.value = null
saveImage(id)
}
}
// Handle image upload if local file
let imageId = form.image_id
if (imageId === 'local-upload' && localImageFile.value) {
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
async function uploadLocalImage() {
if (!localImageFile.value) return
saving.value = true
errorMsg.value = ''
try {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'true')
fetch('/api/image/upload', {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
.then(async (resp) => {
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
// Now update profile
return updateProfile({
...form,
image_id: imageId,
})
})
.catch(() => {
errorMsg.value = 'Failed to upload image.'
loading.value = false
})
} else {
updateProfile(form)
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId.value = data.id
await saveImage(data.id)
} catch {
errorMsg.value = 'Failed to upload image.'
} finally {
saving.value = false
}
}
async function updateProfile(form: {
image_id: string | null
first_name: string
last_name: string
email: string
email_digest_enabled?: boolean
push_enabled?: boolean
}) {
const prevDigest = initialData.value.email_digest_enabled
const prevPush = initialData.value.push_enabled
async function saveImage(id: string | null) {
saving.value = true
errorMsg.value = ''
try {
const body: Record<string, unknown> = {
first_name: form.first_name,
last_name: form.last_name,
image_id: form.image_id,
}
if (form.email_digest_enabled !== undefined && form.email_digest_enabled !== prevDigest) {
body.email_digest_enabled = form.email_digest_enabled
}
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
body.push_notifications_enabled = form.push_enabled
}
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
body: JSON.stringify({ image_id: id }),
})
if (!res.ok) throw new Error('Failed to update profile')
let actualPushEnabled = prevPush
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
const pushOk = await applyPushChange(form.push_enabled)
actualPushEnabled = pushOk ? form.push_enabled : prevPush
}
initialData.value = {
...initialData.value,
...form,
push_enabled: actualPushEnabled,
}
modalTitle.value = 'Profile Updated'
modalSubtitle.value = ''
modalMessage.value = 'Your profile was updated successfully.'
showModal.value = true
} catch {
errorMsg.value = 'Failed to update profile.'
} finally {
loading.value = false
saving.value = false
}
}
// ─── Auto-save: toggles ───
async function onToggleDigest(val: boolean) {
emailDigestEnabled.value = val
if (saving.value) return
saving.value = true
errorMsg.value = ''
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email_digest_enabled: val }),
})
if (!res.ok) throw new Error('Failed to update profile')
} catch {
errorMsg.value = 'Failed to update profile.'
emailDigestEnabled.value = !val
} finally {
saving.value = false
}
}
async function onTogglePush(val: boolean) {
const prevPush = pushEnabled.value
pushEnabled.value = val
if (saving.value) return
saving.value = true
errorMsg.value = ''
pushError.value = ''
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ push_notifications_enabled: val }),
})
if (!res.ok) throw new Error('Failed to update profile')
const pushOk = await applyPushChange(val)
if (!pushOk) {
pushEnabled.value = prevPush
}
} catch {
errorMsg.value = 'Failed to update profile.'
pushEnabled.value = prevPush
} finally {
saving.value = false
}
}
@@ -392,16 +416,13 @@ async function applyPushChange(newValue: boolean): Promise<boolean> {
}
}
async function handlePasswordModalClose() {
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
showModal.value = false
if (wasProfileUpdate) {
router.back()
}
// ─── Tutorial toggle ───
async function onToggleTutorial(val: boolean) {
await setTutorialEnabled(val)
}
// ─── Password reset ───
async function resetPassword() {
// Show modal immediately with loading message
modalTitle.value = 'Change Password'
modalMessage.value = 'Sending password change email...'
modalSubtitle.value = ''
@@ -412,7 +433,7 @@ async function resetPassword() {
const res = await fetch('/api/auth/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: initialData.value.email }),
body: JSON.stringify({ email: email.value }),
})
if (!res.ok) throw new Error('Failed to send reset email')
modalTitle.value = 'Password Change Email Sent'
@@ -426,10 +447,16 @@ async function resetPassword() {
}
}
async function handlePasswordModalClose() {
showModal.value = false
}
// ─── Navigation ───
function goToChangeParentPin() {
router.push({ name: 'ParentPinSetup' })
}
// ─── Delete account ───
function openDeleteWarning() {
confirmEmail.value = ''
showDeleteWarning.value = true
@@ -442,9 +469,6 @@ function closeDeleteWarning() {
async function confirmDeleteAccount() {
if (!isEmailValid(confirmEmail.value)) return
// Set flag before the request so it's guaranteed to be set
// before the force_logout SSE event can arrive on this tab
suppressForceLogout.value = true
deletingAccount.value = true
try {
@@ -453,7 +477,6 @@ async function confirmDeleteAccount() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: confirmEmail.value }),
})
if (!res.ok) {
suppressForceLogout.value = false
const { msg, code } = await parseErrorResponse(res)
@@ -466,8 +489,6 @@ async function confirmDeleteAccount() {
showDeleteError.value = true
return
}
// Success — suppressForceLogout is already set; show confirmation modal
showDeleteWarning.value = false
showDeleteSuccess.value = true
} catch {
@@ -482,12 +503,10 @@ async function confirmDeleteAccount() {
function handleDeleteSuccess() {
showDeleteSuccess.value = false
// Call logout API to clear server cookies
fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
}).finally(() => {
// Clear client-side auth and redirect, regardless of logout response
logoutUser()
router.push('/')
})
@@ -497,65 +516,134 @@ function closeDeleteError() {
showDeleteError.value = false
deleteErrorMessage.value = ''
}
// ─── Tutorial restart ───
function openRestartConfirm() {
showRestartConfirm.value = true
}
async function confirmRestartTutorial() {
showRestartConfirm.value = false
await resetAllProgress()
showRestartSuccess.value = true
}
</script>
<style scoped>
.view {
max-width: 400px;
max-width: 420px;
margin: 0 auto;
}
h2 {
text-align: center;
margin-bottom: 1.5rem;
color: #fff;
}
.profile-card {
background: var(--form-bg);
border-radius: 12px;
box-shadow: 0 4px 24px var(--form-shadow);
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
/* ...existing styles... */
.email-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 1.5rem 1rem;
}
.success-message {
color: var(--success, #16a34a);
.loading-message {
text-align: center;
color: var(--loading-color, #888);
font-size: 1rem;
padding: 2rem 0;
}
.error-message {
.error-banner {
color: var(--error, #e53e3e);
font-size: 0.98rem;
margin-top: 0.4rem;
font-size: 0.95rem;
padding: 0.6rem 0;
margin-bottom: 0.5rem;
text-align: center;
}
.readonly-input {
.field-group {
margin-bottom: 1rem;
}
.field-group label {
display: block;
font-weight: 600;
color: var(--form-label, #444);
margin-bottom: 0.4rem;
font-size: 0.95rem;
}
.field-group input[type='text'],
.field-group input[type='email'] {
width: 100%;
padding: 0.6rem;
border-radius: 7px;
border: 1px solid var(--form-input-border, #e6e6e6);
font-size: 1rem;
background: var(--form-input-bg, #f5f5f5);
color: var(--form-label, #888);
background: var(--form-input-bg, #fff);
color: var(--text-primary, #222);
box-sizing: border-box;
transition: opacity 0.2s;
}
.btn-danger-link {
.field-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.readonly-input {
background: var(--form-input-bg, #f5f5f5) !important;
color: var(--form-label, #888) !important;
}
.action-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.toggle-stack {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.btn-link {
background: none;
border: none;
color: var(--error, #e53e3e);
color: var(--btn-primary, #667eea);
font-size: 0.95rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
margin-top: 0.25rem;
align-self: flex-start;
}
.btn-danger-link:hover {
color: var(--error-hover, #c53030);
.btn-link:hover {
color: var(--btn-primary-hover, #5a67d8);
}
.btn-danger-link:disabled {
.btn-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal-message {
text-align: center;
color: var(--dialog-message, #444);
font-size: 1rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.2rem;
}
.email-confirm-input {
width: 100%;
padding: 0.6rem;
@@ -573,44 +661,17 @@ function closeDeleteError() {
border-color: var(--btn-primary, #4a90e2);
}
.help-section {
margin-top: 1.4rem;
padding-top: 1.2rem;
border-top: 1px solid var(--form-input-border, #cbd5e1);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.help-heading {
margin: 0 0 0.2rem;
font-size: 1rem;
color: var(--form-heading, #667eea);
}
.help-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
}
.help-row-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.help-row-label {
font-weight: 600;
color: var(--text-primary, #222);
}
.help-row-desc {
font-size: 0.88rem;
color: var(--text-secondary, #888);
}
.help-toggle {
width: 22px;
height: 22px;
margin-top: 2px;
accent-color: var(--btn-primary, #667eea);
cursor: pointer;
@media (max-width: 480px) {
.profile-card {
padding: 0.5rem 1rem 1rem;
border-radius: 0;
box-shadow: none;
background: transparent;
}
.view {
max-width: 100%;
padding: 0 0.5rem;
}
}
</style>