feat: add onboarding tutorial for new users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
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:
@@ -27,7 +27,11 @@ import {
|
||||
triggerRoutineAsParent,
|
||||
} from '@/common/api'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
activeStep as tutorialActiveStep,
|
||||
modalTutorialStepId,
|
||||
} from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
import type {
|
||||
Task,
|
||||
@@ -109,6 +113,9 @@ const selectedChoreId = ref<string | null>(null)
|
||||
const menuPosition = ref({ top: 0, left: 0 })
|
||||
const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
// Tutorial auto-demo state
|
||||
const tutorialHighlightedItemId = ref<string | null>(null)
|
||||
|
||||
// Schedule modal
|
||||
const showScheduleModal = ref(false)
|
||||
const scheduleTarget = ref<ChildTask | null>(null)
|
||||
@@ -427,10 +434,45 @@ function openRoutineMenu(routineId: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = routineKebabBtnRefs.value.get(routineId)
|
||||
if (btn) {
|
||||
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||
}
|
||||
activeRoutineMenuFor.value = routineId
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-kebab-menu',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'kebab-edit-points-cost',
|
||||
() => document.querySelector('[data-tutorial~="kebab-edit-points-cost"]') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'routine-schedule',
|
||||
() => document.querySelector('[data-tutorial="routine-schedule"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
const items = childRoutineListRef.value?.items ?? []
|
||||
const routine = items.find((r) => r.id === routineId)
|
||||
if (routine) {
|
||||
if (isRoutineExpired(routine)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-extend-time',
|
||||
() => document.querySelector('[data-tutorial="routine-extend-time"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (isRoutineApprovedToday(routine)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-reset',
|
||||
() => document.querySelector('[data-tutorial="routine-reset"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeRoutineMenu() {
|
||||
@@ -666,7 +708,10 @@ const onDocClick = (e: MouseEvent) => {
|
||||
node.classList.contains('kebab-menu')
|
||||
)
|
||||
})
|
||||
if (!inside) {
|
||||
const fromTutorial = path.some(
|
||||
(n) => n instanceof HTMLElement && n.classList.contains('tutorial-root'),
|
||||
)
|
||||
if (!inside && !fromTutorial) {
|
||||
activeMenuFor.value = null
|
||||
activeRoutineMenuFor.value = null
|
||||
selectedChoreId.value = null
|
||||
@@ -684,14 +729,25 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = kebabBtnRefs.value.get(taskId)
|
||||
if (btn) {
|
||||
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||
}
|
||||
activeMenuFor.value = taskId
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-menu',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'kebab-edit-points-cost',
|
||||
() => document.querySelector('[data-tutorial~="kebab-edit-points-cost"]') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'chore-schedule',
|
||||
() => document.querySelector('[data-tutorial="chore-schedule"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const task = items.find((t) => t.id === taskId)
|
||||
if (task) {
|
||||
@@ -699,10 +755,7 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-extend-time',
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.kebab-menu .menu-item')).find((el) =>
|
||||
/extend\s*time/i.test(el.textContent || ''),
|
||||
) as HTMLElement | null,
|
||||
() => document.querySelector('[data-tutorial="chore-extend-time"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -710,10 +763,7 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-reset',
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.kebab-menu .menu-item')).find((el) =>
|
||||
/^reset/i.test((el.textContent || '').trim()),
|
||||
) as HTMLElement | null,
|
||||
() => document.querySelector('[data-tutorial="chore-reset"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -890,6 +940,13 @@ watch(showOverrideModal, async (newVal) => {
|
||||
if (newVal) {
|
||||
await nextTick()
|
||||
document.getElementById('custom-value')?.focus()
|
||||
modalTutorialStepId.value = 'point-editor-help'
|
||||
tutorialMaybeShow(
|
||||
'point-editor-help',
|
||||
() => document.querySelector('input#custom-value') as HTMLElement | null,
|
||||
)
|
||||
} else {
|
||||
modalTutorialStepId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1034,11 +1091,9 @@ onMounted(async () => {
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
// Fire the per-child overview tour (chains into assign-* steps).
|
||||
// No anchor: this is a general overview so the card stays centered.
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'select-child',
|
||||
() => document.querySelector('.assign-buttons') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow('select-child')
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
@@ -1096,6 +1151,66 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// When the generic kebab-overview step fires, scroll to an assigned item
|
||||
// and select it so its kebab button is visible for the user to discover.
|
||||
watch(
|
||||
() => tutorialActiveStep.value?.def.id,
|
||||
async (stepId, prevStepId) => {
|
||||
if (stepId === 'item-kebab-overview') {
|
||||
const chores: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const routines: ChildRoutine[] = childRoutineListRef.value?.items ?? []
|
||||
if (chores.length > 0 && childChoreListRef.value) {
|
||||
const first = chores[0]
|
||||
childChoreListRef.value.scrollToItem(first.id)
|
||||
selectedChoreId.value = first.id
|
||||
tutorialHighlightedItemId.value = first.id
|
||||
await nextTick()
|
||||
const btn = kebabBtnRefs.value.get(first.id)
|
||||
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
|
||||
tutorialActiveStep.value.anchor = () => btn
|
||||
}
|
||||
} else if (routines.length > 0 && childRoutineListRef.value) {
|
||||
const first = routines[0]
|
||||
childRoutineListRef.value.scrollToItem(first.id)
|
||||
selectedRoutineId.value = first.id
|
||||
tutorialHighlightedItemId.value = first.id
|
||||
await nextTick()
|
||||
const btn = routineKebabBtnRefs.value.get(first.id)
|
||||
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
|
||||
tutorialActiveStep.value.anchor = () => btn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the highlighted selection when the tutorial leaves kebab steps
|
||||
const kebabStepIds = new Set([
|
||||
'item-kebab-overview',
|
||||
'chore-kebab-menu',
|
||||
'chore-edit-points',
|
||||
'chore-schedule',
|
||||
'chore-kebab-extend-time',
|
||||
'chore-kebab-reset',
|
||||
'routine-kebab-menu',
|
||||
'routine-edit',
|
||||
'routine-edit-points',
|
||||
'routine-schedule',
|
||||
'routine-extend-time',
|
||||
'routine-reset',
|
||||
'kebab-edit-points-cost',
|
||||
])
|
||||
if (
|
||||
prevStepId &&
|
||||
kebabStepIds.has(prevStepId) &&
|
||||
!kebabStepIds.has(stepId ?? '') &&
|
||||
tutorialHighlightedItemId.value
|
||||
) {
|
||||
selectedChoreId.value = null
|
||||
selectedRoutineId.value = null
|
||||
tutorialHighlightedItemId.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||
@@ -1372,11 +1487,12 @@ function goToAssignRoutines() {
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button class="menu-item" @mousedown.stop.prevent @click="editChorePoints(item)">
|
||||
<button class="menu-item" data-tutorial="chore-edit-points kebab-edit-points-cost" @mousedown.stop.prevent @click="editChorePoints(item)">
|
||||
Edit Points
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="chore-schedule"
|
||||
@mousedown.stop.prevent
|
||||
@click="openScheduleModal(item, $event)"
|
||||
>
|
||||
@@ -1385,6 +1501,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isChoreExpired(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="chore-extend-time"
|
||||
@mousedown.stop.prevent
|
||||
@click="doExtendTime(item, $event)"
|
||||
>
|
||||
@@ -1393,6 +1510,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isChoreCompletedToday(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="chore-reset"
|
||||
@mousedown.stop.prevent
|
||||
@click="doResetChore(item, $event)"
|
||||
>
|
||||
@@ -1481,11 +1599,12 @@ function goToAssignRoutines() {
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button class="menu-item" @mousedown.stop.prevent @click="editRoutine(item)">
|
||||
<button class="menu-item" data-tutorial="routine-edit" @mousedown.stop.prevent @click="editRoutine(item)">
|
||||
Edit Routine
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="routine-edit-points kebab-edit-points-cost"
|
||||
@mousedown.stop.prevent
|
||||
@click="editRoutinePoints(item)"
|
||||
>
|
||||
@@ -1493,6 +1612,7 @@ function goToAssignRoutines() {
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="routine-schedule"
|
||||
@mousedown.stop.prevent
|
||||
@click="openRoutineScheduleModal(item, $event)"
|
||||
>
|
||||
@@ -1501,6 +1621,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isRoutineExpired(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="routine-extend-time"
|
||||
@mousedown.stop.prevent
|
||||
@click="doExtendRoutineTime(item, $event)"
|
||||
>
|
||||
@@ -1509,6 +1630,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isRoutineApprovedToday(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="routine-reset"
|
||||
@mousedown.stop.prevent
|
||||
@click="doResetRoutine(item, $event)"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user